commit a0a48b5e4559e736495c225daca09b078a4bfc75 Author: wehub-resource-sync Date: Mon Jul 13 12:26:02 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..eeccf71 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: agentseal diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9af748a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Summary + + + +## Testing + +- [ ] I have tested this locally against real data (not just unit tests) +- [ ] `npm test` passes +- [ ] `npm run build` succeeds + +### For new providers only: + +- [ ] I installed the tool and generated real sessions by using it +- [ ] `npm run dev -- today` shows correct costs and session counts for this provider +- [ ] `npm run dev -- models --provider ` shows correct model names and pricing +- [ ] Screenshot or terminal output attached below proving it works with real data + + diff --git a/.github/workflows/block-claude-coauthor.yml b/.github/workflows/block-claude-coauthor.yml new file mode 100644 index 0000000..97db0a1 --- /dev/null +++ b/.github/workflows/block-claude-coauthor.yml @@ -0,0 +1,43 @@ +name: Block Claude / Anthropic co-author trailers + +# Rejects PRs that contain a `Co-authored-by: ... claude ...` or `... anthropic ...` +# trailer in any of their commits. Contributors can still use AI tools to help +# write code, but they must remove the co-author attribution before the PR is +# eligible to merge, per the project's contributor guidelines. + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Scan PR commits for disallowed co-author trailers + run: | + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + FOUND=0 + while IFS= read -r c; do + [ -z "$c" ] && continue + SUBJECT=$(git log -1 "$c" --format=%s) + if git log -1 "$c" --format="%B" | grep -qiE 'co-authored-by:.*(claude|anthropic)'; then + echo "::error::Commit $c ($SUBJECT) has a Claude/Anthropic co-author trailer. Please remove it before this PR can merge." + FOUND=1 + fi + done < <(git log --format=%H "$BASE_SHA".."$HEAD_SHA") + if [ "$FOUND" != "0" ]; then + echo "" + echo "How to fix:" + echo " 1. Run: git rebase -i $BASE_SHA" + echo " 2. Mark each flagged commit as 'reword'" + echo " 3. Delete the Co-authored-by line from the commit message" + echo " 4. Save, then: git push --force-with-lease" + exit 1 + fi + echo "No disallowed co-author trailers found." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1230adc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + semgrep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Semgrep + run: pip install semgrep + + - name: Run Semgrep bracket-assign guard + run: | + set -e + semgrep --config .semgrep/rules/no-bracket-assign-hot-paths.yml \ + --strict --json \ + src/providers/ src/parser.ts > semgrep-out.json + FINDINGS=$(jq '.results | length' semgrep-out.json) + if [ "$FINDINGS" -gt 0 ]; then + jq -r '.results[] | "::error file=\(.path),line=\(.start.line)::\(.extra.message)"' semgrep-out.json + exit 1 + fi diff --git a/.github/workflows/firstlook.yml b/.github/workflows/firstlook.yml new file mode 100644 index 0000000..c5db24c --- /dev/null +++ b/.github/workflows/firstlook.yml @@ -0,0 +1,23 @@ +name: firstlook +on: + pull_request: + types: [opened, reopened, synchronize] + workflow_dispatch: + inputs: + pr-number: + description: 'PR number to scan (leave empty for all open PRs)' + required: false + type: string + +jobs: + assess: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - uses: getagentseal/firstlook@main + with: + pr-number: ${{ inputs.pr-number }} + skip-users: 'dependabot[bot],renovate[bot]' + fail-on: 'unknown' diff --git a/.github/workflows/release-menubar.yml b/.github/workflows/release-menubar.yml new file mode 100644 index 0000000..52e6c5b --- /dev/null +++ b/.github/workflows/release-menubar.yml @@ -0,0 +1,75 @@ +name: Release macOS Menubar + +# Triggers on a `mac-v*` tag push (e.g. `git tag mac-v0.8.0 && git push origin mac-v0.8.0`), +# or manually via the Actions tab. Builds a universal arm64+x86_64 bundle, ad-hoc signs it, +# zips via `ditto`, and uploads the zip to the GitHub Release. The installer verifies +# the checksum and bundle identity before replacing the local app. +on: + push: + tags: + - 'mac-v*' + workflow_dispatch: + inputs: + version: + description: 'Version label for the bundle (e.g. v0.8.0 or dev-preview)' + required: true + default: 'dev-preview' + +permissions: + contents: write # Needed to create the release + upload assets. + +jobs: + build: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve version label + id: version + run: | + if [[ "${GITHUB_REF}" == refs/tags/mac-v* ]]; then + echo "value=${GITHUB_REF#refs/tags/mac-}" >> "$GITHUB_OUTPUT" + else + echo "value=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" + fi + + - name: Show Swift toolchain + run: swift --version + + - name: Build + bundle + zip + run: mac/Scripts/package-app.sh "${{ steps.version.outputs.value }}" + + - name: Upload artifact (for manual runs) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v7 + with: + name: CodeBurnMenubar-${{ steps.version.outputs.value }} + path: | + mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip + mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256 + if-no-files-found: error + + - name: Create / update GitHub Release + if: startsWith(github.ref, 'refs/tags/mac-v') + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ github.ref_name }} + name: Menubar ${{ steps.version.outputs.value }} + body: | + Install with: + + ``` + npm install -g codeburn + codeburn menubar + ``` + + That command drops the app into `~/Applications`, records the persistent + `codeburn` CLI path used by the menubar, verifies the downloaded checksum, + clears quarantine after bundle verification, and launches it. If you download + the zip from this page directly and macOS shows "cannot verify developer", + right-click the app in Finder and pick Open to whitelist it once. + files: | + mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip + mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256 + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b71c159 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +node_modules/ +dist/ +*.tgz + +# OS +.DS_Store +Thumbs.db + +# Editor / IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Planning artifacts (internal, not shipped) +docs/superpowers/ +.claude/ +/CLAUDE.md + +# Config / secrets +.env +.env.* +*.pem +*.key + +# Debug / logs +*.log +npm-debug.log* + +# Cache +.cache/ +*.sqlite + +# Build artifacts +*.tsbuildinfo + +# Local Discord brand / promo assets not yet ready to publish +assets/discord-*.png + +# Desktop app experiments +desktop/ + +# Mac App Store app (private) +appstore/ + +# WIP / not ready +src/summit.ts diff --git a/.semgrep/rules/no-bracket-assign-hot-paths.yml b/.semgrep/rules/no-bracket-assign-hot-paths.yml new file mode 100644 index 0000000..e2e633d --- /dev/null +++ b/.semgrep/rules/no-bracket-assign-hot-paths.yml @@ -0,0 +1,22 @@ +rules: + - id: no-bracket-assign-on-literal-object-map + languages: [typescript] + severity: ERROR + message: > + Bracket-assign on a map created with `{}` allows prototype pollution when + the key comes from external data. Initialize the map with + `Object.create(null)` instead. + patterns: + - pattern-either: + - pattern: $MAP[$KEY] = $MAP[$KEY] ?? $INIT + - pattern: $MAP[$KEY] = $MAP[$KEY] || $INIT + - pattern: $MAP[$KEY] ??= $INIT + - pattern: | + if (!$MAP[$KEY]) $MAP[$KEY] = $INIT + - pattern-not-inside: | + const $MAP = Object.create(null) + ... + paths: + include: + - '/src/providers/*.ts' + - '/src/parser.ts' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ff98513 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1026 @@ +# Changelog + +## 0.9.15 - 2026-07-02 + +### Added (CLI) +- **`codeburn context`.** See what fills a session's context window, by role, + block type, and tool: an interactive terminal browser over Claude Code and + Codex sessions that separates the live window from compacted history and + anchors estimates to the exact API-reported context size. Also available as + a Context page in the browser dashboard and scriptable via + `codeburn context --json`. (#592) +- **Zed provider.** Zed's built-in agent is tracked: per-request token usage + with full cache fields, topped up to each thread's exact cumulative counter + and validated token-for-token against a real store. (#594, format documented + by @chatzinikolakisk in #480) +- **`codeburn audit`.** Per provider-and-model table of where every number + comes from: calls, input, output, reasoning, cache read/write, cost. (#578) +- **User price overrides** for any model via `codeburn price-override`. + (#390, #560, thanks @ozymandiashh) +- **open-design provider** for per-model usage tracking. (#559, thanks @ozymandiashh) +- **Browser dashboard**: fully mobile responsive (#582, thanks @ele-yufo; #589), + instant first paint with the local payload inlined, defaults to today, and + fast-fails offline paired devices. (#573) + +### Fixed (CLI) +- **Cursor tokens are Cursor's own numbers.** Input comes from the + per-conversation context meter instead of text-length guesses, credited once + per conversation on a stable anchor so daily history stays consistent across + re-scans; tools and shell commands come from the agent stream; Composer house + models price at Cursor's published rates; figures are flagged estimated where + they are. (#574, #575; closes #326) +- **Copilot Chat users no longer see $0.00**: VS Code core chatSessions + journals are read for token counts. (#555, #563, thanks @ozymandiashh) +- **Codex** sessions up to 4GB are parsed (streaming cap raised). (#569) +- **Devin** supports ATIF v1.7 (#570, thanks @tvcsantos) and reports friendly + GPT model names with effort tiers. (#585) +- **OpenCode** skills and subagents breakdowns are populated. (#557, thanks @KevNev19) +- **Pi** native skill loads classify as Skill, not Read. (#588, #590) +- **Cache read/write** scoped to the selected period in web and devices CLI. + (#583, #586, thanks @ozymandiashh) +- **Web** rejects invalid dashboard periods instead of exiting. (#554, thanks @ozymandiashh) +- **Pricing**: LiteLLM snapshot refreshed; MiniMax-M3 follows MiniMax's tiered + pricing (standard tier $0.30/$1.20 per M). Daily cache bumped to v10 so + history re-hydrates under the new Cursor accounting and pricing. + +### macOS menubar +- **Local/Combined usage toggle** backed by combined multi-device data in + menubar-json. (#566, #567, #568, thanks @ozymandiashh) +- **Update dialog** detects a codeburn CLI too old to install menubar updates + (pre-0.9.9) and shows the exact CLI upgrade command first. (#593) + +## 0.9.14 - 2026-06-22 + +### Added (CLI) +- **Browser dashboard.** `codeburn web` serves a local React dashboard in your + browser with the same task, model, tool, and project breakdowns as the TUI, + plus charts. Data is read locally and the server binds to localhost. (#531, #533) +- **Combine usage across your devices.** `codeburn share` exposes one device's + usage over your local network (PIN-paired), and `codeburn devices` shows + combined totals by machine. Devices can also be discovered and paired from the + browser dashboard. (#532, #534, #536) +- **New providers:** Grok Build (#521), ZCode (z.ai GLM-5.2) (#537), Hermes Agent + (#544), Kiro CLI sessions (#502), and zerostack (#519, thanks @kevinpauer). +- **`codeburn overview`.** Plain-text monthly usage summary that is + copy-pasteable, with `--no-color` and `--from`/`--to`. (#528, #535) +- **Codex credit usage.** Compute and surface Codex credit consumption alongside + dollar cost. (#408, #495, #510) +- **MCP server usage in exports.** `codeburn export` now includes per-MCP-server + usage in both JSON and CSV. (#496, #514) +- **JSON output for `optimize` and `yield`.** (#492, #500) +- **Claude-scoped agent-type breakdown** in the report. +- **OpenCode 1.1+ file-based JSON sessions.** (#523) +- **Copilot OTel cache-token parsing.** (#477, thanks @steelp02; #498) + +### Fixed (CLI) +- **Model names in reports.** Models priced through a sibling alias no longer + show their internal pricing key: ZCode/Hermes GLM-5.2 and Grok Build display + their real names, gpt-5.5 labels as GPT-5.5, and gpt-5.3-codex-spark is + distinguished from base GPT-5.3 Codex. (#548, #550, #539 thanks @ozymandiashh) +- **Hermes lowercase glm-5.2** prices the same as GLM-5.2. (#545, thanks @ozymandiashh) +- **Daily cache** purges cached today/future entries on hydration and is bumped + to v9 so newly supported providers backfill across history without a manual + cache clear. (#550) +- **Cursor** scans the requested window instead of a blind 250k ROWID cap. (#482, #512) +- **cursor-agent** ingests the workspace-less CLI transcript layout. (#542, thanks @ozymandiashh) +- **Claude Code project names** no longer collapse to a parent folder, and stray + `.git` directories no longer over-group projects. (#540, thanks @ozymandiashh) +- **Copilot** shell commands and skills/agents display correctly. (#527, thanks @jonjozwiak) +- **Codex** attributes MCP calls emitted as `event_msg`/`mcp_tool_call_end`. (#513) +- **Antigravity** reads the current `agy` CLI on-disk layout. (#541, thanks @ozymandiashh) +- Workflow/ultracode subagent usage is now counted. (#470) +- `--provider` is validated and the non-TTY report is deterministic. (#501) +- The dashboard plan banner is scoped to its own provider tab. (#524) +- Test isolation and environment-collision fixes. (#530, thanks @tvcsantos) + +### Added (macOS menubar) +- **Custom daily budget.** Set a custom daily budget amount; the alert respects + the display metric (Cost or Tokens). (#497, #505, #506) +- **Agent tabs** show every active agent for the selected range, ordered by + usage. (#549) +- Polished status-item menu and About tab (Star and Sponsor links). (#509) + +### Fixed (macOS menubar) +- **Keychain prompts.** Stop repeated keychain prompts on token refresh; read the + Claude keychain via the `security` CLI on silent refresh. (#490, #491) +- Restore the right-click status-item menu on macOS 27. (#472, thanks @theparlor) +- Support installer HTTP proxies. (#475, thanks @sleicht) +- Surface the CLI's stdout/stderr on a decode failure so a stray banner is + self-diagnosing. (#515, #547) +- Reduce repeated status parsing and guard against clock skew. (#486, thanks @vaibhavarora14; #499) +- The cost budget stays in USD and an empty custom budget is flagged. (#508) +- Drop the ` tok` suffix from the Total Tokens metric. (#511) + +## 0.9.12 - 2026-06-09 + +### Added (CLI) +- **MCP server.** `codeburn mcp` runs a stdio Model Context Protocol server + exposing `get_usage` and `get_savings` to AI agents, with project names + pseudonymized by default (opt-in reveal). (#429) +- **New providers:** Devin (#444), Antigravity IDE (#418), JetBrains — + IntelliJ/DataGrip via Copilot (#433), coder/mux (#438), and an opt-in + Vercel AI Gateway datasource via `AI_GATEWAY_API_KEY` (#432). +- **Automatic pricing gap-fill** from models.dev and OpenRouter for models + LiteLLM has not indexed yet (e.g. Claude Fable 5). (#457) +- **Proxy-aware cost attribution.** `codeburn proxy-path` marks a project as + routed through a subscription-backed proxy (e.g. Claude Code over GitHub + Copilot); the full API-rate cost is reported as subscription-covered so the + dashboard shows net out-of-pocket, leaving actual cost untouched. (#417, #459) +- **Local-model cost savings reports.** New `codeburn model-savings` command + maps a local-model name (e.g. `llama3.1:8b`) to a paid baseline (e.g. + `gpt-4o`) so the dashboard can report the counterfactual spend the same + tokens would have incurred on the baseline. The local call still costs + $0; the new `savingsUSD` field tracks the avoided spend separately from + `costUSD` everywhere a number is shown (dashboard, JSON/CSV exports, + menubar payload, macOS menubar, GNOME extension, daily cache rollups). + Historical savings are recomputed automatically when the baseline + mapping changes (config-hash invalidation on the daily cache). Daily + cache schema bumped to v8. (#421) +- CNY currency support. (#430) +- Contribution heatmap insight. (#437) + +### Added (CLI) +- **Hermes Agent provider.** Track token usage, cost, and tool breakdowns + for Hermes Agent sessions. Reads from `~/.hermes/state.db` and per-profile + databases. Supports session-level accounting with actual/estimated costs + from Hermes, falling back to CodeBurn's model pricing table. Supersedes + #386, closes #368. + +### Fixed (CLI) +- **Per-file parse isolation.** A single malformed session file no longer + aborts the run or empties the daily-history trend; parse failures are cached + so broken files are not re-read every run. (#441, #450, #453) +- **Codex fork dedupe** is content-addressed, fixing undercounting of + divergent events. (#458) +- **Model-name matching on the version boundary** so e.g. `claude-opus-4-6` + and `claude-opus-4-8` no longer collapse to the same tier. (#417) +- Vercel AI Gateway data now flows through aggregation instead of reporting $0; + Fable 5 and Mythos 5 price correctly ($10/$50). (#432, #466) +- Cache-read tokens are no longer double-counted in the models report. (#447) +- Critical-path fetches (pricing, currency) now time out so a stalled network + cannot wedge the CLI or menubar. (#445, #448) +- Cursor lookback is period-aligned with a 6-month floor. (#432) +- **Antigravity hook stale path repair.** `codeburn antigravity-hook install` + now installs the statusLine command through a persistent `codeburn` binary + from PATH and repairs older CodeBurn-owned hooks that pointed at stale local + build artifacts, preventing `agy` from auto-disabling capture after + `MODULE_NOT_FOUND` failures. + +### Added (macOS menubar) +- App icon. (#455) +- Configure `CLAUDE_CONFIG_DIRS` from Settings. (#434, #436) + +### Fixed (macOS menubar) +- **Refresh reliability.** The app awaits the CLI's exit via its termination + handler instead of blocking a queue thread, and caps concurrent CLI spawns — + fixing the menubar wedging on "Loading…" after a long idle. (#462) +- Recover from stuck loading when an in-flight refresh is orphaned across + sleep/wake. (#412) +- Use the correct currency enum in the Settings picker. (#435) + +## 0.9.11 - 2026-05-27 + +### Added (CLI) +- **MCP project profile advisor.** `codeburn optimize` now flags MCP servers + that are useful in one project but loaded into other projects where they are + never invoked, with a project-scoping prompt that preserves the hot workflow + while reducing cold-project schema overhead. Thanks @ozymandiashh. (#356) +- **MCP and skill reliability report.** `codeburn optimize` now detects MCP + servers and skills whose edit turns are disproportionately retry-heavy, + using turn-level MCP/Skill call evidence and a shared-turn token estimate so + one retry-heavy turn is not double-counted across multiple capabilities. + Thanks @ozymandiashh. (#357) +- **VSCodium storage discovery.** Copilot, Roo Code, and KiloCode now scan + VSCodium and VS Code Insiders storage roots in addition to VS Code, so + usage from VSCodium is included automatically. Thanks @ozymandiashh. (#233) +- **Tooling breakdowns in dashboard and menubar.** New panels showing core + tools, MCP servers, and shell command usage per session and across periods. +- **File-aware retry detection with typed ToolCall.** One-shot rate now tracks + which file was edited, so editing file A then file B after a shell step no + longer counts as a retry. Claude and Codex extract file paths from tool + inputs; Codex also parses `patch_apply_end` changes and JSON-encoded + `function_call` arguments. Providers without file path data fall back to + tool-name-based detection. + +### Fixed (CLI) +- **Codex 100% one-shot rate.** Codex function_call arguments are JSON strings, + not objects, and `patch_apply_end` stores file paths in `changes` object keys. + Both are now parsed correctly. +- **Claude toolSequence missing from session cache.** `apiCallToCachedCall` was + not forwarding the `toolSequence` field, so all cached Claude sessions lost + their tool ordering data. +- **Forge dedup key instability.** The fallback deduplication key used the raw + message array index, which shifts when messages are deleted between scans. + Now uses a composite of model name and token counts. Also fixed a variable + reference before its declaration that would crash at runtime when no tool + call ID was present. +- **Session cache rejected `subagentTypes` field.** The cache validator did not + recognize the `subagentTypes` array, causing entries with this field to be + silently dropped and reparsed on every run. +- **Conflicting date flags on `status` accepted silently.** Passing `--day` + with `--from`/`--to`, or `--days` with any other date flag, produced + undefined behavior. Now exits with a clear error message. + +### Changed (CLI) +- **OpenCode provider uses shared SQLite parser.** Delegates to + `sqlite-session-parser.ts` (same module KiloCode uses), reducing the + provider from 498 to 66 lines with no behavior change. + +### Added (macOS menubar) +- **Configurable menubar status period.** The menubar dropdown now lets you + choose which period (Today, 7 Days, Month, All Time) is shown in the status + bar. Persisted via UserDefaults. Thanks @ozymandiashh. (#302) + +### Fixed (macOS menubar) +- **Loading watchdog killed healthy CLI fetches.** The recovery loop ran every + 8 seconds with no backoff. Each attempt reset the generation counter, + discarding in-flight CLI responses (45s timeout) before they could finish. + Replaced with exponential backoff (8s to 60s, 6 attempts max) that skips + recovery when a fetch is already in flight. Shows an error overlay with a + Retry button after all attempts are exhausted. +- **Multi-day cache key mismatch.** `selectedDay` returned the earliest date + instead of nil when multiple days were selected, and + `startInteractiveSelectionRefresh` did not pass the day set to the cache key + constructor. Both now match `PayloadCacheKey` normalization rules. +- **Dead code cleanup.** Removed `RefreshBackoff.swift`, its test file, and a + broken test that called methods deleted in #393. + +## 0.9.10 - 2026-05-20 + +### Added (CLI) +- **Agent and subagent tracking coverage across providers.** Gemini sessions + now emit one provider call per assistant message with token usage instead of + one aggregate call per session, preserving per-message tools, bash commands, + timestamps, and nearest user prompts. Existing cached aggregate Gemini + entries are reparsed so the new per-message shape takes effect, and per-tool + counts may increase because repeated tools are now attributed to the specific + Gemini message that used them. Claude discovery also scans direct + project-level `subagents/*.jsonl` files, and Codex agent tool normalization + is covered by regression tests. Addresses #336. Thanks @ozymandiashh. (#340) +- **Optimize tab with retry tax, routing waste, and token display modes.** New + `codeburn optimize` surface in the dashboard and menubar, with daily budget + alerts and project drill-down. (#349) + +### Fixed (CLI) +- **OpenCode child sessions are attributed to their root session.** The + OpenCode parser now walks the unarchived `session.parent_id` subtree so + child and grandchild agent sessions contribute token and tool usage under + the discovered root session while still excluding child sessions from + top-level discovery to avoid double counting. Thanks @ozymandiashh. (#343) +- **OpenCode router sessions with missing usage are still reported.** + Some OpenCode router/provider combinations can persist assistant messages + with text or tool activity but zero token and cost fields. The OpenCode + parser now keeps those turns as zero-cost calls instead of dropping the + session entirely. Closes #341. Thanks @ozymandiashh. (#342) +- **OpenCode and Goose sessions on fresh installs.** Both providers returned + zero sessions on first run when their on-disk directories did not yet exist. + Discovery now treats missing directories as empty instead of erroring out. + (#347) +- **One-shot rate detection for all non-Claude providers.** Retry detection + now sees multi-message flows correctly across providers, not only Claude. + Follow-up to the v0.9.9 fix. (#355) +- **Cursor `#cursor-ws=` compound-path separator in `fingerprintFile`.** + `session-cache.ts` only handled the OpenCode `:` separator, so Cursor's + workspace-aware paths could fall back incorrectly. The fingerprint now + strips both `#` and `:` compound suffixes. Thanks @renerichter. (#358) +- **Per-provider multi-day data loss, division-by-zero, and decode + fragility.** Switching to Claude/Codex tab on 7-day/30-day/month periods + previously only showed today's categories, models, sessions, and tokens + because the cache shortcut only merged cost/calls. Per-provider periods now + always do a full parse. Also floors `maxCost` at 0.01 to avoid NaN bar + widths in ActivitySection and ModelsSection. (#362) +- **Kiro post-February 2026 storage discovery.** The Kiro provider now keeps + legacy `.chat` support while also discovering extensionless session index + files and nested execution files. Modern execution JSON is parsed for + identifiers, timestamps, model IDs, conversation text, structured tools, and + estimated token usage. Thanks @ozymandiashh. Closes #329. (#339) + +### Fixed (macOS menubar) +- **Per-provider refresh latency.** Switching provider tabs took ~24s on heavy + histories. Now ~2s via session cache safety and reuse. (#344) + +## 0.9.9 - 2026-05-15 + +### Added (CLI) +- **IBM Bob provider.** Discovers IBM Bob IDE task history, reuses the + Cline-family parser for token/cost records, extracts model tags and + workspace-based project names from session data. Closes #248. + +### Fixed (CLI) +- **One-shot rate detection for non-Claude providers.** Gemini and Mistral Vibe + now emit per-assistant-message calls grouped by user turn, so retry detection + sees multi-message `Edit -> Bash -> Edit` flows instead of counting each + message as an independent one-shot turn. Kiro and Goose record per-message + tool ordering via `toolSequence` for the same effect on aggregated sessions. + Vibe prefers `meta.json.stats.session_cost` over price-derived estimates when + available. Session cache bumped to v2. Closes #351. +- **Reduced Claude parser OOM risk.** Large Claude JSONL sessions retained + full entry objects (text, thinking blocks, tool results) in memory during + parsing, causing V8 heap exhaustion on heavy usage months. Entries are now + compacted immediately after JSON.parse, keeping only the fields needed for + cost/token aggregation. This is a mitigation - very heavy users may still + need the streaming parser refactor planned next. +- **Eager daily-cache hydration caused OOM on most CLI commands.** Eight + commands (report, today, month, export, optimize, compare, models, yield) + called `hydrateCache()` which parses a 365-day backfill, even though only + `status --format menubar-json` consumes the daily cache. Removed from all + paths that parse their own date ranges via `parseAllSessions`. +- **Session cache retained between status parses.** The `status --format json` + path parsed today and month ranges without clearing the in-process session + cache between them, keeping both result sets pinned. Cache is now cleared + after each period is consumed. +- **Claude 1-hour cache write pricing.** 1-hour cache writes are now priced + at 2x base input (previously used the 5-minute 1.25x rate for all writes). + Daily cache bumped to v6 so stale totals are recomputed. Closes #276. +- **OpenCode MCP usage now counted.** OpenCode stores MCP tool calls as + `_` names, which the shared MCP pipeline did not recognize. + The provider now normalizes these to the canonical `mcp____` + form so MCP breakdowns and `optimize` work correctly. Closes #308. +- **Antigravity Windows language-server discovery.** Antigravity detection now + supports Windows process discovery, `--extension_server_port`, + `--extension_server_csrf_token`, `--flag=value` syntax, and both wrapped and + unwrapped Connect-RPC response shapes. Closes #249. +- **Mangled project names in dashboard.** The By Project and Top Sessions + panels decoded slugs by splitting on `-`, which broke directory names + containing dashes or dots (e.g. `my-project` rendered as `my/project`). + Now uses the real project path instead. Closes #320. +- **Cursor undated bubble rows misattributed to Today.** Bubble rows without + a `createdAt` timestamp were defaulting to the current date, inflating + Today's spend. Now skipped at both the SQL and application level. +- **Node version guard.** Running on Node < 22.13.0 now prints a clear + upgrade message instead of crashing with a cryptic `node:sqlite` parse + error. Closes #319. + +### Fixed (macOS menubar) +- **All-provider refresh OOM.** Refreshing with provider set to "All" could + exhaust the V8 heap on accounts with heavy session history. +- **Tab refresh recovery.** Switching tabs during a refresh no longer leaves + the panel in a stale loading state. +- **Stale cache recovery.** The menubar now detects and discards a corrupt or + outdated on-disk cache instead of rendering zeroes until the next restart. +- **Refresh timer hardening.** The 30-second auto-refresh timer is now + cancelled on sleep/wake and restarted cleanly, preventing overlapping + refreshes after lid-open. +- **Version display.** The settings panel now shows the version without the + `v` prefix for consistency with `codeburn --version`. + +## 0.9.8 - 2026-05-10 + +### Added (CLI) +- **Cline provider support.** CodeBurn now reads Cline task usage from both + VS Code globalStorage (`saoudrizwan.claude-dev`) and Cline's + `~/.cline/data` task root. It reuses the existing Cline-family parser for + `ui_messages.json` usage entries, deduplicates migrated tasks by the newest + `ui_messages.json`, and exposes Cline in CLI provider filters, docs, and the + macOS menubar provider tabs. Closes #130. +- **Multiple Claude config directories.** Set `CLAUDE_CONFIG_DIRS` to an + OS-delimited list of paths (`:`-separated on POSIX, `;`-separated on + Windows) to scan more than one Claude data directory in a single run. + Sessions across every configured directory roll up into one project row + per project, so a user with `~/.claude-work` and `~/.claude-personal` + who works on the same repo from both accounts sees one combined row + rather than two split rows. `~` is expanded; missing or unreadable + directories in the list are skipped instead of aborting the scan; if + every listed entry is unreadable a one-line hint is written to stderr + so a misplaced delimiter does not silently produce zero rows. + Precedence: `CLAUDE_CONFIG_DIRS` > `CLAUDE_CONFIG_DIR` > `~/.claude`. + As part of this change `~` and `~/foo` are now also expanded in + `CLAUDE_CONFIG_DIR` (previously the value was passed through verbatim, + which only worked when the shell expanded `~` before exporting). + Closes #208. +- **`codeburn models` command.** Per-model breakdown across all providers, + one row per (provider, model), sorted by cost. Each row carries Input, + Output, Cache Write, Cache Read, Total, and Cost columns plus a Top Task + cell showing the dominant task category and its cost share (e.g. + `Coding (42%)`). Pass `--by-task` to explode each model into one row per + task type, with provider/model cells blanked on subsequent rows of the + same group and a horizontal divider between groups. Filters: `--period` + (default `30days`), `--from/--to`, `--provider`, `--task`, `--top`, + `--min-cost`, `--no-totals`. Output formats: `table` (Unicode box-drawn, + default), `markdown` (GitHub-flavored, copy-paste friendly), `json`, + `csv`. The table renderer auto-sizes every column to its content and + drops cache columns first, then input/output, then top-task when the + terminal is too narrow to fit the full set. Headers are cyan, totals row + is yellow, provider name is dim. Inspired by tokscale's per-model table + and ccusage's responsive cli-table3 layout, ported to plain Node with + no new runtime dependency. +- **Per-day one-shot data in `--format json`.** Each entry of `daily[]` now + carries `turns`, `editTurns`, `oneShotTurns`, and `oneShotRate` (0-100, + one decimal, `null` when no edit turns). Counts match the existing + period-level `activities[]` rollup so a consumer can sum across days and + reconcile. Closes #279. + +### Fixed (CLI) +- **Cursor sessions break down by project, not one row called "cursor".** + Cursor's chat history sat under a single dashboard row labeled `cursor` + because the provider had no way to attribute bubbles to a workspace. + The fix walks `~/Library/Application Support/Cursor/User/workspaceStorage/*` + for each workspace's `workspace.json` (folder URI) and + `composer.composerData` (the composer ids opened in that workspace), + then joins those composer ids against the global bubbles. Each + workspace becomes its own project row, sanitized into the same slug + shape Claude uses (e.g. `-Users-you-myproject`); composers that have + no workspace mapping (multi-root workspaces, "no folder open" + sessions, deleted workspaces) remain under a catch-all `cursor` row. + As part of this the cursor parser now derives `sessionId` from the + bubble row key (`bubbleId::`) instead of the + empty `conversationId` JSON field, which was always falling back to + `'unknown'`. Cursor result cache version bumped to 3 to invalidate + prior caches that recorded the old session id. Closes the per-project + half of #196. +- **Cursor cost shown for every model, not just Auto.** Cursor emits model + names in a `claude--` shape (`claude-4.6-sonnet`, + `claude-4.5-opus`, `claude-4.5-opus-high-thinking`, etc.) plus its own + `composer-1` house model, none of which match the canonical LiteLLM + pricing keys (`claude-sonnet-4-6`, `claude-opus-4-5`). The alias map in + `src/models.ts` filled some of these in v0.9.4 but missed the plain + no-suffix forms (`claude-4.5-opus`, `claude-4.5-sonnet`, + `claude-4.6-opus`), the haiku tier, the forward-looking 4.7 variant, + and `composer-1`. The dashboard rendered $0 for sessions that used any + unaliased model. Visible to users in #159 even after the v0.9.4 fix. + Every Cursor variant in `src/providers/cursor.ts:modelDisplayNames` + now has an alias and a regression test asserting non-zero pricing + resolution. Closes #159. +- **Activity classifier no longer mislabels feature work as debugging.** + Messages like "add error handling", "create an issue tracker", or + "implement the 404 page" used to land in the Debugging bucket because + the classifier checked the debug-keyword regex (which matches `error`, + `issue`, `404`) before the feature regex. Now the keyword that appears + earliest in the user message wins, so "add" beats "error", "create" + beats "issue", etc. A real bug report ("login is broken, traceback + below") still classifies as debugging because the debug word leads. + Fixes the activity-misattribution half of #196. + +### Changed (CLI) +- **`optimize` suggestions now declare their destination.** Every paste-style + fix carries an explicit destination — `claude-md` (permanent project rule), + `session-opener` (one-time paste at the start of a future session), + `prompt` (one-time ask in the current chat), or `shell-config` (append to + `~/.zshrc` / `~/.bashrc`). Output renders a clearly-labeled section header + per destination so users no longer accidentally bake one-time session + openers into their CLAUDE.md as permanent rules. Closes #277. + +## 0.9.7 - 2026-05-07 + +### Added (CLI) +- **MCP tool coverage detector.** New `optimize` finding flags MCP servers + whose tool inventory is largely unused. Inventory is observed from the + Claude `deferred_tools_delta` JSONL attachments (exact tool names per + session) instead of guessed at five tools per server. Token-savings + estimates are cache-aware: schema bytes pay full input price on the first + cache-creation turn of a session, then carry at the cache-read discount + on subsequent turns, capped per call so we never claim more overhead + than the call's own cache buckets could contain. Threshold: + >10 tools available, <20% coverage, observed in ≥2 sessions. Closes #2. +- **Session cost outlier detector.** New `optimize` finding flags sessions costing more than 2x their peer-session average within the same project. Ignores sub-$1 outliers to avoid noise. Requires at least 3 sessions per project for a baseline. +- **Context bloat detector.** New `optimize` finding flags sessions where + effective input/cache tokens are large and disproportionate to output. + Cache reads are discounted in the estimate to avoid overstating cheap cached + context. The report highlights top sessions by imbalance, notes sharp + growth from the previous project session (within a 7-day baseline window), + and suggests starting fresh with only the current goal, relevant files, + failing output, and constraints. Sessions flagged here are excluded from + the cost-outlier finding so the same session is not listed twice. +- **Worth-it score detector.** New `optimize` finding flags expensive sessions + with weak delivery signals: no edit turns, repeated retries, or edit work + that never landed in one shot, when no `git`/`gh` delivery command is + observed. Framed as a conservative review candidate, not proof of waste. + Sessions flagged here take priority and are excluded from both the + context-bloat and cost-outlier findings so the same session is not listed + more than once. +- **Per-model efficiency metrics.** JSON report includes edit turns, one-shot rate, retries per edit, and cost per edit for each model. +- **Custom date range export.** `codeburn export --from --to` exports a single custom period. +- **Live Claude quota bar.** Menubar shows real-time quota usage inside the agent tab strip with OAuth refresh gate. + +### Fixed (CLI) +- **Invalid `--format` silently accepted.** All commands now reject unknown format values with a clear error and exit 1 instead of silently falling back to the default. +- **Invalid `--period` silently accepted.** `getDateRange()` no longer falls back to "week" on unknown periods. All period-accepting commands reject invalid values. +- **`status` help text.** Description said "today + week + month" but only today and month were shown. Fixed to match actual output. +- **Windows Claude project paths.** Claude Code project rollups now prefer + the canonical `cwd` stored in session JSONL files instead of reconstructing + paths from lossy directory slugs, and group case/slash variants together. + Closes #217. +- **`all` period semantics unified between CLI and dashboard.** The dashboard treated `--period all` as all-time (epoch start) while the CLI bounded it to the last 6 months. Both now consistently mean "Last 6 months". Period helpers (`Period`, `PERIODS`, `PERIOD_LABELS`, `toPeriod`, `getDateRange`) consolidated into `cli-date.ts`. Use `--from` / `--to` for unbounded historical ranges. +- **Popover anchor, tab strip flicker, and stale-data refresh.** Batch of UI regressions from the menubar hardening round. +- **Validator hardenings.** Batch of edge-case fixes from the multi-agent bug hunt. +- **Command injection in yield.** `yield` now uses `execFileSync` instead of `execSync` to prevent shell injection via crafted branch names. +- **SHA-256 checksum verification.** Menubar installer verifies download integrity before replacing the running app. + +### Fixed (macOS menubar) +- **Stuck loading spinner.** The menubar ran `--optimize` on every 30-second background refresh. As sessions accumulated, optimize exceeded the 45-second timeout, and the loading overlay stayed forever with no fallback. Optimize is now stripped from all menubar fetches (use `codeburn optimize` in the CLI instead). On fetch failure with empty cache, the app retries without optimize so the spinner always clears. +- **Stale data after overnight sleep.** Cache keys used the period enum (`.today`) not a calendar date, so data from yesterday persisted after midnight. Cache now tracks the current date and clears itself on day rollover. Wake-from-sleep additionally clears all cached entries before fetching fresh data. +- **Refresh button appeared to do nothing.** Clicking refresh with stale cached data never showed the loading overlay because loading state only triggered on empty cache. Manual refresh and wake-from-sleep now explicitly request loading feedback. +- **Update button stuck spinning forever.** `performUpdate()` only reset `isUpdating` on failure. On success the installer kills and relaunches the app, but if the process survives (pkill fails silently), the button stayed on "Updating..." permanently. Now always resets on termination and clears the update badge on success. + +## 0.9.6 - 2026-05-03 + +### Added (CLI) +- **Goose provider.** New provider for Block's Goose AI coding assistant. +- **Antigravity provider.** New provider for Antigravity IDE sessions. +- **Antigravity model aliases.** gemini-3-pro, flash-image, flash-lite, and community-contributed Gemini model IDs. +- **GPT-5.5 display name** for Codex. +- **Deno support.** `deno dx` added as a run method. + +### Fixed (CLI) +- **Streaming dedup.** Claude Code streams each `message.id` multiple times (start, intermediate, stop). The old keep-first strategy lost tool_use blocks and understated output tokens by ~6.3%. Now keeps last occurrence content with first occurrence timestamp for correct date bucketing. +- **`$0.0000` display.** Near-zero costs showed four decimal places instead of `$0.00`. Fixes #205. +- **ANSI escape stripping.** Shell commands containing ANSI color codes now cleaned across all providers. +- **Antigravity dedup collision.** Fixed key collision in session dedup. Added Codex ChatGPT Plus token estimation. +- **Codex large session validation.** Reads full first line for session meta validation; caps read size and handles torn writes. +- **Codex fork dedup.** Deduplicates forked Codex sessions to avoid double-counting. +- **Windows dashboard hang.** Fixed `ExperimentalWarning` and dashboard freeze on Windows. +- **Hardcoded `$` in forecast.** Forecast comparison text now uses the configured currency symbol. + +### Fixed (macOS menubar) +- **Provider tabs showing $0.00 after idle.** CLI timeout increased from 20s to 45s for cold file-cache latency. Loading overlay now appears when the all-provider payload confirms a provider has spend but its dedicated data hasn't loaded yet. +- **Refresh button blocked by in-flight requests.** Manual refresh now bypasses the in-flight guard so users can always re-fetch. +- **Tab strip vs hero cost mismatch.** Tab strip prefers the provider-specific payload cost when available, staying in sync with the hero section. +- **Ghost status item on macOS Tahoe.** + +## 0.9.5 - 2026-05-01 + +### Added (CLI) +- **Homebrew.** `brew install codeburn` (originally via tap, now in homebrew-core). +- **GPT-5.3 and DeepSeek display names.** GPT-5.3, DeepSeek Coder, DeepSeek Coder Max, DeepSeek R1. + +### Fixed (macOS menubar) +- **Menubar refresh loop.** Was a single-fire Task that never repeated; now a proper while loop with 30s interval and `force: true`. +- **Loading overlay flicker.** Counter-based `isLoading` so concurrent fetches don't toggle the overlay. +- **Rapid tab switching race.** Previous fetch is cancelled when switching tabs; stale results are discarded via `Task.isCancelled`. +- **Tab strip vs hero cost desync.** Provider-specific and all-provider data now fetched in parallel so costs arrive from the same snapshot. +- **Stale menubar icon after wake.** `forceRefresh` now fetches today/all in parallel alongside the current selection. +- **Accent color propagation.** `ThemeState` is now `@Observable`; removes `.id()` view hierarchy teardown hack. +- **Currency flash on first switch.** Symbol and rate now apply atomically — no more wrong-symbol-with-old-rate flash. +- **Export UI freeze.** Uses `terminationHandler` instead of `waitUntilExit`; HHmmss in filename prevents overwrite on double-export. +- **CurrencyState concurrency.** Proper `@MainActor` isolation with `Sendable` conformance; `nonisolated` on pure static functions. +- **Streak count.** Iterates calendar days instead of sparse history entries so gaps correctly break streaks. +- **TrendBar chart flicker.** Stable date-based identity instead of UUID. + +## 0.9.4 - 2026-04-29 + +### Added (CLI) +- **OpenClaw provider.** Parses JSONL agent logs from `~/.openclaw/agents/` with legacy path support (`.clawdbot`, `.moltbot`, `.moldbot`). Token usage from assistant message `usage` blocks. +- **Roo Code provider.** Reads Cline-family `ui_messages.json` from VS Code `globalStorage/rooveterinaryinc.roo-cline/tasks/`. +- **KiloCode provider.** Reads Cline-family `ui_messages.json` from VS Code `globalStorage/kilocode.kilo-code/tasks/`. +- **Qwen CLI provider.** Parses JSONL sessions from `~/.qwen/projects//chats/`. +- **Droid provider.** Parses sessions from `~/.factory/projects/`. +- **Durable daily cache.** Cache hydration extracted into shared `ensureCacheHydrated()` called by all commands. Schema migration fills missing fields instead of nuking the cache. Old cache versions backed up before reset. Atomic file writes with fsync. +- **Copilot auto-model buckets.** Transcript inference uses auto-model naming for cleaner dashboard display. +- **Cursor model aliases.** Built-in aliases for Cursor proxy model names. + +### Fixed (CLI) +- **Gemini provider updated for JSONL format.** Supports Gemini CLI 0.39+ which switched from JSON to JSONL. +- **Duplicate `hydrateCache()` call in JSON reports.** Removed redundant cache hydration inside `runJsonReport()`. + +### Changed (CLI) +- Daily cache version bumped to v4 with backward-compatible migration (v2+ supported). +- LiteLLM pricing snapshot replaces hardcoded pricing for Qwen and new models. +- 16 providers now supported (was 10). + +### Added (macOS menubar) +- **OpenClaw, Roo Code, KiloCode, Qwen, Droid tabs.** Agent tab strip updated for all new providers. +- **Instant cached data display.** Shows cached data immediately instead of blocking on CLI refresh. + +### Fixed (macOS menubar) +- **Menubar stops updating after first load.** Background refresh was silently skipped by the cache TTL guard. Data loaded once, then froze. Fixes #179. +- **Menubar not dimming on inactive screens.** +- **Performance improvements.** Reduced unnecessary redraws and CLI invocations. + +### Added (macOS menubar) +- **Right-click context menu.** Right-click the status bar icon for "Check for Updates" and "Quit CodeBurn". +- **Version label in footer.** + +### Changed +- README restructured with honeycomb provider hero image, 2x2 screenshot grid, and complete inline reference. +- `bunx codeburn` added as alternative install option. + +## 0.9.3 - 2026-04-28 + +### Added (CLI) +- **Gemini CLI provider.** Parses `~/.gemini/tmp//chats/session-*.json` from Gemini CLI 0.38+. Uses real embedded token counts (input, output, cached, thoughts) with correct cached/fresh separation to avoid double-charging. Pricing for gemini-3.1-pro-preview, gemini-3-flash-preview, gemini-2.5-pro, gemini-2.5-flash. Tool normalization (ReadFile->Read, SearchText->Grep, Shell->Bash). Closes #166. +- **Kiro provider.** Parses `.chat` JSON session files with token estimation and auto-model naming (`kiro-auto`). Costed at Sonnet 4.5 rates via `BUILTIN_ALIASES`. +- **Copilot VS Code workspace transcripts.** Copilot now reads transcripts from VS Code's `workspaceStorage/*/GitHub.copilot-chat/transcripts/` in addition to the legacy `~/.copilot/session-state/` path. Tokens estimated from content length, model inferred from tool call ID prefixes. Fixes #161. +- **Auto-model naming.** Cursor, Copilot, and Kiro store transparent model names (`cursor-auto`, `copilot-auto`, `kiro-auto`) instead of guessing the underlying model. + +### Fixed (CLI) +- **Cursor provider dropped all data older than 35 days.** Hardcoded lookback silently excluded bubbles outside a 5-week window, making `--period all` return $0. Increased to 180 days. Fixes #159, fixes #163. +- **Cursor-agent subagent transcript discovery.** Scans `subagents/` subdirectories. + +### Added (macOS menubar) +- **Gemini, Kiro, Copilot, OMP tabs.** Agent tab strip now shows all detected providers. Cursor + Cursor Agent merged into a single Cursor tab. +- **Accent color picker.** 9 Apple-style system presets in the menubar header, persisted via UserDefaults. +- **Tab costs match selected period.** Provider tab costs now reflect the active period (Today/7 Days/30 Days/etc.) instead of always showing today. + +### Changed +- Daily cache version bumped to v4 (forces recompute with auto-model naming). +- Cursor cache versioned to invalidate stale model names. +- Case-insensitive provider key matching for tab cost lookups. + +## 0.9.2 - 2026-04-28 + +### Fixed +- **Cursor provider reported $0 on newer Cursor versions.** Cursor v3 stores zero token counts in bubbles. Now estimates tokens from text length when counts are zero. Fixes #159. +- **Cursor provider dropped rows with NULL `createdAt`.** The SQL filter silently excluded bubbles without a timestamp. Now includes them with a fallback timestamp. Fixes #163. +- **AgentKv entries with plain string content were skipped.** Not all agentKv content is a JSON array; plain strings are now counted toward usage. +- **Subagent transcripts were not discovered.** Transcripts inside `subagents/` subdirectories are now picked up by the cursor-agent provider. + +## 0.9.1 - 2026-04-25 + +### Added +- **`codeburn yield` command.** Correlates AI sessions with git history to categorize spend by outcome: **productive** (code shipped to main), **reverted** (commits later undone), or **abandoned** (work that never committed). Shows percentage breakdown so you know not just what you spent, but what happened to it. Accepts `--today`, `--week`, `--month` flags. + +## 0.9.0 - 2026-04-24 + +### Added (CLI) +- **Claude Max 5x plan preset.** `codeburn plan claude-max-5x` sets a $100/month budget for heavy Claude Code users. + +### Fixed (CLI) +- **Cursor provider failed on newer versions.** Cursor 0.50+ stores session data in `agentKv:blob:*` entries instead of `bubbleId:*`. Added fallback parser that extracts usage from the new format. +- **Cursor-agent provider missed Composer 2 sessions.** Composer 2 stores transcripts in `agent-transcripts//.jsonl` subdirectories instead of `.txt` files. Now scans both formats. Fixes #142. +- **Codex showed wrong model names.** Model info is now extracted from `turn_context` entries, showing exact names like "GPT-5.4" instead of generic "GPT-5". +- **Codex edit detection showed 0 edit turns.** Codex records file modifications as `patch_apply_end` events, not tool calls. Now tracks these events to enable one-shot rate and retry metrics. +- **Compare chart bar colors didn't match legend.** Non-winning model bars were grayed out despite the legend showing both colors. Bars now always display their assigned colors. + +### Fixed (macOS menubar) +- **Menubar icon invisible on macOS Tahoe (26.x).** Status item failed to render on macOS 26.4+ due to window server registration timing. Fixed by starting as regular app, activating, then switching to accessory mode after setup. Fixes #146. +- **High CPU usage (~14%).** Removed duplicate refresh timer, increased LaunchAgent interval to 30s, added 5-second debounce on wake events. + +## 0.8.9 - 2026-04-22 + +### Fixed +- **Menubar showed stale prices.** The "all providers" query used `end: now` while per-provider queries used `end: endOfDay`, causing sessions timestamped after the capture moment to be excluded from totals. Now uses `periodInfo.range` consistently across all queries. + +### Changed (macOS menubar) +- **Variable-width status item is now the default.** The menubar pill hugs the rendered text in both compact and default modes instead of reserving a fixed 130pt slot. + +## 0.8.8 - 2026-04-22 + +### Fixed (CLI) +- **OOM crash on large session files.** `scanJsonlFile` and `parseSessionFile` loaded entire files into memory via `readViaStream` (which defeated its own streaming by joining all lines back into one string). Switched both to the existing `readSessionLines` async generator that yields one line at a time. Contributed by @maucher (#132). + +### Added (macOS menubar) +- **Compact mode.** Opt-in tighter menubar display: no decimals, variable width that hugs the text. Enable with `defaults write CodeBurnMenubar CodeBurnMenubarCompact -bool true`. Default off. + +### Fixed (macOS menubar, shipped alongside via mac-v0.8.8) +- **Plan tab never loaded on Claude Code 2.1.x.** Keychain credential lookup filtered on `kSecAttrAccount == "default"`, but Claude Code writes the macOS login username. Removed the hardcoded allowlist; the service name is sufficient to scope the query. +- **Four keychain prompts on debug builds.** Collapsed two-phase keychain enumeration into a single `SecItemCopyMatching` call. +- **App Nap override not sticking.** The `beginActivity` token was immediately overridden by AppKit. Now disables `automaticTerminationSupport` and `suddenTermination` at the process level. + +## 0.8.7 - 2026-04-21 + +### Added +- **MiniMax-M2.7 and MiniMax-M2.7-highspeed pricing.** Added to `FALLBACK_PRICING` plus display names so MiniMax sessions show up with the right cost and readable labels when users route MiniMax through providers like OpenCode. Rates verified against MiniMax's live paygo pricing: base model $0.3/M input, $1.2/M output; highspeed $0.6/M input, $2.4/M output; cache read $0.06/M, cache write $0.375/M on both. +- **OMP provider (Oh My Pi).** Auto-discovers sessions at `~/.omp/agent/sessions/*.jsonl` and tracks them alongside Pi. Shares Pi's JSONL parser via a `providerName` parameter, so OMP rows keep their own `omp:` dedup prefix and never cross-dedupe with Pi on a shared `conversationId` namespace. `codeburn report --provider omp` filters to OMP only; the default combined view includes both. Contributed by @cgrossde (#59). +- **`codeburn model-alias` command.** Maps any provider-emitted model name to a canonical pricing name so cost rows no longer read `$0.00` when a proxy rewrites names. Aliases persist in `~/.config/codeburn/config.json` under `modelAliases`. Usage: `codeburn model-alias ` to set, `--list` to view, `--remove ` to clear. User aliases resolve before the built-in list. Contributed by @cgrossde (#59). +- **Built-in aliases for Anthropic-compatible proxy format.** `anthropic--claude-4.6-opus`, `anthropic--claude-4.6-sonnet`, `anthropic--claude-4.5-opus`, `anthropic--claude-4.5-sonnet`, and `anthropic--claude-4.5-haiku` now resolve to canonical Claude names and price correctly with no user configuration. `getCanonicalName` also strips `provider/` prefixes before alias resolution so double-wrapped forms like `anthropic/anthropic--claude-4.6-opus` work the same way. Contributed by @cgrossde (#59). + +### Fixed (CLI) +- **Prototype pollution in alias resolution.** A model literally named `__proto__` leaked `Object.prototype` through the `??` fallback chain in `resolveAlias`, which then crashed `canonical.startsWith` downstream. The resolver now uses `Object.hasOwn` checks for both user and built-in alias maps. Caught by the existing prototype-pollution test suite during the #59 merge. + +### Fixed (macOS menubar, shipped alongside via mac-v0.8.7) +- **Menubar label froze in the background and only refreshed when you clicked the icon.** Three independent causes fixed: + - `prefetchAll` on launch spawned four concurrent `codeburn` subprocesses that competed with the main refresh loop for disk and parser time. Removed; period tabs now fetch lazily on first click. + - `NSStatusItem` sometimes deferred the status bar paint for an accessory app, so `attributedTitle` updates hit memory but not the screen until the popover opened. Explicit `needsDisplay` + `display()` after each update forces the paint. + - **The real root cause:** macOS App Nap / Automatic Termination was suspending the app whenever the icon sat idle in the background, stretching the 15-second refresh Task's sleep indefinitely. Holding a `ProcessInfo.beginActivity` token for the life of the app opts out. Confirmed via `log show`: `_kLSApplicationWouldBeTerminatedByTALKey` now stays at 0. +- Subprocess `QualityOfService` lifted to `.userInitiated` so `codeburn` runs at terminal speed when spawned from the menubar. + +### Skipped +- 0.8.6 was never published to npm. The version was briefly planned and then skipped to align CLI and macOS menubar versioning at 0.8.7. + +### Notes +- If you are on 0.8.5 and do not use MiniMax, Oh My Pi, or a proxy that rewrites model names to the `anthropic--claude-X.Y-tier` format, CLI behavior is unchanged and you can safely stay on 0.8.5. +- macOS menubar users on `mac-v0.8.6` or earlier should update: the refresh loop only ticks reliably from `mac-v0.8.7` onward. The in-app update pill surfaces within 2 days, or quit and re-run `npx codeburn menubar` to pull immediately. + +## 0.8.5 - 2026-04-21 + +### Fixed +- **Stale Today totals after 0.8.2.** The persistent source cache introduced in 0.8.2 caused Today's cost to under-report and sometimes drop between polls during active Claude Code sessions. The cache keyed entries on `(mtime, size)` fingerprints that diverged from Claude's append-mostly JSONL model, producing empty or partial entries that were served on subsequent polls. Reverted the cache rewrite to the v0.8.1 full-reparse path for Claude sessions. Both the menubar and `codeburn status` now return consistent, monotonically-increasing Today totals. +- **Menubar and terminal status disagreed on Today.** A turn that straddled midnight (user message in one day, response in the next) was bucketed by user timestamp in one code path and by assistant timestamp in another, producing different Today values in the two surfaces. Both paths now count a turn on the day its first assistant call ran. +- **Kept from 0.8.2-0.8.4:** subscription plan tracking, pricing accuracy and CSV injection hardening, cursor-agent provider, menubar prefetch and timezone alignment. Only the cache rewrite and its follow-up patches were reverted. + +### Removed +- `--no-cache` flag on `report`, `today`, `month`, `status`, `export`, `optimize`, and `compare`. The flag existed to bypass the persistent source cache which no longer exists. If your scripts pass `--no-cache`, drop it; the parse runs fresh every time now. + +### Notes +- 0.8.2, 0.8.3, and 0.8.4 on npm contain the buggy cache. Upgrade with `npm i -g codeburn@latest` or `npm i -g codeburn@0.8.5`. +- This release uses a full reparse on every invocation, matching v0.8.1 behavior. On large corpora (5,000+ session files) expect 3 to 10 seconds per invocation. An incremental refresh design that preserves correctness is planned for a follow-up release. + +## 0.8.0 - 2026-04-19 + +### Added +- **`codeburn compare` command.** Side-by-side model comparison across any two models in your session data. Interactive model picker, period switching, and provider filtering. +- **Compare view in dashboard.** Press `c` in the TUI to enter compare mode. Arrow keys switch periods, `b` to return. +- **Performance metrics.** One-shot rate, retry rate, and self-correction detection per model. Self-corrections are detected by scanning JSONL transcripts for tool error followed by retry patterns. +- **Efficiency metrics.** Cost per call, cost per edit turn, output tokens per call, and cache hit rate. +- **Per-category one-shot rates.** Breaks down one-shot success by task category (Coding, Debugging, Feature Dev, etc.) for each model. +- **Working style comparison.** Delegation rate, planning rate (TaskCreate, TaskUpdate, TodoWrite), average tools per turn, and fast mode usage. +- **TUI auto-refresh enabled by default.** Dashboard now refreshes every 30 seconds out of the box. Pass `--refresh 0` to disable. Closes #107. +- **36 comparison tests.** Full coverage for metric computation, category breakdown, working style, self-correction scanning, and planning tool detection. Total suite: 274 tests. + +### Fixed +- **Planning rate showed ~0% in model comparison.** Only counted `EnterPlanMode` (rarely used) instead of all planning tools (TaskCreate, TaskUpdate, TodoWrite, EnterPlanMode, ExitPlanMode). Now detects planning at the turn level across all five tool types. +- **Menubar "All" tab showed stale data.** Three-layer caching (300s in-memory TTL, daily disk cache, 60s parser cache) prevented tab switches from showing fresh numbers. Cache TTL reduced from 300s to 30s, tab switches always fetch fresh data, background refresh interval reduced from 60s to 15s. + +## 0.7.4 - 2026-04-19 + +### Added +- **`codeburn report --from/--to`.** Filter sessions to an exact `YYYY-MM-DD` date range (local time). Either flag alone is valid: `--from` alone runs from the given date through end-of-today, `--to` alone runs from the earliest data through the given date. Inverted ranges or malformed dates exit with a clear error. In the TUI, pressing `1`-`5` still switches to the predefined periods. Credit: @lfl1337 (PR #80). +- **`avgCostPerSession` in reports.** JSON `projects[]` entries gain an `avgCostPerSession` field and `export -f csv` adds an `Avg/Session (USD)` column to `projects.csv`. Column order in `projects.csv` is now `Project, Cost, Avg/Session, Share, API Calls, Sessions` -- scripts parsing by column position should read by header instead. Credit: @lfl1337 (PR #80). +- **Menubar auto-update checker.** Background check every 2 days against GitHub Releases. When a newer menubar build is available, an "Update" pill appears in the popover header. One click downloads, replaces, and relaunches the app automatically. +- **Smart agent tab visibility.** The provider tab strip hides when fewer than two providers have spend, reducing clutter for single-tool users. + +### Fixed +- **Stale daily cache caused wrong menubar costs.** The daily cache never recomputed yesterday once written, so a mid-day CLI run would freeze partial cost data permanently. The "All" provider view relied on this cache, showing wildly incorrect numbers while per-provider tabs (which parse fresh) were correct. Yesterday is now evicted and recomputed on every run. +- **UTC date bucketing instead of local timezone.** Timestamps in session files are UTC ISO strings. Several code paths extracted the date via `.slice(0, 10)` (UTC date) while date range filtering used local-time boundaries. Turns between UTC midnight and local midnight were attributed to the wrong day -- the menubar showed lower today cost than the TUI. All date bucketing now uses local time consistently. +- **OpenCode SQLite ESM loader.** `node:sqlite` is now loaded correctly in ESM runtime. Credit: @aaronflorey (PR #104). +- **Menubar trend tooltip per-provider views.** Tooltip now shows the correct cost when a specific provider tab is selected. +- **Menubar (today, all) cache freshness.** The cache entry powering the menubar title and tab labels is now kept fresh independently of the selected period/provider. +- **Agent tab strip restored.** All detected providers are shown again after a regression hid them. +- **Plan pane button cleanup.** Removed the broken "Connect Claude" button that opened a useless terminal session. The Plan pane now shows only a "Retry" button. + +## 0.7.3 - 2026-04-18 + +### Changed +- **Dropped `better-sqlite3` in favor of Node's built-in `node:sqlite`.** Removes the deprecated `prebuild-install` transitive dependency that npm warned about on every install (issue #75, credit @primeminister). End-user install is now 40 packages down from 167 and shows zero deprecation notices. The experimental-SQLite warning Node 22/23 normally prints on module load is silenced for this specific warning; other warnings pass through unchanged. +- **Minimum Node version raised to 22.** Node 20 reached EOL on 2026-04-30; `node:sqlite` lives in 22+. Users on older Node get a clear upgrade message when a SQLite-backed provider (Cursor, OpenCode) is loaded. + + +## 0.7.2 - 2026-04-17 + +### Added +- **Native macOS menubar app.** Swift + SwiftUI app under `mac/` replaces the SwiftBar plugin. Agent tabs, Today/7/30/Month/All period switcher, Trend/Forecast/Pulse/Stats/Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, instant currency switching, live 60s refresh. +- **`codeburn menubar`.** One-command install: downloads the latest `.app` from GitHub Releases, strips Gatekeeper quarantine, drops it into `~/Applications`, and launches it. `--force` reinstalls in place. +- **`status --format menubar-json`.** Structured payload consumed by the native menubar app. Current-period totals, per-activity and per-model breakdowns, provider costs, optimize findings, and 365-day history. +- **Release workflow.** `.github/workflows/release-menubar.yml` builds a universal `.app` bundle and zip on `mac-v*` tag push. + +### Changed +- **`codeburn export -f csv`** now writes a folder of one-table-per-file CSVs (`summary`, `daily`, `activity`, `models`, `projects`, `sessions`, `tools`, `shell-commands`) plus a `README.txt` index. Each file opens cleanly as a single table in any spreadsheet. +- **`codeburn export -f json`** upgraded to schema `codeburn.export.v2` with currency metadata. + +### Fixed +- **`codeburn status` terminal Today/Month** now buckets by local date instead of UTC, so spend shows correctly during the window between local midnight and UTC midnight. +- **FX rate validation.** Frankfurter responses are checked to be finite and within `[0.0001, 1_000_000]` before they affect displayed costs. + +### Removed +- **SwiftBar plugin.** `src/menubar.ts`, `codeburn install-menubar`, `codeburn uninstall-menubar`, and `status --format menubar` are gone. The native Swift app is the single menubar surface. + +### Security +- **`codeburn export -o` guard.** Writes a `.codeburn-export` marker into every folder it creates and refuses to reuse non-marked directories or overwrite existing files, so a typo like `-o ~/.ssh/id_ed25519` cannot delete a sensitive file. + +## 0.7.1 - 2026-04-17 + +### Security +- **External security audit closed.** 1 HIGH, 2 MEDIUM, and 1 LOW finding fixed. Threat model: a compromised third-party AI CLI with write access to `~/.claude/projects/` dropping malicious session JSONL. +- **Prototype pollution blocked.** Breakdown maps in `parser.ts` (model, tool, MCP, bash) now use `Object.create(null)` so attacker-controlled keys like `__proto__` create own properties instead of mutating `Object.prototype`. Credit: @lfl1337 (PR #67). +- **Bounded session-file reads.** New `src/fs-utils.ts` helper caps reads at 128 MB and switches to stream-based parsing above 8 MB. Applied to 13 reachable read sites across parser, Codex, Copilot, Pi, context-budget, and optimize. Credit: @lfl1337 (PR #67). +- **Menubar label sanitizer.** SwiftBar directive-separator (`|`) and ANSI escape injection via crafted model or category names is now prevented by an allowlist (`[A-Za-z0-9 ._/-]`) plus 14-character truncation. Credit: @lfl1337 (PR #67). + +### Added +- **`--verbose` flag.** Global CLI option that prints warnings to stderr on skipped (oversize) or failed session-file reads. Silent by default. Credit: @lfl1337 (PR #67). +- **11 new security tests.** `tests/security/prototype-pollution.test.ts`, `tests/security/menubar-injection.test.ts`, `tests/fs-utils.test.ts`. Total suite: 209 tests. + +## 0.7.0 - 2026-04-16 + +### Added +- **`codeburn optimize` command.** Scans your sessions and your `~/.claude/` + setup for 11 common waste patterns and hands back exact copy-paste fixes. + Detection-only, never writes to user files. Supports `--period` (today, + week, 30days, month, all) and `--provider` (all, claude, codex, cursor). +- **Setup health grade (A-F).** Urgency-weighted rollup of all findings, with + impact scored against observed waste so the most expensive issues rank + first. High findings penalise more, medium less, low least. +- **Trend tracking.** Repeat runs classify each finding as new, improving, + or resolved against a 48-hour recent window, so fixed issues disappear + instead of lingering as noise. +- **11 detectors:** files Claude re-reads across sessions, low Read:Edit + ratio, projects missing `.claudeignore`, uncapped `BASH_MAX_OUTPUT_LENGTH`, + unused MCP servers, ghost agents, ghost skills, ghost slash commands, + bloated `CLAUDE.md` files (with `@-import` expansion counted), cache + creation overhead, and junk directory reads. +- **Copy-paste fixes.** Each finding comes with a ready-to-paste remedy: a + `CLAUDE.md` line, a `.claudeignore` template, an environment variable, or + a `mv` command to archive unused items. +- **In-TUI optimize view.** Press `o` in the dashboard when the status bar + shows a finding count, `b` to return. Same engine as the standalone + command, scoped to the current period and provider. +- **Per-project context budget column.** By Project panel now shows the + estimated per-session context overhead for each project (system prompt + + tools + `CLAUDE.md` + skills). +- **34 filesystem-mocking tests.** Tmpdir fixtures with `os.homedir` mocked + via `vi.mock` cover the detector surface end to end. Total suite: 198 + tests across 13 files. + +### Performance +- **mtime pre-filter + parallel reads + 60s result cache** cut a cold scan + from 12-17s to 6-7s on a 10k-session history. + +## 0.6.1 - 2026-04-16 + +### Added +- **JSON output on `report`, `today`, `month`.** `--format json` writes the + full dashboard (overview, daily, projects, models, activities, tools, MCP + servers, shell commands, top sessions) to stdout. Contributed by @mallek. +- **Project filters.** `--project ` and `--exclude ` on all + commands (`report`, `today`, `month`, `status`, `export`). Case-insensitive + substring match against project name and path. Both flags are repeatable. + Contributed by @mallek. +- **claude-opus-4-7 model mapping and pricing.** Displays as `Opus 4.7` with + the same Opus pricing as 4.6 and a 6x fast multiplier. Contributed by @mallek. +- **Unit tests for `filterProjectsByName`** covering include/exclude + semantics, case-insensitivity, path matching, and input immutability. + +### Fixed +- **Top Sessions panel truncating the calls column.** Row width filled the + full panel width without leaving room for the border and padding, so Ink + truncated the last 4 characters -- landing exactly on the calls column and + producing rows like `$182.58 ...` with no value. +- **SwiftBar custom plugin directory** now honoured when installing the + menubar widget. Reads the configured path from SwiftBar's defaults before + falling back to the standard location. Contributed by @Galeas. +- **`status --format menubar` per-provider today totals** now respect + `--project`/`--exclude`. The main period blocks already did, the provider + breakdown loop was the one spot that bypassed the filter. + +## 0.6.0 - 2026-04-16 + +### Added +- **GitHub Copilot provider.** Parses `~/.copilot/session-state/*/events.jsonl` + and tracks model changes via `session.model_change` events. Picks up six new + model prices (`gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `gpt-5-mini`, `o3`, + `o4-mini`). Contributed by @theodorosD. Note: Copilot logs only output + tokens, so cost rows will sit below actual API cost. +- **All Time period (key `5`).** Shows every recorded session since CodeBurn + started tracking. Daily Activity expands to every available day instead of + the fixed 14- or 31-day window. `codeburn report -p all` also works from + the CLI. Contributed by @lfl1337. +- **avg/s column in By Project.** Average cost per session next to the + existing total cost and session count. Surfaces projects where individual + sessions are expensive even if the total is modest. Contributed by @lfl1337. +- **Top Sessions panel.** Highlights the five most expensive sessions across + all projects with date, project, cost, and API call count. Helps spot + outliers that drag weekly or monthly totals. Contributed by @lfl1337. + +### Fixed +- `modelDisplayName` now matches longest key first so `gpt-4.1-mini` resolves + to `GPT-4.1 Mini` instead of `GPT-4.1`. +- `TopSessions` handles missing `firstTimestamp` gracefully with a + `----------` placeholder instead of rendering a stray whitespace row. + +## 0.5.0 - 2026-04-15 + +### Added +- **Cursor IDE support.** Reads token usage from Cursor's local SQLite + database. Shows activity classification, model breakdown, and a Languages + panel extracted from code blocks. Costs estimated using Sonnet pricing for + Auto mode (labeled clearly). Supports macOS, Linux, and Windows paths. +- SQLite adapter with lazy-loaded `better-sqlite3` (optional dependency). + Claude Code and Codex users are completely unaffected if it is not installed. +- File-based result cache for Cursor. First run parses the database (can take + up to a minute on very large databases); subsequent runs load from cache + in under 250ms. Cache auto-invalidates when Cursor modifies the database. +- Provider-specific dashboard layout. Cursor shows a Languages panel instead + of Core Tools, Shell Commands, and MCP Servers (Cursor does not log these). +- Provider color coding in the dashboard tab bar (Claude: orange, Codex: green, + Cursor: cyan). +- Broader activity classification patterns: file extensions, script references, + URLs, and HTTP status codes now trigger more accurate categories. +- Debounced period switching. Arrow keys wait 600ms before loading data so + quickly scrolling through periods skips intermediate loads. Number keys + still load immediately. +- Dynamic version reading from package.json (no more hardcoded version string). + +### Fixed +- CLI `--version` reported stale 0.4.1 since v0.4.2. Closes #38. + +## 0.4.4 - 2026-04-15 + +### Added +- Auto-refresh flag. `codeburn report --refresh 60` reloads data at a set + interval. Works on `report`, `today`, and `month` commands. Default off. +- Readable project names. Strips home directory prefix from encoded paths, + shows 3 path segments for more context. Home dir sessions display as "home". +- Responsive dashboard reflows on terminal resize via Ink's useWindowSize + hook. Width cap raised from 104 to 160 columns. Contributed by @AleBles. +- Total downloads and install size badges in README. + +### Fixed +- Agent/subagent session files were excluded, dropping ~46% of API calls. + Subagent sessions live in separate subagents/ directories with unique + message IDs and are now included. Closes #17. +- Codex cache hit always showed 100%. OpenAI includes cached tokens inside + input_tokens (unlike Anthropic). Normalized to prevent double-counting + in cost calculation and cache hit display. Closes #21. +- CSV formula injection. Cells starting with =, +, -, @ are prefixed with + an apostrophe before CSV escaping. Contributed by @serabi. +- Menubar "Open Full Report" and "Export CSV" actions broken for npm-installed + users. Invokes resolved binary directly instead of assuming ~/codeburn + checkout. Currency picker used nonexistent `config currency` subcommand. + Contributed by @MukundaKatta. Closes #32, #27. +- Activity panel moved from full-width to half-width row for better space + usage on wide terminals. + +## 0.4.1 - 2026-04-14 + +### Added +- Multi-currency support. `codeburn currency GBP` sets display currency (162 ISO + 4217 codes). Exchange rates from Frankfurter API (ECB data, 24h cache). Applies + to dashboard, status, menubar, and exports. Contributed by @BlairWelsh. +- 30-day rolling window period (`codeburn report -p 30days`, key `3` in TUI). + Distinct from calendar month. Contributed by @oysteinkrog. +- Menubar currency picker with 17 common currencies. + +### Fixed +- Export "30 Days" period now uses actual 30-day range instead of calendar month. + +## 0.4.0 - 2026-04-14 + +### Added +- Codex (OpenAI) support. Parses sessions from ~/.codex/sessions/ with full + token tracking, cost calculation, task classification, and tool breakdown. +- Provider plugin system. Adding a new provider (Pi, OpenCode, Amp) is a + single file in src/providers/. +- TUI provider toggle. Press p to cycle All / Claude / Codex. Auto-detects + which providers have session data on disk. Hidden when only one is present. +- --provider flag on all CLI commands: report, today, month, status, export. + Values: all (default), claude, codex. +- Codex tool normalization: exec_command -> Bash, read_file -> Read, + write_file/apply_diff/apply_patch -> Edit, spawn_agent -> Agent. +- Codex model pricing: gpt-5, gpt-5.3-codex, gpt-5.4, gpt-5.4-mini with + hardcoded fallbacks to prevent LiteLLM fuzzy matching mispricing. +- CODEX_HOME environment variable support for custom Codex data directories. +- Menubar per-provider cost breakdown when multiple providers have data. +- 1-minute in-memory cache with LRU eviction for instant provider switching. +- 10 new tests (Codex parser, provider registry, tool/model mapping). + +### Fixed +- Model name fuzzy matching: gpt-5.4-mini no longer mispriced as gpt-5 + (more specific prefixes checked first). + +## 0.3.1 - 2026-04-14 + +### Added +- Shell Commands breakdown panel showing which CLI binaries are used most + (git, npm, docker, etc.). Parses compound commands (&&, ;, |) and handles + quoted strings. Contributed by @rafaelcalleja. + +### Changed +- Activity panel is now full-width so the 1-shot column renders cleanly + on all terminal sizes. + +### Fixed +- Crash on unreadable session files (ENOENT). Skips gracefully instead. + +## 0.3.0 - 2026-04-14 + +### Added +- One-shot success rate per activity category. Detects edit/test/fix retry + cycles (Edit -> Bash -> Edit) within each turn. Shows 1-shot percentage + in the By Activity panel for categories that involve code edits. + +### Fixed +- Turn grouping: tool-result entries (type "user" with no text) no longer + split turns. Previously inflated Conversation category by 3-5x at the + expense of Coding, Debugging, and other edit-heavy categories. + +## 0.2.0 - 2026-04-14 + +### Added +- Claude Desktop (code tab) session support. Scans local-agent-mode-sessions + in addition to ~/.claude/projects/. Same JSONL format, deduplication across + both sources. macOS, Windows, and Linux paths. +- CLAUDE_CONFIG_DIR environment variable support. Falls back to ~/.claude if + not set. + +### Fixed +- npm package trimmed from 1.1MB to 41KB by adding files field (ships dist/ + only). +- Image URLs switched to jsDelivr CDN for npm readme rendering. + +## 0.1.1 - 2026-04-13 + +### Fixed +- Readme image URLs for npm rendering. + +## 0.1.0 - 2026-04-13 + +### Added +- Interactive TUI dashboard built with Ink (React for terminals). +- 13-category task classifier (coding, debugging, exploration, brainstorming, + etc.) using tool usage patterns and keyword matching. No LLM calls. +- Breakdowns by daily activity, project, model, task type, core tools, and + MCP servers. +- Gradient bar charts (blue to amber to orange) inspired by btop. +- Responsive layout: side-by-side panels at 90+ cols, stacked below. +- Keyboard navigation: arrow keys switch Today/7 Days/Month, q to quit. +- Column headers on all panels. +- Bottom status bar with key hints (interactive mode only). +- Per-panel accent border colors with rounded corners. +- SwiftBar/xbar menu bar widget with flame icon, activity breakdown, model + costs, and token stats. Refreshes every 5 minutes. +- CSV and JSON export with Today, 7 Days, and 30 Days periods. +- LiteLLM pricing integration with 24h cache and hardcoded fallback. + Supports input, output, cache write, cache read, web search, and fast + mode multiplier. +- Message deduplication by API message ID across all session files. +- Date-range filtering per entry (not per session) to prevent session bleed. +- Compact status command with terminal, menubar, and JSON output formats. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..aebe0f2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,127 @@ +# Contributing to CodeBurn + +Thanks for your interest. This document covers what you need to know to send a working pull request. + +## Prerequisites + +- Node.js 22.20 or newer (`engines.node` in `package.json`). +- npm 10 or newer (ships with recent Node). +- macOS or Linux for full provider coverage. Windows works for most providers but Cursor / Antigravity development is easier on macOS. +- Optional: Swift 6 toolchain if you are touching the macOS menubar (`mac/`). +- Optional: GNOME 45 or newer if you are touching the GNOME extension (`gnome/`). + +## Setup + +```bash +git clone https://github.com/getagentseal/codeburn +cd codeburn +npm install +``` + +There is no separate build step required to run the dev CLI. `npm run dev` runs `tsx` against `src/cli.ts` directly. + +## Common Commands + +| Command | What it does | +|---|---| +| `npm test` | Runs the vitest suite (42 test files, 568 tests). | +| `npm run dev -- status` | Runs the CLI in dev mode against your real data. | +| `npm run build` | Bundles the litellm pricing snapshot, then runs `tsup` to produce `dist/cli.js`. | +| `npm run bundle-litellm` | Refreshes `src/data/litellm-snapshot.json` from the upstream litellm repo. | + +To test a specific suite, pass a path: + +```bash +npm test -- tests/providers/codex.test.ts +``` + +## What to Read Before Editing + +- `docs/architecture.md` for the high-level codebase map. +- `docs/providers/.md` for the provider you intend to change. +- `RELEASING.md` if you are touching version bumps or the release pipeline. +- `SECURITY.md` for the disclosure policy. + +## Project Layout + +``` +src/ CLI, parsers, optimize detectors, cache layers +src/providers/ One file per AI tool integration +src/data/ Bundled litellm pricing snapshot +tests/ vitest specs +mac/ Swift menubar app +gnome/ GNOME shell extension +scripts/ Build helpers (litellm bundle) +``` + +See `docs/architecture.md` for a fuller map. + +## Coding Conventions + +- TypeScript strict mode is on. Do not introduce `any` without a comment explaining why. +- Avoid bracket-assign (`obj[key] = value`) on parsed user input in hot paths inside `src/providers/` and `src/parser.ts`. There is a Semgrep rule (`.semgrep/rules/no-bracket-assign-hot-paths.yml`) enforced in CI that will fail your PR if you do. Use a `Map` or an explicit allowlist instead. +- Provider parsers must be deterministic given the same input. If you read the system clock or the filesystem outside the documented session paths, add a fixture-based test. +- New providers go through `src/providers/index.ts`. Lazy-load anything that pulls a heavy native dependency (sqlite, protobuf) so users without that provider are not slowed down. + +## Tests + +- Each new provider should ship with a fixture-based test under `tests/providers/`. The five providers without test files today (claude, gemini, goose, qwen, antigravity) are a known gap; new code should not add to that list. +- Each new optimize detector in `src/optimize.ts` needs at least one positive and one negative case in `tests/optimize.test.ts`. +- If your change affects the menubar JSON contract, update `tests/menubar-json.test.ts`. + +## Commit Message Format + +Short imperative subject, optional body. Examples from `git log`: + +``` +Enhance GNOME extension with scrollable UI, dark mode, charts, and performance fixes +Add table column headers, oneshot placeholder, currency picker dropdown +``` + +### No AI Co-Author Trailers + +The `.github/workflows/block-claude-coauthor.yml` workflow rejects any PR whose commits contain a `Co-authored-by: ... claude ...` or `... anthropic ...` trailer. You may use AI tools to help write code, but strip the co-author line before pushing. + +If a flagged PR rejects on this check, the workflow prints the exact rebase command to fix it. + +## Before You Start + +**Comment on the issue first.** Before writing code for a feature or new provider, leave a comment on the relevant issue saying what you plan to do. Wait for a maintainer to confirm the approach. Unsolicited PRs that duplicate work already in progress or take an incompatible approach will be closed. + +**One PR at a time.** We will not review a second PR from you until the first is merged or closed. This keeps the review queue manageable and ensures each contribution gets proper attention. + +## Adding a New Provider + +New providers have the highest bar because broken parsing silently produces wrong data for users. Before opening a PR: + +1. **Install the tool and use it.** Generate real sessions by actually coding with the provider. We do this ourselves for every provider we ship. +2. **Test against real data.** Run `npm run dev -- today` and `npm run dev -- models` with your real sessions and confirm the output looks correct — costs are non-zero, model names resolve, session counts match what you see in the tool. +3. **Include proof in the PR.** Attach a screenshot or terminal output showing codeburn correctly parsing your real sessions. PRs for new providers without evidence of local testing will not be reviewed. +4. **Do not rely on AI-generated guesses about storage paths or schemas.** Tools change their data formats between versions. The only way to know the current schema is to install the tool and inspect the actual files on disk. + +PRs that add a provider based solely on online documentation or AI-generated code, without evidence of testing against real data, will be closed. + +## Pull Requests + +1. Fork or branch from `main`. +2. Push your branch and open a PR against `main`. +3. The `firstlook` workflow will auto-assess the PR. The `semgrep` CI workflow runs the hot-path bracket-assign guard. The `block-claude-coauthor` workflow scans commits. +4. A maintainer reviews. For non-trivial changes, expect requests for tests. +5. Squash-merge is the default. Keep the PR title short and accurate; the description carries the context. + +## Reporting Bugs + +File issues at https://github.com/getagentseal/codeburn/issues. Useful details: + +- Output of `codeburn --version`. +- Provider involved and rough size of your session history (`du -sh ~/.codex/sessions`, etc.). +- Output of the failing command with `DEBUG=1` if applicable. +- For parsing bugs: a redacted JSONL or SQLite snippet that reproduces the issue. + +## Security Issues + +Do not file security issues in the public tracker. See `SECURITY.md` for the disclosure process. + +## License + +CodeBurn is MIT-licensed. By contributing, you agree your contributions are licensed under the same terms. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3999da5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AgentSeal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b16391f --- /dev/null +++ b/README.md @@ -0,0 +1,686 @@ +

+ Claude for Open Source Recipient +

+ +

+ CodeBurn +

+ +

See where your AI spend goes.

+ +

+ npm version + total downloads + license + node version + Discord + Follow @_codeburn on X + Sponsor +

+ +**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 32 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.** + +You pay for Claude, Codex, Cursor, and a stack of other AI tools. The bill tells you the total. It never tells you that half of it went to conversation instead of code, or that an expensive model burned your budget on work a cheaper one would have one-shot. + +CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **32 AI tools**. + +Everything runs locally. No wrapper, no proxy, no API keys, nothing leaves your machine. Pricing comes from [LiteLLM](https://github.com/BerriAI/litellm), refreshed daily. + +

+ CodeBurn TUI dashboard +

+

A week across every tool you use, in one screen.

+ +

+ Quick start · + Find waste · + Apply fixes · + Guard · + Compare models · + Track what shipped · + MCP · + Supported tools · + Commands · + Features · + How it reads data +

+ +## Quick start + +**Run it instantly**, no install needed: + +```bash +npx codeburn +``` + +That opens the interactive dashboard (last 7 days by default). Arrow keys switch periods, `q` quits. That is the 30-second version. You now know where your AI budget goes. + +**Install it** for a permanent `codeburn` command: + +```bash +npm install -g codeburn +``` + +Also runs via `bunx codeburn` or `dx codeburn`, or `brew install codeburn` on macOS. + +**Menu bar app** for macOS, with your spend always in the menu bar: + +```bash +codeburn menubar +``` + +On Linux, a GNOME Shell extension gives the same panel view; see [Linux (GNOME)](#linux-gnome). + +Requires **Node.js 22.13+** and at least one supported tool with session data on disk. For Cursor and OpenCode, `better-sqlite3` installs automatically. + +## Your month at a glance + +```bash +codeburn overview # this month, clean tables +codeburn overview --no-color # plain text, ready to paste +codeburn overview --from 2026-06-01 --to 2026-06-15 # any date range +codeburn overview -p all # all time +codeburn overview --provider claude # one tool only +``` + +`codeburn overview` prints a copy-pasteable summary of where your AI spend went: totals (cost, tokens, cache hit), a breakdown by tool and by top model, your highest-value days, top projects, a per-day table, and activity and tool usage. Pipe it anywhere (into `pbcopy`, a PR, Slack, or a tweet); color drops automatically when the output is not a terminal, or pass `--no-color`. + +```text +CodeBurn June 2026 + +Totals + Cost $2,795.10 + Tokens 3.49B in 23.9M / out 20.2M / cache-w 72.5M / cache-r 3.38B + Calls 14,755 sessions 753 + Cache hit 99.3% + +By tool +┌──────────┬───────────┬────────┬───────┐ +│ Tool │ Cost │ Tokens │ Share │ +├──────────┼───────────┼────────┼───────┤ +│ claude │ $2,662.37 │ 3.34B │ 95% │ +│ codex │ $119.12 │ 128.1M │ 4% │ +└──────────┴───────────┴────────┴───────┘ + +(plus Top models, Highest-value days, Top projects, a per-day table, By activity, and Tools) +``` + +## Find and fix waste + +```bash +codeburn optimize # scan the last 30 days +codeburn optimize -p today # today only +codeburn optimize -p week # last 7 days +codeburn optimize --provider claude # restrict to one provider +codeburn optimize --format json # setup health + findings as JSON +``` + +`codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns: + +- Files Claude re-reads across sessions (same content, same context, over and over) +- Low Read:Edit ratio (editing without reading leads to retries and wasted tokens) +- Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise) +- Unused MCP servers still paying their tool-schema overhead every session +- Ghost agents, skills, and slash commands defined in `~/.claude/` but never invoked +- Bloated `CLAUDE.md` files (with `@-import` expansion counted) +- Cache creation overhead and junk directory reads +- Context-heavy sessions where effective input/cache tokens swamp output +- Possibly low-worth expensive sessions with no edit turns or repeated retries + when no `git`/`gh` delivery command is observed + +

+ CodeBurn optimize +

+ +Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A to F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window. + +You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return. + +## Apply fixes, undo anytime + +```bash +codeburn optimize --apply # review and apply fixes interactively +codeburn optimize --apply --dry-run # print the plan, change nothing +codeburn optimize --apply --yes # apply every appliable fix without prompting +codeburn act list # every change CodeBurn has made +codeburn act undo --last # roll the most recent change back +codeburn act report # realized vs estimated savings +``` + +`codeburn optimize` finds the waste; `--apply` fixes the config-class findings for you: settings values, environment variables, archiving unused agents and skills. Every change is backed up and journaled before it lands. `codeburn act list` shows the history and `codeburn act undo ` restores the original files (it refuses if the files changed since being applied, unless you pass `--force`). + +The loop closes on honesty: once an applied fix is at least 3 days old, `codeburn act report` compares its estimated savings against what your sessions actually did, and later `codeburn optimize` runs show that realized figure in the header. Estimates get checked against reality, not just claimed. + +## Guard your budget + +```bash +codeburn guard install # hooks into this project's .claude/settings.json +codeburn guard install --global # or into ~/.claude/settings.json +codeburn guard status # caps, install locations, flagged projects +codeburn guard uninstall # removes cleanly, leaves your own hooks alone +``` + +Guard installs opt-in hooks into Claude Code that watch session cost while you work: + +- **Soft cap** (default $5): a one-time in-session warning when a session passes it. +- **Hard cap** (default $15): stops the session; `codeburn guard allow` lifts it for that session only. +- **Checkpoint** (default $3): if a session ends past this with no edits and no commits, a nudge suggests starting fresh with a named deliverable. +- **Session openers**: projects where optimize found waste get a one-line flag at session start. + +Caps are edited in `~/.config/codeburn/guard.json` (set a value to `null` to disable it). Add `--statusline` to show session cost in the Claude Code status line. Installs go through the same journal as everything else, so `codeburn act undo` removes them too. Hooks fail open: a broken guard never blocks a session. + +## Compare models + +```bash +codeburn compare # interactive model picker (default: last 6 months) +codeburn compare -p week # last 7 days +codeburn compare -p today # today only +codeburn compare --provider claude # Claude Code sessions only +``` + +Which model is actually better for *your* work? Press `c` in the dashboard, or run `codeburn compare`. Arrow keys switch periods, `b` to return. + +

+ CodeBurn compare +

+ +| Section | Metric | What it measures | +|---------|--------|-----------------| +| Performance | One-shot rate | Edits that succeed without retries | +| Performance | Retry rate | Average retries per edit turn | +| Performance | Self-correction | Turns where the model corrected its own mistake | +| Efficiency | Cost per call | Average cost per API call | +| Efficiency | Cost per edit | Average cost per edit turn | +| Efficiency | Output tokens per call | Average output tokens per call | +| Efficiency | Cache hit rate | Proportion of input from cache | + +Also compares per-category one-shot rates, delegation rate, planning rate, average tools per turn, and fast mode usage. + +## Track what shipped + +```bash +codeburn yield # last 7 days (default) +codeburn yield -p today # today only +codeburn yield -p 30days # last 30 days +codeburn yield -p month # this calendar month +codeburn yield --format json # productive/reverted/abandoned spend as JSON +``` + +Did the spend actually ship? `codeburn yield` correlates AI sessions with git commits by timestamp: + +| Category | Meaning | +|----------|---------| +| Productive | Commits from this session landed in main | +| Reverted | Commits were later reverted | +| Abandoned | No commits near session, or commits never merged | + +Requires a git repository. Run from your project directory. + +## Browser dashboard + +```bash +codeburn web # opens http://localhost:4747 in your browser +codeburn web -p 30days # start on a different period +codeburn web --port 8080 # pick a port (falls back to a free one if taken) +codeburn web --no-open # start the server without opening a browser +``` + +A local web dashboard with the same task, model, tool, and project breakdowns as the TUI, rendered with charts. Everything is read from disk on your machine and the server binds to localhost; nothing is uploaded. + +### Combine usage across your devices + +See one total across your laptop, desktop, and work machine on the same network. On each other device, share its usage: + +```bash +codeburn share --pair # opens a pairing window and prints a PIN +``` + +Then add it once from your main device (the PIN authorizes the pairing): + +```bash +codeburn devices add # find nearby devices and pair, or: add --pin +codeburn devices # combined totals by machine +codeburn devices rm # forget a device +``` + +Pairing is PIN-authorized and stays on your local network. You can also discover and pair devices straight from the browser dashboard. + +## Menu bar + +```bash +codeburn menubar +``` + +One command: downloads the latest `.app`, installs into `~/Applications`, and launches it. Re-run with `--force` to reinstall. Native Swift and SwiftUI app lives in `mac/` (see `mac/README.md` for build details). + +

+ CodeBurn macOS menubar +

+ +The menubar icon shows the spend period selected in Settings (Today by default; Week, Month, and 6 Months are also available). Non-today periods add a short suffix such as `$42 / mo` so the menu bar value stays clear. Click to open a popover with agent tabs, period switcher (Today, 7 Days, 30 Days, Month, All), Trend, Forecast, Pulse, Stats, and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes every 30 seconds. + +You can also set the menubar status period from Terminal: + +```bash +defaults write org.agentseal.codeburn-menubar CodeBurnMenubarPeriod -string month +``` + +Allowed values are `today`, `week`, `month`, and `sixMonths`. Relaunch the app to apply external defaults changes. + +**Compact mode** shrinks the menubar item to fit the text, dropping decimals (e.g. `$110` instead of `$110.20`): + +```bash +defaults write org.agentseal.codeburn-menubar CodeBurnMenubarCompact -bool true +``` + +Relaunch the app to apply. To revert: `defaults delete org.agentseal.codeburn-menubar CodeBurnMenubarCompact`. + +**Refresh cadence** is set in Settings under Usage Refresh. Auto (the default) refreshes every 30 seconds on AC power and backs off on battery, in Low Power Mode, and while the display sleeps; fixed 1, 5, or 15 minute cadences and a Manual mode (refresh only when you open the popover or click Refresh Now) are also available. From Terminal: + +```bash +defaults write org.agentseal.codeburn-menubar CodeBurnMenubarRefreshSeconds -int 300 +``` + +Seconds between refreshes: `60`, `300`, or `900`; `0` is Manual and `-1` is Auto. Takes effect on the next refresh tick, no relaunch needed. + +### Linux (GNOME) + +Linux gets the same ambient view through a GNOME Shell extension (GNOME 45+): spend in the top panel, period switcher, compact mode, and daily budget alerts. It lives in [`gnome/`](gnome/): + +```bash +git clone https://github.com/getagentseal/codeburn && cd codeburn/gnome +./install.sh +gnome-extensions enable codeburn@codeburn.dev +``` + +See [gnome/README.md](gnome/README.md) for settings and development notes. On Windows, `codeburn web` is the always-on view for now. + +## CodeBurn in your agent (MCP) + +```bash +claude mcp add codeburn -- npx -y codeburn mcp +``` + +`codeburn mcp` runs a local MCP server over stdio, so Claude Code, Cursor, or any MCP client can ask "where did my tokens go this week?" or "how do I spend less?" mid-conversation. It exposes two tools: + +| Tool | What it returns | +|------|-----------------| +| `get_usage` | Spend and usage with breakdowns by tool, model, project, and task (fast) | +| `get_savings` | Cost reductions: waste findings, retry tax, routing waste (slower, deeper analysis) | + +Everything is read from local disk, same as the CLI. Project names are pseudonymized by default; the agent only sees real names if it asks with `include_project_names: true`. For other MCP clients, configure a stdio server with command `npx` and args `-y codeburn mcp`. + +## Supported tools + +CodeBurn auto-detects which AI tools you use. Each logo links to its provider doc. + +

+ Claude Code & Claude Desktop + Cline + Codex (OpenAI) + Cursor + cursor-agent + Devin + Forge + Gemini CLI + Mistral Vibe + GitHub Copilot + IBM Bob + Kiro + OpenCode + OpenClaw + Pi + OMP (Oh My Pi) + Droid + Roo Code + KiloCode + Qwen + Kimi Code CLI + LingTai TUI + Goose + Antigravity + Crush + Warp + Mux (coder) + Vercel AI Gateway + Zerostack + Grok Build + ZCode + Zed + Hermes Agent +

+ +If multiple providers have session data on disk, press `p` in the dashboard to toggle between them. + +Each provider doc lists the exact data location, storage format, and known quirks. Linux and Windows paths are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues). + +The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `overview`, `status`, `export`, `web`, `optimize`, `compare`, `yield`. + +Adding a new provider is a single file. See `src/providers/codex.ts` for an example. + +## Commands + +
+All commands and keyboard shortcuts + +Run `codeburn` for the dashboard, or use a subcommand below. Most commands also accept `--provider`, `--project` / `--exclude`, and a period flag (`-p today|week|30days|month|all`). + +**Dashboard & reports** + +| Command | What it does | +|---------|--------------| +| `codeburn` | Interactive dashboard, last 7 days (the default view) | +| `codeburn today` | Today's usage | +| `codeburn month` | This calendar month's usage | +| `codeburn overview` | Plain-text monthly summary, copy-pasteable (`--no-color`, `--from`/`--to`) | +| `codeburn report -p 30days` | Rolling 30-day window | +| `codeburn report -p all` | Every recorded session | +| `codeburn report --from 2026-04-01 --to 2026-04-10` | An exact date range | +| `codeburn report --format json` | Full dashboard data as JSON, printed to stdout | +| `codeburn report --refresh 60` | Auto-refresh every 60s (default 30s; `--refresh 0` disables) | + +**Status & export** + +| Command | What it does | +|---------|--------------| +| `codeburn status` | Compact one-liner: today + month totals | +| `codeburn status --format json` | The same totals as JSON | +| `codeburn export` | CSV covering today, 7 days, and 30 days | +| `codeburn export -f json` | Export as JSON instead of CSV | + +**Web & devices** + +| Command | What it does | +|---------|--------------| +| `codeburn web` | Local browser dashboard with charts (http://localhost:4747) | +| `codeburn share --pair` | Share this device's usage to your other devices (PIN pairing) | +| `codeburn devices add` | Find and pair a nearby device | +| `codeburn devices` | Combined usage totals across your paired devices | + +**Analysis** + +| Command | What it does | +|---------|--------------| +| `codeburn audit` | Per provider-model token source table: where every number comes from | +| `codeburn context` | What fills a session's context window: interactive browser (Claude Code and Codex) | +| `codeburn context --json` | The same context tree, scriptable | +| `codeburn optimize` | Scan for waste and print copy-paste fixes (last 30 days) | +| `codeburn optimize -p week` | Scope the waste scan to the last 7 days | +| `codeburn compare` | Side-by-side model comparison | +| `codeburn yield` | Productive vs reverted/abandoned spend, correlated against git | +| `codeburn yield -p 30days` | Yield analysis for the last 30 days | + +**Fix & control** + +| Command | What it does | +|---------|--------------| +| `codeburn optimize --apply` | Interactively apply config-class fixes (`--yes`, `--dry-run`, `--only `) | +| `codeburn act list` | Every change CodeBurn has applied, newest first | +| `codeburn act undo ` | Roll a change back (`--last` for the most recent, `--force` if files drifted) | +| `codeburn act report` | Realized vs estimated savings for applied fixes | +| `codeburn guard install` | Budget-cap hooks for Claude Code (`--global`, `--statusline`) | +| `codeburn guard status` | Show caps, install locations, and flagged projects | +| `codeburn guard allow` | Lift the hard cap for the current session | +| `codeburn mcp` | MCP server (stdio) exposing usage and savings to AI agents | + +**Models** + +| Command | What it does | +|---------|--------------| +| `codeburn models` | Per-model token + cost table (last 30 days) | +| `codeburn models --by-task` | Break each model into per-task-type rows | +| `codeburn models --top 10` | Only the 10 most expensive models | +| `codeburn models --format markdown` | Emit a paste-friendly markdown table | +| `codeburn models --task feature` | Filter to feature-development work | +| `codeburn models --provider claude` | Filter to a single provider | + +Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--from` / `--to` for an exact historical window). Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts, `c` to open model comparison, `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects. + +
+ +## Features + +
+Pricing, task categories, plans, currency, filtering, and more + +### Pricing + +Prices every API call using input, output, cache read, cache write, and web search token counts, with a fast mode multiplier for Claude. Prices are fetched from [LiteLLM](https://github.com/BerriAI/litellm) and cached locally for 24 hours at `~/.cache/codeburn/`. Hardcoded fallbacks for all Claude and GPT-5 models prevent fuzzy-matching mispricing. + +### Task Categories + +13 categories classified from tool usage patterns and user message keywords. No LLM calls, fully deterministic. + +| Category | What triggers it | +|---|---| +| Coding | Edit, Write tools | +| Debugging | Error/fix keywords + tool usage | +| Feature Dev | "add", "create", "implement" keywords | +| Refactoring | "refactor", "rename", "simplify" | +| Testing | pytest, vitest, jest in Bash | +| Exploration | Read, Grep, WebSearch without edits | +| Planning | EnterPlanMode, TaskCreate tools | +| Delegation | Agent tool spawns | +| Git Ops | git push/commit/merge in Bash | +| Build/Deploy | npm build, docker, pm2 | +| Brainstorming | "brainstorm", "what if", "design" | +| Conversation | No tools, pure text exchange | +| General | Skill tool, uncategorized | + +### Breakdowns + +Daily cost chart, per-project, per-model (Opus, Sonnet, Haiku, GPT-5, GPT-4o, Gemini, Kiro, and more), per-activity with one-shot rate, core tools, shell commands, and MCP servers. + +### One-Shot Rate + +For categories that involve code edits, CodeBurn tracks file-aware retry cycles. A retry is when the same file is re-edited after a shell command in between (Edit foo.ts, Bash, Edit foo.ts). Editing different files across shell steps is not a retry. The one-shot column shows the percentage of edit turns that succeeded without retries. Coding at 90% means the AI got it right first try 9 out of 10 times. File-level tracking is available for Claude, Codex, and Goose; other providers fall back to tool-name-based detection. + +### Plans + +```bash +codeburn plan set claude-max # $200/month +codeburn plan set claude-pro # $20/month +codeburn plan set cursor-pro # $20/month +codeburn plan set custom --monthly-usd 200 --provider codex # ChatGPT Pro-style custom plan +codeburn plan reset --provider codex # remove one provider plan +codeburn plan set none # disable plan view +codeburn plan # show configured plans +codeburn plan reset # remove plan config +``` + +Subscription tracking for Claude Pro, Claude Max, Cursor Pro, and custom provider plans. Plans are stored per provider, so you can track Claude and Codex/Cursor subscriptions at the same time; the dashboard shows one overage line per active provider plan. A legacy/custom `all` plan remains a single aggregate plan and is replaced when you add a provider-specific plan, avoiding double-counted overage rows. Existing single-plan config is still read as a fallback. Presets use publicly stated plan prices (as of April 2026); they do not model exact token allowances, because vendors do not publish precise consumer-plan limits. + +### Currency + +```bash +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 +``` + +Any [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) is supported (162 currencies). Exchange rates fetched from [Frankfurter](https://www.frankfurter.app/) (European Central Bank data, free, no API key) and cached for 24 hours. Config stored at `~/.config/codeburn/config.json`. The currency setting applies everywhere: dashboard, status bar, menu bar, CSV/JSON exports, and JSON API output. + +### Model Aliases + +If you see `$0.00` for some models, the model name reported by your provider does not match any entry in the LiteLLM pricing data. This commonly happens when using a proxy that rewrites model names. + +```bash +codeburn model-alias "my-proxy-model" "claude-opus-4-6" # add alias +codeburn model-alias --list # show configured aliases +codeburn model-alias --remove "my-proxy-model" # remove alias +``` + +Aliases are stored in `~/.config/codeburn/config.json` and applied at runtime before pricing lookup. The target name can be anything in the [LiteLLM model list](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json) or a canonical name from the fallback table (e.g. `claude-sonnet-4-6`, `claude-opus-4-5`, `gpt-4o`). Built-in aliases ship for known proxy model name variants. User-configured aliases take precedence over built-ins. + +### Local Models, Custom Prices, and Proxies + +```bash +codeburn price-override my-model --input 0.27 --output 1.10 # USD per 1M tokens +codeburn model-savings "llama3.1:8b" gpt-4o # local model, counted as savings +codeburn proxy-path ~/work/copilot-repo # subscription-covered project +``` + +`price-override` sets exact rates for any model (input, output, cache read, cache creation), useful for private deployments or models LiteLLM prices wrong. `model-savings` maps a free local model to a paid baseline: the local calls stay $0, and the dashboard shows what the same tokens would have cost on the baseline. `proxy-path` marks a project routed through a subscription-backed proxy (e.g. Claude Code over GitHub Copilot), so its API-rate cost is reported as subscription-covered and your net out-of-pocket stays honest. All three support `--list` and `--remove`. + +### Filtering + +```bash +codeburn report --project myapp # show only projects matching "myapp" +codeburn report --exclude myapp # show everything except "myapp" +codeburn report --exclude myapp --exclude tests # exclude multiple projects +codeburn month --project api --project web # include multiple projects +codeburn export --project inventory # export only "inventory" project data +``` + +Filter by provider, project name (case-insensitive substring), or exact date range. The `--project` and `--exclude` flags work on all commands and can be combined with `--provider`. + +```bash +codeburn report --from 2026-04-01 --to 2026-04-10 # explicit window +codeburn report --from 2026-04-01 # this date through today +codeburn report --to 2026-04-10 # earliest data through this date +``` + +Either flag alone is valid. Inverted or malformed dates exit with a clear error. In the TUI, the custom range sets the initial load only; pressing `1` through `5` switches back to predefined periods. + +### JSON Output + +`report`, `today`, and `month` support `--format json` to output the full dashboard data as structured JSON to stdout: + +```bash +codeburn report --format json # 7-day JSON report +codeburn today --format json # today's data as JSON +codeburn month --format json # this month as JSON +codeburn report -p 30days --format json # 30-day window +``` + +The JSON includes all dashboard panels: overview (cost, calls, sessions, cache hit %), daily breakdown, projects (with `avgCostPerSession`), models with token counts, activities with one-shot rates, core tools, MCP servers, and shell commands. Pipe to `jq` for filtering: + +```bash +codeburn report --format json | jq '.projects' +codeburn today --format json | jq '.overview.cost' +``` + +For lighter output, use `status --format json` (today and month totals only), `optimize --format json` (setup health, findings, and copy-paste fixes), `yield --format json` (productive/reverted/abandoned spend), or file exports (`export -f json`). + +
+ +## Reading the dashboard + +
+Signals and what they might mean + +CodeBurn surfaces the data, you read the story. A few patterns worth knowing: + +| Signal you see | What it might mean | +|---|---| +| Cache hit < 80% | System prompt or context is not stable, or caching not enabled | +| Lots of `Read` calls per session | Agent re-reading same files, missing context | +| Low 1-shot rate (Coding 30%) | Agent struggling with edits, retry loops | +| Opus 4.6 dominating cost on small turns | Overpowered model for simple tasks | +| `dispatch_agent` / `task` heavy | Sub-agent fan-out, expected or excessive | +| No MCP usage shown | Either you don't use MCP servers, or your config is broken | +| Bash dominated by `git status`, `ls` | Agent exploring instead of executing | +| Conversation category dominant | Agent talking instead of doing | + +These are starting points, not verdicts. A 60% cache hit on a single experimental session is fine. A persistent 60% cache hit across weeks of work is a config issue. + +
+ +## How it reads your data + +
+Per-tool data locations and parsing + +| Provider | Data location | Notes | +|----------|---------------|-------| +| **Claude Code** | `~/.claude/projects//.jsonl` | Each assistant entry carries model name, token usage (input, output, cache read, cache write), `tool_use` blocks, and timestamps. | +| **Claude (multiple config dirs)** | Set via `CLAUDE_CONFIG_DIRS` (e.g. `~/.claude-work:~/.claude-personal`) | Scans every listed directory and merges sessions into one row per project so totals reflect all your Claude usage. Use `:` on POSIX, `;` on Windows; overrides `CLAUDE_CONFIG_DIR`. Missing or unreadable directories are skipped. | +| **Codex (OpenAI)** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | Reads `token_count` events (per-call and cumulative usage) and `function_call` entries for tool tracking; attributes cost by project working directory. `codeburn report --provider codex` views Codex alone. | +| **Cursor** | SQLite `state.vscdb` under `globalStorage`: macOS `~/Library/Application Support/Cursor/User/globalStorage/`, Linux `~/.config/Cursor/User/globalStorage/`, Windows `%APPDATA%/Cursor/User/globalStorage/`; results cached at `~/.cache/codeburn/cursor-results.json` | Input tokens come from Cursor's own per-conversation context meter (`composerData.promptTokenBreakdown`), credited once per conversation on a stable anchor; tool calls and shell commands are read from the agent stream (`agentKv`), and Composer house models are priced from Cursor's published rates. Output is a reply-text estimate and cache tokens are server-side only, so figures are marked estimated and undercount the Cursor admin console for long conversations. The cache auto-invalidates when the database changes; first run on a large database can take a minute. | +| **OpenCode** | SQLite `~/.local/share/opencode/opencode*.db` (respects `XDG_DATA_HOME`) | Queries `session`, `message`, and `part` read-only and recalculates cost via LiteLLM (falling back to OpenCode's own cost field for unpriced models). Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double counting; multiple channel databases are supported. | +| **Gemini CLI** | `~/.gemini/tmp//chats/session-*.json` | One JSON file per session with real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Input is reported inclusive of cached, so CodeBurn subtracts cached before pricing to avoid double charging. | +| **Antigravity (CLI & IDE)** | Session files under `.gemini/` folders, plus the running language server | Pulls granular trajectory and pricing from the language server process. For the short-lived CLI, optionally install a status-line hook with `codeburn antigravity-hook install` so usage is captured between menubar refreshes. The IDE is detected via the `--app-data-dir antigravity-ide` flag on Windows. | +| **GitHub Copilot** | `~/.copilot/session-state/` (legacy CLI); VS Code/VSCodium `workspaceStorage/*` chat sessions, `GitHub.copilot-chat/transcripts/`, and the `agent-traces.db` OpenTelemetry store; JetBrains IDEs (IntelliJ, PyCharm, …) under `~/.config/github-copilot////copilot-*-nitrite.db` | The OTel SQLite store is preferred when present (it carries real input/output/cache token counts). Other sources carry no explicit counts, so tokens are estimated from content length and the model is inferred from tool call ID prefixes. JetBrains sessions read from a Nitrite (H2 MVStore) `.db`; project comes from the plugin's `projectName` field (else the `.git` root of a referenced file). See [docs/providers/copilot.md](docs/providers/copilot.md). | +| **Kiro** | `.chat` JSON files | Token counts are estimated from content length. The model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates. | +| **Mistral Vibe** | `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`); each folder has `meta.json` + `messages.jsonl` | Reads cumulative prompt/completion totals and model pricing from `meta.json`, then the first user prompt and tool calls from `messages.jsonl`. Emits one record per session (source data is cumulative, not per turn); subagent sessions under `agents/` are counted separately. | +| **OpenClaw** | `~/.openclaw/agents/*.jsonl` (legacy `.clawdbot`, `.moltbot`, `.moldbot`) | Token usage comes from assistant message `usage` blocks; the model from `modelId` or `message.model`. | +| **Warp** | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Stable/warp.sqlite` (Preview fallback) | Reads `agent_conversations`, `ai_queries`, and `blocks`, emitting one call per finalized exchange. Exchange token share is estimated from prompt-size weighting normalized to conversation totals; `run_command` blocks attach to the nearest preceding exchange by timestamp. | +| **Zed** | SQLite `~/Library/Application Support/Zed/threads/threads.db` (Linux `~/.local/share/zed/threads/`) | One row per agent thread; the blob is zstd-compressed JSON with per-request token usage (input, output, cache read, cache write) and the thread's model. Threads are topped up to the exact cumulative counter so totals match the store. Needs Node 22.15+ for built-in zstd. | +| **Forge** | SQLite `~/.forge/.forge.db` | Queries `conversations` read-only and parses `context.messages`. Assistant usage entries provide prompt, completion, and cached counts; CodeBurn subtracts cached from prompt for input pricing, emits one call per assistant message, and extracts tool calls plus shell commands. | +| **Pi / OMP** | `~/.pi/agent/sessions//*.jsonl` (Pi), `~/.omp/agent/sessions//*.jsonl` (OMP) | Each assistant message carries usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` blocks. Tool names normalize to the standard set (`bash` → `Bash`, `dispatch_agent` → `Agent`); bash commands come from `toolCall.arguments.command`. | +| **Codebuff** (formerly Manicode) | `~/.config/manicode/projects//chats//chat-messages.json` (honors `CODEBUFF_DATA_DIR`; walks `manicode-dev` / `manicode-staging`) | Bills in credits, so each completed assistant message is costed at the public rate of $0.01/credit via `msg.credits`. When an upstream provider's stashed RunState records token-level usage (`message.metadata.runState.sessionState.mainAgentState.messageHistory[*].providerOptions`), the real tokens and LiteLLM cost take precedence. Native tool names (`read_files`, `str_replace`, `run_terminal_command`, `spawn_agents`) normalize to `Read`, `Edit`, `Bash`, `Agent`. | +| **Cline / Roo Code / KiloCode** | VS Code `globalStorage`: Cline at `saoudrizwan.claude-dev` and `~/.cline/data`; Roo Code and KiloCode across VS Code, VS Code Insiders, and VSCodium | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. | +| **IBM Bob** | `User/globalStorage/ibm.bob-code/tasks//` (GA `IBM Bob` and preview `Bob-IDE` app folders) | Reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model. | +| **Kimi Code CLI** | `$KIMI_SHARE_DIR/sessions///` or `~/.kimi/sessions///` | Reads `wire.jsonl` `StatusUpdate.token_usage` records, mapping `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns; includes subagents under each session's `subagents/` folder. | +| **LingTai TUI** | `~/.lingtai//logs/token_ledger.jsonl` plus project homes from `~/.lingtai-tui/registry.jsonl` (`/.lingtai//logs/token_ledger.jsonl`); honors `LINGTAI_HOME` / `LINGTAI_TUI_HOME` | Reads LingTai's append-only token ledger, mapping `input - cached` to fresh input, `cached` to cache reads, `output` to output, and `thinking` to reasoning. Nested daemon ledgers are skipped because parent ledgers already mirror daemon usage with `source`/`run_id` tags. | +| **Vercel AI Gateway** | [Vercel AI Gateway reporting API](https://vercel.com/docs/ai-gateway/capabilities/custom-reporting) (cloud, not local logs) | Set `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` (from `vercel env pull` / `vercel dev`); requires a Vercel plan with Custom Reporting. Without credentials it's skipped silently in the combined dashboard. | + +CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP, by chat folder + message ID for Codebuff, by session+message ID for Kimi), filters by date range per entry, and classifies each turn. + +
+ +## Environment Variables + +
+Override data directories and paths + +| Variable | Description | +|----------|-------------| +| `CLAUDE_CONFIG_DIR` | Override Claude Code data directory (default: `~/.claude`) | +| `CLAUDE_CONFIG_DIRS` | OS-delimited list of Claude data directories to scan together (e.g. `~/.claude-work:~/.claude-personal`). Sessions merge into one row per project. Overrides `CLAUDE_CONFIG_DIR` when set. | +| `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) | +| `CODEBUFF_DATA_DIR` | Override Codebuff data directory (default: `~/.config/manicode`) | +| `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) | +| `KIMI_SHARE_DIR` | Override Kimi Code CLI share directory (default: `~/.kimi`) | +| `KIMI_MODEL_NAME` | Override Kimi model name when Kimi sessions do not record the model | +| `LINGTAI_HOME` | Override LingTai data directory (default: `~/.lingtai`) | +| `LINGTAI_TUI_HOME` | Alternate override for LingTai data directory; `LINGTAI_HOME` takes precedence | +| `LINGTAI_TUI_GLOBAL_DIR` | Override LingTai TUI global directory used for project registry discovery (default: `~/.lingtai-tui`) | +| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) | +| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) | +| `WARP_DB_PATH` | Override Warp database path (default: Warp Stable, then Warp Preview) | + +
+ +## Sponsoring CodeBurn + +CodeBurn is free, runs entirely on your machine, and exists to cut your AI bill. If it has already saved you more than a sponsorship costs, consider sending a little of that back. + +Keeping 30 integrations accurate is constant work. The tools underneath change every week: Cursor reshapes its database, Claude moves a config path, new models ship at new prices. Sponsorship keeps CodeBurn current with all of it, so the numbers you see are always the real ones. + +Where your sponsorship goes: + +- **Honest numbers.** New models and price changes mapped quickly, so your cost is the real cost, not a guess. +- **More tools.** Every one of the 30 providers started as a single file. Sponsorship funds the next one. +- **Fast fixes.** When a vendor breaks something, paid time is what gets it patched now instead of someday. + +Sponsoring as a team or company? Your logo lands right here, in front of every developer who opens the repo. The first sponsor gets it to themselves until the next one shows up. + +

+ Sponsor CodeBurn +

+ +## Star History + + + + + + Star History Chart + + + +## License + +MIT +CodeBurn is an AgentSeal open-source project and is not affiliated with CodeBurn Bt. or codeburn.hu. + +## Credits + +Pricing data from [LiteLLM](https://github.com/BerriAI/litellm). Exchange rates from [Frankfurter](https://www.frankfurter.app/). + +Built by [AgentSeal](https://agentseal.org). diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..a4741a5 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`getagentseal/codeburn` +- 原始仓库:https://github.com/getagentseal/codeburn +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..a4cf372 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,182 @@ +# Releasing CodeBurn + +This document describes the actual steps a maintainer takes to cut a CLI or macOS menubar release. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed. + +## Versioning + +CodeBurn uses semantic versioning (major.minor.patch). The CLI and macOS menubar share the same version number for clarity. + +## Before Every Release + +Run the test suite to catch any regressions: + +```bash +npm test +``` + +Verify that the build completes without errors: + +```bash +npm run build +``` + +## CLI Release Process + +### 1. Update the Version + +Edit `package.json` to bump the version number. Update both the `version` field at the top and the `package-lock.json` lockfile to match (npm handles this automatically): + +```bash +npm version +``` + +For example, `npm version 0.9.8` updates both files and creates a commit. + +Alternatively, edit `package.json` by hand and run `npm install` to regenerate the lockfile with the new version. + +### 2. Update the Changelog + +Edit `CHANGELOG.md`. Move all changes from the "Unreleased" section into a new section with the version number and today's date: + +```markdown +## Unreleased + +### ... + +## 0.9.8 - 2026-05-10 + +### Added +- Feature X + +### Fixed +- Bug Y +``` + +Commit these changes: + +```bash +git add CHANGELOG.md package.json package-lock.json +git commit -m "chore: bump to 0.9.8" +``` + +### 3. Publish to npm + +There is no GitHub Actions workflow for the CLI; the maintainer runs `npm publish` from a clean working tree: + +```bash +npm publish +``` + +The `prepublishOnly` script in `package.json` runs `npm run build` first, which bundles the litellm pricing snapshot and then runs `tsup` to emit `dist/cli.js`. + +If publishing for the first time on a new machine, run `npm login` first. + +### 4. Tag the Release + +After npm accepts the publish, tag the commit and push: + +```bash +git tag v0.9.8 +git push origin v0.9.8 +``` + +The tag is for human reference and to anchor the GitHub Release. No workflow runs on `v*` tags for the CLI today. + +### 5. Verify npm Publication + +```bash +npm view codeburn version +``` + +### 6. Create a GitHub Release + +Use the GitHub CLI to create a release with notes from the changelog: + +```bash +gh release create v0.9.8 --title v0.9.8 --notes "$(sed -n '/^## 0.9.8/,/^## /p' CHANGELOG.md | head -n -1)" +``` + +Or use the web interface to draft a release and copy the changelog section into the body. + +## macOS Menubar Release Process + +The macOS menubar is released separately with its own GitHub Release, but shares the same version number as the CLI. + +### 1. Same Version Bump + +Follow the same version bumping process as the CLI. Both `package.json` and `CHANGELOG.md` reflect the shared version. + +### 2. Tag the macOS Release + +After the CLI tag is published, create a separate tag for the menubar: + +```bash +git tag mac-v0.9.8 +git push origin mac-v0.9.8 +``` + +### 3. GitHub Actions Builds the Bundle + +The `.github/workflows/release-menubar.yml` workflow automatically detects the `mac-v*` tag and: + +1. Checks out the repo +2. Runs `mac/Scripts/package-app.sh v0.9.8` +3. Signs the app bundle (ad-hoc signing) +4. Creates a zip file: `CodeBurnMenubar-v0.9.8.zip` +5. Computes a SHA-256 checksum: `CodeBurnMenubar-v0.9.8.zip.sha256` +6. Uploads both to a GitHub Release named "Menubar v0.9.8" + +The script output on the build machine shows: + +``` +✓ Built /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip +✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 + CodeBurnMenubar-v0.9.8.zip +``` + +No manual action is needed; the workflow handles everything. + +### 4. Verify the Release + +After the workflow completes, the GitHub Release page shows the zip and sha256 files. The installed CLI command `codeburn menubar --force` fetches the newest `mac-v*` menubar release that includes both assets, verifies the checksum and bundle identity, and installs it into `~/Applications`. + +## Homebrew Core + +CodeBurn is in homebrew-core. After publishing a new CLI version to npm, the homebrew-core formula is updated automatically by Homebrew's bot or can be bumped manually: + +```bash +brew bump-formula-pr codeburn --url "https://registry.npmjs.org/codeburn/-/codeburn-.tgz" +``` + +Users install with `brew install codeburn` and upgrade with `brew upgrade codeburn`. + +## Replacing Assets on an Existing Release + +If a release is published with broken assets (e.g., a menubar zip with a build error), re-run the build and upload the fixed assets without creating a new tag. + +Use `gh release upload` with the `--clobber` flag to overwrite existing files: + +```bash +# After re-running mac/Scripts/package-app.sh v0.9.8 to regenerate the zip and sha256 +gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip --clobber +gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 --clobber +``` + +The GitHub Release page will now serve the fixed assets. The menubar installer selects the newest `mac-v*` release with `CodeBurnMenubar-v*.zip` plus its checksum, so users who run `codeburn menubar --force` after the replacement get the fixed version automatically. + +## Rollback + +If a released version has a critical bug, the fastest path is to fix the bug and cut a new patch release (e.g., 0.9.8 -> 0.9.9). Delete the broken tag locally and on GitHub if it has not yet been widely distributed: + +```bash +git tag -d v0.9.8 +git push origin --delete v0.9.8 +``` + +npm does not allow republishing to the same version. If you must unpublish from npm, use `npm unpublish codeburn@0.9.8 --force` (requires Owner role), but this is discouraged and all users who installed that version retain it. + +For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. Users will see the update pill in the menubar settings and upgrade automatically (or manually via `codeburn menubar --force`). + +## Summary + +The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c94d7f9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities via [GitHub's private vulnerability reporting](https://github.com/getagentseal/codeburn/security/advisories/new). + +Do not open a public issue for security vulnerabilities. + +## Scope + +Security reports are welcome for: + +- The CLI (`src/`) +- The menubar installer (`src/menubar-installer.ts`) +- The macOS menubar app (`mac/`) +- The desktop app (`desktop/`) +- CI/CD workflows (`.github/workflows/`) + +## Release Integrity + +Menubar release assets include a `.sha256` checksum file. The installer verifies the checksum before extracting and launching the downloaded bundle. diff --git a/assets/compare.jpg b/assets/compare.jpg new file mode 100644 index 0000000..7cee1e4 Binary files /dev/null and b/assets/compare.jpg differ diff --git a/assets/dashboard.jpg b/assets/dashboard.jpg new file mode 100644 index 0000000..7006631 Binary files /dev/null and b/assets/dashboard.jpg differ diff --git a/assets/logo.ico b/assets/logo.ico new file mode 100644 index 0000000..cf74aed Binary files /dev/null and b/assets/logo.ico differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..4560137 Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/menubar-0.7.2.png b/assets/menubar-0.7.2.png new file mode 100644 index 0000000..1ad264a Binary files /dev/null and b/assets/menubar-0.7.2.png differ diff --git a/assets/menubar-0.8.0.png b/assets/menubar-0.8.0.png new file mode 100644 index 0000000..8ef7539 Binary files /dev/null and b/assets/menubar-0.8.0.png differ diff --git a/assets/menubar-0.9.11.png b/assets/menubar-0.9.11.png new file mode 100644 index 0000000..f3cb5c8 Binary files /dev/null and b/assets/menubar-0.9.11.png differ diff --git a/assets/menubar-logo.png b/assets/menubar-logo.png new file mode 100644 index 0000000..cc1961c Binary files /dev/null and b/assets/menubar-logo.png differ diff --git a/assets/optimize.jpg b/assets/optimize.jpg new file mode 100644 index 0000000..9d8939d Binary files /dev/null and b/assets/optimize.jpg differ diff --git a/assets/providers.png b/assets/providers.png new file mode 100644 index 0000000..2d06ee4 Binary files /dev/null and b/assets/providers.png differ diff --git a/assets/providers/antigravity.png b/assets/providers/antigravity.png new file mode 100644 index 0000000..9a0e29f Binary files /dev/null and b/assets/providers/antigravity.png differ diff --git a/assets/providers/claude.jpg b/assets/providers/claude.jpg new file mode 100644 index 0000000..93d3a98 Binary files /dev/null and b/assets/providers/claude.jpg differ diff --git a/assets/providers/cline.svg b/assets/providers/cline.svg new file mode 100644 index 0000000..d00094b --- /dev/null +++ b/assets/providers/cline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/providers/codex.png b/assets/providers/codex.png new file mode 100644 index 0000000..5c8532a Binary files /dev/null and b/assets/providers/codex.png differ diff --git a/assets/providers/copilot.jpg b/assets/providers/copilot.jpg new file mode 100644 index 0000000..3f28afe Binary files /dev/null and b/assets/providers/copilot.jpg differ diff --git a/assets/providers/crush.png b/assets/providers/crush.png new file mode 100644 index 0000000..138a7ab Binary files /dev/null and b/assets/providers/crush.png differ diff --git a/assets/providers/cursor-agent.jpg b/assets/providers/cursor-agent.jpg new file mode 100644 index 0000000..447a9ed Binary files /dev/null and b/assets/providers/cursor-agent.jpg differ diff --git a/assets/providers/cursor.jpg b/assets/providers/cursor.jpg new file mode 100644 index 0000000..447a9ed Binary files /dev/null and b/assets/providers/cursor.jpg differ diff --git a/assets/providers/devin.png b/assets/providers/devin.png new file mode 100644 index 0000000..c9a6873 Binary files /dev/null and b/assets/providers/devin.png differ diff --git a/assets/providers/droid.png b/assets/providers/droid.png new file mode 100644 index 0000000..0aab780 Binary files /dev/null and b/assets/providers/droid.png differ diff --git a/assets/providers/forge.png b/assets/providers/forge.png new file mode 100644 index 0000000..f2eba83 Binary files /dev/null and b/assets/providers/forge.png differ diff --git a/assets/providers/gemini.png b/assets/providers/gemini.png new file mode 100644 index 0000000..6c98e13 Binary files /dev/null and b/assets/providers/gemini.png differ diff --git a/assets/providers/goose.png b/assets/providers/goose.png new file mode 100644 index 0000000..757649e Binary files /dev/null and b/assets/providers/goose.png differ diff --git a/assets/providers/grok.png b/assets/providers/grok.png new file mode 100644 index 0000000..9e1d5c5 Binary files /dev/null and b/assets/providers/grok.png differ diff --git a/assets/providers/hermes.png b/assets/providers/hermes.png new file mode 100644 index 0000000..667d0d6 Binary files /dev/null and b/assets/providers/hermes.png differ diff --git a/assets/providers/ibm-bob.svg b/assets/providers/ibm-bob.svg new file mode 100644 index 0000000..ab76047 --- /dev/null +++ b/assets/providers/ibm-bob.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/providers/kilo-code.png b/assets/providers/kilo-code.png new file mode 100644 index 0000000..2be6018 Binary files /dev/null and b/assets/providers/kilo-code.png differ diff --git a/assets/providers/kimi.svg b/assets/providers/kimi.svg new file mode 100644 index 0000000..c09b36f --- /dev/null +++ b/assets/providers/kimi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/providers/kiro.png b/assets/providers/kiro.png new file mode 100644 index 0000000..f997c66 Binary files /dev/null and b/assets/providers/kiro.png differ diff --git a/assets/providers/mistral-vibe.svg b/assets/providers/mistral-vibe.svg new file mode 100644 index 0000000..f70841a --- /dev/null +++ b/assets/providers/mistral-vibe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/providers/mux.png b/assets/providers/mux.png new file mode 100644 index 0000000..d95a2cf Binary files /dev/null and b/assets/providers/mux.png differ diff --git a/assets/providers/omp.svg b/assets/providers/omp.svg new file mode 100644 index 0000000..f1ccf2a --- /dev/null +++ b/assets/providers/omp.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/providers/openclaw.jpg b/assets/providers/openclaw.jpg new file mode 100644 index 0000000..0394c6f Binary files /dev/null and b/assets/providers/openclaw.jpg differ diff --git a/assets/providers/opencode.png b/assets/providers/opencode.png new file mode 100644 index 0000000..605c441 Binary files /dev/null and b/assets/providers/opencode.png differ diff --git a/assets/providers/pi.png b/assets/providers/pi.png new file mode 100644 index 0000000..17bc550 Binary files /dev/null and b/assets/providers/pi.png differ diff --git a/assets/providers/qwen.png b/assets/providers/qwen.png new file mode 100644 index 0000000..a834c0f Binary files /dev/null and b/assets/providers/qwen.png differ diff --git a/assets/providers/roo-code.png b/assets/providers/roo-code.png new file mode 100644 index 0000000..776a103 Binary files /dev/null and b/assets/providers/roo-code.png differ diff --git a/assets/providers/vercel-gateway.png b/assets/providers/vercel-gateway.png new file mode 100644 index 0000000..4cda755 Binary files /dev/null and b/assets/providers/vercel-gateway.png differ diff --git a/assets/providers/warp.jpg b/assets/providers/warp.jpg new file mode 100644 index 0000000..f8575e7 Binary files /dev/null and b/assets/providers/warp.jpg differ diff --git a/assets/providers/zcode.jpg b/assets/providers/zcode.jpg new file mode 100644 index 0000000..8dbcaae Binary files /dev/null and b/assets/providers/zcode.jpg differ diff --git a/assets/providers/zed.jpg b/assets/providers/zed.jpg new file mode 100644 index 0000000..e50d3b0 Binary files /dev/null and b/assets/providers/zed.jpg differ diff --git a/assets/providers/zerostack.png b/assets/providers/zerostack.png new file mode 100644 index 0000000..6d835a5 Binary files /dev/null and b/assets/providers/zerostack.png differ 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 0000000..e316795 Binary files /dev/null and b/dash/public/codeburn-logo.png differ diff --git a/dash/src/App.tsx b/dash/src/App.tsx new file mode 100644 index 0000000..be8f46e --- /dev/null +++ b/dash/src/App.tsx @@ -0,0 +1,724 @@ +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 { UsageChart, 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 ( + +

{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 modelBars: BarItem[] = c + ? c.topModels.filter((m) => m.cost > 0).slice(0, 8).map((m) => ({ name: m.name, value: m.cost, display: usd(m.cost) })) + : [] + 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) => ) + )} +
+ +
+ + + + + + +
+ +
+ + {isRemote ? ( +

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

+ ) : ( + ({ + name: p.name, + cost: usd(p.cost), + sessions: fmtNum(p.sessions), + }))} + /> + )} +
+ + + +
+ +
+ + ({ 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} + {d.local ? ' · this Mac' : ''} + + ))} + {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..2fa67af --- /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..d9a2c95 --- /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..91f1b0f --- /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..8408b5e --- /dev/null +++ b/dash/src/components/UsageChart.tsx @@ -0,0 +1,163 @@ +import { useMemo } from 'react' +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' + +import type { DailyEntry, DeviceUsage } from '@/lib/api' +import { CHART_COLORS, 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) { + // 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 ( +
+
{fmtDay(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)} +
+
+
+ ) + } +} + +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 }) { + 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 +} + +// 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..7e40ea3 --- /dev/null +++ b/dash/src/lib/api.ts @@ -0,0 +1,257 @@ +export type Period = 'today' | 'week' | '30days' | 'month' | 'all' + +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 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[] } +} + +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 + 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, + })), + })), + }, + } +} + +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) })) } +} + +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: 'All' }, +] + +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/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5f31c7c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,189 @@ +# CodeBurn Architecture + +A map of the codebase. Read this once before opening a non-trivial PR. + +## Three Surfaces + +CodeBurn is one Node.js CLI plus two GUI clients that shell out to it. + +``` ++----------------------+ +-----------------+ +| mac/ (Swift) | ---> | | ++----------------------+ | src/cli.ts | +| gnome/ (JavaScript) | ---> | (the CLI) | ++----------------------+ | | + | status | + | --format | + | menubar-json | + +-----------------+ + | + v + +----------------------------+ + | session files on disk | + | (JSONL, SQLite, protobuf) | + +----------------------------+ +``` + +The macOS menubar (`mac/`) and the GNOME extension (`gnome/`) both invoke `codeburn status --format menubar-json --period

` and parse the JSON. They do not share code with the CLI; they only depend on its output contract. + +## CLI (`src/`) + +`src/cli.ts` is the Commander.js entry point. The bin field in `package.json` points at `dist/cli.js`. Twelve commands are registered: + +| Command | Line | Purpose | +|---|---|---| +| `report` | 274 | Default. Interactive Ink TUI dashboard. | +| `status` | 358 | Compact text status, plus `--format menubar-json` for clients. | +| `today` | 524 | Today-only view of `report`. | +| `month` | 542 | Month-only view of `report`. | +| `export` | 560 | CSV or JSON dump of usage data. | +| `menubar` | 621 | Downloads and launches the macOS menubar bundle. | +| `currency` | 636 | Sets display currency. | +| `model-alias` | 687 | Maps an unknown model name to a known one for pricing. | +| `plan` | 737 | Configures a subscription plan for overage tracking. | +| `optimize` | 857 | Runs all 14 waste detectors. | +| `compare` | 870 | Compares two models side by side. | +| `yield` | 882 | Tracks which sessions shipped to main vs. were reverted (experimental). | + +### Pipeline + +``` +provider.discoverSessions() + | + v +provider.createSessionParser(source, seenKeys) + | + v yields ParsedProviderCall (see src/providers/types.ts) + | + v +src/parser.ts: parseAllSessions() + | + v aggregates into ProjectSummary[] + | + v +src/daily-cache.ts: aggregate per day, persist + | + v +output formatter (Ink TUI, JSON, or menubar-json) +``` + +`src/parser.ts` is the central aggregator. Public exports: `parseAllSessions`, `filterProjectsByName`, `extractMcpInventory`. It owns the dedup `Set` (`seenKeys`) that is passed into every provider parser so a turn that surfaces in two providers (Claude logs vs. Cursor mirror, for instance) is counted once. + +### Cache Layers + +Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`): + +| File | Owner | Invalidation | +|---|---|---| +| `codex-results.json` | `src/codex-cache.ts` | `mtimeMs + sizeBytes` per Codex `.jsonl`. | +| `cursor-results.json` | `src/cursor-cache.ts` | `mtimeMs + sizeBytes` of the Cursor SQLite db. | +| `daily-cache.json` | `src/daily-cache.ts` | Tracks `lastComputedDate`; new days are backfilled, old days are reused. | + +All three use atomic write (temp file + `rename`) and write with mode `0o600`. All three carry a numeric `version` field; bumping it forces a recompute next run. + +### Optimize Detectors + +`src/optimize.ts` exports 14 detectors. Each returns a `WasteFinding | null`. They are composed by `runOptimize()` which collects findings, ranks them by impact, and returns them with `WasteAction` objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config). + +| Detector | Line | What it catches | +|---|---|---| +| `detectJunkReads` | 428 | Reads into `node_modules`, `.git`, `dist`, etc. | +| `detectDuplicateReads` | 477 | Re-reads of the same file in a session. | +| `detectMcpToolCoverage` | 795 | MCP servers with many tools but low usage. | +| `detectUnusedMcp` | 855 | MCP servers configured but never invoked. | +| `detectBloatedClaudeMd` | 944 | `CLAUDE.md` files past a healthy size. | +| `detectLowReadEditRatio` | 987 | Edit-heavy sessions with too few prior reads. | +| `detectCacheBloat` | 1048 | High `cache_creation_input_tokens`. | +| `detectGhostAgents` | 1124 | Defined but never-invoked Claude agents. | +| `detectGhostSkills` | 1154 | Defined but never-invoked skills. | +| `detectGhostCommands` | 1184 | Defined but never-invoked slash commands. | +| `detectBashBloat` | 1228 | Shell output limit set above the recommended 15K chars. | +| `detectLowWorthSessions` | 1405 | Sessions with cost but no edits or git delivery. | +| `detectContextBloat` | 1512 | Input:output token ratio above 25:1. | +| `detectSessionOutliers` | 1558 | Sessions costing more than 2x the project average. | + +### Output Formats + +| Command | `--format` choices | Default | +|---|---|---| +| `report`, `today`, `month` | `tui`, `json` | `tui` | +| `status` | `terminal`, `menubar-json`, `json` | `terminal` | +| `export` | `csv`, `json` | `csv` | +| `plan` | `text`, `json` | `text` | + +The macOS menubar and GNOME extension consume `menubar-json`. `src/menubar-json.ts` defines the contract; `tests/menubar-json.test.ts` pins it. + +## Providers (`src/providers/`) + +Every provider implements the `Provider` interface in `src/providers/types.ts`: + +```ts +type Provider = { + name: string + displayName: string + modelDisplayName(model: string): string + toolDisplayName(rawTool: string): string + discoverSessions(): Promise + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser +} +``` + +`src/providers/index.ts` registers twenty-one providers across two tiers: + +- **Eager**: `claude`, `cline`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load. +- **Lazy**: `antigravity`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf) do not touch users who do not have those tools installed. + +Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run. + +`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `cline`, `ibm-bob`, `kilo-code`, and `roo-code`. It is not registered as a provider on its own. + +For the per-provider data location, storage format, parser quirks, and test coverage, see `docs/providers/`. + +## macOS Menubar (`mac/`) + +Swift package (`mac/Package.swift`), targets macOS 14, strict concurrency on. Layout under `mac/Sources/CodeBurnMenubar/`: + +- `CodeBurnApp.swift` boots the SwiftUI `App` and the `NSStatusItem`. +- `AppStore.swift` is the single source of truth for UI state. +- `Data/` holds models, the CLI client, credential stores, and subscription services. + - `DataClient.swift` spawns the CLI and decodes `MenubarPayload`. See file-level comment for why we never route through `/bin/zsh -c`. + - `MenubarPayload.swift` mirrors the JSON the CLI emits; keep it in sync with `src/menubar-json.ts`. +- `Security/CodeburnCLI.swift` resolves the CLI binary (env override `CODEBURN_BIN`, fallback `codeburn`), validates each argv entry against an allowlist regex, and augments PATH for Homebrew and npm-global installs. The Process is launched via `/usr/bin/env`, never via a shell. +- `Theme/` holds color and typography constants and the dark/light state. +- `Views/` are the SwiftUI components rendered inside `NSPopover`. + +Tests live in `mac/Tests/CodeBurnMenubarTests/` (currently `CapacityEstimatorTests.swift`). + +The build artifact is a zipped `.app` bundle produced by `mac/Scripts/package-app.sh`. See `RELEASING.md` for how the GitHub Actions workflow uses it. + +## GNOME Extension (`gnome/`) + +Plain JavaScript, no bundler. Targets GNOME Shell 45-50 (`metadata.json`). + +- `extension.js` is the entry point. On `enable()` it constructs a `CodeBurnIndicator` and adds it to the panel. +- `indicator.js` is the popover. It owns the period selector, the insight tabs, and the provider filter. +- `dataClient.js` wraps `Gio.Subprocess` to call the CLI. It validates argv against the same allowlist pattern as the macOS client and augments PATH with `~/.local/bin`, `~/.npm-global/bin`, `~/.volta/bin`, `~/.bun/bin`, `~/.cargo/bin`, `~/.asdf/shims`, and a few others. Results are cached for 300 seconds. +- `prefs.js` is the settings dialog backed by `schemas/org.gnome.shell.extensions.codeburn.gschema.xml`. +- `install.sh` copies the extension into `~/.local/share/gnome-shell/extensions/`. + +## Build (`scripts/`, `tsup.config.ts`) + +`npm run build` is two steps: + +1. `node scripts/bundle-litellm.mjs` fetches the latest litellm pricing JSON and writes `src/data/litellm-snapshot.json`. The bundle script keeps a manual override for MiniMax variants. Direct (un-prefixed) entries win over prefixed ones. The result is checked in so the build is reproducible. +2. `tsup` reads `tsup.config.ts` and emits a single ESM bundle at `dist/cli.js` with a Node shebang banner. No source maps in publish builds; sourcemaps on for development. + +The `prepublishOnly` hook in `package.json` runs `npm run build` so `npm publish` always ships fresh code. + +## Tests + +`npm test` runs vitest. Forty-two test files live under `tests/`: + +- `tests/` root (27 files) covers CLI, parser, optimize, cache, format, models, plans. +- `tests/security/` (1 file) covers prototype-pollution guards. +- `tests/providers/` (15 files) covers per-provider parsing. +- `tests/fixtures/` holds redacted real-world session data. + +Five providers ship without dedicated test files today: `antigravity`, `claude`, `gemini`, `goose`, `qwen`. Closing this gap is a standing good-first-issue. + +CI runs Semgrep against `.semgrep/rules/no-bracket-assign-hot-paths.yml` over `src/providers/` and `src/parser.ts` (`.github/workflows/ci.yml`). It does not run vitest in CI today; tests run locally before publish. diff --git a/docs/design/codeburn-mcp-plan.md b/docs/design/codeburn-mcp-plan.md new file mode 100644 index 0000000..83e2caa --- /dev/null +++ b/docs/design/codeburn-mcp-plan.md @@ -0,0 +1,755 @@ +# CodeBurn MCP Server — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `codeburn mcp` stdio MCP server exposing CodeBurn's usage/cost data to AI agents via two tools (`get_usage`, `get_savings`), each returning a markdown table plus typed structured JSON. + +**Architecture:** Extract the existing `status --format menubar-json` aggregation into one reusable `buildMenubarPayloadForRange(periodInfo, opts)` (with an `optimize` boolean — the only expensive call, `scanAndDetect`, is skipped for `get_usage`). A long-lived in-process `McpServer` registers the two tools, injects the aggregator for testability, coalesces concurrent calls, hashes project names by default, and relies on the existing 180 s parser cache for warm reuse. + +**Tech Stack:** TypeScript (ESM, `type: module`, node ≥ 22.13), commander, `@modelcontextprotocol/sdk@^1.29` (v1), zod, tsup, vitest. + +--- + +## File Structure + +- **Create `src/usage-aggregator.ts`** — owns `buildPeriodData` (moved from `main.ts`) and the extracted `buildMenubarPayloadForRange(periodInfo, opts): Promise`. Single responsibility: turn a resolved date range + filters into a `MenubarPayload`. +- **Create `src/mcp/redact.ts`** — `pseudonym()` + `redactProjectNames(payload, include)`. Privacy only. +- **Create `src/mcp/tables.ts`** — markdown renderers (`renderSummaryTable`, `renderBreakdownTable`, `renderSavingsTable`). Presentation only. +- **Create `src/mcp/server.ts`** — `createServer(deps)` (tool registration + handlers, aggregator injectable) and `startStdioServer(version)` (loadPricing + pre-warm + stdio transport). +- **Modify `src/main.ts`** — import `buildPeriodData`/`buildMenubarPayloadForRange` from the aggregator; refactor the `status` menubar branch to call the aggregator; add `.command('mcp')`. +- **Modify `package.json`** — add `@modelcontextprotocol/sdk` + `zod` deps. +- **Modify `tsup.config.ts`** — `external: ['@modelcontextprotocol/sdk', 'zod']`. +- **Create tests** — `tests/usage-aggregator.test.ts`, `tests/mcp-redact.test.ts`, `tests/mcp-tables.test.ts`, `tests/mcp-server.test.ts`. + +Internal MCP period names map to CodeBurn's: `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all` (≈6 months). + +--- + +## Task 1: Add dependencies and externalize them in the bundle + +**Files:** +- Modify: `package.json` (dependencies) +- Modify: `tsup.config.ts` + +- [ ] **Step 1: Add runtime deps** + +Run: `npm install @modelcontextprotocol/sdk@^1.29.0 zod@^3.25.0 --save-exact=false` +Expected: both appear under `"dependencies"` in `package.json`; `node_modules/@modelcontextprotocol/sdk` and `node_modules/zod` exist. + +- [ ] **Step 2: Externalize them so they are not inlined into `dist/main.js`** + +Edit `tsup.config.ts` to: + +```ts +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/main.ts'], + format: ['esm'], + target: 'node20', + outDir: 'dist', + clean: true, + splitting: false, + sourcemap: true, + dts: false, + external: ['@modelcontextprotocol/sdk', 'zod'], +}) +``` + +- [ ] **Step 3: Build and confirm the SDK is external (not bundled)** + +Run: `npm run build && node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); if(/from'@modelcontextprotocol\/sdk|require\(.@modelcontextprotocol\/sdk./.test(s.replace(/\s/g,''))||!/McpServer/.test(s)){} console.log('built, bytes', s.length)"` +Expected: build succeeds, prints `built, bytes `. (The SDK isn't imported anywhere yet, so this just verifies the config builds.) + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json tsup.config.ts +git commit -m "build(mcp): add @modelcontextprotocol/sdk + zod, externalize in tsup" +``` + +--- + +## Task 2: Move `buildPeriodData` into a shared module + +`buildPeriodData` is currently a private function in `main.ts:410`. The aggregator needs it, so move it to the new module and import it back into `main.ts`. + +**Files:** +- Create: `src/usage-aggregator.ts` +- Modify: `src/main.ts:410` (remove local def), import section + +- [ ] **Step 1: Create the module with `buildPeriodData` moved verbatim** + +Create `src/usage-aggregator.ts`. Move the entire `function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { ... }` body from `main.ts` into it, exported, with imports it needs: + +```ts +import type { ProjectSummary } from './types.js' +import { type PeriodData } from './menubar-json.js' + +export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { + // ... exact body moved from main.ts:410 ... +} +``` + +(Copy the body unchanged. Add any imports the body references — e.g. `getShortModelName` from `./models.js` — until `npx tsc --noEmit` is clean.) + +- [ ] **Step 2: Update `main.ts` to import it and delete the local copy** + +Remove the `function buildPeriodData(...) {...}` at `main.ts:410`. Add to the import block near the top: + +```ts +import { buildPeriodData } from './usage-aggregator.js' +``` + +- [ ] **Step 3: Typecheck + run the full suite (parity guard)** + +Run: `npx tsc --noEmit && npm test` +Expected: typecheck clean; all existing tests pass (the `status` paths still use `buildPeriodData`, now imported). + +- [ ] **Step 4: Commit** + +```bash +git add src/usage-aggregator.ts src/main.ts +git commit -m "refactor: move buildPeriodData into usage-aggregator module" +``` + +--- + +## Task 3: Extract `buildMenubarPayloadForRange` + +Move the `status` menubar-json aggregation block (`main.ts:485–759`, everything after `periodInfo`/`now` are computed, ending at the `console.log`) into the aggregator as a function that **returns** the payload instead of printing it. + +**Files:** +- Modify: `src/usage-aggregator.ts` (add the function) +- Modify: `src/main.ts:476–761` (call it) +- Test: existing `tests/cli-status-menubar.test.ts` is the parity guard + +- [ ] **Step 1: Add the function signature + types to `src/usage-aggregator.ts`** + +```ts +import { homedir } from 'node:os' +import type { ProjectSummary, DateRange } from './types.js' +import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, buildMenubarPayload } from './menubar-json.js' +import { parseAllSessions, getAllProviders, filterProjectsByName, filterProjectsByDays } from './parser.js' +import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' +import { aggregateModelEfficiency } from './model-efficiency.js' +import { scanAndDetect } from './optimize.js' +import { hydrateCache, loadDailyCache, getDaysInRange, toDateString, BACKFILL_DAYS, type DailyCache } from './daily-cache.js' + +export type PeriodInfo = { range: DateRange; label: string } +export type AggregateOpts = { + provider?: string + project?: string[] + exclude?: string[] + daysSelection?: { range: DateRange; label: string; days: Set } | null + optimize?: boolean +} + +export async function buildMenubarPayloadForRange( + periodInfo: PeriodInfo, + opts: AggregateOpts = {}, +): Promise { + const pf = opts.provider ?? 'all' + const daysSelection = opts.daysSelection ?? null + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? []) + // ... moved block from main.ts:485–757 (now/todayStart/... through breakdowns) ... + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) + return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns) +} +``` + +Move lines `main.ts:485–757` (from `const now = new Date()` through the `breakdowns` IIFE) into the body unchanged. The block already references only the symbols imported above plus `homedir()`. `hydrateCache` is imported from `./daily-cache.js` (same module as `loadDailyCache`); if tsc reports a different source, follow the import it suggests. + +- [ ] **Step 2: Precondition note — pricing** + +The block assumes `loadPricing()` already ran. The `status` action calls it at `main.ts:473`; the MCP server will call it at startup (Task 6). Do **not** call `loadPricing()` inside the function. + +- [ ] **Step 3: Refactor the `status` menubar branch to call it** + +Replace `main.ts:485–760` (the inline block + `console.log` + `return`) with: + +```ts +const payload = await buildMenubarPayloadForRange(periodInfo, { + provider: pf, + project: opts.project, + exclude: opts.exclude, + daysSelection, + optimize: opts.optimize !== false, +}) +console.log(JSON.stringify(payload)) +return +``` + +Keep `main.ts:477–484` (the `daysSelection`/`customRange`/`daySelection`/`periodInfo` resolution) — `periodInfo` and `daysSelection` are the inputs now passed in. Add `buildMenubarPayloadForRange` to the existing import from `./usage-aggregator.js`. + +- [ ] **Step 4: Typecheck + parity test** + +Run: `npx tsc --noEmit && npm test -- cli-status-menubar` +Expected: clean typecheck; `tests/cli-status-menubar.test.ts` passes — i.e. `status --format menubar-json` output is unchanged (parity). + +- [ ] **Step 5: Add a direct unit test for the aggregator** + +Create `tests/usage-aggregator.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js' +import { getDateRange } from '../src/cli-date.js' + +describe('buildMenubarPayloadForRange', () => { + it('returns a zero payload with no data and skips optimize when optimize:false', async () => { + const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }) + expect(payload.current.cost).toBe(0) + expect(payload.current.calls).toBe(0) + expect(payload.optimize.findingCount).toBe(0) + expect(Array.isArray(payload.current.topProjects)).toBe(true) + }) +}) +``` + +Run: `npm test -- usage-aggregator` +Expected: PASS (uses the empty real environment; `scanAndDetect` not called because `optimize:false`). + +- [ ] **Step 6: Commit** + +```bash +git add src/usage-aggregator.ts src/main.ts tests/usage-aggregator.test.ts +git commit -m "refactor: extract buildMenubarPayloadForRange for reuse by MCP" +``` + +--- + +## Task 4: Project-name redaction + +**Files:** +- Create: `src/mcp/redact.ts` +- Test: `tests/mcp-redact.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/mcp-redact.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { pseudonym, redactProjectNames } from '../src/mcp/redact.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function payload(): MenubarPayload { + const base = { + name: 'secret-client-repo', cost: 5, sessions: 2, avgCostPerSession: 2.5, sessionDetails: [], + } + return { + generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, history: { daily: [] }, + current: { + label: 'Today', cost: 5, calls: 10, sessions: 2, oneShotRate: null, inputTokens: 0, outputTokens: 0, + cacheHitPercent: 0, topActivities: [], topModels: [], providers: {}, + topProjects: [base], modelEfficiency: [], + topSessions: [{ project: 'secret-client-repo', cost: 5, calls: 10, date: '2026-06-01' }], + retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, + routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +describe('redact', () => { + it('pseudonym is stable and path-free', () => { + expect(pseudonym('a')).toBe(pseudonym('a')) + expect(pseudonym('secret-client-repo')).toMatch(/^project-[0-9a-f]{6}$/) + }) + it('hashes project names by default, preserves numbers', () => { + const out = redactProjectNames(payload(), false) + expect(out.current.topProjects[0]!.name).toMatch(/^project-[0-9a-f]{6}$/) + expect(out.current.topSessions[0]!.project).toMatch(/^project-[0-9a-f]{6}$/) + expect(out.current.topProjects[0]!.cost).toBe(5) + }) + it('keeps real names when include=true', () => { + const out = redactProjectNames(payload(), true) + expect(out.current.topProjects[0]!.name).toBe('secret-client-repo') + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npm test -- mcp-redact` +Expected: FAIL ("Cannot find module '../src/mcp/redact.js'"). + +- [ ] **Step 3: Implement** + +Create `src/mcp/redact.ts`: + +```ts +import { createHash } from 'node:crypto' +import type { MenubarPayload } from '../menubar-json.js' + +export function pseudonym(name: string): string { + return `project-${createHash('sha256').update(name).digest('hex').slice(0, 6)}` +} + +export function redactProjectNames(payload: MenubarPayload, includeNames: boolean): MenubarPayload { + if (includeNames) return payload + return { + ...payload, + current: { + ...payload.current, + topProjects: payload.current.topProjects.map(p => ({ ...p, name: pseudonym(p.name) })), + topSessions: payload.current.topSessions.map(s => ({ ...s, project: pseudonym(s.project) })), + }, + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npm test -- mcp-redact` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/redact.ts tests/mcp-redact.test.ts +git commit -m "feat(mcp): hash project names by default with opt-in reveal" +``` + +--- + +## Task 5: Markdown table renderers + +**Files:** +- Create: `src/mcp/tables.ts` +- Test: `tests/mcp-tables.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/mcp-tables.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { renderSummaryTable, renderBreakdownTable, renderSavingsTable } from '../src/mcp/tables.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function payload(): MenubarPayload { + return { + generated: '', optimize: { findingCount: 1, savingsUSD: 2.5, topFindings: [{ title: 'Trim system prompt', impact: 'high', savingsUSD: 2.5 }] }, history: { daily: [] }, + current: { + label: 'Last 7 Days', cost: 12.5, calls: 100, sessions: 4, oneShotRate: 0.5, inputTokens: 1000, outputTokens: 500, + cacheHitPercent: 80, topActivities: [{ name: 'feature', cost: 8, turns: 30, oneShotRate: 0.6 }], + topModels: [{ name: 'Opus 4.8', cost: 10, calls: 60 }], providers: { 'claude code': 12.5 }, + topProjects: [{ name: 'project-abc123', cost: 12.5, sessions: 4, avgCostPerSession: 3.125, sessionDetails: [] }], + modelEfficiency: [], topSessions: [], + retryTax: { totalUSD: 1.2, retries: 4, editTurns: 20, byModel: [{ name: 'Opus 4.8', taxUSD: 1.2, retries: 4, retriesPerEdit: 0.2 }] }, + routingWaste: { totalSavingsUSD: 3, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [{ name: 'Opus 4.8', costPerEdit: 0.05, editTurns: 20, actualUSD: 1, counterfactualUSD: 0.2, savingsUSD: 0.8 }] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +describe('tables', () => { + it('summary shows headline cost and top models', () => { + const t = renderSummaryTable(payload()) + expect(t).toContain('Last 7 Days') + expect(t).toContain('Opus 4.8') + expect(t).toContain('| Model | Cost | Calls |') + }) + it('breakdown by provider lists providers', () => { + expect(renderBreakdownTable(payload(), 'provider', 20)).toContain('claude code') + }) + it('breakdown handles empty dimension gracefully', () => { + const p = payload(); p.current.topActivities = [] + expect(renderBreakdownTable(p, 'task', 20)).toContain('no data') + }) + it('savings shows retry tax and routing waste', () => { + const t = renderSavingsTable(payload()) + expect(t).toContain('Retry tax') + expect(t).toContain('Routing waste') + expect(t).toContain('Trim system prompt') + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npm test -- mcp-tables` +Expected: FAIL ("Cannot find module '../src/mcp/tables.js'"). + +- [ ] **Step 3: Implement** + +Create `src/mcp/tables.ts`: + +```ts +import { formatCost, formatTokens } from '../format.js' +import type { MenubarPayload } from '../menubar-json.js' + +export type BreakdownBy = 'project' | 'model' | 'task' | 'provider' + +function mdTable(headers: string[], rows: string[][]): string { + const head = `| ${headers.join(' | ')} |` + const sep = `| ${headers.map(() => '---').join(' | ')} |` + if (rows.length === 0) return `${head}\n${sep}\n| _(no data)_ |${' |'.repeat(headers.length - 1)}` + return [head, sep, ...rows.map(r => `| ${r.join(' | ')} |`)].join('\n') +} + +const pct = (n: number) => `${Math.round(n)}%` +const oneShot = (r: number | null) => (r === null ? 'n/a' : pct(r * 100)) + +export function renderSummaryTable(p: MenubarPayload): string { + const c = p.current + return [ + `**${c.label}** — ${formatCost(c.cost)} · ${c.calls} calls · ${c.sessions} sessions`, + `cache hit ${pct(c.cacheHitPercent)} · one-shot ${oneShot(c.oneShotRate)} · in ${formatTokens(c.inputTokens)} / out ${formatTokens(c.outputTokens)}`, + '', + '_Top models_', + mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])), + '', + '_Top projects_', + mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])), + ].join('\n') +} + +export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string { + const c = p.current + if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)])) + if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)])) + if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)])) + return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)])) +} + +export function renderSavingsTable(p: MenubarPayload): string { + const c = p.current + const findings = mdTable(['Finding', 'Impact', 'Saves'], p.optimize.topFindings.slice(0, 10).map(f => [f.title, f.impact, formatCost(f.savingsUSD)])) + const retry = mdTable(['Model', 'Retry tax', 'Retries'], c.retryTax.byModel.map(m => [m.name, formatCost(m.taxUSD), String(m.retries)])) + const routing = mdTable(['Model', 'Overpaid', 'vs baseline'], c.routingWaste.byModel.map(m => [m.name, formatCost(m.savingsUSD), c.routingWaste.baselineModel])) + return [ + `**Savings — ${c.label}**`, + `Optimize findings: ${p.optimize.findingCount} (≈ ${formatCost(p.optimize.savingsUSD)})`, + findings, '', + `_Retry tax_ — ${formatCost(c.retryTax.totalUSD)} on ${c.retryTax.retries} retries`, + retry, '', + `_Routing waste_ — ${formatCost(c.routingWaste.totalSavingsUSD)} vs ${c.routingWaste.baselineModel || 'n/a'}`, + routing, + ].join('\n') +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npm test -- mcp-tables` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/tables.ts tests/mcp-tables.test.ts +git commit -m "feat(mcp): markdown table renderers for usage and savings" +``` + +--- + +## Task 6: MCP server (tools, schemas, handlers, coalescing) + +**Files:** +- Create: `src/mcp/server.ts` +- Test: `tests/mcp-server.test.ts` + +- [ ] **Step 1: Write the failing test (in-memory transport, injected aggregator)** + +Create `tests/mcp-server.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { createServer } from '../src/mcp/server.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function fakePayload(calls = 100): MenubarPayload { + return { + generated: '', optimize: { findingCount: 1, savingsUSD: 2, topFindings: [{ title: 'X', impact: 'high', savingsUSD: 2 }] }, history: { daily: [] }, + current: { + label: 'Today', cost: 9, calls, sessions: 1, oneShotRate: 0.5, inputTokens: 10, outputTokens: 5, cacheHitPercent: 50, + topActivities: [{ name: 'feature', cost: 9, turns: 5, oneShotRate: 0.5 }], topModels: [{ name: 'Opus 4.8', cost: 9, calls }], + providers: { 'claude code': 9 }, topProjects: [{ name: 'real-repo', cost: 9, sessions: 1, avgCostPerSession: 9, sessionDetails: [] }], + modelEfficiency: [], topSessions: [{ project: 'real-repo', cost: 9, calls, date: '2026-06-01' }], + retryTax: { totalUSD: 1, retries: 2, editTurns: 5, byModel: [{ name: 'Opus 4.8', taxUSD: 1, retries: 2, retriesPerEdit: 0.4 }] }, + routingWaste: { totalSavingsUSD: 1, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +async function connect(aggregate: any) { + const server = createServer({ version: 'test', aggregate }) + const [a, b] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '1' }) + await Promise.all([server.connect(a), client.connect(b)]) + return client +} + +describe('mcp server', () => { + it('exposes exactly two read-only tools', async () => { + const client = await connect(async () => fakePayload()) + const { tools } = await client.listTools() + expect(tools.map(t => t.name).sort()).toEqual(['get_savings', 'get_usage']) + expect(tools.find(t => t.name === 'get_usage')!.annotations?.readOnlyHint).toBe(true) + }) + it('get_usage hashes project names by default', async () => { + const client = await connect(async () => fakePayload()) + const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project' } }) + expect(JSON.stringify(res)).not.toContain('real-repo') + expect(JSON.stringify(res)).toMatch(/project-[0-9a-f]{6}/) + expect(res.isError).toBeFalsy() + }) + it('get_usage reveals names when opted in', async () => { + const client = await connect(async () => fakePayload()) + const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project', include_project_names: true } }) + expect(JSON.stringify(res)).toContain('real-repo') + }) + it('empty data returns a friendly message, not a zero table', async () => { + const client = await connect(async () => fakePayload(0)) + const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today' } }) + expect(res.content[0].text.toLowerCase()).toContain('no usage') + }) + it('aggregator failure surfaces as isError', async () => { + const client = await connect(async () => { throw new Error('boom') }) + const res: any = await client.callTool({ name: 'get_savings', arguments: {} }) + expect(res.isError).toBe(true) + expect(res.content[0].text).toContain('boom') + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npm test -- mcp-server` +Expected: FAIL ("Cannot find module '../src/mcp/server.js'"). + +- [ ] **Step 3: Implement the server** + +Create `src/mcp/server.ts`: + +```ts +import { z } from 'zod' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import { getDateRange } from '../cli-date.js' +import { loadPricing } from '../models.js' +import { buildMenubarPayloadForRange, type PeriodInfo } from '../usage-aggregator.js' +import type { MenubarPayload } from '../menubar-json.js' +import { redactProjectNames } from './redact.js' +import { renderSummaryTable, renderBreakdownTable, renderSavingsTable, type BreakdownBy } from './tables.js' + +const PERIOD = { today: 'today', last_7_days: 'week', last_30_days: '30days', month_to_date: 'month', last_6_months: 'all' } as const +type McpPeriod = keyof typeof PERIOD +const periodSchema = z.enum(['today', 'last_7_days', 'last_30_days', 'month_to_date', 'last_6_months']) + +type Aggregate = (periodInfo: PeriodInfo, opts: { provider?: string; optimize?: boolean }) => Promise + +const INSTRUCTIONS = + 'CodeBurn exposes local AI-coding spend data. Use get_usage for spend/usage and breakdowns (fast); ' + + 'use get_savings to find cost reductions (slower — runs a deeper analysis). Project names are pseudonymized ' + + 'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' + + 'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.' + +export function createServer(deps: { version: string; aggregate?: Aggregate }): McpServer { + const aggregate = deps.aggregate ?? buildMenubarPayloadForRange + const inflight = new Map>() + + const getPayload = (period: McpPeriod, optimize: boolean): Promise => { + const key = `${optimize ? 'sav' : 'use'}:${period}` + const existing = inflight.get(key) + if (existing) return existing + const { range, label } = getDateRange(PERIOD[period]) + const p = aggregate({ range, label }, { provider: 'all', optimize }).finally(() => inflight.delete(key)) + inflight.set(key, p) + return p + } + + const server = new McpServer({ name: 'codeburn', version: deps.version }, { instructions: INSTRUCTIONS }) + + server.registerTool( + 'get_usage', + { + title: 'CodeBurn — usage & cost', + description: + 'Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break ' + + 'it down by project, model, task, or provider (Claude Code / Cursor / Codex). Fast. Local to this machine.', + inputSchema: { + period: periodSchema.default('today'), + by: z.enum(['project', 'model', 'task', 'provider']).optional(), + limit: z.number().int().min(1).max(100).default(20), + include_project_names: z.boolean().default(false), + }, + outputSchema: { + period: z.string(), + empty: z.boolean(), + totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }), + breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(), + }, + annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true }, + }, + async ({ period, by, limit, include_project_names }): Promise => { + try { + const payload = redactProjectNames(await getPayload(period, false), include_project_names) + const c = payload.current + const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate } + if (c.calls === 0) { + return { content: [{ type: 'text', text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }], structuredContent: { period: c.label, empty: true, totals, breakdown: null } } + } + const text = by ? renderBreakdownTable(payload, by as BreakdownBy, limit) : renderSummaryTable(payload) + const breakdown = by ? breakdownRows(payload, by as BreakdownBy, limit) : null + return { content: [{ type: 'text', text }], structuredContent: { period: c.label, empty: false, totals, breakdown } } + } catch (err) { + return { content: [{ type: 'text', text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }], isError: true } + } + }, + ) + + server.registerTool( + 'get_savings', + { + title: 'CodeBurn — savings opportunities', + description: + 'Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing ' + + 'work), and routing waste (what you would have saved on a cheaper model). Slower than get_usage.', + inputSchema: { period: periodSchema.default('last_7_days'), include_project_names: z.boolean().default(false) }, + outputSchema: { + period: z.string(), + optimize: z.object({ findingCount: z.number(), savingsUSD: z.number(), topFindings: z.array(z.object({ title: z.string(), impact: z.string(), savingsUSD: z.number() })) }), + retryTaxUSD: z.number(), + routingWasteUSD: z.number(), + }, + annotations: { title: 'CodeBurn — savings opportunities', readOnlyHint: true, openWorldHint: false, idempotentHint: true }, + }, + async ({ period, include_project_names }): Promise => { + try { + const payload = redactProjectNames(await getPayload(period, true), include_project_names) + const c = payload.current + return { + content: [{ type: 'text', text: renderSavingsTable(payload) }], + structuredContent: { period: c.label, optimize: payload.optimize, retryTaxUSD: c.retryTax.totalUSD, routingWasteUSD: c.routingWaste.totalSavingsUSD }, + } + } catch (err) { + return { content: [{ type: 'text', text: `codeburn: failed to compute savings — ${err instanceof Error ? err.message : String(err)}` }], isError: true } + } + }, + ) + + return server +} + +function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> { + const c = p.current + if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost })) + if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost })) + if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost })) + return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost })) +} + +export async function startStdioServer(version: string): Promise { + await loadPricing() + const server = createServer({ version }) + // Pre-warm the parser cache for the common case; ignore failures. + void buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }).catch(() => {}) + await server.connect(new StdioServerTransport()) +} +``` + +> If the installed SDK rejects the raw-shape `inputSchema`/`outputSchema` (object of zod validators), wrap each in `z.object({ ... })` — the in-memory test in Step 4 will surface this immediately. Likewise, if `InMemoryTransport`'s import path differs, it is exported from `@modelcontextprotocol/sdk/inMemory.js` in v1. + +- [ ] **Step 4: Run to verify pass** + +Run: `npm test -- mcp-server` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mcp/server.ts tests/mcp-server.test.ts +git commit -m "feat(mcp): get_usage + get_savings tools with annotations, schemas, coalescing" +``` + +--- + +## Task 7: Wire the `codeburn mcp` command + +**Files:** +- Modify: `src/main.ts` (add command + stdout guard) + +- [ ] **Step 1: Add the command** + +In `src/main.ts`, alongside the other `.command(...)` registrations (e.g. after the `status` block), add: + +```ts +program + .command('mcp') + .description('Run a Model Context Protocol server (stdio) exposing usage + savings to AI agents') + .action(async () => { + // stdout MUST carry only JSON-RPC; route stray logs to stderr. + console.log = ((...args: unknown[]) => process.stderr.write(args.join(' ') + '\n')) as typeof console.log + const { startStdioServer } = await import('./mcp/server.js') + await startStdioServer(version) + }) +``` + +(`version` is already in scope at `main.ts:30`.) + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: succeeds. `src/mcp/server.ts` is reachable from the `main.ts` import graph (via the dynamic import), so tsup bundles it; the `@modelcontextprotocol/sdk` and `zod` imports stay external (Task 1). + +- [ ] **Step 3: Smoke-test the built server over stdio** + +Run: +```bash +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | node dist/cli.js mcp 2>/dev/null | head -2 +``` +Expected: two JSON-RPC result lines on stdout; the second lists `get_usage` and `get_savings`. No non-JSON noise on stdout (warnings, if any, went to stderr). + +- [ ] **Step 4: Verify the SDK is external in the bundle** + +Run: `node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); console.log('McpServer source inlined?', /class McpServer/.test(s))"` +Expected: `McpServer source inlined? false` (it's imported from node_modules at runtime, not bundled). + +- [ ] **Step 5: Commit** + +```bash +git add src/main.ts +git commit -m "feat(mcp): add 'codeburn mcp' stdio command with stdout guard" +``` + +--- + +## Task 8: Full suite + final verification + +**Files:** none (verification) + +- [ ] **Step 1: Run everything** + +Run: `npx tsc --noEmit && npm test && npm run build` +Expected: typecheck clean; all tests pass (incl. the pre-existing suite — parity preserved); build succeeds. + +- [ ] **Step 2: Manual end-to-end against real data (optional but recommended)** + +Run: `node dist/cli.js mcp` then, in another shell, point a local MCP client (or the smoke command from Task 7) at it and call `get_usage {"period":"today"}` and `get_savings {"period":"last_7_days"}`. Confirm tables render and project names are hashed. + +- [ ] **Step 3: Commit any cleanup** + +```bash +git add -A && git commit -m "chore(mcp): final verification" || echo "nothing to commit" +``` + +--- + +## Deferred (not in v1 — see spec §10 and note below) + +- **Per-provider graceful degradation (`degraded[]`).** The spec (§5/§8) called for `allSettled` per-provider isolation. That requires changing the shared `parseAllSessions` loop (`parser.ts:2133`), which every command uses — out of scope for v1 to avoid destabilizing the parser. v1 handles failures at the tool boundary (`isError: true` with the message). A malformed provider aborting a scan is a **pre-existing** behavior shared with the `status`/menubar path, not introduced here. *This is the one intentional deviation from the committed spec.* +- HTTP/SSE transport, `compare_periods`, MCP prompts/resources, README + registry listings. + +## Self-Review + +- **Spec coverage:** in-process stdio server (Task 6/7) ✓; cheap vs optimize split via `optimize` flag (Task 3, refined — `scanAndDetect` is the only expensive call) ✓; 2 tools with annotations + outputSchema + isError (Task 6) ✓; hash-by-default redaction (Task 4) ✓; in-flight coalescing + pre-warm (Task 6) ✓; tsup external + pinned deps (Task 1) ✓; stdout guard (Task 7) ✓; period-enum rename + `last_6_months` semantics (Task 6 + instructions) ✓; empty-state message (Task 6) ✓; token discipline via `limit` + summarized history (Task 6, no daily array returned) ✓. Deviation: per-provider `degraded[]` deferred (documented above). +- **Placeholders:** none — every code step has full source; the only "move verbatim" steps (Tasks 2–3) are mechanical relocations of existing, cited code, gated by the existing parity test. +- **Type consistency:** `buildMenubarPayloadForRange(PeriodInfo, AggregateOpts)`, `redactProjectNames(MenubarPayload, boolean)`, `renderBreakdownTable(payload, BreakdownBy, limit)`, `createServer({version, aggregate})` — names/signatures consistent across tasks and tests. diff --git a/docs/design/codeburn-mcp.md b/docs/design/codeburn-mcp.md new file mode 100644 index 0000000..b2c7a31 --- /dev/null +++ b/docs/design/codeburn-mcp.md @@ -0,0 +1,125 @@ +# CodeBurn MCP Server — Design Spec + +- **Date:** 2026-06-02 +- **Status:** Approved design (pre-implementation) +- **Author:** brainstormed + validator/devil's-advocate hardened + +## 1. Context & Goal + +CodeBurn already aggregates rich AI-coding usage/cost data (by task, model, project, provider; retry tax; routing waste; optimize findings; 365-day history). This spec adds an **MCP server** that exposes that data to AI agents (Claude Code, Cursor, Claude Desktop) so an agent can answer "where did my tokens go?" and "how do I spend less?" mid-conversation. + +**Why (and why not):** This is a **product / differentiation** play, not a downloads play. We measured that MCP is a niche *download* lever (`@ccusage/mcp` ≈ 1.1% of ccusage's downloads; competitor tokscale ships no MCP), and the real download lever is npx-first CLI positioning — tracked separately. The MCP's value is agent-facing utility on data competitors don't expose (retry tax, routing waste, one-shot rate, task attribution). + +## 2. Decisions (from brainstorming) + +1. **Use case:** unified — serves both live self-optimization and historical analysis from one tool set. +2. **Output contract:** every tool returns a ready-to-display **markdown table** *and* the same data as **structured JSON**. +3. **Architecture:** Approach A — a `codeburn mcp` subcommand on the existing CLI (no second package), reusing the existing aggregation; run as a **long-lived in-process stdio server**. +4. **Tool surface:** **2 tools** — `get_usage` and `get_savings`. (`compare_periods` cut — no reusable backend, overlap-incoherent.) +5. **Privacy:** project/session names **hashed by default** (`project-<6hex>`); real names only when `include_project_names: true`. Absolute paths never exposed. + +## 3. Architecture + +### Process model +- `codeburn mcp` starts a **resident, in-process** MCP server over **stdio** (`StdioServerTransport`). Not exec-per-call — a resident process is required so the existing in-process session cache (180s TTL, `src/parser.ts`) amortizes across tool calls. Measured cost otherwise: `--period all` is ~17.6s **even warm** when the process exits between calls. +- **First line of the `mcp` action:** `console.log = console.error`. The aggregation path is stdout-clean today (writes go to stderr), but the global `preAction` hook and `runOptimize` contain stdout `console.log`s; reassigning immunizes the JSON-RPC stream against any present or future stdout write. (Verified: `scanAndDetect`, `parseAllSessions`, providers, caches, `buildMenubarPayload` do not write to stdout.) +- Pre-warm `today` usage on boot so the first interactive call is fast. + +### Modules +- **`src/usage-aggregator.ts`** *(new)* — extracted from the inline logic in the `status` handler (`src/main.ts` ~476–760): + - `buildUsage(period, opts): Promise` — **cheap path**, no optimize pass. + - `buildSavings(period, opts): Promise` — adds `scanAndDetect` (optimize findings + retry tax + routing waste). + - `opts: { provider?: string; project?: string[]; exclude?: string[]; range?: ResolvedRange }`. The `provider` and project/range filters are **required** for parity — the `status` body forks on `isAllProviders` and resolves a range from `day/days/from/to` before `period`. `status --format menubar-json` is refactored to call these (one shared path). +- **`src/mcp/server.ts`** *(new)* — builds `McpServer`, registers the 2 tools, connects `StdioServerTransport`, owns the in-flight coalescing map and the empty-state messages. +- **`src/mcp/tables.ts`** *(new)* — compact markdown renderers per slice, reusing `format.ts` and `models-report.ts` where they already render tables. +- **`src/mcp/redact.ts`** *(new)* — stable pseudonym hashing for project/session names; applied unless the caller passes `include_project_names: true`. +- **`src/main.ts`** — add `.command('mcp')` that dynamic-`import()`s `./mcp/server.js` and starts it. + +### Dependencies & build +- Add to **`dependencies`**: `@modelcontextprotocol/sdk@^1.29.0` (v1 line; import paths `@modelcontextprotocol/sdk/server/mcp.js` and `/server/stdio.js`) and `zod@^3.25` (NOT transitive — it's a peer dep of the SDK and absent from the lockfile today). +- Add to **`tsup.config.ts`**: `external: ['@modelcontextprotocol/sdk', 'zod']`. The current config bundles all deps (`splitting: false`, no `external`), so without this a dynamic import would inline the SDK (~MBs) into `dist/main.js`. Externalizing keeps `dist` small and makes the dynamic import a real lazy load from `node_modules`. (`files: ["dist"]` means externalized deps must be runtime `dependencies`.) +- Pin the SDK major: a separately-named v2 package exists with different import paths; `^1.29.0` keeps the v1 paths valid. + +## 4. Tool Surface + +Period enum (LLM-clear names, mapped internally): `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all`. Documented: `last_6_months` is the maximum window (codeburn's "all" = ~6 months); history is summarized, not dumped. + +Both tools carry annotations `{ readOnlyHint: true, openWorldHint: false, idempotentHint: true, title }`, a zod `inputSchema` and `outputSchema`, and return `{ content: [{ type:'text', text: }], structuredContent: }`. + +### 4.1 `get_usage` +- **title:** "CodeBurn — usage & cost" +- **description (agent-facing):** "Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break it down by project, model, task, or provider. Fast (does not run the deeper savings analysis). Data is local to this machine and current as of the last scan." +- **inputSchema:** + - `period?: enum` (default `today`) + - `by?: "project" | "model" | "task" | "provider"` + - `limit?: number` (default 20, max 100) — row cap for breakdowns + - `include_project_names?: boolean` (default `false`) +- **Behavior:** no `by` → headline (cost, calls, sessions, input/output tokens, cache-hit %, one-shot rate) + top-N models/projects/tasks. With `by` → one ranked table for that dimension (`project→topProjects`, `model→topModels`, `task→topActivities`, `provider→providers` cost map). Uses `buildUsage` (cheap path). +- **outputSchema:** the relevant subset of `MenubarPayload.current` (typed). + +### 4.2 `get_savings` +- **title:** "CodeBurn — savings opportunities" +- **description (agent-facing):** "Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing work), and routing waste (what you'd have saved on a cheaper model). Runs a deeper analysis, so it is slower than get_usage." +- **inputSchema:** `period?: enum` (default `last_7_days` — never default to the slow `last_6_months`), `include_project_names?: boolean` (default `false`). +- **Behavior:** runs `buildSavings`; returns optimize findings (title, impact, $ saved), retry tax (total + by model), routing waste (total savings, baseline model, by model). +- **outputSchema:** `{ optimize, retryTax, routingWaste }` (typed). + +### 4.3 Server `instructions` +> "CodeBurn exposes local AI-coding spend data. Use `get_usage` for spend/usage and breakdowns (fast); use `get_savings` to find cost reductions (slower). Project names are pseudonymized unless you pass `include_project_names: true`. All data is read locally from this machine; `last_6_months` is the widest window. Numbers reflect the most recent scan and may lag the current in-flight session by a short interval." + +## 5. Data Flow + +``` +agent tool call + → zod inputSchema validation (invalid params → protocol error, auto) + → in-flight coalesce on {kind, period, opts-hash} (parallel callers share one scan) + → buildUsage / buildSavings(period, {provider:'all', ...}) + → parseAllSessions with PER-PROVIDER allSettled isolation → degraded[] + → existing 180s in-process session cache amortizes + → redact project/session names unless include_project_names + → render markdown table + build structuredContent (validated vs outputSchema) + → return { content:[{type:'text',text}], structuredContent } (isError:true on failure) +``` + +## 6. Privacy / Redaction +- Name-bearing fields — `topProjects[].name`, `topSessions[].project`, `topProjects[].sessionDetails` — are replaced with stable pseudonyms `project-<6hex(sha256(name))>` unless `include_project_names: true`. +- Absolute paths are never emitted (they aren't in the payload today; the MCP must not enrich with them). +- Rationale: an MCP is an egress surface to possibly-remote/cloud agents; codeburn's brand is "data never leaves your machine." Hashing keeps breakdowns coherent (stable pseudonyms) without leaking identity; local users who want real names opt in per call. + +## 7. Performance +- **Two paths:** `get_usage` uses the cheap aggregation (`--no-optimize` seam already exists; ~3s `today`, ~5s `all`). `get_savings` runs the optimize pass (~+13s) — isolated to the one tool that needs it. Without this split, every tool paid the 13s tax. +- **Resident process** + existing 180s session cache → warm calls are cheap. +- **In-flight coalescing:** concurrent calls for the same `{kind, period, opts}` await a single scan (agents fire tools in parallel). +- **Pre-warm** `today` usage on boot. +- **Token discipline:** breakdowns capped at `limit` (default 20); history is summarized (totals + top-N), never the full 365-day array; tables are compact. + +## 8. Error Handling +- Invalid params → zod → MCP protocol error (auto). +- No data for period (fresh install) → `isError: false` with a friendly "no usage recorded for yet — run some coding sessions" message (not a zero-filled table). +- One provider fails to parse → isolate via `allSettled`, return partial data, list skipped providers in `degraded[]` and a table footer note. +- Aggregation throw / payload-shape mismatch → `isError: true` with a clear message (incl. version-skew hint); never hang the transport. + +## 9. Testing +- **Parity:** `buildUsage('today', {provider:'all'})` deep-equals current `status --format menubar-json --period today` (plus one more period). Guards the extraction. +- **Per-tool:** fixture payload → expected markdown table (snapshot) + structuredContent keys; `by` each dimension; redaction on/off (no real names/paths leak when off; pseudonyms stable); empty-state message. +- **MCP protocol smoke:** in-memory transport — `listTools` returns the 2 tools with annotations + outputSchema; call each; assert result shape (`content` + `structuredContent`) and `isError` paths; concurrent identical calls coalesce to one scan. +- **Build:** assert SDK + zod are externalized (absent from `dist`) and declared in `dependencies`. + +## 10. Out of Scope (v1 — YAGNI) +- HTTP/SSE transport (stdio only). +- `compare_periods` (agent can diff two `get_usage` calls; backend is net-new and overlap-incoherent). +- Write/mutating tools, auth, multi-machine/remote data. +- MCP **prompts** and **resources** primitives (tools-only v1; revisit if a "cost-review" prompt template proves useful). +- README "MCP" section and MCP-registry listings (lobehub/Smithery/mcp.so) — follow-up tasks, not code. + +## 11. Provenance — review findings incorporated +Hardened by a validator + devil's-advocate pass: +- **BLOCKER** in-process resident server (caches don't help exec-per-call) — §3, §7. +- **BLOCKER** split cheap vs optimize path (optimize ≈ 70% of cost) — §7. +- **BLOCKER** redact names by default (raw repo names + dated spend would egress) — §6. +- **MAJOR** `buildUsage/buildSavings` signature carries provider/project/range for parity — §3. +- **MAJOR** tsup `external` + deps in `dependencies`; pin SDK `^1.29.0`; add `zod` explicitly — §3. +- **MAJOR** per-provider `allSettled` isolation + `degraded[]` — §5, §8. +- **MAJOR** in-flight coalescing — §5, §7. +- **MAJOR** drop `compare_periods`; rename period enums; default `today` — §4, §10. +- **MINOR** `console.log→console.error` stdout guard; friendly empty-state; run compiled `dist` not `tsx`; validate payload shape; `last_6_months` is the real max — §3, §8, §4. diff --git a/docs/providers/README.md b/docs/providers/README.md new file mode 100644 index 0000000..5ef798a --- /dev/null +++ b/docs/providers/README.md @@ -0,0 +1,68 @@ +# Provider Docs + +One file per provider integration. If you are fixing a bug or adding a feature scoped to a single provider, read the file for that provider first; it tells you which file to edit, where on disk the source data lives, and what edge cases the test suite already covers. + +For the architectural picture, see `../architecture.md`. + +## Provider Index + +### Eager (always loaded) + +| Provider | Storage | Source | Test | +|---|---|---|---| +| [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) | +| [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` | +| [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` | +| [Copilot](copilot.md) | JSONL + SQLite (OTel) + Nitrite .db (JetBrains) | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | +| [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.test.ts` | +| [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` | +| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none | +| [Hermes Agent](hermes.md) | SQLite | `src/providers/hermes.ts` | `tests/providers/hermes.test.ts` | +| [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` | +| [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` | +| [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` | +| [Kimi](kimi.md) | JSONL | `src/providers/kimi.ts` | `tests/providers/kimi.test.ts` | +| [LingTai TUI](lingtai-tui.md) | JSONL | `src/providers/lingtai-tui.ts` | `tests/providers/lingtai-tui.test.ts` | +| [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` | +| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` | +| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` | +| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` | +| [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none | +| [Roo Code](roo-code.md) | JSON | `src/providers/roo-code.ts` | `tests/providers/roo-code.test.ts` | +| [Zerostack](zerostack.md) | JSON | `src/providers/zerostack.ts` | `tests/providers/zerostack.test.ts` | +| [Grok Build](grok.md) | JSON/JSONL | `src/providers/grok.ts` | `tests/providers/grok.test.ts` | + +### Lazy (loaded on first call) + +| Provider | Storage | Source | Test | +|---|---|---|---| +| [Antigravity](antigravity.md) | protobuf over RPC | `src/providers/antigravity.ts` | none | +| [Crush](crush.md) | SQLite (per-project) | `src/providers/crush.ts` | `tests/providers/crush.test.ts` | +| [Forge](forge.md) | SQLite | `src/providers/forge.ts` | `tests/providers/forge.test.ts` | +| [Cursor](cursor.md) | SQLite | `src/providers/cursor.ts` | `tests/providers/cursor.test.ts` | +| [Cursor Agent](cursor-agent.md) | text / JSONL | `src/providers/cursor-agent.ts` | `tests/providers/cursor-agent.test.ts` | +| [Goose](goose.md) | SQLite | `src/providers/goose.ts` | none | +| [OpenCode](opencode.md) | SQLite | `src/providers/opencode.ts` | `tests/providers/opencode.test.ts` | +| [Warp](warp.md) | SQLite | `src/providers/warp.ts` | `tests/providers/warp.test.ts` | +| [Vercel AI Gateway](vercel-gateway.md) | REST API | `src/providers/vercel-gateway.ts` | `tests/providers/vercel-gateway.test.ts` | +| [ZCode](zcode.md) | SQLite | `src/providers/zcode.ts` | `tests/providers/zcode.test.ts` | + +### Shared + +| Helper | Used by | Source | +|---|---|---| +| [vscode-cline-parser](vscode-cline-parser.md) | `cline`, `ibm-bob`, `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` | + +## File Format + +Each provider doc has the same structure: + +1. **One-line summary** of what the provider integrates. +2. **Where it reads from** on disk (or over RPC). +3. **Storage format** and validation rules. +4. **Caching** (which cache layer, if any). +5. **Deduplication key** so you understand cross-provider dedup. +6. **Quirks** that have bitten us before. +7. **When fixing a bug here** as a checklist. + +If you add a new provider, copy `claude.md` as a template and fill in your provider's specifics. Update this index, and prefer adding a real test fixture under `tests/providers/`. diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md new file mode 100644 index 0000000..4438c04 --- /dev/null +++ b/docs/providers/antigravity.md @@ -0,0 +1,70 @@ +# Antigravity + +Google Antigravity (CLI and IDE). CodeBurn discovers session files on disk and queries the local language-server RPC endpoint to parse them. + +- **Source:** `src/providers/antigravity.ts` +- **Loading:** lazy via `src/providers/index.ts`. Lazy because the protobuf dependency is heavy. +- **Test:** focused helper coverage in `tests/providers/antigravity.test.ts`. + +## Where it reads from + +CodeBurn discovers Antigravity sessions from local directories on disk, then queries the live language-server process (if running) to fetch detailed trajectory generator metadata: + +1. **Session Discovery:** It scans the following folders for `.pb` or `.db` files: + - **Antigravity CLI:** `%USERPROFILE%\.gemini\antigravity-cli\conversations` (and `implicit`) + - **Antigravity App/older path:** `%USERPROFILE%\.gemini\antigravity\conversations` + - **Antigravity IDE (VSCode-based):** `%USERPROFILE%\.gemini\antigravity-ide\conversations` (and `implicit`) +2. **Language Server RPC Query:** It locates the active language-server process via `ps` on POSIX or `Get-CimInstance Win32_Process` on Windows. It extracts the port and CSRF token from the process arguments, and queries the local HTTPS RPC endpoint `GetCascadeTrajectoryGeneratorMetadata` to parse the session. +3. **Cache Fallback:** If the language server is not running, it falls back to the local results cache. + +Antigravity exposes slightly different process flags across platforms: +POSIX builds have used `--https_server_port` and `--csrf_token`; Windows +builds can expose `--extension_server_port` and +`--extension_server_csrf_token`. Both space-separated and `--flag=value` +forms are supported. The parser identifies the target app type using the `--app-data-dir` flag (e.g., `antigravity`, `antigravity-cli`, or `antigravity-ide`). + +For Antigravity CLI (`agy`), CodeBurn can also install an opt-in status line +hook with `codeburn antigravity-hook install`. The hook records the CLI's +sanitized `context_window.current_usage` payload while `agy` is still alive, +without prompts or local working-directory paths. It also attempts a best-effort +RPC snapshot for full response metadata. The installed command points at a +persistent `codeburn` binary from PATH rather than a local build artifact, and +running `codeburn antigravity-hook install` again repairs older CodeBurn-owned +statusLine commands that used stale absolute paths. Remove it with `codeburn +antigravity-hook uninstall`; if `--force` replaced an existing statusLine +command, uninstall restores that previous command. + +## Storage format + +Protobuf. Cascade and response objects map to `ParsedProviderCall` directly. + +## Caching + +Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute. + +## Deduplication + +Per `:` for RPC data. The status line fallback collapses +repeated identical usage snapshots, ignores singleton intermediate snapshots +when a later stabilized usage total is observed for the same conversation, and +uses positive deltas for monotonic snapshots so cumulative counters are not +double-counted. + +## Quirks + +- **Antigravity is the only provider that requires a live process.** A user who closes Antigravity loses the most-recent data until next launch (the cache covers older runs). +- **Antigravity CLI has a shorter capture window than the desktop app.** `agy` + exposes its language server only while the CLI session is active. The status + line hook closes that gap for future sessions; older CLI `.pb` files still + cannot be priced exactly unless an RPC snapshot was captured. +- The 16 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine. +- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens`. Thinking is billed at output rate. + +## When fixing a bug here + +1. Reproducing the full provider path requires Antigravity running locally. + The unit tests cover process flag parsing and wrapped/unwrapped RPC response + extraction, but they do not stand up a live Antigravity RPC endpoint. +2. Before any change, capture a sample protobuf response (anonymized) so future regressions can be tested against a recording. +3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling is the place to look. +4. If the bug is "stale data", check whether the RPC is reachable; the cache fallback can mask connectivity issues. diff --git a/docs/providers/claude.md b/docs/providers/claude.md new file mode 100644 index 0000000..b5954c1 --- /dev/null +++ b/docs/providers/claude.md @@ -0,0 +1,54 @@ +# Claude + +Anthropic Claude Code CLI and Claude Desktop's local agent mode. + +- **Source:** `src/providers/claude.ts` +- **Loading:** eager (`src/providers/index.ts:1`) +- **Test:** none directly. Coverage comes from `tests/parser-claude-cwd.test.ts`, `tests/parser-filter.test.ts`, and `tests/parser-mcp-inventory.test.ts`, which exercise `src/parser.ts` end-to-end against fixture session files. + +## Where it reads from + +| Source | Path | +|---|---| +| Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` | +| Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` | + +For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`. + +## Storage format + +JSONL, one event per line, per session file. Sessions live under `/.jsonl`. + +## Parser + +`createSessionParser` returns an empty async generator (`claude.ts:101-105`). Claude is a special case: `src/parser.ts` reads Claude JSONL files directly with full turn grouping, dedup of streaming message IDs, and MCP tool inventory extraction. The provider object exists only so `discoverSessions` can return Claude session sources alongside the others. + +## Pricing + +Claude Code reports total cache-write tokens in `usage.cache_creation_input_tokens`. +When available, it also splits those writes by duration in +`usage.cache_creation.ephemeral_5m_input_tokens` and +`usage.cache_creation.ephemeral_1h_input_tokens`. CodeBurn keeps the existing +aggregate cache-write token total for reports, but prices the 1-hour portion at +2x base input cost (1.6x the 5-minute cache-write rate exposed by LiteLLM). +If the split fields are missing, the parser falls back to the legacy behavior +and prices every cache write at the 5-minute rate. + +## Caching + +None at the provider level. The daily aggregation cache (`src/daily-cache.ts`) reuses prior computed days. + +## Quirks + +- The parser is in `src/parser.ts`, not in `src/providers/claude.ts`. Anything that changes Claude parsing belongs in `parser.ts`. +- Streaming responses produce duplicate message IDs across resumed sessions; `parser.ts` strips them via the global `seenMsgIds` Set. +- Model display names are mapped in `claude.ts:7-20`; add new versions there when Anthropic releases them. + +## When fixing a bug here + +1. Confirm whether the bug is in **discovery** (sessions not picked up) or **parsing** (sessions found but data wrong). +2. Discovery bugs live in `claude.ts:78-99`. Verify the directory layout you expect actually matches what Claude writes today. +3. Parsing bugs live in `src/parser.ts`. Look for `parseSessionFile`, `groupIntoTurns`, and `dedupeStreamingMessageIds`. +4. Add a fixture under `tests/fixtures/` and a test under `tests/parser-claude-cwd.test.ts` (or a new file). Do not mock the filesystem. diff --git a/docs/providers/cline.md b/docs/providers/cline.md new file mode 100644 index 0000000..65f27ea --- /dev/null +++ b/docs/providers/cline.md @@ -0,0 +1,50 @@ +# Cline + +Cline VS Code extension and Cline home-data task storage. + +- **Source:** `src/providers/cline.ts` +- **Loading:** eager (`src/providers/index.ts:2`) +- **Test:** `tests/providers/cline.test.ts` + +## Where it reads from + +Two task roots are scanned: + +1. VS Code extension globalStorage for `saoudrizwan.claude-dev`. +2. Cline's home-data root at `~/.cline/data`. + +Both roots are expected to contain a `tasks/` child directory. Discovery is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`, so a task is only included when it has a `ui_messages.json` file. + +## Storage format + +Per-task directories with: + +``` +tasks// + ui_messages.json + api_conversation_history.json + task_metadata.json +``` + +`ui_messages.json` provides the `api_req_started` usage entries. `api_conversation_history.json` is used for model extraction. See [`vscode-cline-parser`](vscode-cline-parser.md) for the full schema description. +`task_metadata.json` is part of Cline's task layout but is not read by CodeBurn today. + +## Caching + +None at the provider level; delegates to the shared helper and normal parser/cache layers. + +## Deduplication + +Discovery deduplicates by task id across the two Cline roots so a migrated task is not scanned twice. If the same task id exists in multiple roots, the one with the newest `ui_messages.json` wins. Parsing still uses the shared per-call key: `::`. + +## Quirks + +- This provider is intentionally a thin wrapper over the shared Cline-family parser. +- Cline can keep data in both VS Code globalStorage and `~/.cline/data`, depending on version and workflow. +- If Cline changes the JSON shape, fix `vscode-cline-parser.ts` only if Roo Code and KiloCode still pass. Branch provider-specific parsing rather than duplicating the whole parser. + +## When fixing a bug here + +1. Reproduce with a minimal task directory containing `ui_messages.json` and `api_conversation_history.json`. +2. Run `tests/providers/cline.test.ts`, plus `tests/providers/roo-code.test.ts` and `tests/providers/kilo-code.test.ts` if the shared parser changes. +3. Keep the provider name `cline`; downstream filters and dedup keys depend on it. diff --git a/docs/providers/codex.md b/docs/providers/codex.md new file mode 100644 index 0000000..268fd35 --- /dev/null +++ b/docs/providers/codex.md @@ -0,0 +1,50 @@ +# Codex + +OpenAI Codex CLI. + +- **Source:** `src/providers/codex.ts` +- **Loading:** eager (`src/providers/index.ts:2`) +- **Test:** `tests/providers/codex.test.ts` (374 lines) + +## Where it reads from + +`$CODEX_HOME` if set, otherwise `~/.codex`. Sessions are nested by date: + +``` +~/.codex/sessions///
/rollout-*.jsonl +``` + +The discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on each path component. + +## Storage format + +JSONL. The first line must be a `session_meta` entry with `payload.originator` starting with `codex` (case-insensitive). Files that fail this check are silently skipped. + +The first line read is capped at 1 MB (`FIRST_LINE_READ_CAP`). Codex CLI 0.128+ embeds the full system prompt in `session_meta`, which can run 20-27 KB; the cap leaves headroom while bounding memory if a corrupt file has no newline. + +## Caching + +`src/codex-cache.ts` writes `~/.cache/codeburn/codex-results.json` (or `$CODEBURN_CACHE_DIR/codex-results.json`). Each entry is keyed by absolute file path and validated against `mtimeMs + sizeBytes`. Cached entries are returned wholesale. + +A session that yielded zero parseable lines does **not** write to the cache (`codex.ts:419`); this prevents a transient read failure from pinning an empty result against a fingerprint. + +## Deduplication + +`codex:::` for accounted events, plus `codex:::est` for estimated events that fall back to char-counting. + +## Quirks + +- Codex CLI emits both `last_token_usage` (per turn) and `total_token_usage` (cumulative). The parser handles three modes: + 1. `last_token_usage` present: use it directly. + 2. Only cumulative: compute deltas against the prior turn. + 3. Neither: estimate from message text length (`CHARS_PER_TOKEN = 4`). +- `prevCumulativeTotal` is initialized to `null`, not `0`. A session whose first event reports `total = 0` would otherwise be dropped as a "duplicate" of the initial state. +- `prev*` token counters are advanced on **every** event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes. +- OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate). + +## When fixing a bug here + +1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`. +2. If the bug is "zero tokens reported", first check whether the file is being skipped by `isValidCodexSession`. +3. If the bug is "tokens counted twice", look at `prevCumulativeTotal` and the prev-counter advancement. +4. If you change the dedup key shape, run `tests/providers/codex.test.ts` and `tests/parser-filter.test.ts` together; cross-provider dedup happens via the global `seenKeys` Set. diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md new file mode 100644 index 0000000..478687e --- /dev/null +++ b/docs/providers/copilot.md @@ -0,0 +1,192 @@ +# Copilot + +GitHub Copilot Chat (CLI, VS Code core chat sessions, VS Code extension transcripts, and JetBrains IDE sessions). + +- **Source:** `src/providers/copilot.ts` +- **Loading:** eager (`src/providers/index.ts:3`) +- **Test:** `tests/providers/copilot.test.ts` + +## Where it reads from + +Three JSONL locations plus an optional OpenTelemetry SQLite source (see below). OTel is +preferred when present; chatSessions are only discovered when no OTel source is found. +Other discovered sources are walked on every run; results merge and dedupe. + +1. **Legacy CLI sessions:** `~/.copilot/session-state/` +2. **VS Code core chat sessions:** `~/Library/Application Support/Code/User/workspaceStorage//chatSessions/*.jsonl` plus `~/Library/Application Support/Code/User/globalStorage/emptyWindowChatSessions/*.jsonl` and equivalents on Windows / Linux +3. **VS Code transcripts:** `~/Library/Application Support/Code/User/workspaceStorage//GitHub.copilot-chat/transcripts/` and equivalents on Windows / Linux +4. **OTel SQLite store:** VS Code Copilot Chat's `agent-traces.db` (see the OTel section). Preferred when present because it carries full input / output / cache token counts; legacy JSONL sources only record output tokens. +5. **JetBrains IDE sessions:** `~/.config/github-copilot////copilot-*-nitrite.db` (see the JetBrains section). Covers IntelliJ IDEA, PyCharm, RubyMine, etc. + +## Storage format + +JSONL in the first three locations (schemas differ; the parser switches by source type / event shape), a SQLite DB for the OTel source, and a Nitrite (H2 MVStore) `.db` for the JetBrains source. VS Code core chat sessions use a delta journal: `kind:0` sets the root object, `kind:1` writes a value at path `k`, and `kind:2` appends items to an array path. + +## OpenTelemetry (OTel) source + +When VS Code Copilot Chat's `agent-traces.db` exists, the parser reads per-LLM-call token +breakdowns (input, output, cache-read, cache-creation) from it, which the JSONL sources do +not record. Discovery is skipped with `CODEBURN_COPILOT_DISABLE_OTEL=1`, and the DB path +can be overridden with `CODEBURN_COPILOT_OTEL_DB`. + +If OTel discovery finds at least one source, workspace `chatSessions/*.jsonl` and +`emptyWindowChatSessions/*.jsonl` are skipped. Those journals can mirror the same Copilot +turns under IDs that do not match OTel turn IDs, so CodeBurn prefers the richer OTel data +instead of trying to dedupe across stores. + +- **Requires Node 22+.** The OTel source uses the built-in `node:sqlite` module (the same + backend as Cursor / OpenCode). On Node 20, or if the DB is missing / locked / corrupt / + wrong-schema, OTel is skipped and the JSONL/transcript sources are used as a fallback. +- **Durable cache (monotonic totals).** Copilot is marked `durableSources`: OTel-derived + cache entries are never evicted when VS Code prunes old spans from the DB, so + month-to-date totals do not drop as the DB rotates. Entries age out after 90 days. +- **Upgrade note.** The first run after upgrading to the OTel version bumps the copilot + parse version, which discards the prior copilot cache. Spans already pruned from the DB + before the upgrade cannot be recovered, so monotonicity starts from the upgrade point, + not retroactively. + +## JetBrains IDEs (IntelliJ, PyCharm, …) + +The JetBrains Copilot plugin does **not** write to any of the VS Code or CLI +locations above. It persists chat/agent sessions under the shared GitHub Copilot +config root, in one store directory per session store: + +``` +~/.config/github-copilot//// + copilot-*-nitrite.db # Nitrite (H2 MVStore) — the session content + blobs/ +``` + +`` is a per-IDE dir (`iu` for IntelliJ IDEA Ultimate, `intellij` for the +community edition, `PyCharm2025.2`, …). `` ∈ `chat-agent-sessions`, +`chat-sessions`, `chat-edit-sessions` (agent / ask / edit mode). The root follows +XDG rules: `$XDG_CONFIG_HOME/github-copilot` when set, else +`~/.config/github-copilot` (macOS / Linux) or `%LOCALAPPDATA%\github-copilot` +(Windows). + +**Storage: the Nitrite `.db`.** An H2 MVStore file (header +`H:2,block:9,…format:3`) of Java-serialized Nitrite documents (`NtAgentSession`, +`NtAgentTurn`). It is read as `latin1` (byte-offset-stable, lossless) and scanned +— no Java deserializer, no new deps, and it is **not** SQLite so `node:sqlite` is +not used. Each assistant reply is a `{"__first__":{"type":"Subgraph",…}}` blob. +`extractResponseText` recovers the reply by unescaping one level at a time and, +at the first depth where the record markers appear bare, reading the reply +**structurally** (the payload is parsed as a delimited JSON-string literal, so a +reply containing its own quotes is never truncated). + +**Two turn shapes, both handled** (a blob is one or the other — verified across +every observed store that they never coexist): + +- **Ask mode** — the reply is a `Markdown` record's `text`. +- **Agent / plan mode** (agent sessions, `/plan …`, e.g. in PyCharm) — the reply + is the `reply` field of an `AgentRound` record; here the `Markdown` records + hold the *user's* prompt instead. The mode is decided by the **presence** of an + `AgentRound` record, and only its `reply` is read — so an agent turn with an + empty reply (a failed turn or a pure tool-call round) is billed **$0** rather + than falling back to the prompt. A multi-round blob contributes every non-empty + round's reply. + +Sidecar records that plan/agent mode also writes — `Thinking` (chain-of-thought), +`PendingChanges` (proposed code diff, stored under `content` not `data`), +`AskQuestion`, `Notification`, `SubTurn`, and file-read `text` results — are +**not** billable assistant output and are deliberately skipped. User prompts are +the simpler `{"":{"type":"Value",…}}` value-maps. + +**Old plugin format (≤1.5.x, e.g. 1.5.59-243).** Older plugins do not write +per-turn `__first__`/Subgraph blobs at all — they store the whole session as ONE +binary-framed outer Nitrite document of UUID-keyed `Value` entries, with the +`AgentRound` records one escaping level deeper. When the Subgraph scan finds no +turns but the raw file contains `AgentRound` text, a fallback locates that outer +document (`extractJetBrainsDbTurns`), runs it through the same +`extractResponseText` depth-unescape, and emits **one session-level call** per +document (all rounds' replies joined). Cost and tokens are correct; only the +per-turn call-count granularity is coarser than the new format — an accepted +tradeoff for legacy data. The fallback is gated on the new-format scan yielding +nothing, so current sessions are never affected or double-counted. + +(Store dirs may also contain a legacy `00000000000.xd` Xodus log from older +plugin versions. On every installation observed it is either empty or shadowed +by the `.db`, so CodeBurn reads only the `.db`. If a real `.xd`-only session ever +surfaces, add a reader with a captured fixture.) + +- **No token accounting.** No store records token counts. Output tokens are + **estimated** from the reply text via `estimateTokens` (`CHARS_PER_TOKEN = 4`, + as for Cursor and legacy Copilot JSONL); input tokens are 0; every JetBrains + call is marked `costIsEstimated: true`. +- **Errored turns.** A failed generation ("Sorry, an error occurred …") is stored + as an assistant blob with an error status and no reply text; it is detected and + billed **$0** (not conflated with an empty success). In agent mode a failed turn + has an empty `AgentRound` reply — the parser does not fall back to the prompt + `Markdown`, so the user's words are never billed as the assistant's output. +- **Per-turn model.** The model varies per turn within one `.db`. It is recovered + from inside the assistant blob when present, else a store-wide default, else a + generic Copilot bucket. Dotted Claude names are normalised to canonical ids + (`claude-opus-4.5` → `claude-opus-4-5`); GPT/Gemini names kept verbatim. +- **Duplicates.** The store keeps several byte-copies of each reply (original, + lowercased, revisions); assistant turns are de-duplicated by reply content. +- **One `.db` holds many chat tabs.** A single store `.db` contains multiple + conversations, each with an internal GUID and an evolving title + (`New Agent Session` → auto-name → final title). CodeBurn recovers the + `GUID → title` map (`extractJetBrainsConversations`, keeping the latest + non-default title), attributes each turn to the nearest preceding conversation + GUID, and emits **one session per conversation** (not one per `.db`). Reply + content is de-duplicated per conversation. +- **Project.** Resolved in three tiers, most authoritative first: + 1. **`projectName` field (plugin 1.12+).** Recent plugins serialize the repo + label directly on the session doc (`extractJetBrainsProjectName`) — the + JetBrains analogue of the OTel source's `github.copilot.git.repository`. + **Cross-kind join:** the billable turns live in `chat-agent-sessions`, but + the `projectName` is usually written only into the sibling + `chat-sessions` / `chat-edit-sessions` store. Discovery + (`resolveJetBrainsProjectNames`) joins them by **store id** so the agent + session inherits the label from whichever store recorded it. Read + length-prefixed (Java `TC_STRING`) so an embedded quote/newline can't + truncate it. + 2. **`.git` walk-up (older plugins / no `projectName`).** For each `file://` + URI a chat referenced, walk UP the real filesystem to the nearest ancestor + containing a `.git` and use that repo's basename (e.g. `pinot`). + 3. **`copilot-jetbrains`** bucket when neither signal exists (chat referenced + no files and no `projectName` was recorded, or the repo no longer exists on + disk). + + The conversation **title** is a chat-thread name, NOT a project — it is the + session label (`userMessage`) and deliberately kept out of `project` so it does + not pollute the By-Project view. Note that `bg-agent-sessions/` (a newer kind + dir holding `copilot-agent-snapshots.db` / `copilot-session-metadata.db`) is + **not** scanned: those DBs carry file snapshots and metadata, not billable + turns, and the same session's turns are already read from + `chat-agent-sessions`. +- **Override the root** with `CODEBURN_COPILOT_JETBRAINS_DIR`. + +## Caching + +None for the JSONL sources. The OTel source uses a durable cache (see above). + +## Deduplication + +Legacy JSONL and transcript sessions dedupe per `messageId`. Core chat sessions dedupe per `copilot-chatsession::`, and are not discovered when an OTel source is present. JetBrains `.db` turns dedupe per `copilot:jb::` (a per-conversation index, plus reply-content dedup within each conversation). These sources otherwise touch disjoint locations from the VS Code / CLI sources. + +If a workspace hash contains at least one `chatSessions/*.jsonl` file, the provider skips that hash's legacy `GitHub.copilot-chat/transcripts/` directory. The core chat session journal is the modern token-bearing source for the same conversations, so reading both would inflate call counts. + +## Model inference + +Copilot does not always tag the model on each message. The parser infers it from the tool-call ID prefix: + +| Prefix | Inferred model family | +|---|---| +| `toolu_bdrk_`, `toolu_vrtx_`, `tooluse_`, `toolu_` | Anthropic | +| `call_` | OpenAI | + +See `copilot.ts:176-213`. + +## Quirks + +- `toolRequests` can be missing or non-array on older sessions; the parser guards against that (`copilot.ts:126`, `:260`). +- When `outputTokens` is missing the parser falls back to char-counting (`CHARS_PER_TOKEN = 4`, `copilot.ts:252-254`). +- A single chat may be mirrored across both legacy and transcript paths if the user upgraded; the dedup `messageId` collision handles this. + +## When fixing a bug here + +1. Determine which schema reproduces the bug. The two parsers share little code on purpose; do not unify them unless you understand both formats. +2. If the model is misidentified, look at the tool-call ID prefix list and consider whether a new prefix should be added. +3. New fixtures go under `tests/fixtures/copilot/` and are referenced from `tests/providers/copilot.test.ts`. diff --git a/docs/providers/crush.md b/docs/providers/crush.md new file mode 100644 index 0000000..b293002 --- /dev/null +++ b/docs/providers/crush.md @@ -0,0 +1,87 @@ +# Crush + +Charmbracelet's Crush TUI coding agent. + +- **Source:** `src/providers/crush.ts` +- **Loading:** lazy (`src/providers/index.ts`). Lazy because Crush ships per-project SQLite databases and we use `node:sqlite` to read them. +- **Test:** `tests/providers/crush.test.ts` (10 tests, fixture-based) + +## Where it reads from + +Crush keeps a global registry that lists every project it has touched, and a separate SQLite database **per project**. + +| File | Path | +|---|---| +| Registry (project list) | `$CRUSH_GLOBAL_DATA/projects.json`, otherwise `$XDG_DATA_HOME/crush/projects.json`, otherwise `~/.local/share/crush/projects.json` (Linux/macOS) or `%LOCALAPPDATA%/crush/projects.json` (Windows). | +| Per-project db | `//crush.db` where `data_dir` defaults to `.crush`. | + +The registry shape is an object keyed by project id (modern Crush) or an array (older builds and tokscale's sample fixtures). The parser accepts both. + +## Storage format + +SQLite. Schema verified against `charmbracelet/crush` v0.66.1 (`internal/db/migrations/20250424200609_initial.sql` plus subsequent additive migrations). + +Two tables matter for codeburn: + +```sql +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0.0, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + ... +); + +CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + ... +); +``` + +## Caching + +None at the provider level. + +## Deduplication + +Per `crush:` (`crush.ts`). + +## What we extract + +| codeburn field | Crush source | +|---|---| +| `inputTokens` | `sessions.prompt_tokens` | +| `outputTokens` | `sessions.completion_tokens` | +| `costUSD` | `sessions.cost` (already in dollars) | +| `model` | dominant value of `messages.model` for the session, picked by `GROUP BY model ORDER BY COUNT(*) DESC LIMIT 1`. Falls back to `unknown`. | +| `timestamp` | `sessions.updated_at` if set, otherwise `created_at` | + +Cache tokens, reasoning tokens, web-search counts, tools, and bash commands are all left as zero / empty. Crush does not record per-message token data, so per-turn attribution is not available. + +## Quirks worth knowing + +- **Timestamps are seconds, not milliseconds.** The Crush schema *comments* in the upstream migration claim millisecond timestamps, but every actual `INSERT`/`UPDATE` in `internal/db/sql/{sessions,messages}.sql` uses `strftime('%s', 'now')`, which returns Unix seconds. The parser multiplies by 1000 before constructing a `Date`. **Tokscale's parser (junhoyeo/tokscale#346) gets this wrong and is off by 1000x.** Confirmed against Crush v0.66.1. +- **Cost is stored in dollars as a `REAL`.** No conversion needed. +- **Child sessions are skipped.** Only rows with `parent_session_id IS NULL` are surfaced. Crush sub-agents inherit cost into the parent. +- **Zero-spend rows are filtered.** Discovery skips sessions with `cost = 0 AND prompt_tokens = 0 AND completion_tokens = 0`. +- **Optimize detectors that depend on tools (`detectJunkReads`, `detectDuplicateReads`, `detectLowReadEditRatio`) will not flag Crush sessions.** That is correct: Crush does not log per-tool calls in a way we can read today. +- **`detectLowWorthSessions` may flag Crush sessions** because it looks for cost without edits. That is a known false positive; if it becomes noisy, we can branch the detector on provider. + +## When fixing a bug here + +1. Confirm the issue against a real Crush install (`brew install charmbracelet/tap/crush`) before assuming the schema has changed. Migrations in the last six months have only added columns to `sessions`/`messages`, never removed any of the ones we read. +2. If the bug is "Crush sessions show timestamps from 1970-something", check whether someone "fixed" the seconds-vs-milliseconds handling by removing the `* 1000`. The schema comment is wrong; the data is in seconds. +3. If the bug is "Crush model column shows `unknown`", the session has no messages with a non-null `model`. Some early Crush builds did not record provider on every message; add `LIKE` matching against `provider` if you want a stronger fallback. +4. If the bug is "no sessions discovered", the registry path probably has not been verified for the user's setup. Print `getRegistryPath()` and have them confirm the file exists at that location. +5. New fixtures go under the inline schema in `tests/providers/crush.test.ts`; keep the `CREATE TABLE` literal and synchronized with the upstream migration. diff --git a/docs/providers/cursor-agent.md b/docs/providers/cursor-agent.md new file mode 100644 index 0000000..d77775b --- /dev/null +++ b/docs/providers/cursor-agent.md @@ -0,0 +1,41 @@ +# Cursor Agent + +Cursor's background agent transcripts (separate from the regular chat). + +- **Source:** `src/providers/cursor-agent.ts` +- **Loading:** lazy (`src/providers/index.ts:62-87`) +- **Test:** `tests/providers/cursor-agent.test.ts` (243 lines) + +## Where it reads from + +`~/.cursor/projects//agent-transcripts/`. Inside each project, two layouts coexist: + +1. **Legacy:** `*.txt` flat files. +2. **Composer 2:** UUID-named subdirectories, each containing JSONL. + +Subagents (delegated runs) live in `subagents/` subdirectories under the parent (`cursor-agent.ts:479-490`). They are picked up too. + +## Storage format + +- Legacy: free-form text transcripts. The parser does line-based heuristic parsing (`cursor-agent.ts:219-314`). +- Composer 2: JSONL (`cursor-agent.ts:167-217`). + +## Caching + +None at the provider level. Conversation metadata is read from the same Cursor SQLite db (`state.vscdb`), specifically the `conversation_summaries` table (`cursor-agent.ts:46-50`). If the summary is missing, file mtime is used as the timestamp. + +## Deduplication + +Per `::` (`cursor-agent.ts:379`). + +## Quirks + +- A file with a UUID-shaped name is treated as the conversation ID directly (`cursor-agent.ts:142-143`); other names are derived from the parent directory. +- Token counts are estimated from char count (`CHARS_PER_TOKEN = 4`, `cursor-agent.ts:35`, `:81-84`). The legacy text format never reports real tokens. +- The text parser is regex-driven and brittle. It is easier to fix a Composer 2 (JSONL) bug than a legacy (text) bug. + +## When fixing a bug here + +1. Check which format the failing transcript uses before opening a fix. +2. For text-format bugs, copy the redacted transcript verbatim into `tests/fixtures/cursor-agent/` so the regex change can be regression-tested. +3. If the bug is "wrong project", look at `cursor-agent.ts:46-50` and whether a `conversation_summaries` row exists for the conversation. diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md new file mode 100644 index 0000000..8ccf6c4 --- /dev/null +++ b/docs/providers/cursor.md @@ -0,0 +1,50 @@ +# Cursor + +Cursor IDE chat history. + +- **Source:** `src/providers/cursor.ts` +- **Loading:** lazy (`src/providers/index.ts:44-57`). The `node:sqlite` import is the heavy dependency that justifies lazy loading. +- **Test:** `tests/providers/cursor.test.ts` (77 lines), `tests/providers/cursor-bubble-dedup.test.ts` (176 lines) + +## Where it reads from + +A single SQLite database per platform: + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | +| Windows | `%APPDATA%/Cursor/User/globalStorage/state.vscdb` | +| Linux | `~/.config/Cursor/User/globalStorage/state.vscdb` | + +## Storage format + +SQLite. Two parallel sources within the same db: + +1. **Bubbles** (`cursor.ts:201-331`): per-message rows. The richer source. +2. **agentKv** (`cursor.ts:350-460`): per-conversation key-value blobs. The fallback for older sessions. + +The parser tries both and dedupes via `seenKeys`. + +## Caching + +`src/cursor-cache.ts` writes `~/.cache/codeburn/cursor-results.json` (override with `$CODEBURN_CACHE_DIR`). The fingerprint is `dbMtimeMs + dbSizeBytes` of `state.vscdb`. Atomic write via temp + rename. + +## Deduplication + +- Bubbles: per `bubbleId` (`cursor.ts:282`). +- agentKv: per `requestId` (`cursor.ts:429`). + +## Quirks + +- **180-day lookback.** The bubbles query bounds itself to the trailing 180 days (`cursor.ts:205`). Older history is ignored. If a user reports "Cursor data missing", confirm the date range first. +- **250 000 bubble cap.** Power users with massive history are capped to prevent unbounded memory. If you need to raise this, also raise the cache size budget. +- **Per-conversation user-message queue.** The parser caches the user-message stream per conversation to avoid an O(n) shift on every turn (`cursor.ts:171-191`). +- **agentKv has no per-message timestamp.** The DB file's mtime is used as the timestamp for every agentKv-derived call (`cursor.ts:358-363`). This is wrong but consistent. +- **Cursor v3 reports zero token counts.** The parser falls back to char-counting (`CHARS_PER_TOKEN = 4`) for those rows (`cursor.ts:265-272`). + +## When fixing a bug here + +1. **Always reproduce against a fixture, not a real db.** SQLite over the live db is racy; the user might be using Cursor while you read. +2. If the bug is "tokens are zero", check whether the row is a v3 zero-token bubble, in which case the char-fallback should kick in. +3. If the bug is "duplicate counts", check both `bubbleId` dedup and the cross-provider `seenKeys` dedup. +4. Cache poisoning is the most common failure mode after a Cursor schema change. Bump `CURSOR_CACHE_VERSION` in `src/cursor-cache.ts` so old caches are invalidated. diff --git a/docs/providers/devin.md b/docs/providers/devin.md new file mode 100644 index 0000000..be495ea --- /dev/null +++ b/docs/providers/devin.md @@ -0,0 +1,191 @@ +# Devin + +Cognition Devin CLI local usage tracking. + +- **Source:** `src/providers/devin.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **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/droid.md b/docs/providers/droid.md new file mode 100644 index 0000000..b8288e5 --- /dev/null +++ b/docs/providers/droid.md @@ -0,0 +1,36 @@ +# Droid + +Factory's Droid CLI. + +- **Source:** `src/providers/droid.ts` +- **Loading:** eager (`src/providers/index.ts:4`) +- **Test:** `tests/providers/droid.test.ts` (148 lines) + +## Where it reads from + +`$FACTORY_DIR` if set, otherwise `~/.factory/sessions//*.jsonl`. + +The parser ignores the `.factory/` directory itself (`droid.ts:293-296`); some installs nest it accidentally. + +## Storage format + +JSONL. + +## Caching + +None. + +## Deduplication + +Per `messageId` within a session (`droid.ts:253`). + +## Quirks + +- **Token totals are session-level only.** Droid does not report per-message tokens. The parser reads `settings.tokenUsage` once per session and **splits it evenly** across all assistant calls, with the remainder added to the last call (`droid.ts:223-251`). This is approximate but consistent. +- Project name is derived from the session's `cwd`. If the cwd contains `projects/`, that name is preferred over the basename (`droid.ts:299-319`). + +## When fixing a bug here + +1. If the bug is "tokens unevenly attributed", that is by design. The session-level total is the only signal Droid emits. +2. If the bug is "no sessions found", confirm the user does not have `$FACTORY_DIR` pointing somewhere unexpected. +3. New fixtures go under `tests/fixtures/droid/`. diff --git a/docs/providers/forge.md b/docs/providers/forge.md new file mode 100644 index 0000000..52800d6 --- /dev/null +++ b/docs/providers/forge.md @@ -0,0 +1,37 @@ +# Forge + +Forge agent CLI. + +- **Source:** `src/providers/forge.ts` +- **Loading:** lazy (`src/providers/index.ts:48`) +- **Test:** `tests/providers/forge.test.ts` + +## Where it reads from + +`~/.forge/.forge.db` (`forge.ts:31`). + +## Storage format + +SQLite (`forge.ts:225-252`). CodeBurn reads the `conversations` table and parses JSON from `context.messages` (`forge.ts:154-171`). + +## Caching + +None. + +## Deduplication + +Per `::` (`forge.ts:193`). + +## Quirks + +- `workspace_id` is cast to text in SQL because Forge can store values larger than JavaScript's safe integer range (`forge.ts:35`, `forge.ts:155`, `forge.ts:238`). +- Forge reports prompt tokens inclusive of cached tokens. CodeBurn subtracts cached tokens from prompt tokens before pricing (`forge.ts:185-188`). +- One CodeBurn call is emitted per assistant message with usage; zero-token assistant messages are skipped (`forge.ts:183-190`). +- Project names come from the conversation title, falling back to `workspace_id` (`forge.ts:244`). + +## When fixing a bug here + +1. If discovery returns no sessions, confirm the SQLite schema still has `conversations` with `conversation_id`, `workspace_id`, `context`, `created_at`, and `updated_at`. +2. If Node SQLite throws on real data, check whether a numeric field needs `CAST(... AS TEXT)` like `workspace_id`. +3. If costs look too high, verify cached tokens are still subtracted from prompt tokens before calling `calculateCost`. +4. If duplicate rows appear, inspect whether `tool_calls[].call_id` is missing and the parser is falling back to message index. diff --git a/docs/providers/gemini.md b/docs/providers/gemini.md new file mode 100644 index 0000000..b411d23 --- /dev/null +++ b/docs/providers/gemini.md @@ -0,0 +1,35 @@ +# Gemini + +Google Gemini CLI. + +- **Source:** `src/providers/gemini.ts` +- **Loading:** eager (`src/providers/index.ts:5`) +- **Test:** none. Adding a fixture-based test is a known good first issue. + +## Where it reads from + +`~/.gemini/tmp//chats/session-*.json` and `session-*.jsonl` (`gemini.ts:218-252`). + +## Storage format + +Either a single JSON document per session or JSONL, depending on Gemini CLI version. The parser sniffs the first non-whitespace character to decide (`gemini.ts:197-206`). + +## Caching + +None. + +## Deduplication + +Per `sessionId` (`gemini.ts:72`). Gemini sessions are aggregated to a single call per session. + +## Quirks + +- **Cached tokens are a subset of input.** Gemini reports cached tokens included inside `promptTokenCount`. The parser subtracts them so callers see Anthropic semantics (cached are separate). +- **Thoughts are billed at output rate** (`gemini.ts:125`). +- Each session collapses to one `ParsedProviderCall`. If you need per-turn data, the upstream format does not support it without re-parsing the prompt history. + +## When fixing a bug here + +1. The lack of a test file is a hazard. **Add a fixture and a test before changing parsing logic** so future regressions are caught. +2. If the bug involves a new Gemini version's schema, sniff with the same first-character heuristic; do not call `JSON.parse` on the whole file. +3. If the bug is "Gemini sessions report less than expected", check whether the cached-token subtraction is over-correcting. diff --git a/docs/providers/goose.md b/docs/providers/goose.md new file mode 100644 index 0000000..d203d55 --- /dev/null +++ b/docs/providers/goose.md @@ -0,0 +1,42 @@ +# Goose + +Block's Goose CLI. + +- **Source:** `src/providers/goose.ts` +- **Loading:** lazy (`src/providers/index.ts:29-42`) +- **Test:** none. Adding a fixture-based test is a known good first issue. + +## Where it reads from + +A SQLite database. Path resolution honors `XDG_DATA_HOME` and a `GOOSE_PATH_ROOT` override: + +| Platform | Default path | +|---|---| +| macOS / Linux | `~/.local/share/goose/sessions/sessions.db` | +| Windows | `%APPDATA%/Block/goose/sessions/sessions.db` | + +See `goose.ts:52-62`. + +## Storage format + +SQLite. + +## Caching + +None. + +## Deduplication + +Per `sessionId` (`goose.ts:174`). + +## Quirks + +- Source paths are encoded as `:` so a single db can yield many session sources. The discovery code splits on the last colon (`goose.ts:148-150`). +- Tool inventory comes from the `messages` table queried with `LIKE '%toolRequest%'` (`goose.ts:90`). This will miss tools whose payloads are encoded differently in a future Goose version. +- Tokens are read directly from `accumulated_input_tokens` and `accumulated_output_tokens`. No estimation. + +## When fixing a bug here + +1. Add a fixture-based test before changing logic. `tests/providers/goose.test.ts` does not exist yet; create it and use a small SQLite file under `tests/fixtures/goose/`. +2. If the bug is "no sessions", check `XDG_DATA_HOME` and `GOOSE_PATH_ROOT` first; users on non-default Linux setups will not match the default path. +3. The `LIKE '%toolRequest%'` query is fragile. If Goose changes the message envelope, this is where it will break. 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..9300ef9 --- /dev/null +++ b/docs/providers/hermes.md @@ -0,0 +1,67 @@ +# Hermes Agent + +Hermes Agent CLI profiles. + +- **Source:** `src/providers/hermes.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **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.). When a session message includes a clean `Current working directory: /path` line, parsing can attach that project path 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. + +## Caching + +The shared session cache fingerprints Hermes state DB files. `HERMES_HOME` is included in the provider environment fingerprint so changing the runtime home invalidates stale cached results. + +## 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/ibm-bob.md b/docs/providers/ibm-bob.md new file mode 100644 index 0000000..c9d4373 --- /dev/null +++ b/docs/providers/ibm-bob.md @@ -0,0 +1,55 @@ +# IBM Bob + +IBM Bob IDE task history. + +- **Source:** `src/providers/ibm-bob.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/ibm-bob.test.ts` + +## Where It Reads From + +IBM Bob stores IDE task history below `User/globalStorage/ibm.bob-code/tasks/` in the application data directory. + +Default paths checked: + +| Platform | Paths | +|---|---| +| macOS | `~/Library/Application Support/IBM Bob/User/globalStorage/ibm.bob-code/`, `~/Library/Application Support/Bob-IDE/User/globalStorage/ibm.bob-code/` | +| Windows | `%APPDATA%/IBM Bob/User/globalStorage/ibm.bob-code/`, `%APPDATA%/Bob-IDE/User/globalStorage/ibm.bob-code/` | +| Linux | `$XDG_CONFIG_HOME/IBM Bob/User/globalStorage/ibm.bob-code/`, `$XDG_CONFIG_HOME/Bob-IDE/User/globalStorage/ibm.bob-code/` with `~/.config` fallback | + +The `Bob-IDE` paths cover the preview-era app name that some installs used before the GA `IBM Bob` directory. + +## Storage Format + +Each task is a directory under `tasks//` and must contain `ui_messages.json`. + +CodeBurn parses the same Cline-family UI event format used by Roo Code and KiloCode: + +- `ui_messages.json` entries with `type: "say"` and `say: "api_req_started"` contain serialized token/cost metrics. +- `ui_messages.json` user text entries seed the turn's first user message. +- `api_conversation_history.json` is optional and is used to extract the selected model from `...` environment details when present. +- `task_metadata.json` may exist upstream, but CodeBurn does not need it for usage math today. + +If no model tag is present, the parser uses `ibm-bob-auto`, which is priced through the same conservative Sonnet fallback used for Cline-family auto modes. + +## Caching + +None at the provider level. + +## Deduplication + +Per `::` via `vscode-cline-parser.ts`. + +## Quirks + +- IBM Bob has shipped under both `IBM Bob` and `Bob-IDE` application data folder names. +- This provider intentionally covers the IDE task-history format. Bob Shell's `~/.bob` checkpoint data is a separate storage surface and is not parsed until we have a stable usage schema fixture. +- The shared Cline parser does not currently extract individual tool names from UI messages, so tool breakdowns are empty for IBM Bob just like Roo Code and KiloCode. + +## When Fixing A Bug Here + +1. Check whether the install uses `IBM Bob` or `Bob-IDE` as the application data directory. +2. Confirm the task folder still contains `ui_messages.json` and `api_conversation_history.json`. +3. If the UI message schema changed, add a focused fixture to `tests/providers/ibm-bob.test.ts`. +4. If the change also affects Roo Code or KiloCode, update `src/providers/vscode-cline-parser.ts` and run all three provider test files. diff --git a/docs/providers/kilo-code.md b/docs/providers/kilo-code.md new file mode 100644 index 0000000..51527ef --- /dev/null +++ b/docs/providers/kilo-code.md @@ -0,0 +1,34 @@ +# KiloCode + +KiloCode VS Code extension. + +- **Source:** `src/providers/kilo-code.ts` +- **Loading:** eager (`src/providers/index.ts:6`) +- **Test:** `tests/providers/kilo-code.test.ts` (62 lines) + +## Where it reads from + +VS Code extension globalStorage for `kilocode.kilo-code` (extension ID set at `kilo-code.ts:4`). The actual walk is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`. + +## Storage format + +Per-task directories with `ui_messages.json` and `api_conversation_history.json`. See [`vscode-cline-parser`](vscode-cline-parser.md) for the full schema description. + +## Caching + +None at the provider level; delegates to the shared helper. + +## Deduplication + +Delegated. Per `::` (handled in `vscode-cline-parser.ts:109`). + +## Quirks + +- This file is a thin wrapper. Almost every bug for KiloCode actually lives in `vscode-cline-parser.ts`. +- The VS Code extension wrappers using the Cline-family parser differ **only** by extension ID. + +## When fixing a bug here + +1. If the bug is "Cline, KiloCode, and Roo Code all broken in the same way", fix it in `vscode-cline-parser.ts`. +2. If the bug is "KiloCode broken, Roo Code fine", the difference is upstream (KiloCode's emitted JSON differs slightly). Reproduce with a fixture and consider whether the cline parser needs to branch on extension ID. +3. Read [`vscode-cline-parser.md`](vscode-cline-parser.md) before editing. diff --git a/docs/providers/kimi.md b/docs/providers/kimi.md new file mode 100644 index 0000000..19d6876 --- /dev/null +++ b/docs/providers/kimi.md @@ -0,0 +1,62 @@ +# Kimi + +Kimi Code CLI session parser. + +- **Source:** `src/providers/kimi.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/kimi.test.ts` + +## Where it reads from + +`$KIMI_SHARE_DIR/sessions/` if set, otherwise `~/.kimi/sessions/`. + +Kimi stores sessions by work-directory hash: + +```text +~/.kimi/ + kimi.json + config.toml + sessions/ + / + / + context.jsonl + wire.jsonl + state.json + subagents/ + / + context.jsonl + wire.jsonl +``` + +`kimi.json` maps each work-directory hash back to the original working path. CodeBurn uses that to display the project basename; if the metadata file is missing, the hash directory name is used. + +## Storage Format + +CodeBurn reads `wire.jsonl`. Each data line is a persisted wire record: + +```json +{"timestamp":1776162403,"message":{"type":"StatusUpdate","payload":{"message_id":"msg-1","token_usage":{"input_other":100,"input_cache_read":25,"input_cache_creation":10,"output":40}}}} +``` + +`TurnBegin` / `SteerInput` provide the user prompt, `ToolCall` / `ToolCallRequest` provide tool names and shell commands, and `StatusUpdate.token_usage` provides the billable token counts. + +## Caching + +None. + +## Deduplication + +Per `kimi::`, falling back to the status-update line index if the message id is absent. + +## Quirks + +- Kimi's official `TokenUsage` separates `input_other`, `input_cache_read`, `input_cache_creation`, and `output`. CodeBurn maps those directly into input, cache read, cache write, and output. +- The current Kimi wire schema does not persist the model on every usage update. CodeBurn uses `KIMI_MODEL_NAME` when set, then the active `~/.kimi/config.toml` default model, then `kimi-auto`. +- `kimi-auto`, `kimi-code`, and `kimi-for-coding` are priced as `kimi-k2-thinking` so managed Kimi Code sessions do not show as `$0` when the exact backend model is hidden. +- Subagent sessions are discovered from `subagents//wire.jsonl` and parsed as separate Kimi sessions under the same project. + +## When Fixing A Bug Here + +1. Reproduce with a tiny `wire.jsonl` fixture in `tests/providers/kimi.test.ts`. +2. If token totals look wrong, inspect `StatusUpdate.token_usage` first; `context.jsonl` only stores context checkpoints and cumulative counts, not per-step billing detail. +3. If tools are missing, check whether Kimi emitted `ToolCall`, `ToolCallRequest`, or nested `SubagentEvent`; CodeBurn intentionally counts subagent wire files separately to avoid double-counting parent mirrors. diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md new file mode 100644 index 0000000..6fe2819 --- /dev/null +++ b/docs/providers/kiro.md @@ -0,0 +1,53 @@ +# Kiro + +Kiro IDE chat history. + +- **Source:** `src/providers/kiro.ts` +- **Loading:** eager (`src/providers/index.ts:7`) +- **Test:** `tests/providers/kiro.test.ts` + +## Where it reads from + +VS Code-style globalStorage at `kiro.kiroagent`: + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent` | +| Windows | `%APPDATA%/Kiro/User/globalStorage/kiro.kiroagent` | +| Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` | + +Sessions are under hash-named workspace subdirectories. Discovery keeps backward compatibility with legacy `.chat` files and also scans the post-February 2026 extensionless format: + +- `/.chat` legacy session files +- `/` extensionless session index files +- `//` extensionless execution files inside session directories + +## Storage format + +Kiro has two known JSON formats: + +- Legacy `.chat` files with `{ chat, metadata, executionId }` +- Modern extensionless execution files with identifiers/timestamps at the top level plus conversation fields such as `messages`, `conversation`, `chat`, `transcript`, `entries`, `events`, or direct prompt/response fields + +Session index files with `{ executions: [...] }` are discovered but skipped during parsing because they do not contain conversation content. + +## Caching + +None. + +## Deduplication + +Modern files deduplicate per session/execution pair. Legacy `.chat` files deduplicate per workflow/execution pair. + +## Quirks + +- **Workspace hash resolution** is non-trivial. The parser tries `workspace.json` first; if that fails, it base64-decodes the directory name to recover the workspace path. +- **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot. Add new versions here when Kiro ships them. +- **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `...` or expose structured `toolCalls` / `tool_calls` / `tools` entries. +- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`). + +## When fixing a bug here + +1. If the bug is "wrong workspace", check the base64 fallback path. Some users name their workspaces with characters that are not valid base64. +2. If the bug is "missing model in pricing", add the model to the normalization map and verify against `tests/providers/kiro.test.ts`. +3. If the bug is "tools missing", check both text-envelope extraction and structured tool-call extraction. Kiro changes its envelope occasionally. diff --git a/docs/providers/lingtai-tui.md b/docs/providers/lingtai-tui.md new file mode 100644 index 0000000..b34681a --- /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: + +| LingTai `source` | CodeBurn activity | +|---|---| +| `main` and unknown sources | Conversation | +| `tc_wake` and other task-coordinator wake sources | Delegation | +| `daemon` | Delegation | +| `summarize_apriori` | 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/mistral-vibe.md b/docs/providers/mistral-vibe.md new file mode 100644 index 0000000..244ebae --- /dev/null +++ b/docs/providers/mistral-vibe.md @@ -0,0 +1,45 @@ +# Mistral Vibe + +Mistral Vibe CLI. + +- **Source:** `src/providers/mistral-vibe.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/mistral-vibe.test.ts` + +## Where it reads from + +`$VIBE_HOME/logs/session/` when `VIBE_HOME` is set, otherwise `~/.vibe/logs/session/`. + +## Storage format + +Vibe 2.x stores each session as a directory: + +- `meta.json` contains session metadata, cumulative token totals, active model config, model prices, timestamps, working directory, and available tools. +- `messages.jsonl` contains non-system messages and assistant `tool_calls`. + +Subagent traces are stored under a parent session's `agents/` folder with the same `meta.json` / `messages.jsonl` shape, so CodeBurn scans those one level down as separate sessions. + +## Caching + +Current Vibe local logs do not expose cache-read/cache-write token fields, so +CodeBurn reports cache token counts as `0`. When `meta.json.stats.session_cost` +is present, CodeBurn uses that session total instead of re-estimating from +prompt/completion token prices because it is the best cache-aware cost signal +available in the local log shape. + +## Deduplication + +Per `mistral-vibe:`. + +## Quirks + +- **Usage is cumulative per session.** Vibe does not write per-assistant-message token usage into `messages.jsonl`; token counts come from `meta.json.stats.session_prompt_tokens` and `session_completion_tokens`. CodeBurn splits assistant-message tools into their user turns for classification and distributes the cumulative token/cost totals across those assistant calls so session totals remain unchanged. +- **Cost prefers Vibe's own session total.** `meta.json.stats.session_cost` is used first. If it is missing, `meta.json.stats.input_price_per_million` and `output_price_per_million` are used with the active model config as a fallback. LiteLLM pricing is only used when Vibe provides no price data. +- **Project names come from metadata.** Discovery uses `meta.json.environment.working_directory` and falls back to the session directory name if that field is missing. +- **Tool calls come from messages.** Assistant `tool_calls[*].function.name` is normalized to the standard CodeBurn names (`bash` to `Bash`, `search_replace` to `Edit`, etc.). Bash commands are extracted from `function.arguments.command`. + +## When fixing a bug here + +1. Reproduce with a fixture that has both `meta.json` and `messages.jsonl`; both files are required for current Vibe sessions. +2. If the bug is "wrong total", check `meta.json.stats` first. `messages.jsonl` is only for prompts and tool calls. +3. If a future Vibe release adds per-turn usage, add tests before changing the one-record-per-session behavior so historical sessions continue to parse correctly. diff --git a/docs/providers/mux.md b/docs/providers/mux.md new file mode 100644 index 0000000..15b00cc --- /dev/null +++ b/docs/providers/mux.md @@ -0,0 +1,56 @@ +# Mux + +[coder/mux](https://github.com/coder/mux) — Coder's desktop/CLI app for parallel agentic development. Mux makes its own LLM API calls (via the Vercel AI SDK) and records per-turn token usage natively, so codeburn reads it directly. + +- **Source:** `src/providers/mux.ts` +- **Loading:** eager (`src/providers/index.ts:13`) +- **Test:** `tests/providers/mux.test.ts` + +## Where it reads from + +| Order | Path | +|---|---| +| 1 | `$CODEBURN_MUX_DIR` (codeburn-only override) | +| 2 | `$MUX_ROOT` (mux's own override) | +| 3 | `~/.mux` (default) | + +Session files: `/sessions//chat.jsonl`, plus each spawned sub-agent's own transcript at `/sessions//subagent-transcripts//chat.jsonl` (see Quirks). Dev builds of mux use `~/.mux-dev` and an older install may use `~/.cmux` (migrated to `~/.mux`); point `CODEBURN_MUX_DIR`/`MUX_ROOT` at those if needed. + +The human-readable project name is resolved from `/config.json` (shape: `{ projects: Array<[projectPath, { workspaces: [{ id }] }] > }`); the directory under `sessions/` is the `workspaceId`, matched against `workspace.id`. Falls back to the raw `workspaceId` when there's no mapping. + +## Storage format + +JSONL, one `MuxMessage` per line. Token usage rides on each assistant message's `metadata.usage` (`{ inputTokens, outputTokens, reasoningTokens, cachedInputTokens }`, typed `z.any()` upstream so it's read defensively). Tool calls and the user prompt are `parts[]` entries (`dynamic-tool` / `text`). + +## Pricing + +Cost is recomputed locally via `calculateCost` (mux and codeburn both price from the LiteLLM snapshot). On-disk model strings are `provider:modelId` (e.g. `anthropic:claude-opus-4-8`); the `provider:` prefix is stripped **at parse time** so the stored model is the bare LiteLLM id. This matters because codeburn's canonicalizer (`getCanonicalName`) only strips slash-style prefixes, and `parser.ts` re-prices from the stored model after the cache round-trip — a colon-prefixed model would price at $0 and render raw in the breakdown. + +Token decomposition matches mux's own `displayUsage.ts` (AI SDK v6 semantics): + +- `inputTokens` is **inclusive** of cache-read and cache-creation → `input = inputTokens − cachedInputTokens − cacheCreation`. +- `outputTokens` is **inclusive** of reasoning → `output = outputTokens − reasoning`; reasoning bills at the output rate, so it's folded back into the output arg of `calculateCost`. +- Cache-creation tokens are provider-specific: read from `metadata.providerMetadata.anthropic.cacheCreationInputTokens`. + +## Caching + +None at the provider level. + +## Deduplication + +Per `mux::`. `message.id` is required by mux's schema; for corrupt id-less lines it falls back to the (unique) line index. + +## Quirks + +- **No double-counting with `claude`.** Mux calls model APIs itself and stores usage under `~/.mux`; it does not shell out to Claude Code or read `~/.claude`. +- **Sub-agents.** A spawned sub-agent is its own LLM-client session, but mux writes it to `sessions//subagent-transcripts//chat.jsonl` — *nested under the parent workspace*, not as a top-level `sessions/` dir. `discoverSessions` walks these explicitly and attributes them to the parent's project; missing them undercounts real sessions substantially (sub-agent calls are routinely the majority of a session's turns). No double-count: the dedup key is derived from the `` directory name, which is disjoint from every workspace id. +- **Reasoning vs. output decomposition.** `output = outputTokens − reasoningTokens` assumes `outputTokens` is reasoning-inclusive, which holds for every record carrying `outputTokenDetails` (text + reasoning). A small fraction of Google `gemini-3-*-preview` records report `reasoningTokens > outputTokens`; for those the text component clamps to 0 (reasoning is still billed at the output rate). This matches mux's own `displayUsage.ts` and the token/cost effect is negligible. +- **Read only `chat.jsonl`.** `session-usage.json` (pre-aggregated per model) and `analytics/analytics.db` (DuckDB, derived from `chat.jsonl`) describe the *same* usage; summing them would double-count. +- **No web-search field.** Mux records no web-search/server-tool request count, so `webSearchRequests` is always `0`. +- **Remote runtimes.** Mux can run workspaces on SSH/Docker, but the desktop app is the LLM client and writes `sessions/*/chat.jsonl` on the machine running mux — so usage is captured locally regardless of where commands execute. + +## When fixing a bug here + +1. Confirm whether the bug is in **discovery** (sessions not picked up — `discoverSessions`/`loadProjectMap`) or **parsing** (`createParser`). +2. `metadata.usage` is untyped upstream and its exact field set varies by provider family (anthropic/openai/google/xai/deepseek). Validate against a real `chat.jsonl` sample and cross-check the parsed per-model totals against the sibling `session-usage.json` `byModel` — they should match. +3. Add a fixture-driven case to `tests/providers/mux.test.ts`. Do not mock the filesystem; write a temp `~/.mux` layout like the existing tests. diff --git a/docs/providers/omp.md b/docs/providers/omp.md new file mode 100644 index 0000000..4546a2f --- /dev/null +++ b/docs/providers/omp.md @@ -0,0 +1,34 @@ +# OMP + +OMP CLI. Same parser as Pi, different data directory. + +- **Source:** `src/providers/pi.ts` (the `omp` export) +- **Loading:** eager (`src/providers/index.ts:9`) +- **Test:** `tests/providers/omp.test.ts` (225 lines) + +## Where it reads from + +`~/.omp/agent/sessions/` (`pi.ts:59-61`). + +## Storage format + +JSONL, identical schema to Pi. + +## Caching + +None. + +## Deduplication + +Identical to Pi: `::` with timestamp / line-index fallbacks (`pi.ts:164`). + +## Quirks + +- OMP and Pi share the **same** `createParser` function. The provider object differs only in name, displayName, and the discovery directory. +- If OMP and Pi diverge in a future release, do **not** copy-paste the parser. Add a discriminator to `createParser` and branch. + +## When fixing a bug here + +1. Check if the bug also reproduces against Pi. If yes, fix both with one change; the parser is shared. +2. If the bug is OMP-specific, the right fix is usually to pass an option into `createParser` rather than to fork the file. +3. Read [`pi.md`](pi.md) for the parser-level details. diff --git a/docs/providers/openclaw.md b/docs/providers/openclaw.md new file mode 100644 index 0000000..255b736 --- /dev/null +++ b/docs/providers/openclaw.md @@ -0,0 +1,41 @@ +# OpenClaw + +OpenClaw, plus the older Clawdbot / Moltbot / Moldbot lineage. + +- **Source:** `src/providers/openclaw.ts` +- **Loading:** eager (`src/providers/index.ts:8`) +- **Test:** `tests/providers/openclaw.test.ts` (192 lines) + +## Where it reads from + +Four directories, all checked on every run (`openclaw.ts:62-70`): + +- `~/.openclaw/agents` +- `~/.clawdbot/agents` +- `~/.moltbot/agents` +- `~/.moldbot/agents` + +The legacy directories are kept for users who upgraded from older builds. + +## Storage format + +JSONL (`openclaw.ts:242`). Each agents directory has a `sessions.json` index file plus per-session `.jsonl` files. The parser reads the index when present and falls back to a directory scan if it is missing or stale (`openclaw.ts:220-247`). + +## Caching + +None. + +## Deduplication + +Per `:` (`openclaw.ts:169`). + +## Quirks + +- **Cost is preferred from the provider when reported.** OpenClaw emits `costUSD` in `message.usage`; the parser uses it directly when present (`openclaw.ts:174-177`) and only computes from tokens when it is missing. +- Tokens are reported across `input`, `output`, `cacheRead`, and `cacheWrite`. Anthropic semantics throughout, no normalization needed. + +## When fixing a bug here + +1. If the bug is "session not found", check the four legacy dirs. A user might have a stray `~/.moltbot/` that the parser is reading instead of the real `~/.openclaw/`. +2. If the bug is "wrong cost", confirm whether `costUSD` is present in the source data; the parser trusts it over its own calculation. +3. The `sessions.json` index can drift when the user crashes mid-session. Make sure the directory-scan fallback triggers in those cases. diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md new file mode 100644 index 0000000..8a04efe --- /dev/null +++ b/docs/providers/opencode.md @@ -0,0 +1,47 @@ +# OpenCode + +OpenCode (sst/opencode). + +- **Source:** `src/providers/opencode.ts` +- **Loading:** lazy (`src/providers/index.ts:59-75`) +- **Test:** `tests/providers/opencode.test.ts` (676 lines, the largest provider test) + +## Where it reads from + +Default `~/.local/share/opencode/` or `$XDG_DATA_HOME/opencode/`. The discovery walk picks up `opencode*.db` files (`opencode.ts:71-88`). + +## Storage format + +SQLite. + +## Caching + +None. + +## Deduplication + +Per `:`. + +## Quirks + +- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects. This is the right behavior; do not silently swallow these. +- Source paths are encoded as `:`. +- Discovery only emits root sessions (`parent_id IS NULL`) to avoid double + counting. Parsing a root session walks the unarchived `session.parent_id` + subtree, so child and grandchild agent sessions contribute their message, + token, and tool usage back to the root session. +- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness. +- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics. +- Assistant messages with missing router usage are kept as zero-cost calls + when their parts contain non-empty text or tool activity. Empty zero-usage + assistant placeholders are still skipped. +- External MCP tools are stored as `_` names (for example + `clickup_clickup_get_task`). The provider normalizes those to CodeBurn's + canonical `mcp____` names before aggregation so shared MCP + panels and `optimize` findings count OpenCode usage. + +## When fixing a bug here + +1. The 558-line test suite catches a lot. Run `npm test -- tests/providers/opencode.test.ts` before and after any change. +2. If the bug is "missing table" warning, do not catch and silence it. Either upgrade the version expectation in the parser or document the breaking schema change. +3. If the bug is "reasoning tokens off by one", check the parts index ordering. diff --git a/docs/providers/pi.md b/docs/providers/pi.md new file mode 100644 index 0000000..ca6e623 --- /dev/null +++ b/docs/providers/pi.md @@ -0,0 +1,36 @@ +# Pi + +Pi agent CLI. + +- **Source:** `src/providers/pi.ts` +- **Loading:** eager (`src/providers/index.ts:9`) +- **Test:** `tests/providers/pi.test.ts` (336 lines) + +## Where it reads from + +`~/.pi/agent/sessions/` (`pi.ts:55-57`). + +## Storage format + +JSONL (`pi.ts:98`). + +## Caching + +None. + +## Deduplication + +Per `::` when a response ID is present, falling back to the entry timestamp, and finally to a line index (`pi.ts:164`). + +## Quirks + +- Undefined token fields in `message.usage` are coerced to `0` (`pi.ts:156-159`); never `undefined`. +- The provider name is taken from `source.provider` (`pi.ts:182`), not hard-coded. This matters because `pi.ts` is the parser for **both** Pi and OMP; see [`omp.md`](omp.md). +- Tool-call content type is extracted from the message envelope (`pi.ts:169-176`). +- Pi/OMP have no dedicated skill tool: a native skill load is a `read` whose path points at a skill's `SKILL.md` (or a `skill://` URI in newer OMP builds). The parser surfaces these as the `Skill` tool and records the name in `skills` (mirroring the Claude parser) instead of counting a `Read`, so the shared classifier tags the turn `general` and the Skills & Agents breakdown picks it up (`skillLoadName`, issue #588). + +## When fixing a bug here + +1. If you change parsing logic, also run `tests/providers/omp.test.ts` because OMP shares this code. +2. If the bug is "tokens are NaN", look at the coercion at `pi.ts:156-159`. A regression on this is silent and easy to miss. +3. If the bug is specific to the dedup behavior, decide which of the three fallback keys was used by adding a temporary log; the keys collide differently for old vs. new Pi versions. diff --git a/docs/providers/qwen.md b/docs/providers/qwen.md new file mode 100644 index 0000000..1970328 --- /dev/null +++ b/docs/providers/qwen.md @@ -0,0 +1,36 @@ +# Qwen + +Qwen Code CLI. + +- **Source:** `src/providers/qwen.ts` +- **Loading:** eager (`src/providers/index.ts:10`) +- **Test:** none. Adding a fixture-based test is a known good first issue. + +## Where it reads from + +`$QWEN_DATA_DIR` if set, otherwise `~/.qwen/projects//chats/*.jsonl` (`qwen.ts:52-54`). + +## Storage format + +JSONL. + +## Caching + +None. + +## Deduplication + +Per `:` (`qwen.ts:110`). + +## Quirks + +- **Project name comes from the last path component** (`qwen.ts:56-59`), not from any in-file field. If a user puts the same project under two different paths, they will appear as two projects. +- **Thought parts are filtered out** before token accounting (`qwen.ts:97`). Qwen reports `thoughtsTokenCount` separately from `candidatesTokenCount`; this parser counts both as output but does not double-count thoughts in the main message. +- **Tool calls** are extracted from a fixed envelope shape (`qwen.ts:61-76`). If Qwen restructures its tool-call format in a future release, this is where it will break first. +- Tokens come from `usageMetadata`: `promptTokenCount`, `candidatesTokenCount`, `thoughtsTokenCount`, `cachedContentTokenCount`. + +## When fixing a bug here + +1. Add a fixture and a test before changing logic. The lack of `tests/providers/qwen.test.ts` makes regressions invisible. +2. If the bug is "tools missing", look at the function-call extraction loop at `qwen.ts:61-76`. +3. If the bug is "duplicate counts", confirm `:` actually uniquely identifies a turn in your reproducer; some Qwen builds repeat UUIDs across resumed sessions. diff --git a/docs/providers/roo-code.md b/docs/providers/roo-code.md new file mode 100644 index 0000000..e829064 --- /dev/null +++ b/docs/providers/roo-code.md @@ -0,0 +1,34 @@ +# Roo Code + +Roo Code VS Code extension. + +- **Source:** `src/providers/roo-code.ts` +- **Loading:** eager (`src/providers/index.ts:11`) +- **Test:** `tests/providers/roo-code.test.ts` (247 lines) + +## Where it reads from + +VS Code extension globalStorage for `rooveterinaryinc.roo-cline` (extension ID set at `roo-code.ts:4`). The actual walk is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`. + +## Storage format + +Per-task directories with `ui_messages.json` and `api_conversation_history.json`. See [`vscode-cline-parser`](vscode-cline-parser.md) for the schema. + +## Caching + +None at the provider level; delegates to the shared helper. + +## Deduplication + +Delegated. Per `::` (in `vscode-cline-parser.ts:109`). + +## Quirks + +- Thin wrapper. Almost every Roo Code bug actually lives in `vscode-cline-parser.ts`. +- The VS Code extension wrappers using the Cline-family parser differ **only** by extension ID. + +## When fixing a bug here + +1. If the bug also reproduces against Cline or KiloCode, fix it in `vscode-cline-parser.ts`. +2. If the bug is Roo Code-specific, the difference is upstream JSON shape. Reproduce with a fixture and consider whether the cline parser needs to branch on extension ID. +3. Read [`vscode-cline-parser.md`](vscode-cline-parser.md) before editing. diff --git a/docs/providers/vercel-gateway.md b/docs/providers/vercel-gateway.md new file mode 100644 index 0000000..24df98c --- /dev/null +++ b/docs/providers/vercel-gateway.md @@ -0,0 +1,45 @@ +# Vercel AI Gateway + +Cloud usage for [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) via the reporting API. + +- **Source:** `src/providers/vercel-gateway.ts` +- **Loading:** lazy (`src/providers/index.ts`) +- **Test:** `tests/providers/vercel-gateway.test.ts` + +## Where it reads from + +Not local disk. CodeBurn calls: + +``` +GET https://ai-gateway.vercel.sh/v1/report?start_date=...&end_date=...&date_part=day&group_by=model +``` + +See [Custom Reporting](https://vercel.com/docs/ai-gateway/capabilities/custom-reporting). + +## Authentication + +Set one of: + +- `AI_GATEWAY_API_KEY` +- `VERCEL_OIDC_TOKEN` (from `vercel env pull` when using `vercel dev`) + +## Caching + +None. Each parse issues one API request for the requested date range. + +## Deduplication + +Per `vercel-gateway::`. + +## Quirks + +- Requires Pro/Enterprise Custom Reporting on your Vercel account. +- Data can lag by a few minutes after requests complete. +- Rows are daily aggregates per model, not per chat session. +- `total_cost` is used as `costUSD`; token fields map directly when present. + +## When fixing a bug here + +1. Confirm env vars are set in the same shell running `codeburn`. +2. Reproduce with `codeburn report --provider vercel-gateway -p week --format json`. +3. Compare totals to the Vercel dashboard AI Gateway usage view. diff --git a/docs/providers/vscode-cline-parser.md b/docs/providers/vscode-cline-parser.md new file mode 100644 index 0000000..3535e63 --- /dev/null +++ b/docs/providers/vscode-cline-parser.md @@ -0,0 +1,50 @@ +# vscode-cline-parser (Shared Helper) + +Shared discovery and parsing for Cline and VS Code extensions descended from Cline. + +- **Source:** `src/providers/vscode-cline-parser.ts` +- **Loading:** not a provider; imported by `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. +- **Test:** none directly. Coverage comes from `tests/providers/cline.test.ts`, `tests/providers/ibm-bob.test.ts`, `tests/providers/kilo-code.test.ts`, and `tests/providers/roo-code.test.ts`. + +## What it does + +Two responsibilities: + +1. `discoverClineTasks(extensionId)` walks a base directory's `tasks/` child and returns one source per task that has a `ui_messages.json` file (`vscode-cline-parser.ts:25-50`). Without an override directory it uses VS Code's `globalStorage//` path. +2. `discoverClineTasksInBaseDirs(baseDirs)` does the same for non-VS Code apps with compatible task storage, such as IBM Bob. +3. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model, tools, and token counts, and yields `ParsedProviderCall` objects. + +## Storage layout + +Per task directory: + +``` +/tasks// + ui_messages.json # event stream + api_conversation_history.json # full prompt history with model tags +``` + +## Model resolution + +The model is extracted from `api_conversation_history.json` by searching user message content blocks for a `...` tag. Falls back to the provider-supplied auto model (`cline-auto` by default) if no tag is found. + +## Token extraction + +From `api_req_started` entries inside `ui_messages.json`. Each such entry's `text` field is JSON-parsed; the parsed object holds `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, and (optionally) `cost`. + +If `cost` is present, it is used directly. If not, `calculateCost` from `src/models.ts` computes it from tokens. + +## Deduplication + +Per `::` where `index` is the position of the `api_req_started` entry within `ui_messages.json`. + +## Quirks + +- Only the **first** user message is emitted as `userMessage` in the `ParsedProviderCall`. Subsequent user turns are accounted but not surfaced. +- The model regex looks inside content blocks, not at top-level fields. Some Cline-derivative extensions emit the model elsewhere; if you add support for one, branch on extension ID rather than rewriting the regex. + +## When fixing a bug here + +1. A change here ripples to Cline, IBM Bob, KiloCode, and Roo Code. Run all four provider test files before opening a PR. +2. If you find that one of the extensions emits a different shape, branch on the extension ID parameter that the discovery function already takes; do not duplicate the parser. +3. If you add support for another Cline-family task store, register it as a thin wrapper file in the same shape as `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`. diff --git a/docs/providers/warp.md b/docs/providers/warp.md new file mode 100644 index 0000000..a4a047f --- /dev/null +++ b/docs/providers/warp.md @@ -0,0 +1,62 @@ +# Warp + +Warp Oz agent sessions from Warp's local SQLite database. + +- **Source:** `src/providers/warp.ts` +- **Loading:** lazy (`src/providers/index.ts`) +- **Test:** `tests/providers/warp.test.ts` + +## Where it reads from + +A SQLite database in Warp's group container. + +| Channel | Default path | +|---|---| +| Stable | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Stable/warp.sqlite` | +| Preview | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Preview/warp.sqlite` | + +Override with `WARP_DB_PATH` when needed. + +## Storage format + +SQLite. The parser requires these tables: + +- `agent_conversations` +- `ai_queries` +- `blocks` + +## Caching + +No provider-specific cache. Standard CodeBurn session cache applies. + +## Deduplication + +Per exchange: `warp::`. + +## What we extract + +| codeburn field | Warp source | +|---|---| +| `sessionId` | `agent_conversations.conversation_id` | +| `timestamp` | `ai_queries.start_ts` | +| `userMessage` | `ai_queries.input[0].Query.text` (when present) | +| `project` / `projectPath` | `ai_queries.working_directory` | +| `model` | `ai_queries.model_id` with fallback to dominant `conversation_data.token_usage[*].model_id` | +| `tools` / `bashCommands` | `blocks.stylized_command` grouped onto nearest preceding exchange | +| `inputTokens` | Estimated from prompt-size weighting, normalized to conversation token totals | + +`outputTokens` are set to `0` because Warp does not expose a reliable per-exchange input/output split in these tables. + +## Quirks + +- `ai_queries.output_status` may be stored as a JSON-quoted string (for example `"Completed"`). The parser normalizes this before filtering. +- Warp auto model IDs (`auto-efficient`, `auto-powerful`) are resolved using dominant conversation model information when available. +- Token and cost attribution is estimated. Calls are emitted with `costIsEstimated: true`. +- Command blocks are attached to the closest preceding exchange in the same conversation based on `start_ts`. + +## When fixing a bug here + +1. Reproduce with a fixture SQLite in `tests/providers/warp.test.ts` before changing parser logic. +2. Verify schema compatibility first (`agent_conversations`, `ai_queries`, `blocks`) before debugging parse behavior. +3. If model/cost looks wrong, inspect both `ai_queries.model_id` and `conversation_data.conversation_usage_metadata.token_usage`. +4. If command attribution is wrong, compare `blocks.start_ts` ordering against `ai_queries.start_ts`; attribution is timestamp-based. diff --git a/docs/providers/zcode.md b/docs/providers/zcode.md new file mode 100644 index 0000000..9d42139 --- /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 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-5p1` (GLM-5.1) in `BUILTIN_ALIASES` (`src/models.ts`). Reports therefore show the model as `glm-5p1`, the same way any aliased model displays as its priced-as target. Drop 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. +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..5c6d8b9 --- /dev/null +++ b/docs/providers/zed.md @@ -0,0 +1,49 @@ +# Zed + +Zed's built-in AI agent. + +- **Source:** `src/providers/zed.ts` +- **Loading:** lazy (`src/providers/index.ts:177`) +- **Test:** `tests/providers/zed.test.ts` + +## Where it reads from + +One SQLite database with one row per agent thread (`zed.ts:19`): + +- macOS: `~/Library/Application Support/Zed/threads/threads.db` +- Linux: `~/.local/share/zed/threads/threads.db` +- Windows: `%LOCALAPPDATA%\Zed\threads\threads.db` + +## Storage format + +The `threads` table stores each thread's `data` BLOB as zstd-compressed JSON (`data_type = "zstd"`; legacy rows may be uncompressed `"json"`, both are read, `zed.ts:117-127`). Decompression uses Node's built-in `zlib.zstdDecompressSync` (`zed.ts:17`), no extra dependency. + +The decompressed thread JSON carries: + +- `model`: `{ "provider": ..., "model": ... }` +- `request_token_usage`: map of user-message id to `{ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens }` (zero-valued fields are omitted) +- `cumulative_token_usage`: same shape, whole-thread totals + +Token semantics match Anthropic's (separate cache-creation and cache-read fields), so pricing maps directly onto the LiteLLM engine. Shapes verified against Zed's serialization source (`crates/agent/src/db.rs`: `DbThread`, `TokenUsage`, `SerializedLanguageModel`, `DataType`) and a real store. + +## Caching + +None. + +## Deduplication + +Per `zed::` (`zed.ts:96`), where `requestKey` is the user-message id from `request_token_usage` or the synthetic `cumulative-remainder`. + +## Quirks + +- `request_token_usage` is keyed by user message and does not cover every request a thread made (verified on a real thread: cumulative was ~3x the map sum). One remainder entry per thread tops usage up to the exact `cumulative_token_usage` (`zed.ts:133-153`), so totals always match the store. +- The per-request map carries no timestamps, so every call in a thread uses the thread's `updated_at`; day-level attribution inside long-running threads is approximate. +- Node's zlib gained zstd in 22.15. On older Nodes the provider skips with a notice instead of failing (`zed.ts:14-17`). +- All Zed usage currently lands under a single `zed` project; `folder_paths` is not yet mapped to per-project attribution. + +## When fixing a bug here + +1. If discovery returns no sessions, confirm `threads.db` exists at the platform path and the `threads` table still has `id`, `summary`, `updated_at`, `data_type`, `data`. +2. If threads are skipped, check `data_type` values on disk; only `zstd` and `json` are read. +3. If totals disagree with the store, compare against `cumulative_token_usage` per thread; the remainder logic must bring each thread exactly to it. +4. If model names stop pricing, inspect `model.model` strings in a real thread and add aliases if Zed introduces new hosted-model ids. 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/README.md b/gnome/README.md new file mode 100644 index 0000000..3b76632 --- /dev/null +++ b/gnome/README.md @@ -0,0 +1,70 @@ +# CodeBurn GNOME Extension + +Monitor AI coding assistant token usage and costs from your GNOME desktop panel. + +## Requirements + +- GNOME Shell 45 or later +- CodeBurn CLI installed (`npm i -g codeburn`) +- `glib-compile-schemas` (usually part of `glib2-devel` or `libglib2.0-dev`) + +## Install + +```bash +cd gnome +chmod +x install.sh +./install.sh +``` + +Then restart GNOME Shell: +- **Wayland:** Log out and back in +- **X11:** Press `Alt+F2`, type `r`, press Enter + +Enable the extension: + +```bash +gnome-extensions enable codeburn@codeburn.dev +``` + +## Configure + +Open preferences: + +```bash +gnome-extensions prefs codeburn@codeburn.dev +``` + +Or use the GNOME Extensions app. + +### Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| Refresh Interval | 30s | How often to poll CodeBurn CLI | +| Default Period | Today | Period shown on open | +| Compact Mode | Off | Hide cost label, show icon only | +| Budget Threshold | $0 | Daily budget alert (0 = disabled) | +| Budget Alerts | Off | Show warning when budget exceeded | +| CLI Path | (auto) | Custom path to `codeburn` binary | + +## Uninstall + +```bash +gnome-extensions disable codeburn@codeburn.dev +rm -r ~/.local/share/gnome-shell/extensions/codeburn@codeburn.dev +``` + +## Development + +Test changes without installing: + +```bash +# Compile schemas locally +glib-compile-schemas schemas/ + +# Symlink for development +ln -sf "$(pwd)" ~/.local/share/gnome-shell/extensions/codeburn@codeburn.dev + +# Watch logs +journalctl -f -o cat /usr/bin/gnome-shell +``` diff --git a/gnome/dataClient.js b/gnome/dataClient.js new file mode 100644 index 0000000..4d0056b --- /dev/null +++ b/gnome/dataClient.js @@ -0,0 +1,161 @@ +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; + +const TIMEOUT_SECONDS = 15; +const SAFE_ARG_RE = /^[A-Za-z0-9 ._/\-]+$/; + +function buildAdditionalPaths() { + const home = GLib.get_home_dir(); + return [ + '/usr/local/bin', + `${home}/.local/bin`, + `${home}/.npm-global/bin`, + `${home}/.volta/bin`, + `${home}/.bun/bin`, + `${home}/.cargo/bin`, + `${home}/.asdf/shims`, + `${home}/.local/share/fnm/aliases/default/bin`, + `${home}/.local/share/pnpm`, + ]; +} + +export class DataClient { + _cache = new Map(); + _inFlight = null; + _codeburnPath; + _augmentedPath; + + constructor(codeburnPath) { + this._codeburnPath = codeburnPath || ''; + this._augmentedPath = this._buildAugmentedPath(); + } + + setCodeburnPath(path) { + this._codeburnPath = path || ''; + } + + cancelInFlight() { + if (this._inFlight) { + this._inFlight.cancellable.cancel(); + this._inFlight = null; + } + } + + getCached(period, provider) { + const key = `${period}:${provider}`; + return this._cache.get(key) ?? null; + } + + async fetch(period, provider) { + this.cancelInFlight(); + + const cancellable = new Gio.Cancellable(); + this._inFlight = { cancellable }; + + try { + const payload = await this._spawn(period, provider, cancellable); + const key = `${period}:${provider}`; + this._cache.set(key, payload); + return payload; + } finally { + if (this._inFlight?.cancellable === cancellable) + this._inFlight = null; + } + } + + _buildArgv(period, provider) { + let base; + if (this._codeburnPath && SAFE_ARG_RE.test(this._codeburnPath)) { + base = this._codeburnPath.split(' ').filter(s => s.length > 0); + } else { + base = ['codeburn']; + } + + const args = [ + ...base, + 'status', + '--format', 'menubar-json', + '--period', period, + '--no-optimize', + ]; + + if (provider && provider !== 'all') + args.push('--provider', provider); + + return args; + } + + _buildAugmentedPath() { + const currentPath = GLib.getenv('PATH') || '/usr/bin:/bin'; + const parts = currentPath.split(':'); + for (const extra of buildAdditionalPaths()) { + if (!parts.includes(extra)) + parts.push(extra); + } + return parts.join(':'); + } + + _spawn(period, provider, cancellable) { + return new Promise((resolve, reject) => { + const argv = this._buildArgv(period, provider); + let settled = false; + + const settle = (fn, value) => { + if (settled) return; + settled = true; + fn(value); + }; + + let proc; + try { + const launcher = Gio.SubprocessLauncher.new( + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE + ); + launcher.setenv('PATH', this._augmentedPath, true); + proc = launcher.spawnv(argv); + } catch (e) { + settle(reject, new Error(`CLI not found: ${e.message}`)); + return; + } + + let timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, TIMEOUT_SECONDS, () => { + timeoutId = 0; + proc.force_exit(); + settle(reject, new Error('CLI timeout')); + return GLib.SOURCE_REMOVE; + }); + + proc.communicate_utf8_async(null, cancellable, (_proc, res) => { + if (timeoutId) { + GLib.Source.remove(timeoutId); + timeoutId = 0; + } + + try { + const [, stdout, stderr] = _proc.communicate_utf8_finish(res); + + if (!_proc.get_successful()) { + const msg = stderr?.trim() || 'CLI exited with error'; + settle(reject, new Error(msg)); + return; + } + + if (!stdout || stdout.trim().length === 0) { + settle(reject, new Error('CLI returned empty output')); + return; + } + + const payload = JSON.parse(stdout); + settle(resolve, payload); + } catch (e) { + settle(reject, e); + } + }); + }); + } + + destroy() { + this.cancelInFlight(); + this._cache.clear(); + } +} diff --git a/gnome/extension.js b/gnome/extension.js new file mode 100644 index 0000000..fba94fd --- /dev/null +++ b/gnome/extension.js @@ -0,0 +1,17 @@ +import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import { CodeBurnIndicator } from './indicator.js'; + +export default class CodeBurnExtension extends Extension { + _indicator = null; + + enable() { + this._indicator = new CodeBurnIndicator(this); + Main.panel.addToStatusArea('codeburn-indicator', this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/gnome/icons/codeburn-symbolic.svg b/gnome/icons/codeburn-symbolic.svg new file mode 100644 index 0000000..3a4ee85 --- /dev/null +++ b/gnome/icons/codeburn-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/gnome/indicator.js b/gnome/indicator.js new file mode 100644 index 0000000..6805c4d --- /dev/null +++ b/gnome/indicator.js @@ -0,0 +1,1020 @@ +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Clutter from 'gi://Clutter'; +import Soup from 'gi://Soup?version=3.0'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import { DataClient } from './dataClient.js'; + +const CACHE_TTL_MS = 300_000; +const TOP_ACTIVITIES = 10; +const CHART_HEIGHT = 52; +const BAR_TRACK_WIDTH = 240; + +const PERIODS = [ + { id: 'today', label: 'Today' }, + { id: 'week', label: '7 Days' }, + { id: '30days', label: '30 Days' }, + { id: 'month', label: 'Month' }, + { id: 'all', label: '6 Months' }, +]; + +const INSIGHTS = [ + { id: 'activity', label: 'Activity' }, + { id: 'trend', label: 'Trend' }, + { id: 'forecast', label: 'Forecast' }, + { id: 'pulse', label: 'Pulse' }, + { id: 'stats', label: 'Stats' }, +]; + +const PROVIDERS = [ + { id: 'all', label: 'All' }, + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'copilot', label: 'Copilot' }, + { id: 'devin', label: 'Devin' }, + { id: 'opencode', label: 'OpenCode' }, + { id: 'pi', label: 'Pi' }, + { id: 'droid', label: 'Droid' }, + { id: 'gemini', label: 'Gemini' }, + { id: 'kilo-code', label: 'Kilo Code' }, + { id: 'kiro', label: 'Kiro' }, + { id: 'kimi', label: 'Kimi' }, + { id: 'roo-code', label: 'Roo Code' }, +]; + +const CURRENCIES = [ + { code: 'USD', symbol: '$' }, + { code: 'EUR', symbol: '€' }, + { code: 'GBP', symbol: '£' }, + { code: 'CAD', symbol: 'C$' }, + { code: 'AUD', symbol: 'A$' }, + { code: 'JPY', symbol: '¥' }, + { code: 'INR', symbol: '₹' }, + { code: 'BRL', symbol: 'R$' }, + { code: 'CHF', symbol: 'CHF ' }, + { code: 'SEK', symbol: 'kr ' }, + { code: 'SGD', symbol: 'S$' }, + { code: 'HKD', symbol: 'HK$' }, + { code: 'KRW', symbol: '₩' }, + { code: 'MXN', symbol: 'MX$' }, + { code: 'ZAR', symbol: 'R ' }, + { code: 'DKK', symbol: 'kr ' }, + { code: 'RON', symbol: 'lei ' }, + { code: 'CNY', symbol: '¥' }, +]; + +const PROVIDER_PATHS = { + claude: '.claude/projects', + codex: '.codex/sessions', + cursor: '.config/Cursor/User/globalStorage/state.vscdb', + copilot: '.copilot/session-state', + devin: '.local/share/devin/cli', + kimi: '.kimi/sessions', + pi: '.pi/agent/sessions', +}; + +function formatCost(value, currency, rate = 1, exact = false) { + const n = (Number(value) || 0) * (Number(rate) || 1); + const abs = Math.abs(n); + const symbol = currency?.symbol || '$'; + if (!exact && abs >= 1000) return `${symbol}${(n / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`; + const parts = n.toFixed(2).split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return `${symbol}${parts.join('.')}`; +} + +function formatTokensCompact(n) { + const v = Number(n) || 0; + if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(1)}B`; + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (v >= 1000) return `${(v / 1000).toFixed(1)}k`; + return String(v); +} + +function formatTime(date) { + if (!date || Number.isNaN(date.getTime())) return ''; + const now = new Date(); + const diffSec = Math.floor((now.getTime() - date.getTime()) / 1000); + if (diffSec < 60) return 'just now'; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + return date.toLocaleDateString(); +} + +export const CodeBurnIndicator = GObject.registerClass( +class CodeBurnIndicator extends PanelMenu.Button { + _init(extension) { + super._init(0.0, 'CodeBurn'); + + this._extension = extension; + this._settings = extension.getSettings(); + this._dataClient = new DataClient(this._settings.get_string('codeburn-path')); + this._settingsChangedIds = []; + + this._period = this._settings.get_string('default-period') || 'today'; + this._insight = 'activity'; + this._availableProviders = this._detectProviders(); + this._provider = this._availableProviders.length === 1 ? this._availableProviders[0] : 'all'; + + this._currency = this._loadCurrency(); + this._exactCosts = this._settings.get_boolean('show-exact-costs'); + this._fxRate = 1; + this._fxCache = { USD: 1 }; + this._soupSession = new Soup.Session(); + this._payload = null; + this._payloadCache = new Map(); + this._inFlightKeys = new Set(); + this._refreshGen = 0; + this._refreshSourceId = 0; + this._chartSummaryText = ''; + this._destroyed = false; + + this._themeSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); + this._themeSignal = this._themeSettings.connect('changed::color-scheme', () => this._applyThemeClass()); + this._applyThemeClass(); + this._updateFxRate(); + + this._buildPanelButton(); + this._buildPopup(); + this._connectSettings(); + this._startRefreshLoop(); + this._refresh(); + } + + // -- Panel button -- + + _buildPanelButton() { + const box = new St.BoxLayout({ style_class: 'panel-status-menu-box codeburn-panel' }); + this._panelIcon = new St.Label({ + text: '🔥', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'codeburn-flame', + }); + this._panelLabel = new St.Label({ + text: '...', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'codeburn-label', + }); + box.add_child(this._panelIcon); + box.add_child(this._panelLabel); + this._panelLabel.visible = !this._settings.get_boolean('compact-mode'); + this.add_child(box); + } + + // -- Popup -- + + _buildPopup() { + try { + this.menu.box.add_style_class_name('codeburn-menu'); + this._popupHost = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false }); + this._popupHost.add_style_class_name('codeburn-host'); + this.menu.addMenuItem(this._popupHost); + + this._root = new St.BoxLayout({ vertical: true, style_class: 'codeburn-root', x_expand: true }); + this._popupHost.add_child(this._root); + + this._buildBrandHeader(); + + this._scrollView = new St.ScrollView({ + style_class: 'codeburn-scroll', + hscrollbar_policy: St.PolicyType.NEVER, + vscrollbar_policy: St.PolicyType.AUTOMATIC, + y_expand: true, + }); + this._scrollContent = new St.BoxLayout({ vertical: true, x_expand: true }); + this._scrollView.set_child(this._scrollContent); + this._root.add_child(this._scrollView); + + this._buildAgentTabs(); + this._buildHero(); + this._buildPeriodTabs(); + this._buildInsightPills(); + this._buildTokenChart(); + this._buildLoadingIndicator(); + this._buildContentArea(); + this._buildBudgetAlert(); + this._buildFindingsSection(); + this._buildFooter(); + } catch (e) { + log(`CodeBurn: popup build error: ${e.message}\n${e.stack}`); + } + } + + _buildBrandHeader() { + const header = new St.BoxLayout({ vertical: true, style_class: 'codeburn-brand-header' }); + const title = new St.BoxLayout({ style_class: 'codeburn-brand-row' }); + title.add_child(new St.Label({ text: 'Code', style_class: 'codeburn-brand-primary' })); + title.add_child(new St.Label({ text: 'Burn', style_class: 'codeburn-brand-accent' })); + header.add_child(title); + header.add_child(new St.Label({ text: 'AI Coding Cost Tracker', style_class: 'codeburn-brand-subhead' })); + this._root.add_child(header); + } + + _buildAgentTabs() { + const detected = this._availableProviders; + this._agentTabs = new Map(); + this._agentTabRow = null; + if (detected.length === 0) return; + + const disabled = this._getDisabledProviders(); + const tabs = detected.length === 1 + ? PROVIDERS.filter(p => p.id === detected[0]) + : [PROVIDERS[0], ...PROVIDERS.slice(1).filter(p => detected.includes(p.id) && !disabled.has(p.id))]; + + if (tabs.length === 1) { + const badge = new St.Label({ text: tabs[0].label, style_class: 'codeburn-agent-badge' }); + const row = new St.BoxLayout({ style_class: 'codeburn-tab-row' }); + row.add_child(badge); + this._scrollContent.add_child(row); + return; + } + + const useScroll = tabs.length > 5; + this._agentTabRow = new St.BoxLayout({ style_class: 'codeburn-tab-row' }); + for (const p of tabs) { + const btn = new St.Button({ label: p.label, style_class: 'codeburn-tab', can_focus: true, x_expand: !useScroll }); + btn.connect('clicked', () => { + this._provider = p.id; + this._updateAgentTabStyle(); + this._refresh(); + }); + this._agentTabRow.add_child(btn); + this._agentTabs.set(p.id, btn); + } + if (useScroll) { + const agentScroll = new St.ScrollView({ + style_class: 'codeburn-agent-scroll', + hscrollbar_policy: St.PolicyType.AUTOMATIC, + vscrollbar_policy: St.PolicyType.NEVER, + }); + agentScroll.set_child(this._agentTabRow); + this._scrollContent.add_child(agentScroll); + } else { + this._scrollContent.add_child(this._agentTabRow); + } + this._updateAgentTabStyle(); + } + + _updateAgentTabStyle() { + for (const [id, btn] of this._agentTabs) { + if (id === this._provider) btn.add_style_class_name('codeburn-tab-active'); + else btn.remove_style_class_name('codeburn-tab-active'); + } + } + + _buildHero() { + const hero = new St.BoxLayout({ vertical: true, style_class: 'codeburn-hero' }); + const topLine = new St.BoxLayout({ style_class: 'codeburn-hero-top' }); + this._heroDot = new St.Widget({ style_class: 'codeburn-hero-dot' }); + this._heroLabel = new St.Label({ text: 'Loading...', style_class: 'codeburn-hero-label' }); + topLine.add_child(this._heroDot); + topLine.add_child(this._heroLabel); + this._heroAmount = new St.Label({ text: '$0.00', style_class: 'codeburn-hero-amount' }); + this._heroMeta = new St.Label({ text: '', style_class: 'codeburn-hero-meta' }); + hero.add_child(topLine); + hero.add_child(this._heroAmount); + hero.add_child(this._heroMeta); + this._scrollContent.add_child(hero); + } + + _buildPeriodTabs() { + const row = new St.BoxLayout({ style_class: 'codeburn-tab-row codeburn-period-row' }); + this._periodTabs = new Map(); + for (const p of PERIODS) { + const btn = new St.Button({ label: p.label, style_class: 'codeburn-period', can_focus: true, x_expand: true }); + btn.connect('clicked', () => { + this._period = p.id; + this._updatePeriodTabStyle(); + this._refresh(); + }); + row.add_child(btn); + this._periodTabs.set(p.id, btn); + } + this._scrollContent.add_child(row); + this._updatePeriodTabStyle(); + } + + _updatePeriodTabStyle() { + for (const [id, btn] of this._periodTabs) { + if (id === this._period) btn.add_style_class_name('codeburn-period-active'); + else btn.remove_style_class_name('codeburn-period-active'); + } + } + + _buildInsightPills() { + const row = new St.BoxLayout({ style_class: 'codeburn-insight-row' }); + this._insightPills = new Map(); + for (const i of INSIGHTS) { + const btn = new St.Button({ label: i.label, style_class: 'codeburn-insight-pill', can_focus: true, x_expand: true }); + btn.connect('clicked', () => { + this._insight = i.id; + this._updateInsightPillStyle(); + this._renderContent(); + }); + row.add_child(btn); + this._insightPills.set(i.id, btn); + } + this._scrollContent.add_child(row); + this._updateInsightPillStyle(); + } + + _updateInsightPillStyle() { + for (const [id, btn] of this._insightPills) { + if (id === this._insight) btn.add_style_class_name('codeburn-insight-pill-active'); + else btn.remove_style_class_name('codeburn-insight-pill-active'); + } + } + + _buildTokenChart() { + this._chartContainer = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart' }); + const header = new St.BoxLayout({ style_class: 'codeburn-chart-header' }); + this._chartLabel = new St.Label({ text: 'Tokens', style_class: 'codeburn-chart-label', x_expand: true }); + this._chartTotal = new St.Label({ text: '', style_class: 'codeburn-chart-total' }); + header.add_child(this._chartLabel); + header.add_child(this._chartTotal); + this._chartContainer.add_child(header); + this._chartBars = new St.BoxLayout({ style_class: 'codeburn-chart-bars' }); + this._chartContainer.add_child(this._chartBars); + this._scrollContent.add_child(this._chartContainer); + } + + _buildContentArea() { + this._scrollContent.add_child(new St.Widget({ style_class: 'codeburn-divider' })); + this._contentArea = new St.BoxLayout({ vertical: true, style_class: 'codeburn-content' }); + this._scrollContent.add_child(this._contentArea); + } + + _buildBudgetAlert() { + this._budgetLabel = new St.Label({ text: '', style_class: 'codeburn-budget-warning', visible: false }); + this._scrollContent.add_child(this._budgetLabel); + } + + _buildFindingsSection() { + this._findingsBtn = new St.Button({ style_class: 'codeburn-findings', visible: false }); + const box = new St.BoxLayout({ style_class: 'codeburn-findings-inner' }); + this._findingsCount = new St.Label({ text: '', style_class: 'codeburn-findings-count' }); + this._findingsSavings = new St.Label({ text: '', style_class: 'codeburn-findings-savings' }); + box.add_child(this._findingsCount); + box.add_child(this._findingsSavings); + this._findingsBtn.set_child(box); + this._findingsBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'optimize'])); + this._scrollContent.add_child(this._findingsBtn); + } + + _buildLoadingIndicator() { + this._loadingBox = new St.BoxLayout({ vertical: true, style_class: 'codeburn-loading', visible: false, x_expand: true }); + const widths = [0.85, 0.6, 0.92, 0.5, 0.75, 0.45]; + for (const w of widths) { + const bar = new St.Widget({ style_class: 'codeburn-skeleton-bar', x_expand: false }); + bar.set_width(Math.round(308 * w)); + bar.set_height(10); + this._loadingBox.add_child(bar); + } + this._scrollContent.add_child(this._loadingBox); + } + + _showLoading() { + if (!this._loadingBox) return; + this._loadingBox.visible = true; + this._loadingBox.get_children().forEach((bar, i) => { + bar.opacity = 255; + bar.ease({ + opacity: 60, + duration: 900, + delay: i * 120, + mode: Clutter.AnimationMode.EASE_IN_OUT_SINE, + repeatCount: -1, + autoReverse: true, + }); + }); + } + + _hideLoading() { + if (!this._loadingBox) return; + this._loadingBox.visible = false; + this._loadingBox.get_children().forEach(bar => { + bar.remove_all_transitions(); + bar.opacity = 255; + }); + } + + _buildFooter() { + this._currencyPicker = new St.ScrollView({ + style_class: 'codeburn-currency-picker', + visible: false, + hscrollbar_policy: St.PolicyType.NEVER, + vscrollbar_policy: St.PolicyType.AUTOMATIC, + }); + const pickerList = new St.BoxLayout({ vertical: true, style_class: 'codeburn-currency-list' }); + for (const c of CURRENCIES) { + const item = new St.Button({ label: `${c.symbol} ${c.code}`, style_class: 'codeburn-currency-item', can_focus: true }); + if (c.code === this._currency.code) item.add_style_class_name('codeburn-currency-item-active'); + item.connect('clicked', () => { + this._setCurrency(c.code); + this._currencyPicker.hide(); + pickerList.get_children().forEach(ch => ch.remove_style_class_name('codeburn-currency-item-active')); + item.add_style_class_name('codeburn-currency-item-active'); + }); + pickerList.add_child(item); + } + this._currencyPicker.set_child(pickerList); + this._root.add_child(this._currencyPicker); + + const footer = new St.BoxLayout({ style_class: 'codeburn-footer' }); + + this._currencyBtn = new St.Button({ + label: `${this._currency.code} ⌄`, + style_class: 'codeburn-footer-btn codeburn-currency-btn', + can_focus: true, + }); + this._currencyBtn.connect('clicked', () => this._toggleCurrencyPicker()); + footer.add_child(this._currencyBtn); + + const refreshBtn = new St.Button({ label: 'Refresh', style_class: 'codeburn-footer-btn', can_focus: true, x_expand: true }); + refreshBtn.connect('clicked', () => this._refresh(true)); + footer.add_child(refreshBtn); + + const reportBtn = new St.Button({ label: 'Full Report', style_class: 'codeburn-footer-btn codeburn-footer-cta', can_focus: true, x_expand: true }); + reportBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'report', '--period', this._period, '--provider', this._provider])); + footer.add_child(reportBtn); + + const prefsBtn = new St.Button({ label: '⚙', style_class: 'codeburn-footer-btn codeburn-prefs-btn', can_focus: true }); + prefsBtn.connect('clicked', () => { + this._extension.openPreferences(); + this.menu.close(); + }); + footer.add_child(prefsBtn); + + this._root.add_child(footer); + this._updatedLabel = new St.Label({ text: '', style_class: 'codeburn-updated' }); + this._root.add_child(this._updatedLabel); + } + + // -- Settings -- + + _connectSettings() { + const watch = (key, cb) => { + const id = this._settings.connect(`changed::${key}`, cb); + this._settingsChangedIds.push(id); + }; + watch('refresh-interval', () => this._restartRefreshLoop()); + watch('compact-mode', () => { this._panelLabel.visible = !this._settings.get_boolean('compact-mode'); }); + watch('codeburn-path', () => { + this._dataClient.setCodeburnPath(this._settings.get_string('codeburn-path')); + this._refresh(true); + }); + watch('default-period', () => { + this._period = this._settings.get_string('default-period'); + this._updatePeriodTabStyle(); + this._refresh(); + }); + watch('budget-threshold', () => this._updateBudget()); + watch('budget-alert-enabled', () => this._updateBudget()); + watch('force-dark-mode', () => this._applyThemeClass()); + watch('show-exact-costs', () => { + this._exactCosts = this._settings.get_boolean('show-exact-costs'); + if (this._payload) this._render(this._payload); + }); + watch('disabled-providers', () => { + if (this._payload) this._render(this._payload); + }); + } + + _getDisabledProviders() { + return new Set(this._settings.get_strv('disabled-providers')); + } + + // -- Provider detection -- + + _detectProviders() { + const home = GLib.get_home_dir(); + const xdgData = GLib.getenv('XDG_DATA_HOME') || `${home}/.local/share`; + const checks = Object.fromEntries( + Object.entries(PROVIDER_PATHS).map(([id, rel]) => [id, `${home}/${rel}`]) + ); + checks.opencode = `${xdgData}/opencode`; + const out = []; + for (const [id, path] of Object.entries(checks)) { + if (Gio.File.new_for_path(path).query_exists(null)) out.push(id); + } + return out; + } + + // -- Refresh loop -- + + _startRefreshLoop() { + const interval = this._settings.get_uint('refresh-interval') || 30; + this._refreshSourceId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => { + this._refresh(); + return GLib.SOURCE_CONTINUE; + }); + } + + _restartRefreshLoop() { + if (this._refreshSourceId) { + GLib.Source.remove(this._refreshSourceId); + this._refreshSourceId = 0; + } + this._startRefreshLoop(); + } + + // -- Data fetching with cache -- + + _cacheKey() { + return `${this._period}|${this._provider}`; + } + + async _refresh(force = false) { + const key = this._cacheKey(); + const cached = this._payloadCache.get(key); + const cacheAge = cached ? Date.now() - cached.fetchedAt : Infinity; + + if (!force && cached && cacheAge < CACHE_TTL_MS) { + this._payload = cached.payload; + this._render(this._payload); + return; + } + + if (this._inFlightKeys.has(key)) return; + this._inFlightKeys.add(key); + const gen = ++this._refreshGen; + + if (cached) { + this._payload = cached.payload; + this._render(this._payload); + } else { + this._showLoading(); + if (this._contentArea) this._contentArea.opacity = 120; + } + + try { + const payload = await this._dataClient.fetch(this._period, this._provider); + this._inFlightKeys.delete(key); + if (this._destroyed || gen !== this._refreshGen) return; + this._payloadCache.set(key, { payload, fetchedAt: Date.now() }); + if (this._cacheKey() === key) { + this._payload = payload; + this._hideLoading(); + if (this._contentArea) this._contentArea.opacity = 255; + this._render(this._payload); + } + } catch (e) { + this._inFlightKeys.delete(key); + if (this._destroyed) return; + this._hideLoading(); + if (this._contentArea) this._contentArea.opacity = 255; + if (gen !== this._refreshGen) return; + if (e.message?.includes('cancelled')) return; + log(`CodeBurn: refresh error: ${e.message}`); + if (!this._payload) this._renderError(e.message); + } + } + + // -- Rendering -- + + _render(payload) { + const current = payload?.current ?? {}; + const cost = Number(current.cost ?? 0); + const savings = Number(current?.localModelSavings?.totalUSD ?? 0); + + this._panelLabel.set_text(this._fmt(cost)); + this._heroLabel.set_text(current.label || ''); + this._heroAmount.set_text(this._fmt(cost)); + + const calls = Number(current.calls ?? 0); + const sessions = Number(current.sessions ?? 0); + const metaParts = [`${calls.toLocaleString()} calls`, `${sessions} sessions`]; + if (savings > 0) metaParts.push(`saved ${this._fmt(savings)}`); + this._heroMeta.set_text(metaParts.join(' ')); + + this._renderChart(payload?.history?.daily ?? []); + this._renderContent(); + this._renderFindings(payload?.optimize ?? {}); + this._updateBudget(); + + const updated = payload?.generated ? formatTime(new Date(payload.generated)) : ''; + this._updatedLabel.set_text(updated ? `Updated ${updated}` : ''); + } + + _renderChart(daily) { + this._chartBars.destroy_all_children(); + const days = Array.isArray(daily) ? daily.slice(-19) : []; + if (days.length === 0) { + this._chartContainer.visible = false; + return; + } + const inTotals = days.map(d => Number(d?.inputTokens) || 0); + const outTotals = days.map(d => Number(d?.outputTokens) || 0); + const totals = inTotals.map((v, i) => v + outTotals[i]); + let maxTotal = 1; + let totalIn = 0; + let totalOut = 0; + let hasAnyTokens = false; + for (let i = 0; i < days.length; i++) { + if (totals[i] > maxTotal) maxTotal = totals[i]; + if (totals[i] > 0) hasAnyTokens = true; + totalIn += inTotals[i]; + totalOut += outTotals[i]; + } + if (!hasAnyTokens) { + this._chartContainer.visible = false; + return; + } + this._chartContainer.visible = true; + const summaryText = `In: ${formatTokensCompact(totalIn)} Out: ${formatTokensCompact(totalOut)}`; + this._chartTotal.set_text(summaryText); + this._chartSummaryText = summaryText; + + const chartWidth = 308; + const gap = 2; + const barW = Math.max(4, Math.floor((chartWidth - gap * (days.length - 1)) / days.length)); + + for (let i = 0; i < days.length; i++) { + const h = Math.max(2, Math.round((totals[i] / maxTotal) * CHART_HEIGHT)); + const col = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart-col', reactive: true }); + col.set_width(barW); + col.set_height(CHART_HEIGHT); + const spacer = new St.Widget({ style_class: 'codeburn-chart-spacer' }); + spacer.set_height(CHART_HEIGHT - h); + const bar = new St.Widget({ style_class: 'codeburn-chart-bar' }); + bar.set_width(barW); + bar.set_height(h); + col.add_child(spacer); + col.add_child(bar); + + const date = days[i]?.date || ''; + const inTok = formatTokensCompact(inTotals[i]); + const outTok = formatTokensCompact(outTotals[i]); + const cost = days[i]?.cost != null ? this._fmt(days[i].cost) : ''; + col.connect('enter-event', () => { + this._chartTotal.set_text(`${date} ${inTok}/${outTok} ${cost}`); + this._chartTotal.add_style_class_name('codeburn-chart-total-hover'); + bar.add_style_class_name('codeburn-chart-bar-hover'); + return Clutter.EVENT_PROPAGATE; + }); + col.connect('leave-event', () => { + this._chartTotal.set_text(this._chartSummaryText); + this._chartTotal.remove_style_class_name('codeburn-chart-total-hover'); + bar.remove_style_class_name('codeburn-chart-bar-hover'); + return Clutter.EVENT_PROPAGATE; + }); + + this._chartBars.add_child(col); + } + } + + _renderContent() { + this._contentArea.destroy_all_children(); + switch (this._insight) { + case 'trend': return this._renderTrendView(); + case 'forecast': return this._renderForecastView(); + case 'pulse': return this._renderPulseView(); + case 'stats': return this._renderStatsView(); + default: return this._renderActivityView(); + } + } + + _renderActivityView() { + const current = this._payload?.current ?? {}; + this._contentArea.add_child(this._sectionTitle('Activity')); + const actHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' }); + actHeader.add_child(new St.Label({ text: 'Name', style_class: 'codeburn-th', x_expand: true })); + actHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' })); + actHeader.add_child(new St.Label({ text: 'Turns', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' })); + actHeader.add_child(new St.Label({ text: '1-shot', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' })); + this._contentArea.add_child(actHeader); + const rows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-activity-rows' }); + const activities = Array.isArray(current.topActivities) ? current.topActivities : []; + if (!activities.length) { + rows.add_child(new St.Label({ text: 'No activity for this period', style_class: 'codeburn-empty' })); + } else { + const maxCost = activities.reduce((m, a) => Math.max(m, Number(a.cost) || 0), 0) || 1; + for (const a of activities.slice(0, TOP_ACTIVITIES)) { + rows.add_child(this._buildActivityRow(a, maxCost)); + } + } + this._contentArea.add_child(rows); + + const models = Array.isArray(current.topModels) ? current.topModels : []; + if (models.length) { + this._contentArea.add_child(this._sectionTitle('Models')); + const modHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' }); + modHeader.add_child(new St.Label({ text: 'Model', style_class: 'codeburn-th', x_expand: true })); + modHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' })); + modHeader.add_child(new St.Label({ text: 'Calls', style_class: 'codeburn-th codeburn-th-right codeburn-th-calls' })); + this._contentArea.add_child(modHeader); + const mrows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-models-rows' }); + for (const m of models.slice(0, 3)) mrows.add_child(this._buildModelRow(m)); + this._contentArea.add_child(mrows); + } + } + + _renderTrendView() { + const daily = this._payload?.history?.daily ?? []; + if (!daily.length) { + this._contentArea.add_child(new St.Label({ text: 'Not enough history yet', style_class: 'codeburn-empty' })); + return; + } + for (const d of daily.slice(-7).reverse()) { + const row = new St.BoxLayout({ style_class: 'codeburn-trend-row' }); + row.add_child(new St.Label({ text: d.date, style_class: 'codeburn-trend-date', x_expand: true })); + const costLabel = new St.Label({ text: this._fmt(d.cost), style_class: 'codeburn-trend-cost' }); + costLabel.clutter_text.x_align = Clutter.ActorAlign.END; + row.add_child(costLabel); + const callsLabel = new St.Label({ text: `${Number(d.calls).toLocaleString()} calls`, style_class: 'codeburn-trend-calls' }); + callsLabel.clutter_text.x_align = Clutter.ActorAlign.END; + row.add_child(callsLabel); + this._contentArea.add_child(row); + } + } + + _renderForecastView() { + const daily = this._payload?.history?.daily ?? []; + if (daily.length < 3) { + this._contentArea.add_child(new St.Label({ text: 'Need at least 3 days of history', style_class: 'codeburn-empty' })); + return; + } + const last7 = daily.slice(-7); + const avg = last7.reduce((s, d) => s + Number(d.cost || 0), 0) / last7.length; + const yesterday = daily.at(-2); + const yestCost = Number(yesterday?.cost || 0); + const todCost = Number(daily.at(-1)?.cost || 0); + const dod = yestCost > 0 ? ((todCost - yestCost) / yestCost) * 100 : 0; + const now = new Date(); + const dayOfMonth = now.getUTCDate(); + const daysInMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 0).getUTCDate(); + + this._contentArea.add_child(this._kvRow('7-day avg', this._fmt(avg))); + this._contentArea.add_child(this._kvRow('Yesterday', yesterday ? this._fmt(yestCost) : '-')); + this._contentArea.add_child(this._kvRow('Day-over-day', `${dod > 0 ? '+' : ''}${dod.toFixed(1)}%`)); + this._contentArea.add_child(this._kvRow('Month projection', this._fmt(avg * daysInMonth))); + this._contentArea.add_child(this._kvRow('Days elapsed', `${dayOfMonth} of ${daysInMonth}`)); + } + + _renderPulseView() { + const current = this._payload?.current ?? {}; + const daily = this._payload?.history?.daily ?? []; + this._contentArea.add_child(this._sectionTitle('Pulse')); + const row = new St.BoxLayout({ style_class: 'codeburn-pulse-row' }); + row.add_child(this._pulseTile(this._fmt(current.cost), 'cost')); + row.add_child(this._pulseTile(Number(current.calls || 0).toLocaleString(), 'calls')); + row.add_child(this._pulseTile(`${Number(current.cacheHitPercent || 0).toFixed(0)}%`, 'cache hit')); + this._contentArea.add_child(row); + + if (daily.length) { + this._contentArea.add_child(this._sectionTitle('Last 7 days')); + const last7 = daily.slice(-7); + const sumCost = last7.reduce((s, d) => s + Number(d.cost || 0), 0); + const sumCalls = last7.reduce((s, d) => s + Number(d.calls || 0), 0); + const peakDay = last7.reduce((best, d) => Number(d.cost || 0) > Number(best.cost || 0) ? d : best, last7[0]); + this._contentArea.add_child(this._kvRow('Total spend', this._fmt(sumCost))); + this._contentArea.add_child(this._kvRow('Total calls', Number(sumCalls).toLocaleString())); + this._contentArea.add_child(this._kvRow('Peak day', `${peakDay?.date || '-'} ${this._fmt(peakDay?.cost)}`)); + } + } + + _renderStatsView() { + const current = this._payload?.current ?? {}; + const daily = this._payload?.history?.daily ?? []; + this._contentArea.add_child(this._sectionTitle('Stats')); + const models = Array.isArray(current.topModels) ? current.topModels : []; + const favModel = models[0]?.name ?? '-'; + const activeDays = daily.filter(d => Number(d.cost || 0) > 0).length; + const peakDay = daily.reduce((best, d) => Number(d.cost || 0) > Number((best || {}).cost || 0) ? d : best, null); + let streak = 0; + for (let i = daily.length - 1; i >= 0; i--) { + if (Number(daily[i].cost || 0) > 0) streak++; + else break; + } + this._contentArea.add_child(this._kvRow('Favorite model', favModel)); + this._contentArea.add_child(this._kvRow('Active days', `${activeDays}`)); + this._contentArea.add_child(this._kvRow('Current streak', `${streak} days`)); + if (peakDay) this._contentArea.add_child(this._kvRow('Peak day', `${peakDay.date} ${this._fmt(peakDay.cost)}`)); + } + + _renderFindings(optimize) { + const count = Number(optimize?.findingCount ?? 0); + if (count === 0) { + this._findingsBtn.hide(); + return; + } + const savings = Number(optimize?.savingsUSD ?? 0); + this._findingsCount.set_text(`${count} optimize findings`); + this._findingsSavings.set_text(`save ~${this._fmt(savings)}`); + this._findingsBtn.show(); + } + + _renderError(message) { + this._panelLabel.set_text('!'); + if (message?.includes('not found') || message?.includes('No such file')) { + this._heroLabel.set_text('CodeBurn CLI not found'); + this._heroMeta.set_text('Install: npm i -g codeburn'); + } else { + this._heroLabel.set_text('Error loading data'); + this._heroMeta.set_text(message?.substring(0, 80) || 'Unknown error'); + } + this._heroAmount.set_text(''); + this._findingsBtn.hide(); + } + + // -- Budget -- + + _updateBudget() { + const enabled = this._settings.get_boolean('budget-alert-enabled'); + const threshold = this._settings.get_double('budget-threshold'); + if (!enabled || threshold <= 0 || !this._payload?.current) { + this._budgetLabel.visible = false; + return; + } + const cost = Number(this._payload.current.cost ?? 0) * this._fxRate; + const thresholdConverted = threshold * this._fxRate; + if (cost >= thresholdConverted) { + this._budgetLabel.set_text(`Budget exceeded: ${this._fmt(cost)} / ${this._fmt(thresholdConverted)}`); + this._budgetLabel.visible = true; + } else { + this._budgetLabel.visible = false; + } + } + + // -- Currency -- + + _loadCurrency() { + const configPath = GLib.build_filenamev([GLib.get_home_dir(), '.config', 'codeburn', 'config.json']); + try { + const [ok, contents] = GLib.file_get_contents(configPath); + if (ok) { + const config = JSON.parse(new TextDecoder().decode(contents)); + if (config.currency?.code) { + const known = CURRENCIES.find(c => c.code === config.currency.code); + if (known) return known; + return { code: config.currency.code, symbol: config.currency.symbol || `${config.currency.code} ` }; + } + } + } catch (_) { /* default */ } + return CURRENCIES[0]; + } + + _toggleCurrencyPicker() { + this._currencyPicker.visible = !this._currencyPicker.visible; + } + + _setCurrency(code) { + try { + Gio.Subprocess.new(['codeburn', 'currency', code], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); + } catch (_) { /* CLI missing */ } + const known = CURRENCIES.find(c => c.code === code); + this._currency = known || { code, symbol: `${code} ` }; + this._currencyBtn.set_label(`${this._currency.code} ⌄`); + this._updateFxRate(); + } + + _updateFxRate() { + const code = this._currency?.code || 'USD'; + if (this._fxCache[code] !== undefined) { + this._fxRate = this._fxCache[code]; + if (this._payload) this._render(this._payload); + return; + } + const url = `https://api.frankfurter.app/latest?from=USD&to=${code}`; + const msg = Soup.Message.new('GET', url); + this._soupSession.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, null, (session, result) => { + if (this._destroyed) return; + try { + const bytes = session.send_and_read_finish(result); + if (!bytes) return; + const json = JSON.parse(new TextDecoder().decode(bytes.get_data())); + const rate = json?.rates?.[code]; + if (typeof rate === 'number' && rate > 0) { + this._fxCache[code] = rate; + this._fxRate = rate; + if (this._payload) this._render(this._payload); + } + } catch (_) { /* FX fetch failed */ } + }); + } + + _fmt(value) { + return formatCost(value, this._currency, this._fxRate, this._exactCosts); + } + + // -- UI helpers -- + + _sectionTitle(text) { + return new St.Label({ text, style_class: 'codeburn-section-title' }); + } + + _kvRow(label, value) { + const row = new St.BoxLayout({ style_class: 'codeburn-kv-row' }); + row.add_child(new St.Label({ text: label, style_class: 'codeburn-kv-label', x_expand: true })); + row.add_child(new St.Label({ text: String(value ?? '-'), style_class: 'codeburn-kv-value' })); + return row; + } + + _pulseTile(value, label) { + const tile = new St.BoxLayout({ vertical: true, style_class: 'codeburn-pulse-tile', x_expand: true }); + tile.add_child(new St.Label({ text: value, style_class: 'codeburn-pulse-value' })); + tile.add_child(new St.Label({ text: label, style_class: 'codeburn-pulse-label' })); + return tile; + } + + _buildActivityRow(activity, maxCost) { + const row = new St.BoxLayout({ vertical: true, style_class: 'codeburn-activity-row' }); + const topLine = new St.BoxLayout({ style_class: 'codeburn-activity-top' }); + topLine.add_child(new St.Label({ text: activity.name, style_class: 'codeburn-activity-name', x_expand: true })); + const costLabel = new St.Label({ text: this._fmt(activity.cost), style_class: 'codeburn-activity-cost' }); + costLabel.clutter_text.x_align = Clutter.ActorAlign.END; + topLine.add_child(costLabel); + const turnsLabel = new St.Label({ text: `${Number(activity.turns) || 0}`, style_class: 'codeburn-activity-turns' }); + turnsLabel.clutter_text.x_align = Clutter.ActorAlign.END; + topLine.add_child(turnsLabel); + const osText = activity.oneShotRate != null ? `${Math.round(Number(activity.oneShotRate) * 100)}%` : '--'; + const osLabel = new St.Label({ text: osText, style_class: 'codeburn-activity-oneshot' }); + osLabel.clutter_text.x_align = Clutter.ActorAlign.END; + topLine.add_child(osLabel); + row.add_child(topLine); + + const track = new St.BoxLayout({ style_class: 'codeburn-bar-track' }); + const pct = Math.max(0.02, Math.min(1, Number(activity.cost) / maxCost)); + const fill = new St.Widget({ style_class: 'codeburn-bar-fill' }); + fill.set_width(Math.round(BAR_TRACK_WIDTH * pct)); + track.add_child(fill); + row.add_child(track); + return row; + } + + _buildModelRow(model) { + const row = new St.BoxLayout({ style_class: 'codeburn-model-row' }); + row.add_child(new St.Label({ text: model.name, style_class: 'codeburn-model-name', x_expand: true })); + const mc = new St.Label({ text: this._fmt(model.cost), style_class: 'codeburn-model-cost' }); + mc.clutter_text.x_align = Clutter.ActorAlign.END; + row.add_child(mc); + // Show saved counterfactual when this local model has a savings + // mapping. Kept as a separate column so it never gets summed with + // the actual cost on the left. + const savings = Number(model.savingsUSD || 0); + const savedLabel = new St.Label({ + text: savings > 0 ? this._fmt(savings) : '—', + style_class: 'codeburn-model-saved', + }); + savedLabel.clutter_text.x_align = Clutter.ActorAlign.END; + row.add_child(savedLabel); + const mcalls = new St.Label({ text: `${Number(model.calls || 0).toLocaleString()}`, style_class: 'codeburn-model-calls' }); + mcalls.clutter_text.x_align = Clutter.ActorAlign.END; + row.add_child(mcalls); + return row; + } + + // -- Theme -- + + _applyThemeClass() { + const forceDark = this._settings.get_boolean('force-dark-mode'); + const scheme = this._themeSettings.get_string('color-scheme'); + const isDark = forceDark || scheme === 'prefer-dark'; + if (isDark) { + this._root?.add_style_class_name('codeburn-dark'); + this._root?.remove_style_class_name('codeburn-light'); + } else { + this._root?.add_style_class_name('codeburn-light'); + this._root?.remove_style_class_name('codeburn-dark'); + } + } + + // -- Terminal spawning -- + + _spawnTerminal(argv) { + const command = `${argv.join(' ')}; echo; read -n 1 -s -r -p 'Press any key to close...'`; + try { + Gio.Subprocess.new(['gnome-terminal', '--', 'bash', '-lc', command], Gio.SubprocessFlags.NONE); + } catch (e) { + log(`CodeBurn: terminal spawn error: ${e.message}`); + } + this.menu.close(); + } + + // -- Cleanup -- + + destroy() { + this._destroyed = true; + if (this._refreshSourceId) { + GLib.Source.remove(this._refreshSourceId); + this._refreshSourceId = 0; + } + if (this._themeSettings && this._themeSignal) { + this._themeSettings.disconnect(this._themeSignal); + this._themeSignal = null; + this._themeSettings = null; + } + for (const id of this._settingsChangedIds) this._settings.disconnect(id); + this._settingsChangedIds = []; + this._dataClient?.destroy(); + if (this._soupSession) { + this._soupSession.abort(); + this._soupSession = null; + } + super.destroy(); + } +}); diff --git a/gnome/install.sh b/gnome/install.sh new file mode 100755 index 0000000..df03881 --- /dev/null +++ b/gnome/install.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail + +UUID="codeburn@codeburn.dev" +INSTALL_DIR="${HOME}/.local/share/gnome-shell/extensions/${UUID}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "Installing CodeBurn GNOME extension..." + +# Compile GSettings schema +echo "Compiling schemas..." +glib-compile-schemas "${SCRIPT_DIR}/schemas/" + +# Create install directory +mkdir -p "${INSTALL_DIR}" + +# Copy extension files +cp "${SCRIPT_DIR}/metadata.json" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/extension.js" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/indicator.js" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/dataClient.js" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/prefs.js" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/stylesheet.css" "${INSTALL_DIR}/" + +# Copy schemas +mkdir -p "${INSTALL_DIR}/schemas" +cp "${SCRIPT_DIR}/schemas/"* "${INSTALL_DIR}/schemas/" + +# Copy icons +mkdir -p "${INSTALL_DIR}/icons" +cp "${SCRIPT_DIR}/icons/"* "${INSTALL_DIR}/icons/" + +echo "Extension installed to ${INSTALL_DIR}" +echo "" +echo "Next steps:" +echo " 1. Restart GNOME Shell (log out and back in on Wayland)" +echo " 2. Enable: gnome-extensions enable ${UUID}" +echo " 3. Configure: gnome-extensions prefs ${UUID}" diff --git a/gnome/metadata.json b/gnome/metadata.json new file mode 100644 index 0000000..be8d2c0 --- /dev/null +++ b/gnome/metadata.json @@ -0,0 +1,8 @@ +{ + "name": "CodeBurn Monitor", + "description": "Monitor AI coding assistant token usage and costs", + "uuid": "codeburn@codeburn.dev", + "shell-version": ["45", "46", "47", "48", "49", "50"], + "url": "https://github.com/getagentseal/codeburn", + "settings-schema": "org.gnome.shell.extensions.codeburn" +} diff --git a/gnome/prefs.js b/gnome/prefs.js new file mode 100644 index 0000000..dafc1a2 --- /dev/null +++ b/gnome/prefs.js @@ -0,0 +1,171 @@ +import Adw from 'gi://Adw'; +import Gtk from 'gi://Gtk'; +import Gio from 'gi://Gio'; +import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +const PROVIDERS = [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'copilot', label: 'Copilot' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'devin', label: 'Devin' }, + { id: 'droid', label: 'Droid' }, + { id: 'gemini', label: 'Gemini' }, + { id: 'goose', label: 'Goose' }, + { id: 'kilo-code', label: 'Kilo Code' }, + { id: 'kiro', label: 'Kiro' }, + { id: 'kimi', label: 'Kimi' }, + { id: 'openclaw', label: 'OpenClaw' }, + { id: 'opencode', label: 'OpenCode' }, + { id: 'pi', label: 'Pi' }, + { id: 'qwen', label: 'Qwen' }, + { id: 'roo-code', label: 'Roo Code' }, + { id: 'antigravity', label: 'Antigravity' }, +]; + +const PERIODS = [ + { id: 'today', label: 'Today' }, + { id: 'week', label: '7 Days' }, + { id: '30days', label: '30 Days' }, + { id: 'month', label: 'Month' }, + { id: 'all', label: '6 Months' }, +]; + +export default class CodeBurnPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings(); + + const displayPage = new Adw.PreferencesPage({ + title: 'Display', + icon_name: 'preferences-desktop-display-symbolic', + }); + window.add(displayPage); + + const displayGroup = new Adw.PreferencesGroup({ + title: 'Display', + description: 'Configure how CodeBurn appears in the panel', + }); + displayPage.add(displayGroup); + + const refreshRow = new Adw.SpinRow({ + title: 'Refresh Interval', + subtitle: 'Seconds between data refreshes', + adjustment: new Gtk.Adjustment({ + lower: 5, + upper: 300, + step_increment: 5, + page_increment: 30, + value: settings.get_uint('refresh-interval'), + }), + }); + settings.bind('refresh-interval', refreshRow, 'value', Gio.SettingsBindFlags.DEFAULT); + displayGroup.add(refreshRow); + + const compactRow = new Adw.SwitchRow({ + title: 'Compact Mode', + subtitle: 'Show only the icon, hide the cost label', + }); + settings.bind('compact-mode', compactRow, 'active', Gio.SettingsBindFlags.DEFAULT); + displayGroup.add(compactRow); + + const darkModeRow = new Adw.SwitchRow({ + title: 'Force Dark Mode', + subtitle: 'Always use dark theme for the popup', + }); + settings.bind('force-dark-mode', darkModeRow, 'active', Gio.SettingsBindFlags.DEFAULT); + displayGroup.add(darkModeRow); + + const exactCostsRow = new Adw.SwitchRow({ + title: 'Show Exact Costs', + subtitle: 'Show full values like $2,655.23 instead of $2.7k', + }); + settings.bind('show-exact-costs', exactCostsRow, 'active', Gio.SettingsBindFlags.DEFAULT); + displayGroup.add(exactCostsRow); + + const periodModel = new Gtk.StringList(); + for (const p of PERIODS) + periodModel.append(p.label); + + const periodRow = new Adw.ComboRow({ + title: 'Default Period', + subtitle: 'Time period shown when extension opens', + model: periodModel, + }); + const currentPeriod = settings.get_string('default-period'); + const periodIndex = PERIODS.findIndex(p => p.id === currentPeriod); + periodRow.set_selected(periodIndex >= 0 ? periodIndex : 0); + periodRow.connect('notify::selected', () => { + const idx = periodRow.get_selected(); + if (idx >= 0 && idx < PERIODS.length) + settings.set_string('default-period', PERIODS[idx].id); + }); + displayGroup.add(periodRow); + + const alertsGroup = new Adw.PreferencesGroup({ + title: 'Budget Alerts', + description: 'Get warned when spending exceeds a threshold', + }); + displayPage.add(alertsGroup); + + const budgetEnabledRow = new Adw.SwitchRow({ + title: 'Enable Budget Alerts', + subtitle: 'Show a warning when daily spending exceeds the threshold', + }); + settings.bind('budget-alert-enabled', budgetEnabledRow, 'active', Gio.SettingsBindFlags.DEFAULT); + alertsGroup.add(budgetEnabledRow); + + const budgetRow = new Adw.SpinRow({ + title: 'Daily Budget (USD)', + subtitle: 'Set to 0 to disable', + adjustment: new Gtk.Adjustment({ + lower: 0, + upper: 1000, + step_increment: 1, + page_increment: 10, + value: settings.get_double('budget-threshold'), + }), + digits: 2, + }); + settings.bind('budget-threshold', budgetRow, 'value', Gio.SettingsBindFlags.DEFAULT); + alertsGroup.add(budgetRow); + + const providersGroup = new Adw.PreferencesGroup({ + title: 'Providers', + description: 'Toggle providers on/off for cost accounting', + }); + displayPage.add(providersGroup); + + const disabledProviders = settings.get_strv('disabled-providers'); + + for (const provider of PROVIDERS) { + const row = new Adw.SwitchRow({ + title: provider.label, + active: !disabledProviders.includes(provider.id), + }); + row.connect('notify::active', () => { + const current = settings.get_strv('disabled-providers'); + if (row.get_active()) { + settings.set_strv('disabled-providers', current.filter(p => p !== provider.id)); + } else { + if (!current.includes(provider.id)) + settings.set_strv('disabled-providers', [...current, provider.id]); + } + }); + providersGroup.add(row); + } + + const advancedGroup = new Adw.PreferencesGroup({ + title: 'Advanced', + }); + displayPage.add(advancedGroup); + + const pathRow = new Adw.EntryRow({ + title: 'CodeBurn CLI Path', + text: settings.get_string('codeburn-path'), + }); + pathRow.connect('changed', () => { + settings.set_string('codeburn-path', pathRow.get_text()); + }); + advancedGroup.add(pathRow); + } +} diff --git a/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml new file mode 100644 index 0000000..e122cd8 --- /dev/null +++ b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml @@ -0,0 +1,68 @@ + + + + + + 30 + Refresh interval + Seconds between automatic data refreshes + + + + + 'today' + Default time period + Period shown when extension opens (today, week, 30days, month, all) + + + + 0.0 + Budget threshold + Daily budget threshold in USD. Set to 0 to disable. + + + + false + Enable budget alerts + Show warning when spending exceeds budget threshold + + + + false + Compact mode + Show only icon in panel, hide cost label + + + + false + Force dark mode + Always use dark theme for the popup, regardless of system theme + + + + false + Show exact costs + Show full decimal values instead of compact notation (e.g. $2,655.23 instead of $2.7k) + + + + '' + CodeBurn CLI path + Custom path to the codeburn executable. Leave empty to use PATH. + + + + 'all' + Default provider filter + Default provider to filter by (all shows everything) + + + + [] + Disabled providers + Providers excluded from cost accounting and display + + + + diff --git a/gnome/stylesheet.css b/gnome/stylesheet.css new file mode 100644 index 0000000..74bf896 --- /dev/null +++ b/gnome/stylesheet.css @@ -0,0 +1,610 @@ +/* ---- panel button ---- */ +.codeburn-panel { + spacing: 4px; +} +.codeburn-flame { + font-size: 14px; +} +.codeburn-label { + font-weight: 500; + padding-left: 2px; + padding-right: 2px; +} + +/* ---- popup host ---- */ +.codeburn-menu { + padding: 0; +} +.codeburn-host { + padding: 0; + margin: 0; + background: transparent; + border: none; +} +.codeburn-host:hover, +.codeburn-host:focus, +.codeburn-host:active, +.codeburn-host:selected { + background: transparent; +} +.codeburn-root { + width: 340px; + height: 540px; + padding: 0; + spacing: 0; +} +.codeburn-scroll { + padding: 0; +} + +/* ---- brand header ---- */ +.codeburn-brand-header { + padding: 14px 16px 10px 16px; + spacing: 2px; +} +.codeburn-brand-row { + spacing: 0; +} +.codeburn-brand-primary { + font-weight: 700; + font-size: 18px; +} +.codeburn-brand-accent { + font-weight: 700; + font-size: 18px; + color: #ff8c42; +} +.codeburn-brand-subhead { + font-size: 10.5px; + opacity: 0.55; +} + +/* ---- tab rows ---- */ +.codeburn-tab-row { + padding: 4px 10px 8px 10px; + spacing: 4px; +} +.codeburn-period-row { + padding-top: 0; + padding-bottom: 10px; +} +.codeburn-tab, +.codeburn-period { + padding: 5px 6px; + border-radius: 6px; + font-size: 11px; + font-weight: 500; + background: transparent; + border: none; + opacity: 0.7; + transition-duration: 80ms; +} +.codeburn-tab:hover, +.codeburn-period:hover { + background: rgba(255, 140, 66, 0.08); + opacity: 1; +} +.codeburn-tab-active, +.codeburn-period-active { + background: rgba(255, 140, 66, 0.18); + color: #ff8c42; + opacity: 1; + font-weight: 600; +} +.codeburn-agent-scroll { + padding: 0; +} +.codeburn-agent-badge { + padding: 3px 10px; + border-radius: 10px; + background: rgba(255, 140, 66, 0.12); + color: #ff8c42; + font-size: 10.5px; + font-weight: 500; +} + +/* ---- hero ---- */ +.codeburn-hero { + padding: 4px 16px 10px 16px; + spacing: 2px; +} +.codeburn-hero-top { + spacing: 6px; +} +.codeburn-hero-dot { + width: 6px; + height: 6px; + border-radius: 3px; + background-color: #ff8c42; + margin-top: 7px; +} +.codeburn-hero-label { + font-size: 11px; + opacity: 0.65; + font-weight: 500; +} +.codeburn-hero-amount { + font-size: 28px; + font-weight: 700; + color: #ffd700; +} +.codeburn-hero-meta { + font-size: 11px; + opacity: 0.6; +} + +/* ---- activity section ---- */ +.codeburn-section-title { + font-weight: 600; + font-size: 11px; + opacity: 0.6; + padding-bottom: 2px; +} +/* ---- table headers ---- */ +.codeburn-table-header { + spacing: 6px; + padding: 2px 0 4px 0; +} +.codeburn-th { + font-size: 10px; + font-weight: 600; + opacity: 0.45; +} +.codeburn-th-cost { + min-width: 64px; +} +.codeburn-th-turns { + min-width: 40px; +} +.codeburn-th-calls { + min-width: 50px; +} + +.codeburn-activity-rows { + spacing: 0; +} +.codeburn-activity-row { + spacing: 3px; + padding: 6px 0; +} +.codeburn-activity-top { + spacing: 6px; +} +.codeburn-activity-name { + font-size: 11.5px; + font-weight: 500; + min-width: 120px; +} +.codeburn-activity-cost { + font-size: 11.5px; + font-family: monospace; + font-weight: 600; + color: #ffd700; + min-width: 64px; +} +.codeburn-activity-turns { + font-size: 10.5px; + font-family: monospace; + opacity: 0.6; + min-width: 40px; +} +.codeburn-activity-oneshot { + font-size: 10.5px; + font-family: monospace; + color: #4ec972; + min-width: 40px; +} +.codeburn-bar-track { + height: 4px; + border-radius: 2px; + background-color: rgba(255, 255, 255, 0.08); + width: 240px; +} +.codeburn-bar-fill { + height: 4px; + border-radius: 2px; + background-color: #ff8c42; +} +.codeburn-empty { + font-style: italic; + opacity: 0.55; + padding: 6px 0; +} + +/* ---- loading skeleton ---- */ +.codeburn-loading { + padding: 10px 16px; + spacing: 10px; +} +.codeburn-skeleton-bar { + background-color: rgba(255, 140, 66, 0.15); + border-radius: 4px; +} +.codeburn-light .codeburn-skeleton-bar { + background-color: rgba(200, 80, 30, 0.12); +} + +/* ---- findings CTA ---- */ +.codeburn-findings { + margin: 2px 16px 10px 16px; + padding: 9px 11px; + border-radius: 8px; + background: rgba(255, 140, 66, 0.12); + border: none; + transition-duration: 120ms; +} +.codeburn-findings:hover { + background: rgba(255, 140, 66, 0.2); +} +.codeburn-findings-inner { + spacing: 8px; +} +.codeburn-findings-count { + font-size: 11.5px; + font-weight: 600; + color: #ff8c42; +} +.codeburn-findings-savings { + font-size: 11.5px; + font-weight: 500; + color: #ff8c42; + opacity: 0.8; +} + +/* ---- footer ---- */ +.codeburn-footer { + padding: 10px 12px; + spacing: 6px; +} +.codeburn-footer-btn { + padding: 6px 10px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + border: none; + font-size: 11px; + font-weight: 500; + transition-duration: 80ms; +} +.codeburn-footer-btn:hover { + background: rgba(255, 255, 255, 0.1); +} +.codeburn-currency-box { + spacing: 2px; +} +.codeburn-currency-btn { + font-family: monospace; + min-width: 62px; +} +.codeburn-currency-picker { + background: rgba(30, 30, 30, 0.95); + border-radius: 8px; + padding: 4px; + height: 180px; +} +.codeburn-currency-list { + spacing: 1px; +} +.codeburn-currency-item { + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + font-family: monospace; + background: transparent; + border: none; +} +.codeburn-currency-item:hover { + background: rgba(255, 140, 66, 0.12); +} +.codeburn-currency-item-active { + background: rgba(255, 140, 66, 0.2); + color: #ff8c42; + font-weight: 600; +} +.codeburn-footer-cta { + background: #c9521d; + color: #ffffff; +} +.codeburn-footer-cta:hover { + background: #ff8c42; +} +.codeburn-updated { + font-size: 10px; + opacity: 0.45; + padding: 0 16px 10px 16px; +} + +/* ---- insight pills row ---- */ +.codeburn-insight-row { + padding: 4px 10px 8px 10px; + spacing: 4px; +} +.codeburn-insight-pill { + padding: 4px 4px; + border-radius: 6px; + font-size: 10.5px; + font-weight: 500; + background: transparent; + border: none; + opacity: 0.65; + transition-duration: 80ms; +} +.codeburn-insight-pill:hover { + background: rgba(255, 140, 66, 0.08); + opacity: 1; +} +.codeburn-insight-pill-active { + background: rgba(255, 140, 66, 0.18); + color: #ff8c42; + opacity: 1; + font-weight: 600; +} + +/* ---- token histogram chart ---- */ +.codeburn-chart { + padding: 0 16px 10px 16px; + spacing: 4px; +} +.codeburn-chart-header { + spacing: 6px; +} +.codeburn-chart-label { + font-weight: 600; + font-size: 11px; + opacity: 0.6; +} +.codeburn-chart-total { + font-family: monospace; + font-size: 11px; + opacity: 0.7; + color: #ff8c42; +} +.codeburn-chart-bars { + spacing: 2px; + height: 52px; +} +.codeburn-chart-col { + height: 52px; +} +.codeburn-chart-spacer { + background: transparent; +} +.codeburn-chart-bar { + background-color: #ff8c42; + border-radius: 2px 2px 0 0; +} +.codeburn-chart-bar-hover { + background-color: #ffa94d; +} +.codeburn-chart-total-hover { + font-weight: 600; +} +.codeburn-divider { + height: 1px; + background-color: rgba(255, 255, 255, 0.08); + margin: 4px 16px; +} + +/* ---- trend, pulse, stats, kv rows ---- */ +.codeburn-content { + padding: 6px 16px 10px 16px; + spacing: 6px; +} +.codeburn-trend-row, +.codeburn-kv-row { + padding: 4px 0; + spacing: 8px; +} +.codeburn-trend-date, +.codeburn-kv-label { + font-size: 11.5px; + font-weight: 500; +} +.codeburn-trend-cost, +.codeburn-kv-value { + font-family: monospace; + font-size: 11.5px; + font-weight: 600; + color: #ffd700; +} +.codeburn-trend-calls { + font-size: 10.5px; + opacity: 0.6; + min-width: 62px; +} + +/* ---- pulse tiles ---- */ +.codeburn-pulse-row { + spacing: 6px; + padding: 4px 0; +} +.codeburn-pulse-tile { + padding: 10px 8px; + border-radius: 8px; + background: rgba(255, 140, 66, 0.08); + spacing: 2px; +} +.codeburn-pulse-value { + font-size: 16px; + font-weight: 700; + color: #ff8c42; + font-family: monospace; +} +.codeburn-pulse-label { + font-size: 10px; + opacity: 0.6; +} + +/* ---- models rows ---- */ +.codeburn-models-rows { + spacing: 0; + padding-top: 4px; +} +.codeburn-model-row { + spacing: 8px; + padding: 6px 0; +} +.codeburn-model-name { + font-size: 11.5px; + min-width: 120px; +} +.codeburn-model-cost { + font-family: monospace; + font-size: 11.5px; + color: #ffd700; + min-width: 64px; +} +.codeburn-model-calls { + font-family: monospace; + font-size: 10.5px; + opacity: 0.6; + min-width: 50px; +} + +/* ---- settings gear button ---- */ +.codeburn-prefs-btn { + padding: 6px 8px; + font-size: 14px; +} + +/* ---- budget warning ---- */ +.codeburn-budget-warning { + color: #e5a50a; + font-weight: bold; + font-size: 11.5px; + padding: 6px 16px; +} + +/* ---- dark theme ---- */ +.codeburn-dark { + background-color: rgba(30, 30, 30, 0.98); + color: #e0e0e0; + border-radius: 12px; +} +.codeburn-dark .codeburn-brand-primary { + color: #ffffff; +} +.codeburn-dark .codeburn-brand-subhead { + color: rgba(255, 255, 255, 0.55); +} +.codeburn-dark .codeburn-hero-label, +.codeburn-dark .codeburn-hero-meta { + color: rgba(255, 255, 255, 0.65); +} +.codeburn-dark .codeburn-section-title, +.codeburn-dark .codeburn-th, +.codeburn-dark .codeburn-chart-label { + color: rgba(255, 255, 255, 0.5); +} +.codeburn-dark .codeburn-activity-name, +.codeburn-dark .codeburn-model-name, +.codeburn-dark .codeburn-trend-date, +.codeburn-dark .codeburn-kv-label { + color: #e0e0e0; +} +.codeburn-dark .codeburn-activity-turns, +.codeburn-dark .codeburn-model-calls, +.codeburn-dark .codeburn-trend-calls { + color: rgba(255, 255, 255, 0.5); +} +.codeburn-dark .codeburn-footer-btn { + background: rgba(255, 255, 255, 0.08); + color: #e0e0e0; +} +.codeburn-dark .codeburn-footer-btn:hover { + background: rgba(255, 255, 255, 0.14); +} +.codeburn-dark .codeburn-currency-picker { + background: rgba(20, 20, 20, 0.98); +} +.codeburn-dark .codeburn-currency-item { + color: #e0e0e0; +} +.codeburn-dark .codeburn-tab, +.codeburn-dark .codeburn-period, +.codeburn-dark .codeburn-insight-pill { + color: rgba(255, 255, 255, 0.7); +} +.codeburn-dark .codeburn-updated { + color: rgba(255, 255, 255, 0.45); +} + +/* ---- light theme ---- */ +.codeburn-light { + background-color: rgba(255, 255, 255, 0.98); + color: #1a1a1a; + border-radius: 12px; +} +.codeburn-light .codeburn-brand-primary { + color: #1a1a1a; +} +.codeburn-light .codeburn-brand-subhead { + color: rgba(0, 0, 0, 0.5); +} +.codeburn-light .codeburn-hero-label, +.codeburn-light .codeburn-hero-meta { + color: rgba(0, 0, 0, 0.6); +} +.codeburn-light .codeburn-hero-amount { + color: #c9521d; +} +.codeburn-light .codeburn-section-title, +.codeburn-light .codeburn-th, +.codeburn-light .codeburn-chart-label { + color: rgba(0, 0, 0, 0.45); +} +.codeburn-light .codeburn-activity-name, +.codeburn-light .codeburn-model-name, +.codeburn-light .codeburn-trend-date, +.codeburn-light .codeburn-kv-label { + color: #1a1a1a; +} +.codeburn-light .codeburn-activity-cost, +.codeburn-light .codeburn-model-cost, +.codeburn-light .codeburn-trend-cost, +.codeburn-light .codeburn-kv-value { + color: #c9521d; +} +.codeburn-light .codeburn-activity-turns, +.codeburn-light .codeburn-model-calls, +.codeburn-light .codeburn-trend-calls { + color: rgba(0, 0, 0, 0.5); +} +.codeburn-light .codeburn-activity-oneshot { + color: #1b7a35; +} +.codeburn-light .codeburn-bar-track { + background-color: rgba(0, 0, 0, 0.08); +} +.codeburn-light .codeburn-bar-fill { + background-color: #c9521d; +} +.codeburn-light .codeburn-chart-bar { + background-color: #c9521d; +} +.codeburn-light .codeburn-footer-btn { + background: rgba(0, 0, 0, 0.06); + color: #1a1a1a; +} +.codeburn-light .codeburn-footer-btn:hover { + background: rgba(0, 0, 0, 0.1); +} +.codeburn-light .codeburn-currency-picker { + background: rgba(245, 245, 245, 0.98); +} +.codeburn-light .codeburn-currency-item { + color: #1a1a1a; +} +.codeburn-light .codeburn-tab, +.codeburn-light .codeburn-period, +.codeburn-light .codeburn-insight-pill { + color: rgba(0, 0, 0, 0.65); +} +.codeburn-light .codeburn-pulse-tile { + background: rgba(255, 140, 66, 0.1); +} +.codeburn-light .codeburn-updated { + color: rgba(0, 0, 0, 0.4); +} +.codeburn-light .codeburn-divider { + background-color: rgba(0, 0, 0, 0.1); +} diff --git a/mac/.gitignore b/mac/.gitignore new file mode 100644 index 0000000..a14fabd --- /dev/null +++ b/mac/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +Package.resolved +*.xcodeproj/ +*.xcworkspace/ +DerivedData/ diff --git a/mac/Package.swift b/mac/Package.swift new file mode 100644 index 0000000..b525848 --- /dev/null +++ b/mac/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "CodeBurnMenubar", + platforms: [ + // macOS 14 (Sonoma) is the floor: matches Info.plist LSMinimumSystemVersion, + // the CLI install guard (MIN_MACOS_MAJOR=14), and mac/README. The earlier .v15 + // bump for NSAttributedString(attachment:) was a misdiagnosis, that initializer + // is AppKit since macOS 10.0, so the binary's minos must not exclude Sonoma users. + .macOS(.v14) + ], + products: [ + .executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"]) + ], + targets: [ + .executableTarget( + name: "CodeBurnMenubar", + path: "Sources/CodeBurnMenubar", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "CodeBurnMenubarTests", + dependencies: ["CodeBurnMenubar"], + path: "Tests/CodeBurnMenubarTests" + ) + ] +) diff --git a/mac/README.md b/mac/README.md new file mode 100644 index 0000000..fc636f4 --- /dev/null +++ b/mac/README.md @@ -0,0 +1,112 @@ +# CodeBurn Menubar (macOS) + +Native Swift + SwiftUI menubar app. The codeburn menubar surface. + +## Requirements + +- macOS 14+ (Sonoma) +- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone) +- `codeburn` CLI installed globally (`npm install -g codeburn`) + +## Install (end users) + +One command: + +```bash +codeburn menubar +``` + +That's it. The command records the persistent `codeburn` CLI path, downloads the latest `.app` from the newest `mac-v*` GitHub Release with a matching checksum, verifies it, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise. + +### Build from source + +For contributors running a local build instead of the packaged release: + +```bash +npm install -g codeburn # CLI the app shells out to for data +git clone https://github.com/getagentseal/codeburn.git +cd codeburn/mac +swift build -c release +.build/release/CodeBurnMenubar # launch +``` + +#### On macOS 14 (Sonoma) without Xcode 16 + +`swift build` above assumes the macOS 15 SDK, whose SwiftUI marks the `View` +protocol `@MainActor`. The Sonoma SDK (shipped with Command Line Tools) lacks +that annotation, so a plain build fails with ~80 `main actor-isolated ... from a +nonisolated context` errors. + +(The `-10825` launch failure itself is fixed by `Package.swift`'s `.macOS(.v14)` +deployment target: ld64 drops the macOS-15-only `libswift_errno.dylib` +dependency for any build with that target, regardless of which SDK built it, including the CI-distributed release. This local-build path exists only for +the narrower case of building on a Sonoma machine with nothing but the +Command Line Tools, where the SDK's un-annotated `View` protocol needs the +`@MainActor` patch below.) + +Use the helper, which builds against the local macOS 14 SDK with a standalone +[swift.org](https://www.swift.org/install/macos/) Swift 6.x toolchain and +adds explicit `@MainActor` to the views in a scratch copy (repo sources stay +clean), producing a `minos = 14.0` bundle installed to `~/Applications`: + +```bash +mac/Scripts/build-local.sh # then: codeburn menubar +``` + +## Build & run (dev against a local CLI checkout) + +```bash +cd mac +swift build +# Point the app at your dev CLI build instead of the globally installed `codeburn`: +npm --prefix .. run build +CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run +``` + +The app registers itself as a menubar accessory (`LSUIElement = true` at runtime). No Dock icon. + +## Data source + +On launch and every 60 seconds thereafter, the app spawns `codeburn status --format menubar-json --no-optimize` directly (argv, no shell) via `CodeburnCLI.makeProcess` and decodes the JSON into `MenubarPayload`. The manual refresh button in the footer invokes the same command without `--no-optimize`, which includes optimize findings but takes longer. + +Release installs record a persistent absolute CLI path in `~/Library/Application Support/CodeBurn/codeburn-cli-path.v1`, then fall back to Homebrew's common `codeburn` locations. For development only, set `CODEBURN_ALLOW_DEV_BIN=1` with `CODEBURN_BIN`; the value is validated against a strict allowlist before use, so a malicious env var can't inject shell commands. + +## Project layout + +``` +mac/ +├── Package.swift SwiftPM manifest (deployment target: macOS 14) +├── Scripts/ +│ ├── package-app.sh CI: universal signed .app + zip + checksum +│ └── build-local.sh Local macOS 14 build (Sonoma SDK + @MainActor patch) +├── Sources/CodeBurnMenubar/ +│ ├── CodeBurnApp.swift @main + MenuBarExtra scene +│ ├── AppStore.swift @Observable store + enums +│ ├── Data/MenubarPayload.swift Codable payload types + placeholder +│ ├── Theme/Theme.swift Design tokens (warm terracotta palette) +│ └── Views/MenuBarContent.swift Popover layout + footer action bar +└── README.md This file +``` + +## Status + +Live data wired. Next iterations: + +1. FSEvents watch for `~/.claude/projects/` changes (debounced refresh on real edits) +2. Persistent disk cache for optimize findings so the default refresh can include them without the 30-second penalty +3. Currency metadata in the JSON payload + Swift-side formatting +4. Sparkle auto-update +5. DMG packaging + Homebrew Cask tap + +## Design tokens + +Sourced from `~/codeburn-menubar-mac-swiftui.html`. Warm terracotta-ember palette: + +- Accent (light): `#C9521D` +- Accent (dark): `#E8774A` +- Ember deep: `#8B3E13` +- Ember glow: `#F0A070` +- Surface (light): `#FAF7F3` +- Surface (dark): `#1C1816` + +SF Mono for currency values; SF Pro Rounded for hero. diff --git a/mac/Scripts/build-local.sh b/mac/Scripts/build-local.sh new file mode 100755 index 0000000..fbd20f4 --- /dev/null +++ b/mac/Scripts/build-local.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# ============================================================================ +# build-local.sh, Build CodeBurnMenubar.app on a macOS 14 (Sonoma) machine. +# ============================================================================ +# Why this exists +# --------------- +# Package.swift's `.macOS(.v14)` deployment target already fixes the -10825 +# launch failure for every build, including the CI-distributed release: ld64 +# drops the macOS-15-only libswift_errno.dylib dependency based on the +# deployment target, not the SDK used to build. This script is not about that. +# +# It exists for the narrower case of building on a Sonoma machine that only +# has the Command Line Tools (macOS 14 SDK). That SDK's SwiftUI does NOT carry +# the @MainActor annotations the macOS 15 SDK added to the `View` protocol, so +# a plain `swift build` there fails with ~80 `main actor-isolated ... from a +# nonisolated context` errors. This script copies the sources to a scratch +# dir, gives every `View`/`App` conformance an explicit `@MainActor` there +# (repo sources stay untouched), and builds a universal bundle with a +# swift.org Swift 6.2 toolchain against the local macOS 14 SDK. +# +# Prerequisites +# - Command Line Tools (provides the macOS 14 SDK + sips/iconutil/codesign) +# - A swift.org Swift 6.x toolchain in ~/Library/Developer/Toolchains/ +# download: https://www.swift.org/install/macos/ (Swift 6.2 recommended) +# +# Usage: mac/Scripts/build-local.sh [] (defaults to "dev") +# ---------------------------------------------------------------------------- +set -euo pipefail + +VERSION="${1:-dev}" +BUNDLE_ID="org.agentseal.codeburn-menubar" +EXE="CodeBurnMenubar" +MIN_MACOS="14.0" + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +MAC_DIR="${ROOT}/mac" +ICON_SOURCE="${ROOT}/assets/menubar-logo.png" +SCRATCH="$(mktemp -d /tmp/codeburn-local-build.XXXXXX)" +APPS="${HOME}/Applications" +BUNDLE="${APPS}/${EXE}.app" + +trap 'rm -rf "${SCRATCH}"' EXIT + +# --- locate a Swift 6.x toolchain ------------------------------------------- +TC="" +for cand in "${HOME}/Library/Developer/Toolchains/swift-6.2-RELEASE.xctoolchain" \ + "${HOME}/Library/Developer/Toolchains/swift-latest.xctoolchain" \ + /Library/Developer/Toolchains/swift-latest.xctoolchain; do + [[ -x "${cand}/usr/bin/swift" ]] && { TC="${cand}"; break; } +done +if [[ -z "${TC}" ]]; then + echo "✗ No swift.org Swift 6.x toolchain found in ~/Library/Developer/Toolchains/." >&2 + echo " Install one from https://www.swift.org/install/macos/ (Swift 6.2)." >&2 + exit 1 +fi +SWIFT="${TC}/usr/bin/swift" +export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" +SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)" +case "${SDK_VERSION}" in + 14.*) ;; + *) + echo "✗ Active SDK is macOS ${SDK_VERSION}, not 14.x, xcode-select is likely" >&2 + echo " pointed at a newer Xcode instead of the Command Line Tools. Run:" >&2 + echo " sudo xcode-select -s /Library/Developer/CommandLineTools" >&2 + exit 1 + ;; +esac +echo "▸ Toolchain : $("${SWIFT}" --version | head -1)" +echo "▸ SDK : ${SDKROOT} (${SDK_VERSION})" + +# --- copy sources and add explicit @MainActor to SwiftUI views -------------- +echo "▸ Staging sources in ${SCRATCH}..." +# Tests/ is copied only so the manifest's testTarget path resolves; `swift build` +# (product only) never compiles it, so it needs no @MainActor patching. +cp -R "${MAC_DIR}/Sources" "${MAC_DIR}/Tests" "${MAC_DIR}/Package.swift" "${SCRATCH}/" +find "${SCRATCH}/Sources" -name "*.swift" -print0 | while IFS= read -r -d '' f; do + # Slurp mode ([^{]* spans newlines) so this also catches multi-line generic + # struct headers and `extension X: View` conformances, not just the + # single-line `struct X: View {` shape the current sources happen to use. + perl -0777 -i -pe ' + s/^((?:private |public |fileprivate |internal )?(?:struct|extension)\s+\w+(?:<[^{]*?>)?\s*:[^{]*\bView\b[^{]*\{)/\@MainActor\n$1/gm; + s/^(struct\s+\w+\s*:[^{]*\bApp\b[^{]*\{)/\@MainActor\n$1/gm; + s/\@MainActor\n\@MainActor\n/\@MainActor\n/g; + ' "$f" +done + +# --- build each arch separately, then lipo into one universal binary -------- +# `swift build --arch arm64 --arch x86_64` together shells out to xcbuild, +# which the Command Line Tools doesn't ship, each arch alone stays on the +# plain SwiftPM build path, so build twice and merge with lipo instead. +BINS=() +for arch in arm64 x86_64; do + echo "▸ Building ${arch} release..." + ( cd "${SCRATCH}" && "${SWIFT}" build -c release --arch "${arch}" ) + bin="$(cd "${SCRATCH}" && "${SWIFT}" build -c release --arch "${arch}" --show-bin-path)/${EXE}" + [[ -x "${bin}" ]] || { echo "✗ ${arch} build produced no binary" >&2; exit 1; } + BINS+=("${bin}") +done +BIN="${SCRATCH}/${EXE}-universal" +lipo -create -output "${BIN}" "${BINS[@]}" + +# --- assemble the .app bundle ------------------------------------------------ +echo "▸ Assembling ${BUNDLE}..." +pkill -x "${EXE}" 2>/dev/null || true; sleep 1 +rm -rf "${BUNDLE}" +mkdir -p "${BUNDLE}/Contents/MacOS" "${BUNDLE}/Contents/Resources" +cp "${BIN}" "${BUNDLE}/Contents/MacOS/${EXE}" +cp "${ICON_SOURCE}" "${BUNDLE}/Contents/Resources/menubar-logo.png" + +ICONSET="${SCRATCH}/AppIcon.iconset"; mkdir -p "${ICONSET}" +for spec in "16:16x16" "32:16x16@2x" "32:32x32" "64:32x32@2x" "128:128x128" \ + "256:128x128@2x" "256:256x256" "512:256x256@2x" "512:512x512"; do + sips -z "${spec%%:*}" "${spec%%:*}" "${ICON_SOURCE}" --out "${ICONSET}/icon_${spec##*:}.png" >/dev/null +done +cp "${ICON_SOURCE}" "${ICONSET}/icon_512x512@2x.png" +iconutil -c icns "${ICONSET}" -o "${BUNDLE}/Contents/Resources/AppIcon.icns" + +cat > "${BUNDLE}/Contents/Info.plist" < + + + + CFBundleDevelopmentRegionen + CFBundleDisplayNameCodeBurn Menubar + CFBundleExecutable${EXE} + CFBundleIconFileAppIcon + CFBundleIdentifier${BUNDLE_ID} + CFBundleInfoDictionaryVersion6.0 + CFBundleName${EXE} + CFBundlePackageTypeAPPL + CFBundleShortVersionString${VERSION} + CFBundleVersion${VERSION} + LSMinimumSystemVersion${MIN_MACOS} + LSUIElement + NSHighResolutionCapable + NSHumanReadableCopyright© AgentSeal + + +PLIST +printf 'APPL????' > "${BUNDLE}/Contents/PkgInfo" + +echo "▸ Ad-hoc signing..." +codesign --force --sign - --timestamp=none --deep "${BUNDLE}" +codesign --verify --deep --strict "${BUNDLE}" + +echo "" +echo "✓ Installed ${BUNDLE}" +lipo -info "${BUNDLE}/Contents/MacOS/${EXE}" | sed 's/^/ /' +vtool -show-build "${BUNDLE}/Contents/MacOS/${EXE}" 2>/dev/null | grep -iE "minos|sdk" | sed 's/^/ /' +echo " Launch with: codeburn menubar (or: open '${BUNDLE}')" diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh new file mode 100755 index 0000000..db8c3b4 --- /dev/null +++ b/mac/Scripts/package-app.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Builds a universal CodeBurnMenubar.app bundle from the SwiftPM target and drops a +# distributable zip alongside. Used by the GitHub release workflow; also runnable locally. +# +# Usage: +# mac/Scripts/package-app.sh [] +# Defaults to `dev` if no version is given. + +set -euo pipefail + +VERSION="${1:-dev}" +ASSET_VERSION="${VERSION#mac-}" +BUNDLE_VERSION="${ASSET_VERSION#v}" +BUNDLE_NAME="CodeBurnMenubar.app" +BUNDLE_ID="org.agentseal.codeburn-menubar" +EXECUTABLE_NAME="CodeBurnMenubar" +MIN_MACOS="14.0" + +repo_root() { + git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/../.." && pwd) +} + +ROOT=$(repo_root) +MAC_DIR="${ROOT}/mac" +DIST_DIR="${MAC_DIR}/.build/dist" +ICON_SOURCE="${ROOT}/assets/menubar-logo.png" + +cd "${MAC_DIR}" + +echo "▸ Cleaning previous dist..." +rm -rf "${DIST_DIR}" +mkdir -p "${DIST_DIR}" + +echo "▸ Building universal binary (arm64 + x86_64)..." +swift build -c release --arch arm64 --arch x86_64 + +BIN_PATH=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path) +BUILT_BINARY="${BIN_PATH}/${EXECUTABLE_NAME}" +if [[ ! -x "${BUILT_BINARY}" ]]; then + echo "Binary not found at ${BUILT_BINARY}" >&2 + exit 1 +fi + +echo "▸ Assembling ${BUNDLE_NAME}..." +BUNDLE="${DIST_DIR}/${BUNDLE_NAME}" +mkdir -p "${BUNDLE}/Contents/MacOS" +mkdir -p "${BUNDLE}/Contents/Resources" +cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}" +cp "${ICON_SOURCE}" "${BUNDLE}/Contents/Resources/menubar-logo.png" + +ICONSET="${DIST_DIR}/AppIcon.iconset" +rm -rf "${ICONSET}" +mkdir -p "${ICONSET}" +sips -z 16 16 "${ICON_SOURCE}" --out "${ICONSET}/icon_16x16.png" >/dev/null +sips -z 32 32 "${ICON_SOURCE}" --out "${ICONSET}/icon_16x16@2x.png" >/dev/null +sips -z 32 32 "${ICON_SOURCE}" --out "${ICONSET}/icon_32x32.png" >/dev/null +sips -z 64 64 "${ICON_SOURCE}" --out "${ICONSET}/icon_32x32@2x.png" >/dev/null +sips -z 128 128 "${ICON_SOURCE}" --out "${ICONSET}/icon_128x128.png" >/dev/null +sips -z 256 256 "${ICON_SOURCE}" --out "${ICONSET}/icon_128x128@2x.png" >/dev/null +sips -z 256 256 "${ICON_SOURCE}" --out "${ICONSET}/icon_256x256.png" >/dev/null +sips -z 512 512 "${ICON_SOURCE}" --out "${ICONSET}/icon_256x256@2x.png" >/dev/null +sips -z 512 512 "${ICON_SOURCE}" --out "${ICONSET}/icon_512x512.png" >/dev/null +cp "${ICON_SOURCE}" "${ICONSET}/icon_512x512@2x.png" +iconutil -c icns "${ICONSET}" -o "${BUNDLE}/Contents/Resources/AppIcon.icns" +rm -rf "${ICONSET}" + +cat > "${BUNDLE}/Contents/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + CodeBurn Menubar + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIconFile + AppIcon + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${EXECUTABLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${BUNDLE_VERSION} + CFBundleVersion + ${BUNDLE_VERSION} + LSMinimumSystemVersion + ${MIN_MACOS} + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + © AgentSeal + + +PLIST + +cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG' +APPL???? +PKG + +# Sign so macOS treats the bundle as internally consistent. Set CODESIGN_IDENTITY +# to a stable identity (Developer ID Application for distribution, or an Apple +# Development cert for local testing) so the TCC "access data from other apps" +# grant persists across rebuilds. Falls back to ad-hoc when unset (e.g. CI), which +# re-prompts on every build because each ad-hoc build has a fresh code identity. +CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}" +if [[ -n "${CODESIGN_IDENTITY}" ]]; then + echo "▸ Signing with identity: ${CODESIGN_IDENTITY}" + codesign --force --sign "${CODESIGN_IDENTITY}" --options runtime --timestamp=none --deep "${BUNDLE}" +else + echo "▸ Ad-hoc signing (set CODESIGN_IDENTITY for a persistent TCC grant)..." + codesign --force --sign - --timestamp=none --deep "${BUNDLE}" +fi +codesign --verify --deep --strict "${BUNDLE}" + +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-${ASSET_VERSION}.zip" +ZIP_PATH="${DIST_DIR}/${ZIP_NAME}" +echo "▸ Packaging ${ZIP_NAME}..." +(cd "${DIST_DIR}" && COPYFILE_DISABLE=1 /usr/bin/ditto -c -k --norsrc --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}") + +CHECKSUM_NAME="${ZIP_NAME}.sha256" +CHECKSUM_PATH="${DIST_DIR}/${CHECKSUM_NAME}" +echo "▸ Computing SHA-256 checksum..." +(cd "${DIST_DIR}" && shasum -a 256 "${ZIP_NAME}" > "${CHECKSUM_NAME}") + +echo "" +echo "✓ Built ${ZIP_PATH}" +echo "✓ Checksum ${CHECKSUM_PATH}" +cat "${CHECKSUM_PATH}" +ls -la "${DIST_DIR}" diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift new file mode 100644 index 0000000..3d3be9a --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -0,0 +1,1566 @@ +import Foundation +import Observation + +private let cacheTTLSeconds: TimeInterval = 30 +private let interactiveRefreshResetSeconds: TimeInterval = 120 +private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod" + +struct CachedPayload { + let payload: MenubarPayload + let fetchedAt: Date + var isFresh: Bool { Date().timeIntervalSince(fetchedAt) < cacheTTLSeconds } +} + +struct PayloadCacheKey: Hashable { + let scope: MenubarScope + let period: Period + let provider: ProviderFilter + let day: String? + let days: Set + let claudeConfigSourceId: String? + + init(scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + days: Set = [], + claudeConfigSourceId: String? = nil) { + self.scope = scope + self.period = period + self.provider = provider + self.day = days.count <= 1 ? (day ?? days.first) : nil + self.days = days.count > 1 ? days : [] + self.claudeConfigSourceId = claudeConfigSourceId + } + + var label: String { + if !days.isEmpty, let first = days.min(), let last = days.max() { + return "\(first)..\(last)" + } + return day.map { "Day(\($0))" } ?? period.rawValue + } +} + +@MainActor +@Observable +final class AppStore { + var selectedProvider: ProviderFilter = .all + var selectedPeriod: Period = .today + var selectedScope: MenubarScope = MenubarScope.savedMenubarScope() + var selectedClaudeConfigSourceId: String? + var selectedDays: Set = [] + var activeScope: MenubarScope { effectiveSelectedScope } + + private var effectiveSelectedScope: MenubarScope { + selectedDays.count > 1 ? .local : selectedScope + } + + var selectedDay: String? { + guard selectedDays.count == 1 else { return nil } + return selectedDays.first + } + private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() { + didSet { menubarPeriod.persistAsMenubarDefault() } + } + private(set) var menubarScope: MenubarScope = MenubarScope.savedMenubarScope() { + didSet { menubarScope.persistAsMenubarDefault() } + } + var selectedInsight: InsightMode = .trend + var accentPreset: AccentPreset = ThemeState.shared.preset { + didSet { ThemeState.shared.preset = accentPreset } + } + var showingAccentPicker: Bool = false + var currency: String = "USD" + /// Which Settings tab to show; lets the menu's "About CodeBurn" item jump + /// straight to the About tab even when the Settings window is reused. + var settingsTab: String = "general" + var displayMetric: DisplayMetric = DisplayMetric(rawValue: UserDefaults.standard.string(forKey: "CodeBurnDisplayMetric") ?? "") ?? .cost { + didSet { UserDefaults.standard.set(displayMetric.rawValue, forKey: "CodeBurnDisplayMetric") } + } + var dailyBudget: Double = UserDefaults.standard.double(forKey: "CodeBurnDailyBudget") { + didSet { UserDefaults.standard.set(dailyBudget, forKey: "CodeBurnDailyBudget") } + } + // Token-denominated daily budget, used when the display metric is token-based. + // Stored separately from the cost budget so switching metric never reinterprets + // a dollar threshold as a token count (or vice versa). + var dailyTokenBudget: Double = UserDefaults.standard.double(forKey: "CodeBurnDailyTokenBudget") { + didSet { UserDefaults.standard.set(dailyTokenBudget, forKey: "CodeBurnDailyTokenBudget") } + } + + /// True when the menubar metric counts tokens rather than cost. + var isTokenMetric: Bool { displayMetric == .tokens || displayMetric == .totalTokens } + + /// Active daily-budget threshold for the current metric: a token count when + /// tracking tokens, otherwise USD cost. 0 means the alert is off. + var activeDailyBudget: Double { isTokenMetric ? dailyTokenBudget : dailyBudget } + + /// Today's total in the active metric (USD cost, or input+output tokens), + /// or nil when today's payload has not loaded yet. + var todayMetricTotal: Double? { + guard let current = todayPayload?.current else { return nil } + return isTokenMetric ? Double(current.inputTokens + current.outputTokens) : current.cost + } + + /// True when today's usage has reached or passed the active daily budget. + var isOverDailyBudget: Bool { + guard activeDailyBudget > 0, let total = todayMetricTotal else { return false } + return total >= activeDailyBudget + } + + var shouldShowDailyBudgetWarning: Bool { + isOverDailyBudget && activeScope == .local + } + + /// The active daily-budget threshold formatted for display (tokens, or USD). + /// The cost budget is defined in USD (matching the "$" presets and field), so + /// it is not run through the display-currency conversion here. + var dailyBudgetLabel: String { + isTokenMetric ? "\(activeDailyBudget.asCompactTokens()) tokens" : activeDailyBudget.asUSD() + } + + var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } } + var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 } + var hasAttemptedCurrentKeyLoad: Bool { + attemptedKeys.contains(currentKey) || + (effectiveSelectedScope == .combined && attemptedKeys.contains(localCurrentKey)) + } + var lastError: String? { lastErrorByKey[currentKey] } + private var loadingCountsByKey: [PayloadCacheKey: Int] = [:] + private var loadingStartedAtByKey: [PayloadCacheKey: Date] = [:] + private var attemptedKeys: Set = [] + private var lastErrorByKey: [PayloadCacheKey: String] = [:] + var subscription: SubscriptionUsage? + var subscriptionError: String? + var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped + var capacityEstimates: [String: CapacityEstimate] = [:] + + var codexUsage: CodexUsage? + var codexError: String? + var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped + + /// Generation tokens for the in-flight refresh tasks. Incremented on every + /// disconnect / reset so a fetch that started before the disconnect cannot + /// resume after the await and re-populate the freshly-cleared state. + private var claudeRefreshGen: Int = 0 + private var codexRefreshGen: Int = 0 + + private var cache: [PayloadCacheKey: CachedPayload] = [:] + private var cacheDate: String = "" + private var switchTask: Task? + private var payloadRefreshGeneration: UInt64 = 0 +#if DEBUG + private var refreshSuppressedForTesting = false +#endif + /// Tracks the last successful fetch timestamp per key for stuck-loading + /// diagnostics. NOT used for cache-freshness logic — `CachedPayload.fetchedAt` + /// is authoritative there. This map persists across cache wipes (day + /// rollover, etc.) so we can distinguish "fresh install, never fetched" + /// from "cache was wiped 10 minutes ago and we still haven't refilled". + private var lastSuccessByKey: [PayloadCacheKey: Date] = [:] + + static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = .current + return formatter + }() + + static func dayString(from date: Date) -> String { + dayFormatter.string(from: date) + } + + private func staleSecondsForKey(_ key: PayloadCacheKey) -> TimeInterval { + guard let last = lastSuccessByKey[key] else { return .infinity } + return Date().timeIntervalSince(last) + } + + private var todayAllKey: PayloadCacheKey { + PayloadCacheKey(scope: .local, period: .today, provider: .all, day: nil) + } + + private var menubarStatusKey: PayloadCacheKey { + // Scope the menu-bar figure to the selected Claude config so the icon + // matches the popover instead of always showing the merged All total. + PayloadCacheKey(scope: .local, period: menubarPeriod, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId) + } + + private var currentKey: PayloadCacheKey { + PayloadCacheKey( + scope: effectiveSelectedScope, + period: selectedPeriod, + provider: selectedProvider, + day: selectedDay, + days: selectedDays, + claudeConfigSourceId: selectedClaudeConfigSourceId + ) + } + + private var localCurrentKey: PayloadCacheKey { + PayloadCacheKey( + scope: .local, + period: selectedPeriod, + provider: selectedProvider, + day: selectedDay, + days: selectedDays, + claudeConfigSourceId: selectedClaudeConfigSourceId + ) + } + + private var periodAllKey: PayloadCacheKey { + PayloadCacheKey( + scope: .local, + period: selectedPeriod, + provider: .all, + day: selectedDay, + days: selectedDays, + claudeConfigSourceId: selectedClaudeConfigSourceId + ) + } + + var payload: MenubarPayload { + if effectiveSelectedScope == .combined { + let combinedPayload = cache[currentKey]?.payload + if let localPayload = cache[localCurrentKey]?.payload { + if let combined = combinedPayload?.combined { + return MenubarPayload( + generated: combinedPayload?.generated ?? localPayload.generated, + current: localPayload.current, + optimize: localPayload.optimize, + history: localPayload.history, + combined: combined, + claudeConfigs: localPayload.claudeConfigs + ) + } + return localPayload + } + if let combinedPayload { + return combinedPayload + } + } + return cache[currentKey]?.payload ?? .empty + } + + /// Today (across all providers) backs day-specific views in the popover. + var todayPayload: MenubarPayload? { + cache[todayAllKey]?.payload + } + + var todayPayloadAgeSeconds: Int? { + guard let cached = cache[todayAllKey] else { return nil } + return Int(Date().timeIntervalSince(cached.fetchedAt)) + } + + var menubarPayloadAgeSeconds: Int? { + guard let cached = cache[menubarStatusKey] else { return nil } + return Int(Date().timeIntervalSince(cached.fetchedAt)) + } + + var needsStatusPayloadRefresh: Bool { + cache[menubarStatusKey]?.isFresh != true + } + + var menubarPayload: MenubarPayload? { + cache[menubarStatusKey]?.payload + } + + /// All-provider payload for the selected period. Used by the tab strip to show + /// per-provider costs that match the active period, not just today. + var periodAllPayload: MenubarPayload? { + cache[periodAllKey]?.payload + } + + var claudeConfigOptions: [ClaudeConfigOption] { + payload.claudeConfigs?.options + ?? periodAllPayload?.claudeConfigs?.options + ?? todayPayload?.claudeConfigs?.options + ?? [] + } + + var shouldShowClaudeConfigSelector: Bool { + claudeConfigOptions.count > 1 + } + + var isDayMode: Bool { + !selectedDays.isEmpty + } + + var selectionLabel: String { + if selectedDays.count > 1, let first = selectedDays.min(), let last = selectedDays.max() { + return "\(selectedDays.count) days (\(first) .. \(last))" + } + return selectedDay.map { "Day (\($0))" } ?? selectedPeriod.rawValue + } + + var trendPeriod: Period { + isDayMode ? .today : selectedPeriod + } + + var hasCachedData: Bool { + cache[currentKey] != nil || (effectiveSelectedScope == .combined && cache[localCurrentKey] != nil) + } + + var hasStaleLoading: Bool { + let now = Date() + return loadingStartedAtByKey.values.contains { + now.timeIntervalSince($0) > loadingWatchdogSeconds + } + } + + var hasStaleInteractivePayload: Bool { + staleInteractivePayloadAgeSeconds != nil + } + + var hasMissingInteractivePayloadWithoutAttempt: Bool { + !hasCachedData && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad + } + + var shouldResetInteractiveRefreshPipeline: Bool { + hasStaleLoading || hasStaleInteractivePayload || hasMissingInteractivePayloadWithoutAttempt + } + + var staleInteractivePayloadAgeSeconds: Int? { + let keys = Set([ + currentKey, + localCurrentKey, + todayAllKey, + periodAllKey, + ]) + let staleAges = keys.compactMap { key -> TimeInterval? in + guard let cached = cache[key] else { return nil } + let age = Date().timeIntervalSince(cached.fetchedAt) + return age > interactiveRefreshResetSeconds ? age : nil + } + return staleAges.max().map(Int.init) + } + + var needsInteractivePayloadRefresh: Bool { + var requiredKeys: Set = [currentKey, todayAllKey, periodAllKey] + if effectiveSelectedScope == .combined { + requiredKeys.insert(localCurrentKey) + } + return requiredKeys.contains { cache[$0]?.isFresh != true } || hasStaleLoading + } + + /// True if any cached payload reports at least one provider. Used to keep the + /// AgentTabStrip visible across period/provider switches even when the current + /// key's payload is briefly empty (e.g. immediately after a `switchTo` and + /// before the new fetch lands). + var hasAnyProvidersInCache: Bool { + cache.values.contains { !$0.payload.current.providers.isEmpty } + } + +#if DEBUG + func setCachedPayloadForTesting(_ payload: MenubarPayload, + scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + days: Set = [], + claudeConfigSourceId: String? = nil, + fetchedAt: Date) { + cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)] = CachedPayload(payload: payload, fetchedAt: fetchedAt) + } + + func cachedPayloadForTesting(scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + days: Set = [], + claudeConfigSourceId: String? = nil) -> MenubarPayload? { + cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)]?.payload + } + + func setLastErrorForTesting(_ error: String, + scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + days: Set = []) { + lastErrorByKey[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)] = error + } + + func seedInFlightForTesting(scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + insertedAt: Date) { + inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] = insertedAt + } + + func isInFlightForTesting(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil) -> Bool { + inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] != nil + } + + func suppressRefreshesForTesting() { + refreshSuppressedForTesting = true + } +#endif + + var findingsCount: Int { + payload.optimize.findingCount + } + + /// Switch to a period. Cancels any in-flight switch and fetches provider-specific + + /// all-provider data in parallel so tab strip costs stay in sync with the hero. + func switchTo(period: Period) { + selectedPeriod = period + selectedDays = [] + startInteractiveSelectionRefresh() + } + + func switchToYesterday() { + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() + switchTo(day: yesterday) + } + + func switchTo(day: Date) { + let clamped = min(Calendar.current.startOfDay(for: day), Calendar.current.startOfDay(for: Date())) + selectedDays = [Self.dayString(from: clamped)] + startInteractiveSelectionRefresh() + } + + func switchTo(days: Set) { + selectedDays = days + startInteractiveSelectionRefresh() + } + + func shiftSelectedDay(by delta: Int) { + let base = selectedDayDate ?? Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() + let shifted = Calendar.current.date(byAdding: .day, value: delta, to: base) ?? base + switchTo(day: shifted) + } + + var selectedDayDate: Date? { + guard let selectedDay else { return nil } + return Self.dayFormatter.date(from: selectedDay) + } + + var canShiftSelectedDayForward: Bool { + guard let selectedDayDate else { return false } + return Calendar.current.startOfDay(for: selectedDayDate) < Calendar.current.startOfDay(for: Date()) + } + + func setMenubarPeriod(_ period: Period) { + guard Period.menubarMetricCases.contains(period) else { return } + guard menubarPeriod != period else { return } + menubarPeriod = period + Task { [weak self] in + await self?.refreshQuietly(period: period) + } + } + + func setMenubarScope(_ scope: MenubarScope) { + let shouldResetProvider = scope == .combined && selectedProvider != .all + guard menubarScope != scope || selectedScope != scope || shouldResetProvider else { return } + menubarScope = scope + selectedScope = scope + if shouldResetProvider { + selectedProvider = .all + } + if scope == .combined { + selectedClaudeConfigSourceId = nil + } +#if DEBUG + if refreshSuppressedForTesting { return } +#endif + Task { [weak self] in + guard let self else { return } + await self.refreshSelectionQuietly(scope: self.effectiveSelectedScope, force: true) + } + } + + /// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only + /// runs the CLI for the final selection. Fetches provider-specific and all-provider data + /// in parallel so the tab strip costs stay in sync with the hero. + func switchTo(provider: ProviderFilter) { + selectedProvider = provider + // A Claude config scope only applies to All/Claude views; picking any + // other provider tab clears it (the CLI rejects the contradictory combo). + if provider != .all && provider != .claude { + selectedClaudeConfigSourceId = nil + } + startInteractiveSelectionRefresh() + } + + func switchTo(claudeConfigSourceId: String?) { + guard selectedClaudeConfigSourceId != claudeConfigSourceId else { return } + selectedClaudeConfigSourceId = claudeConfigSourceId + if claudeConfigSourceId != nil { + selectedProvider = .all + selectedScope = .local + } + startInteractiveSelectionRefresh() + } + + func switchTo(scope: MenubarScope) { + let shouldResetProvider = scope == .combined && selectedProvider != .all + guard selectedScope != scope || shouldResetProvider else { return } + selectedScope = scope + if shouldResetProvider { + selectedProvider = .all + } + if scope == .combined { + selectedClaudeConfigSourceId = nil + } + startInteractiveSelectionRefresh() + } + + private func startInteractiveSelectionRefresh() { + switchTask?.cancel() + resetLoadingState() +#if DEBUG + if refreshSuppressedForTesting { return } +#endif + let period = selectedPeriod + let provider = selectedProvider + let scope = effectiveSelectedScope + let day = selectedDay + let days = selectedDays + let claudeConfigSourceId = selectedClaudeConfigSourceId + let key = PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId) + let localKey = PayloadCacheKey(scope: .local, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId) + let allKey = PayloadCacheKey(scope: .local, period: period, provider: .all, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId) + lastErrorByKey[key] = nil + switchTask = Task { + if scope == .combined { + async let local: Void = refresh(key: localKey, includeOptimize: false, force: false, showLoading: false) + async let combined: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true) + if provider == .all { + _ = await (local, combined) + } else { + async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false) + _ = await (local, combined, all) + } + } else if provider == .all { + await refresh(key: key, includeOptimize: false, force: true, showLoading: true) + } else { + async let main: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true) + async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false) + _ = await (main, all) + } + } + } + + private var inFlightKeys: [PayloadCacheKey: Date] = [:] + + func resetLoadingState() { + payloadRefreshGeneration &+= 1 + loadingCountsByKey.removeAll() + loadingStartedAtByKey.removeAll() + inFlightKeys.removeAll() + attemptedKeys.removeAll() + } + + func resetRefreshState(clearCache: Bool = false) { + switchTask?.cancel() + switchTask = nil + resetLoadingState() + attemptedKeys.removeAll() + lastErrorByKey.removeAll() + if clearCache { + cache.removeAll() + } + } + + private let loadingWatchdogSeconds: TimeInterval = 60 + + @discardableResult + func clearStaleLoadingIfNeeded() -> Bool { + let now = Date() + let staleLoading = loadingStartedAtByKey.filter { + now.timeIntervalSince($0.value) > loadingWatchdogSeconds + } + let staleInFlight = inFlightKeys.filter { (key, insertedAt) in + now.timeIntervalSince(insertedAt) > loadingWatchdogSeconds && + loadingStartedAtByKey[key] == nil + } + guard !staleLoading.isEmpty || !staleInFlight.isEmpty else { return false } + + for (key, started) in staleLoading { + NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing", + Int(now.timeIntervalSince(started)), key.label, key.provider.rawValue) + loadingCountsByKey[key] = nil + loadingStartedAtByKey[key] = nil + inFlightKeys[key] = nil + if cache[key] == nil { + lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background." + } + } + for (key, insertedAt) in staleInFlight { + NSLog("CodeBurn: orphaned in-flight key stuck for %ds on %@/%@ — clearing", + Int(now.timeIntervalSince(insertedAt)), key.label, key.provider.rawValue) + inFlightKeys[key] = nil + } + return true + } + + private func beginLoading(for key: PayloadCacheKey) { + if loadingCountsByKey[key, default: 0] == 0 { + loadingStartedAtByKey[key] = Date() + } + loadingCountsByKey[key, default: 0] += 1 + } + + private func finishLoading(for key: PayloadCacheKey) { + guard let count = loadingCountsByKey[key], count > 0 else { return } + if count == 1 { + loadingCountsByKey[key] = nil + loadingStartedAtByKey[key] = nil + } else { + loadingCountsByKey[key] = count - 1 + } + } + + private func currentCacheDate() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: Date()) + } + + private func invalidateStaleDayCache() { + let today = currentCacheDate() + if cacheDate != today { + payloadRefreshGeneration &+= 1 + cache.removeAll() + loadingCountsByKey.removeAll() + loadingStartedAtByKey.removeAll() + inFlightKeys.removeAll() + attemptedKeys.removeAll() + lastErrorByKey.removeAll() + cacheDate = today + NSLog("CodeBurn: reset menubar payload cache for new day %@", today) + } + } + + func invalidateCache() { + cache.removeAll() + } + + private func reconcileClaudeConfigSelection(from payload: MenubarPayload, for key: PayloadCacheKey) { + guard let selected = key.claudeConfigSourceId else { return } + guard selectedClaudeConfigSourceId == selected else { return } + let valid = payload.claudeConfigs?.options.contains { $0.id == selected } ?? false + if !valid || payload.claudeConfigs?.selectedId != selected { + selectedClaudeConfigSourceId = nil + } + } + + func recoverFromStuckLoading() async { + guard prepareStuckLoadingRecovery() else { return } + await refresh(includeOptimize: false, force: true, showLoading: true) + } + + /// Decides whether stuck-loading recovery should kick off a fresh fetch for + /// the current key, preparing the loading bookkeeping when it can. + /// + /// A quiet refresh torn down across sleep/wake (or a generation reset) can + /// leave an orphaned `inFlightKeys` entry behind. Without clearing stale + /// state first the in-flight guard would bail on every retry, trapping the + /// popover on the spinner forever. A healthy in-flight fetch (younger than + /// the watchdog) is still respected so recovery never kills it. + @discardableResult + func prepareStuckLoadingRecovery() -> Bool { + _ = clearStaleLoadingIfNeeded() + let key = currentKey + guard inFlightKeys[key] == nil else { return false } + loadingCountsByKey[key] = nil + loadingStartedAtByKey[key] = nil + return true + } + + func setRecoveryExhausted(for label: String) { + lastErrorByKey[currentKey] = "Could not load \(label). Check that the codeburn CLI is installed and working." + } + + func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { + if effectiveSelectedScope == .combined { + async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: includeOptimize, force: force) + async let combined: Void = refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading) + _ = await (local, combined) + } else { + await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading) + } + } + + private func refreshSelectionQuietly(scope: MenubarScope, force: Bool = false) async { + let scopedKey = PayloadCacheKey( + scope: scope, + period: selectedPeriod, + provider: selectedProvider, + day: selectedDay, + days: selectedDays, + claudeConfigSourceId: selectedClaudeConfigSourceId + ) + if scope == .combined { + async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: false, force: false) + async let combined: Void = refreshQuietly(key: scopedKey, includeOptimize: false, force: force) + _ = await (local, combined) + } else { + await refreshQuietly(key: scopedKey, includeOptimize: false, force: force) + } + } + + private func refresh(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { + invalidateStaleDayCache() + let cacheDateAtStart = cacheDate + let generationAtStart = payloadRefreshGeneration + if Task.isCancelled { return } + if !force, cache[key]?.isFresh == true { return } + if inFlightKeys[key] != nil { return } + inFlightKeys[key] = Date() + attemptedKeys.insert(key) + lastErrorByKey[key] = nil + let didShowLoading = showLoading || cache[key] == nil + if didShowLoading { + beginLoading(for: key) + } + // Diagnostic anchor: if this key has been empty for a long time (the + // popover would currently be showing "Loading..."), log how stale the + // miss is so the next time a user reports a stuck-loading bug we have + // a concrete data point — "no successful fetch for (today, claude) + // in 14 minutes" beats squinting at unified-log noise. We deliberately + // skip the first-attempt case (no prior success ever, finite check + // below filters .infinity) — that's just the cold path, not a bug. + let staleSeconds = staleSecondsForKey(key) + if staleSeconds.isFinite, staleSeconds > 120 { + NSLog("CodeBurn: refresh attempt for stale key \(key.label)/\(key.provider.rawValue) — last success was \(Int(staleSeconds))s ago") + } + defer { + let abandonedAttempt = Task.isCancelled || generationAtStart != payloadRefreshGeneration + inFlightKeys[key] = nil + if didShowLoading { + finishLoading(for: key) + } + if abandonedAttempt && cache[key] == nil && lastErrorByKey[key] == nil { + attemptedKeys.remove(key) + } + } + do { + let fresh = try await DataClient.fetch( + period: key.period, + day: key.day, + days: key.days, + provider: key.provider, + includeOptimize: includeOptimize, + scope: key.scope, + claudeConfigSourceId: key.claudeConfigSourceId + ) + if generationAtStart != payloadRefreshGeneration { + NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch") + return + } + if Task.isCancelled { + // Distinguish cancellation (user switched tabs mid-fetch) from + // the silent-no-result path. Without this log, a cancelled + // fetch leaves cache empty + lastError nil and the user sees + // perpetual loading with nothing in the diagnostics. + NSLog("CodeBurn: fetch for \(key.label)/\(key.provider.rawValue) cancelled before result was applied") + return + } + // Day-rollover race guard: if the calendar date changed during the + // fetch, this payload was computed against yesterday's date and + // would pollute today's freshly-cleared cache. Drop it; the next + // tick will refetch with today's data. + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() + NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — calendar rolled mid-fetch") + return + } + cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) + reconcileClaudeConfigSelection(from: fresh, for: key) + lastSuccessByKey[key] = Date() + lastErrorByKey[key] = nil + } catch { + if Task.isCancelled { return } + NSLog("CodeBurn: fetch failed for \(key.label)/\(key.provider.rawValue): \(error)") + if includeOptimize, cache[key] == nil { + do { + let fallback = try await DataClient.fetch( + period: key.period, + day: key.day, + days: key.days, + provider: key.provider, + includeOptimize: false, + scope: key.scope, + claudeConfigSourceId: key.claudeConfigSourceId + ) + guard !Task.isCancelled else { return } + if generationAtStart != payloadRefreshGeneration { return } + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() + return + } + cache[key] = CachedPayload(payload: fallback, fetchedAt: Date()) + reconcileClaudeConfigSelection(from: fallback, for: key) + lastSuccessByKey[key] = Date() + lastErrorByKey[key] = nil + return + } catch { + if Task.isCancelled { return } + NSLog("CodeBurn: fallback fetch also failed: \(error)") + } + } + lastErrorByKey[key] = String(describing: error) + } + + let allKey = PayloadCacheKey( + scope: .local, + period: key.period, + provider: .all, + day: key.day, + days: key.days, + claudeConfigSourceId: key.claudeConfigSourceId + ) + if key != allKey, cache[allKey]?.isFresh != true { + await refreshQuietly(key: allKey, includeOptimize: false, force: false) + } + } + + /// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge). + /// Does not toggle isLoading, so the popover's loading overlay is unaffected. + /// Always uses the .all provider since the menubar badge shows total spend. + func refreshQuietly(period: Period, day: String? = nil, force: Bool = false) async { + // Scope the status-payload fetch to the selected config so the menu-bar + // figure matches the popover (see menubarStatusKey). + await refreshQuietly(key: PayloadCacheKey(scope: .local, period: period, provider: .all, day: day, claudeConfigSourceId: selectedClaudeConfigSourceId), includeOptimize: false, force: force) + } + + private func refreshQuietly(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false) async { + invalidateStaleDayCache() + if !force, cache[key]?.isFresh == true { return } + if inFlightKeys[key] != nil { return } + inFlightKeys[key] = Date() + attemptedKeys.insert(key) + let cacheDateAtStart = cacheDate + let generationAtStart = payloadRefreshGeneration + if key.day == nil && key.period == .today, let age = todayPayloadAgeSeconds, age > 120 { + NSLog("CodeBurn: refreshing stale today status payload after %ds", age) + } + defer { + inFlightKeys[key] = nil + } + do { + let fresh = try await DataClient.fetch( + period: key.period, + day: key.day, + days: key.days, + provider: key.provider, + includeOptimize: includeOptimize, + scope: key.scope, + claudeConfigSourceId: key.claudeConfigSourceId + ) + if generationAtStart != payloadRefreshGeneration { + NSLog("CodeBurn: dropping quiet fetch result for \(key.label) — refresh pipeline reset mid-fetch") + return + } + // Same day-rollover guard as refresh(): drop yesterday's payload if + // the calendar rolled over during the fetch. + if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { + invalidateStaleDayCache() + return + } + cache[key] = CachedPayload(payload: fresh, fetchedAt: Date()) + reconcileClaudeConfigSelection(from: fresh, for: key) + lastSuccessByKey[key] = Date() + lastErrorByKey[key] = nil + } catch { + NSLog("CodeBurn: quiet refresh failed for \(key.label): \(error)") + if key.scope == .combined { + lastErrorByKey[key] = String(describing: error) + } + } + } + + /// User-initiated. Reads Claude's source (this is what triggers the macOS keychain + func activateClaudeFromDormant() async { + guard case .dormant = subscriptionLoadState else { return } + await bootstrapSubscription() + } + + func activateCodexFromDormant() async { + guard case .dormant = codexLoadState else { return } + await bootstrapCodex() + } + + func bootstrapSubscription() async { + subscriptionLoadState = .bootstrapping + do { + let usage = try await ClaudeSubscriptionService.bootstrap() + subscription = usage + subscriptionError = nil + subscriptionLoadState = .loaded + await captureSnapshots(for: usage) + } catch let err as ClaudeSubscriptionService.FetchError { + applyFetchError(err) + } catch { + subscriptionError = String(describing: error) + subscriptionLoadState = .failed + } + } + + /// Background refresh. No-op if the user has not yet connected. Never triggers + /// a keychain prompt — uses our own keychain item exclusively. + func refreshSubscription() async { + _ = await refreshSubscriptionReportingSuccess() + } + + /// Same as `refreshSubscription` but returns whether the fetch produced a + /// `.loaded` state, so the caller can anchor cadence timing on real success + /// rather than every attempt. + @discardableResult + func refreshSubscriptionReportingSuccess() async -> Bool { + if case .dormant = subscriptionLoadState { return false } + guard ClaudeCredentialStore.isBootstrapCompleted else { + if subscriptionLoadState != .notBootstrapped { + subscriptionLoadState = .notBootstrapped + } + return false + } + let gen = claudeRefreshGen + if subscription == nil { subscriptionLoadState = .loading } + do { + guard let usage = try await ClaudeSubscriptionService.refreshIfBootstrapped() else { + return false + } + // Disconnect-during-fetch guard: if the user clicked Disconnect + // while we were awaiting Anthropic, the generation token will + // have advanced and we must drop this result instead of writing + // it back over the freshly-cleared state. + guard gen == claudeRefreshGen else { return false } + subscription = usage + subscriptionError = nil + subscriptionLoadState = .loaded + await captureSnapshots(for: usage) + return true + } catch let err as ClaudeSubscriptionService.FetchError { + guard gen == claudeRefreshGen else { return false } + applyFetchError(err) + return false + } catch { + guard gen == claudeRefreshGen else { return false } + subscriptionError = sanitizeForUI(String(describing: error)) + subscriptionLoadState = .failed + return false + } + } + + /// User-initiated disconnect — clears our keychain item and bootstrap flag, + /// plus all derived state so a reconnect (potentially under a different + /// account or tier) starts clean. capacityEstimates and the snapshot store + /// would otherwise contaminate "Based on last cycle" projections. + func disconnectSubscription() { + ClaudeSubscriptionService.disconnect() + // Bump the generation token so any in-flight refreshSubscription that + // resumes after this point detects the disconnect and discards its + // result instead of re-populating the cleared state. + claudeRefreshGen &+= 1 + subscription = nil + subscriptionError = nil + subscriptionLoadState = .notBootstrapped + capacityEstimates = [:] + Task.detached { await SubscriptionSnapshotStore.clearAll() } + // Notify the AppDelegate to clear its cadence-loop anchor so the next + // reconnect doesn't measure against a pre-disconnect timestamp. + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + // MARK: - Codex + + func bootstrapCodex() async { + codexLoadState = .bootstrapping + do { + let usage = try await CodexSubscriptionService.bootstrap() + codexUsage = usage + codexError = nil + codexLoadState = .loaded + } catch let err as CodexSubscriptionService.FetchError { + applyCodexFetchError(err) + } catch { + codexError = sanitizeForUI(String(describing: error)) + codexLoadState = .failed + } + } + + func refreshCodex() async { + _ = await refreshCodexReportingSuccess() + } + + @discardableResult + func refreshCodexReportingSuccess() async -> Bool { + if case .dormant = codexLoadState { return false } + guard CodexCredentialStore.isBootstrapCompleted else { + if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped } + return false + } + let gen = codexRefreshGen + if codexUsage == nil { codexLoadState = .loading } + do { + guard let usage = try await CodexSubscriptionService.refreshIfBootstrapped() else { + return false + } + guard gen == codexRefreshGen else { return false } + codexUsage = usage + codexError = nil + codexLoadState = .loaded + return true + } catch let err as CodexSubscriptionService.FetchError { + guard gen == codexRefreshGen else { return false } + applyCodexFetchError(err) + return false + } catch { + guard gen == codexRefreshGen else { return false } + codexError = sanitizeForUI(String(describing: error)) + codexLoadState = .failed + return false + } + } + + func disconnectCodex() { + CodexSubscriptionService.disconnect() + codexRefreshGen &+= 1 + codexUsage = nil + codexError = nil + codexLoadState = .notBootstrapped + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + private func applyCodexFetchError(_ err: CodexSubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + codexError = sanitized + if err.isTerminal { + codexLoadState = .terminalFailure(reason: sanitized) + } else if let retryAt = err.rateLimitRetryAt { + codexLoadState = .transientFailure(retryAt: retryAt) + } else if case .notBootstrapped = err { + codexLoadState = .notBootstrapped + } else if case let .bootstrapFailed(storeErr) = err, case .bootstrapNoSource = storeErr { + codexLoadState = .noCredentials + } else { + codexLoadState = .failed + } + } + + private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + subscriptionError = sanitized + if err.isTerminal { + subscriptionLoadState = .terminalFailure(reason: sanitized) + } else if let retryAt = err.rateLimitRetryAt { + subscriptionLoadState = .transientFailure(retryAt: retryAt) + } else if case .notBootstrapped = err { + subscriptionLoadState = .notBootstrapped + } else if case let .bootstrapFailed(storeErr) = err, case .bootstrapNoSource = storeErr { + subscriptionLoadState = .noCredentials + } else { + subscriptionLoadState = .failed + } + } + + /// Strip control characters and any token-shaped substrings from server-error + /// strings before they land in NSLog or the UI. Anthropic / OpenAI error + /// envelopes don't typically echo tokens, but we also surface this in + /// unified-log paths readable by other local users via `log stream`. + private func sanitizeForUI(_ s: String?) -> String? { + guard let s, !s.isEmpty else { return nil } + var cleaned = s.replacingOccurrences(of: "\u{0000}", with: "") + // Token-shaped redaction. Apply to all known auth-token formats so + // an error body that quotes the request/response token is masked. + let patterns: [(pattern: String, replacement: String)] = [ + (#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"), + (#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"), + (#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"), + (#"(?i)Bearer\s+\S+"#, "Bearer ***"), + ] + for entry in patterns { + cleaned = cleaned.replacingOccurrences(of: entry.pattern, with: entry.replacement, options: .regularExpression) + } + // Cap length so a runaway server body cannot fill stderr. + if cleaned.count > 240 { cleaned = String(cleaned.prefix(240)) + "…" } + return cleaned + } + + /// Snapshot of live quota state for a given provider. Returns nil when the user + /// has not connected yet — the bar slot stays empty so we never trigger a + /// keychain prompt at startup. Once bootstrapped, the bar persists across all + /// subsequent states (loading / stale / transient failure / terminal failure) + /// so it doesn't flicker on every refresh tick. + /// Aggregate quota status across all connected providers, used by the menu + /// bar flame icon (color) and the popover warning row. Severity = worst + /// observed across any provider's worst window. Warning providers are + /// every connected provider at >= 70% utilization. + struct AggregateQuotaStatus { + let severity: QuotaSummary.Severity + let warnings: [(name: String, percent: Double)] // sorted desc by percent + } + + var aggregateQuotaStatus: AggregateQuotaStatus { + var providers: [(name: String, percent: Double)] = [] + if let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) { + let worst = [ + usage.fiveHourPercent, + usage.sevenDayPercent, + usage.sevenDayOpusPercent, + usage.sevenDaySonnetPercent, + ].compactMap { $0 }.max() ?? 0 + if worst > 0 { providers.append(("Claude", worst)) } + } + if let usage = codexUsage, shouldIncludeCachedQuota(loadState: codexLoadState) { + let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0) + if worst > 0 { providers.append(("Codex", worst)) } + } + let worst = providers.map(\.percent).max() ?? 0 + let severity = QuotaSummary.severity(for: worst / 100) + let sorted = providers.sorted { $0.percent > $1.percent } + let warnings = sorted.filter { $0.percent >= 70 } + return AggregateQuotaStatus(severity: severity, warnings: warnings) + } + + private func shouldIncludeCachedQuota(loadState: SubscriptionLoadState) -> Bool { + switch loadState { + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: + return false + case .loading, .loaded, .failed, .terminalFailure, .transientFailure: + return true + } + } + + func quotaSummary(for filter: ProviderFilter) -> QuotaSummary? { + switch filter { + case .claude: return claudeQuotaSummary(filter: filter) + case .codex: return codexQuotaSummary(filter: filter) + default: return nil + } + } + + private func claudeQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = subscriptionLoadState { return nil } + if case .bootstrapping = subscriptionLoadState { return nil } + if case .noCredentials = subscriptionLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch subscriptionLoadState { + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected + case .loading: return subscription == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return subscription == nil ? .loading : .stale + case let .terminalFailure(reason): return .terminalFailure(reason: reason) + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = subscription { + if let pct = usage.fiveHourPercent { + details.append(.init(label: "5-hour", percent: pct / 100, resetsAt: usage.fiveHourResetsAt)) + } + if let pct = usage.sevenDayPercent { + let weekly = QuotaSummary.Window(label: "Weekly", percent: pct / 100, resetsAt: usage.sevenDayResetsAt) + primary = weekly + details.append(weekly) + } + if let pct = usage.sevenDayOpusPercent { + details.append(.init(label: "Weekly · Opus", percent: pct / 100, resetsAt: usage.sevenDayOpusResetsAt)) + } + 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: []) + } + + private func codexQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = codexLoadState { return nil } + if case .bootstrapping = codexLoadState { return nil } + if case .noCredentials = codexLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch codexLoadState { + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected + case .loading: return codexUsage == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return codexUsage == nil ? .loading : .stale + case let .terminalFailure(reason): return .terminalFailure(reason: reason) + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = codexUsage { + if let w = usage.primary { + let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + primary = row + details.append(row) + } + if let w = usage.secondary { + let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + // Some Codex plans (free / guest tiers) only return a secondary + // window. Promote it to primary so the chip bar always has a + // data source instead of rendering as an empty track. + if primary == nil { primary = row } + details.append(row) + } + // Surface per-model additional rate limits (e.g. "GPT-5.3-Codex-Spark") + // only when the user has actually hit them. Skipping zero rows keeps + // the popover compact for the common case where the user only uses + // the main Codex window. + for extra in usage.additionalLimits { + if let p = extra.primary, p.usedPercent > 0 { + details.append(.init(label: "\(extra.name) · \(p.windowLabel)", percent: p.usedPercent / 100, resetsAt: p.resetsAt)) + } + if let s = extra.secondary, s.usedPercent > 0 { + details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt)) + } + } + } + let plan = codexUsage?.plan.displayName + var footerLines: [String] = [] + if let balance = codexUsage?.creditsBalance, balance > 0 { + // Format as plain dollars; ChatGPT settles in USD regardless of + // the user's display-currency preference. + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + formatter.maximumFractionDigits = 2 + let formatted = formatter.string(from: NSNumber(value: balance)) ?? "$\(balance)" + footerLines.append("Credits remaining · \(formatted)") + } + return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines) + } + + /// Persist one snapshot per window so we can answer "what did the prior cycle end at?" + /// when the current window has just reset and projection from current data isn't meaningful. + /// Also computes the effective_tokens consumed inside each 7-day window from local history, + /// which the CapacityEstimator uses to derive the absolute token capacity per tier. + private func captureSnapshots(for usage: SubscriptionUsage) async { + let now = Date() + let history = payload.history.daily + + let captures: [(key: String, percent: Double?, resetsAt: Date?, effective: Double?)] = [ + ("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, nil), + ("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt, + effectiveTokensInLast7Days(history: history, asOf: now)), + ("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, nil), + ("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, nil), + ] + for capture in captures { + guard let percent = capture.percent, let resetsAt = capture.resetsAt else { continue } + await SubscriptionSnapshotStore.record(SubscriptionSnapshot( + windowKey: capture.key, + percent: percent, + resetsAt: resetsAt, + capturedAt: now, + effectiveTokens: capture.effective + )) + } + + await refreshCapacityEstimates() + } + + /// Sum effective tokens (input + 5*output + cache_creation + 0.1*cache_read) across the + /// last 7 days of dailyHistory. Used as the "tokens consumed in 7-day window" reading paired + /// with the API-reported percent for capacity estimation. + private func effectiveTokensInLast7Days(history: [DailyHistoryEntry], asOf now: Date) -> Double { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = .current + let cutoff = f.string(from: now.addingTimeInterval(-7 * 86400)) + return history + .filter { $0.date >= cutoff } + .reduce(0.0) { $0 + $1.effectiveTokens } + } + + /// Run CapacityEstimator over each window's accumulated snapshots. Only snapshots with a + /// non-nil effectiveTokens contribute. Result lives in capacityEstimates dict for UI gating. + private func refreshCapacityEstimates() async { + var next: [String: CapacityEstimate] = [:] + for key in ["seven_day", "seven_day_opus", "seven_day_sonnet"] { + let snaps = await SubscriptionSnapshotStore.snapshots(for: key) + let capacitySnaps = snaps.compactMap { s -> CapacitySnapshot? in + guard let effective = s.effectiveTokens, effective > 0 else { return nil } + return CapacitySnapshot(percent: s.percent, effectiveTokens: effective, capturedAt: s.capturedAt) + } + if let estimate = CapacityEstimator.estimate(capacitySnaps) { + next[key] = estimate + } + } + capacityEstimates = next + } +} + +enum SupportedCurrency: String, CaseIterable, Identifiable { + 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 { + case .USD: "US Dollar" + case .GBP: "British Pound" + case .EUR: "Euro" + case .AUD: "Australian Dollar" + case .CAD: "Canadian Dollar" + case .NZD: "New Zealand Dollar" + case .JPY: "Japanese Yen" + case .CNY: "Chinese Yuan" + case .CHF: "Swiss Franc" + case .INR: "Indian Rupee" + case .BRL: "Brazilian Real" + case .SEK: "Swedish Krona" + case .SGD: "Singapore Dollar" + case .HKD: "Hong Kong Dollar" + case .KRW: "South Korean Won" + case .MXN: "Mexican Peso" + case .ZAR: "South African Rand" + case .DKK: "Danish Krone" + case .RON: "Romanian Leu" + } + } +} + +enum ProviderFilter: String, CaseIterable, Identifiable { + case all = "All" + case claude = "Claude" + case cline = "Cline" + case codex = "Codex" + case cursor = "Cursor" + case cursorAgent = "Cursor Agent" + case copilot = "Copilot" + case devin = "Devin" + case droid = "Droid" + case gemini = "Gemini" + case ibmBob = "IBM Bob" + case kiro = "Kiro" + case kimi = "Kimi" + case lingtaiTui = "LingTai TUI" + case kiloCode = "KiloCode" + case openclaw = "OpenClaw" + case opencode = "OpenCode" + case pi = "Pi" + case qwen = "Qwen" + case omp = "OMP" + case rooCode = "Roo Code" + case crush = "Crush" + case antigravity = "Antigravity" + case goose = "Goose" + case grok = "Grok" + case hermes = "Hermes" + case zcode = "ZCode" + + var id: String { rawValue } + + var providerKeys: [String] { + switch self { + case .cursor: ["cursor"] + case .cursorAgent: ["cursor-agent", "cursor agent"] + case .cline: ["cline"] + case .rooCode: ["roo-code", "roo code"] + case .kiloCode: ["kilo-code", "kilocode"] + case .ibmBob: ["ibm-bob", "ibm bob"] + case .openclaw: ["openclaw"] + case .antigravity: ["antigravity"] + case .goose: ["goose"] + case .grok: ["grok", "grok build"] + case .hermes: ["hermes", "hermes agent"] + case .lingtaiTui: ["lingtai-tui", "lingtai tui"] + default: [rawValue.lowercased()] + } + } + + var cliArg: String { + switch self { + case .all: "all" + case .claude: "claude" + case .cline: "cline" + case .codex: "codex" + case .cursor: "cursor" + case .cursorAgent: "cursor-agent" + case .copilot: "copilot" + case .devin: "devin" + case .droid: "droid" + case .gemini: "gemini" + case .ibmBob: "ibm-bob" + case .kiloCode: "kilo-code" + case .kiro: "kiro" + case .kimi: "kimi" + case .lingtaiTui: "lingtai-tui" + case .openclaw: "openclaw" + case .opencode: "opencode" + case .pi: "pi" + case .qwen: "qwen" + case .omp: "omp" + case .rooCode: "roo-code" + case .crush: "crush" + case .antigravity: "antigravity" + case .goose: "goose" + case .grok: "grok" + case .hermes: "hermes" + case .zcode: "zcode" + } + } +} + +extension Notification.Name { + static let codeBurnSubscriptionDisconnected = Notification.Name("com.codeburn.subscriptionDisconnected") +} + +enum SubscriptionLoadState: Sendable, Equatable { + case notBootstrapped // no Keychain access yet — waiting for user to click Connect + case dormant // previously bootstrapped; keychain not yet accessed this session + case bootstrapping // user clicked Connect; reading Claude's keychain (PROMPTS) + case loading // background fetch in progress (subscription may already be populated) + case loaded // success; subscription is populated + case noCredentials // bootstrap tried; user has no Claude credentials at all + case failed // generic non-recoverable failure + case terminalFailure(reason: String?) // refresh-token invalid; user must reconnect + case transientFailure(retryAt: Date?) // 429 / network blip; backing off automatically +} + +enum DisplayMetric: String { + case cost, tokens, totalTokens, credits, iconOnly +} + +enum InsightMode: String, CaseIterable, Identifiable { + case plan = "Plan" + case trend = "Trend" + case forecast = "Forecast" + case calendar = "Calendar" + case pulse = "Pulse" + case stats = "Stats" + case optimize = "Optimize" + var id: String { rawValue } +} + +enum Period: String, CaseIterable, Identifiable { + case today = "Today" + case sevenDays = "7 Days" + case thirtyDays = "30 Days" + case month = "Month" + case all = "6 Months" + + var id: String { rawValue } + + /// Maps to the CLI's `--period` argument values. + var cliArg: String { + switch self { + case .today: "today" + case .sevenDays: "week" + case .thirtyDays: "30days" + case .month: "month" + case .all: "all" + } + } + + static let menubarMetricCases: [Period] = [.today, .sevenDays, .month, .all] + + var menubarMetricLabel: String { + switch self { + case .today: "Today" + case .sevenDays: "Week" + case .thirtyDays: "30 Days" + case .month: "Month" + case .all: "6 Months" + } + } + + var menubarDefaultsValue: String { + switch self { + case .today: "today" + case .sevenDays: "week" + case .thirtyDays: "30days" + case .month: "month" + case .all: "sixMonths" + } + } + + init(menubarDefaultsValue: String?) { + switch menubarDefaultsValue { + case "today": self = .today + case "week", "sevenDays": self = .sevenDays + case "month": self = .month + case "sixMonths", "all": self = .all + default: self = .today + } + } + + static func savedMenubarPeriod(defaults: UserDefaults = .standard) -> Period { + Period(menubarDefaultsValue: defaults.string(forKey: menubarPeriodDefaultsKey)) + } + + func persistAsMenubarDefault(defaults: UserDefaults = .standard) { + let period = Period.menubarMetricCases.contains(self) ? self : Period.today + defaults.set(period.menubarDefaultsValue, forKey: menubarPeriodDefaultsKey) + } + + func menubarSuffix(compact: Bool) -> String { + switch self { + case .today: "" + case .sevenDays: compact ? "/wk" : " / wk" + case .thirtyDays: compact ? "/30d" : " / 30d" + case .month: compact ? "/mo" : " / mo" + case .all: compact ? "/6mo" : " / 6mo" + } + } +} + +/// NumberFormatter is expensive to instantiate (~microseconds each) and currency/token values +/// are formatted dozens of times per popover refresh. These shared instances avoid thousands of +/// allocations per frame while SwiftUI's Observation framework still triggers redraws when +/// CurrencyState.shared mutates. +private let groupedDecimalFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.groupingSeparator = "," + f.decimalSeparator = "." + f.maximumFractionDigits = 2 + f.minimumFractionDigits = 2 + return f +}() + +private let thousandsFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.groupingSeparator = "," + return f +}() + +@MainActor extension Double { + func asCurrency() -> String { + let state = CurrencyState.shared + let converted = self * state.rate + return state.symbol + (groupedDecimalFormatter.string(from: NSNumber(value: converted)) ?? "\(converted)") + } + + func asCompactCurrency() -> String { + let state = CurrencyState.shared + return String(format: "\(state.symbol)%.2f", self * state.rate) + } + + func asCompactCurrencyWhole() -> String { + let state = CurrencyState.shared + return "\(state.symbol)\(Int((self * state.rate).rounded()))" + } + + func asCompactTokens() -> String { + let n = self + if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) } + if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) } + if n >= 1_000 { return String(format: "%.0fK", n / 1_000) } + return String(format: "%.0f", n) + } + + /// Formats a raw USD amount with a "$" and grouping, without applying the + /// display-currency rate. Used for the USD-denominated daily budget. + func asUSD() -> String { + "$" + (groupedDecimalFormatter.string(from: NSNumber(value: self)) ?? "\(Int(self))") + } +} + +extension Int { + func asThousandsSeparated() -> String { + thousandsFormatter.string(from: NSNumber(value: self)) ?? "\(self)" + } +} diff --git a/mac/Sources/CodeBurnMenubar/AppVersion.swift b/mac/Sources/CodeBurnMenubar/AppVersion.swift new file mode 100644 index 0000000..c5ee14a --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/AppVersion.swift @@ -0,0 +1,43 @@ +import Foundation + +enum AppVersion { + static var bundleShortVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + } + + static var bundleBuildVersion: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + } + + static var normalizedBundleShortVersion: String { + normalize(bundleShortVersion) + } + + static var normalizedBundleBuildVersion: String { + normalize(bundleBuildVersion) + } + + static var displayBundleShortVersion: String { + display(bundleShortVersion) + } + + static func normalize(_ version: String) -> String { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("mac-v") { + return String(trimmed.dropFirst(5)) + } + if trimmed.lowercased().hasPrefix("v") { + return String(trimmed.dropFirst()) + } + return trimmed + } + + static func display(_ version: String) -> String { + let normalized = normalize(version) + guard !normalized.isEmpty else { return "v?" } + if normalized == "?" || normalized == "dev" || normalized == "dev-preview" || normalized == "—" { + return normalized + } + return "v\(normalized)" + } +} diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift new file mode 100644 index 0000000..34b68d6 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -0,0 +1,1116 @@ +import SwiftUI +import AppKit +import Observation + +private let refreshIntervalSeconds: UInt64 = 30 +private let forceRefreshWatchdogSeconds: TimeInterval = 90 +private let refreshLoopWatchdogSeconds: TimeInterval = 90 +private let statusPayloadRefreshWatchdogSeconds: TimeInterval = 60 +private let refreshRateLimitSeconds: TimeInterval = 5 +private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30 +private let statusItemWidth: CGFloat = NSStatusItem.variableLength +private let popoverWidth: CGFloat = 360 +private let popoverHeight: CGFloat = 660 +private let menubarTitleFontSize: CGFloat = 13 + +@main +struct CodeBurnApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate + + var body: some Scene { + // The Settings scene gives us a real macOS Settings window with the + // standard ⌘, shortcut and the menubar "Settings…" item. Provider tabs + // (Claude today, Codex/Cursor/etc. in follow-ups) live inside SettingsView. + Settings { + SettingsView() + .environment(delegate.store) + } + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { + private var statusItem: NSStatusItem! + private var popover: NSPopover! + private var rightClickMonitor: Any? + private var lastContextMenuPresentedAt: Date = .distantPast + fileprivate let store = AppStore() + let updateChecker = UpdateChecker() + /// True while the displays are asleep. Refresh ticks skip spawning + /// entirely then: nobody can see the menubar, and every fetch is a full + /// Node process (#647). + private var displayAsleep = false + /// Bounds status staleness under App Nap: the 30s timer can be coalesced + /// once the app naps (no permanent activity assertion anymore, #647), so + /// this system-scheduled activity guarantees a tick attempt every few + /// minutes. It goes through the same cadence gate as every other tick. + private var napBackstop: NSBackgroundActivityScheduler? + private var pendingRefreshWork: DispatchWorkItem? + private var refreshTimer: DispatchSourceTimer? + private var forceRefreshTask: Task? + private var forceRefreshStartedAt: Date? + private var forceRefreshGeneration: UInt64 = 0 + private var statusPayloadRefreshTask: Task? + private var statusPayloadRefreshStartedAt: Date? + private var statusPayloadRefreshGeneration: UInt64 = 0 + private var manualRefreshTask: Task? + private var manualRefreshGeneration: UInt64 = 0 + private var claudeQuotaRefreshTask: Task? + private var codexQuotaRefreshTask: Task? + private var refreshLoopHeartbeatAt: Date = .distantPast + + func applicationWillTerminate(_ notification: Notification) { + if let monitor = rightClickMonitor { + NSEvent.removeMonitor(monitor) + rightClickMonitor = nil + } + } + + func applicationWillFinishLaunching(_ notification: Notification) { + // Set accessory policy before the app's focus chain forms. On macOS Tahoe + // (26.x), setting it after didFinishLaunching causes ghost status items + // because the policy gets baked into the initial focus chain. + NSApp.setActivationPolicy(.accessory) + } + + private func observeSubscriptionDisconnect() { + NotificationCenter.default.addObserver( + forName: .codeBurnSubscriptionDisconnected, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.resetSubscriptionCadenceAnchor() + } + } + } + + func applicationDidFinishLaunching(_ notification: Notification) { + ProcessInfo.processInfo.automaticTerminationSupportEnabled = false + ProcessInfo.processInfo.disableSuddenTermination() + // Deliberately NO app-lifetime beginActivity here. A permanent + // assertion opted the app out of App Nap forever, which kept the 30s + // spawn loop running at full tilt on battery and with the display + // off (#647). App Nap coalescing an idle tick is the desired + // behavior; each in-flight refresh holds its own short .background + // activity so a fetch is never napped mid-flight, and any user + // interaction (popover open, wake) refreshes immediately. + + restorePersistedCurrency() + setupStatusItem() + setupPopover() + observeStore() + startRefreshLoop() + startNapBackstop() + setupWakeObservers() + removeLegacyRefreshAgent() + registerLoginItemIfNeeded() + observeSubscriptionDisconnect() + Task { await updateChecker.checkIfNeeded() } + } + + private func setupWakeObservers() { + // Pause the refresh loop while the machine is asleep. Without this, + // Task.sleep keeps a wakeup pending across the suspension and the + // loop tick fires the same instant the wake notifications do, + // producing 2-3 concurrent CLI spawns within ms of every wake. + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.prepareRefreshPipelineForSleep() + } + } + + // didWakeNotification + screensDidWakeNotification can both fire on + // the same wake. forceRefreshTask squashes overlap; both notifications + // still bypass the short manual-click rate limit so a just-before-sleep + // refresh cannot block wake recovery. + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.displayAsleep = false + self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake") + } + } + + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.displayAsleep = false + self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake") + } + } + + // Display sleep without system sleep (clamshell displays off, screen + // saver energy settings) previously kept the full spawn cadence + // running for hours. Skip refreshes until the screens wake. + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.screensDidSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.displayAsleep = true + } + } + } + + private func prepareRefreshPipelineForSleep() { + // Leave the timer running: the kernel pauses it during sleep, and tearing + // it down stranded the loop whenever a wake notification was missed. + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + manualRefreshTask?.cancel() + manualRefreshTask = nil + manualRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + store.resetLoadingState() + lastRefreshTime = .distantPast + } + + private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool, clearCache: Bool = false, reason: String) { + if resetLoading { + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + manualRefreshTask?.cancel() + manualRefreshTask = nil + manualRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + store.resetRefreshState(clearCache: clearCache) + } else { + _ = store.clearStaleLoadingIfNeeded() + } + let now = Date() + let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt) + if refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds { + if refreshTimer != nil { + NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason) + } + startRefreshLoop(forceQuotaOnStart: false) + } else { + // Manual cadence means no automatic spawns, wakes included; the + // user refreshes via popover open or Refresh Now. The tick still + // runs so stuck-state cleanup happens. + runRefreshLoopTick( + reason: reason, + forcePayload: UsageRefreshCadence.current != .manual, + forceQuota: false + ) + } + } + + // Earlier builds installed a launchd job that re-fetched data out of process. + // macOS attributes a launchd-spawned binary differently from the LaunchServices + // app, so it triggered its own "access data from other apps" prompt on every + // run. Remove any such leftover job on upgrade; the in-app loop is the source of + // truth and writes the badge backstop file itself. + private func removeLegacyRefreshAgent() { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser.path + let destPath = "\(home)/Library/LaunchAgents/com.codeburn.refresh.plist" + guard fm.fileExists(atPath: destPath) else { return } + + let unload = Process() + unload.launchPath = "/bin/launchctl" + unload.arguments = ["unload", destPath] + try? unload.run() + unload.waitUntilExit() + try? fm.removeItem(atPath: destPath) + } + + private func registerLoginItemIfNeeded() { + let key = "codeburn.loginItemRegistered" + guard !UserDefaults.standard.bool(forKey: key) else { return } + + let appPath = Bundle.main.bundlePath + let script = "tell application \"System Events\" to make login item at end with properties {path:\(appleScriptStringLiteral(appPath)), hidden:false}" + + let process = Process() + process.launchPath = "/usr/bin/osascript" + process.arguments = ["-e", script] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus == 0 { + UserDefaults.standard.set(true, forKey: key) + } + } catch { + NSLog("CodeBurn: Login item registration failed: \(error)") + } + } + + private func appleScriptStringLiteral(_ value: String) -> String { + var escaped = value.replacingOccurrences(of: "\\", with: "\\\\") + escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"") + escaped = escaped.replacingOccurrences(of: "\r", with: "") + escaped = escaped.replacingOccurrences(of: "\n", with: "") + return "\"\(escaped)\"" + } + + private var lastRefreshTime: Date = .distantPast + + @discardableResult + private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool { + if forceRefreshTask != nil { + guard let started = forceRefreshStartedAt else { + NSLog("CodeBurn: force refresh task had no start timestamp - clearing") + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshGeneration &+= 1 + store.resetLoadingState() + return true + } + let elapsed = now.timeIntervalSince(started) + guard elapsed > forceRefreshWatchdogSeconds else { return false } + NSLog("CodeBurn: force refresh stuck for %ds - cancelling and restarting", Int(elapsed)) + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + store.resetLoadingState() + return true + } + return false + } + + @discardableResult + private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool { + if statusPayloadRefreshTask != nil { + guard let started = statusPayloadRefreshStartedAt else { + NSLog("CodeBurn: today status refresh task had no start timestamp - clearing") + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshGeneration &+= 1 + return true + } + let elapsed = now.timeIntervalSince(started) + guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false } + NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed)) + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + return true + } + return false + } + + private func refreshStatusPayloadIfNeeded(reason: String, force: Bool = false, minAgeSeconds: TimeInterval = 0) { + let now = Date() + _ = clearStaleStatusPayloadRefreshIfNeeded(now: now) + guard statusPayloadRefreshTask == nil else { return } + guard force || store.needsStatusPayloadRefresh else { return } + // The 30s cache TTL would otherwise defeat the battery/low-power + // backoff through this fallback path: honor the same spawn interval + // the main loop uses. A missing payload (nil age) always fetches. + if !force, minAgeSeconds > 0, let age = store.menubarPayloadAgeSeconds, + TimeInterval(age) < minAgeSeconds { return } + + let menubarPeriod = store.menubarPeriod + if let age = store.menubarPayloadAgeSeconds, age > 120 { + NSLog("CodeBurn: status payload stale for %ds on %@ refresh", age, reason) + } + + statusPayloadRefreshStartedAt = now + statusPayloadRefreshGeneration &+= 1 + let generation = statusPayloadRefreshGeneration + statusPayloadRefreshTask = Task { [weak self] in + guard let self else { return } + let activity = ProcessInfo.processInfo.beginActivity( + options: .background, reason: "CodeBurn status refresh") + defer { ProcessInfo.processInfo.endActivity(activity) } + await self.store.refreshQuietly(period: menubarPeriod, force: true) + self.refreshStatusButton() + guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return } + self.statusPayloadRefreshTask = nil + self.statusPayloadRefreshStartedAt = nil + } + } + + private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) { + let now = Date() + _ = clearStaleForceRefreshIfNeeded(now: now) + if forceRefreshTask != nil { + refreshStatusPayloadIfNeeded(reason: "blocked force refresh") + } + guard forceRefreshTask == nil else { return } + if !bypassRateLimit { + guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return } + } + lastRefreshTime = now + forceRefreshStartedAt = now + forceRefreshGeneration &+= 1 + let generation = forceRefreshGeneration + + forceRefreshTask = Task { + let activity = ProcessInfo.processInfo.beginActivity( + options: .background, reason: "CodeBurn refresh") + defer { ProcessInfo.processInfo.endActivity(activity) } + async let main: Void = refreshUsagePayloads(force: true, showLoading: true) + async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota) + _ = await main + refreshStatusButton() + _ = await quotas + await MainActor.run { [weak self] in + guard let self, self.forceRefreshGeneration == generation else { return } + self.forceRefreshTask = nil + self.forceRefreshStartedAt = nil + self.lastRefreshTime = Date() + } + } + } + + private func refreshUsagePayloads(force: Bool, showLoading: Bool = false) async { + let menubarPeriod = store.menubarPeriod + + // With the popover closed, only the payloads the status item actually + // renders are worth a Node spawn: the menubar period, plus today for + // the context-menu summary and day views. The popover's selected key + // is refreshed by refreshPayloadForPopoverOpen the moment it opens, + // so a closed-popover tick never pays for it (#647). + if !(popover?.isShown ?? false) { + async let menubar: Void = store.refreshQuietly(period: menubarPeriod, force: force) + async let today: Void = menubarPeriod != .today + ? store.refreshQuietly(period: .today, force: force) + : () + _ = await (menubar, today) + return + } + + let needsMenubarPayload = store.selectedPeriod != menubarPeriod || store.selectedProvider != .all + let needsTodayPayload = (store.selectedPeriod != .today || store.selectedProvider != .all) && menubarPeriod != .today + + async let visible: Void = store.refresh(includeOptimize: false, force: force, showLoading: showLoading) + async let menubar: Void = needsMenubarPayload + ? store.refreshQuietly(period: menubarPeriod, force: force) + : () + async let today: Void = needsTodayPayload + ? store.refreshQuietly(period: .today, force: force) + : () + _ = await (visible, menubar, today) + } + + /// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where + /// the user left off. Rate is resolved from the on-disk FX cache if present, otherwise + /// fetched live in the background. + private func restorePersistedCurrency() { + guard let code = CLICurrencyConfig.loadCode(), code != "USD" else { return } + let symbol = CurrencyState.symbolForCode(code) + store.currency = code + + Task { + let cached = await FXRateCache.shared.cachedRate(for: code) + await MainActor.run { + CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol) + } + let fresh = await FXRateCache.shared.rate(for: code) + if let fresh, fresh != cached { + await MainActor.run { + CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol) + } + } + } + } + + fileprivate var lastSubscriptionRefreshAt: Date? + fileprivate var lastCodexRefreshAt: Date? + + @discardableResult + private func refreshLiveQuotaProgressIfDue(force: Bool = false) async -> Bool { + let cadence = SubscriptionRefreshCadence.current + if !force && cadence == .manual { return false } + + let now = Date() + let threshold = force ? 0 : TimeInterval(cadence.rawValue) + let shouldRefreshClaude = force || now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold + let shouldRefreshCodex = force || now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) >= threshold + guard shouldRefreshClaude || shouldRefreshCodex else { return false } + + switch (shouldRefreshClaude, shouldRefreshCodex) { + case (true, true): + async let claude = refreshClaudeQuotaSingleFlight() + async let codex = refreshCodexQuotaSingleFlight() + if await claude { lastSubscriptionRefreshAt = Date() } + if await codex { lastCodexRefreshAt = Date() } + case (true, false): + if await refreshClaudeQuotaSingleFlight() { + lastSubscriptionRefreshAt = Date() + } + case (false, true): + if await refreshCodexQuotaSingleFlight() { + lastCodexRefreshAt = Date() + } + case (false, false): + break + } + return true + } + + private func refreshClaudeQuotaSingleFlight() async -> Bool { + if let task = claudeQuotaRefreshTask { + return await task.value + } + let task = Task { [store] in + await store.refreshSubscriptionReportingSuccess() + } + claudeQuotaRefreshTask = task + let result = await task.value + if claudeQuotaRefreshTask != nil { + claudeQuotaRefreshTask = nil + } + return result + } + + private func refreshCodexQuotaSingleFlight() async -> Bool { + if let task = codexQuotaRefreshTask { + return await task.value + } + let task = Task { [store] in + await store.refreshCodexReportingSuccess() + } + codexQuotaRefreshTask = task + let result = await task.value + if codexQuotaRefreshTask != nil { + codexQuotaRefreshTask = nil + } + return result + } + + private func refreshLiveQuotaProgressForPopoverOpen() { + let now = Date() + let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) + let codexElapsed = now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) + guard claudeElapsed >= interactiveQuotaRefreshFloorSeconds || + codexElapsed >= interactiveQuotaRefreshFloorSeconds else { return } + + Task { [weak self] in + guard let self else { return } + _ = await self.refreshLiveQuotaProgressIfDue(force: true) + } + } + + private func refreshPayloadForPopoverOpen() { + // A user viewing the popover is ground truth and must always recover. + // Unconditionally ensure the loop is alive, then clear the current + // key's stuck loading / in-flight / generation bookkeeping and force a + // fresh fetch — even if the cache looks "not stale yet". This is the + // guaranteed one-round-trip recovery path. + // An open popover also proves the screens are on: recover from a + // missed screensDidWake so a latched flag can't suppress refreshes. + displayAsleep = false + if refreshTimer == nil { + startRefreshLoop(forceQuotaOnStart: false) + } + // The status figure may have gone stale under App Nap while the + // popover was closed; catch it up alongside the visible payload. + refreshStatusPayloadIfNeeded(reason: "popover open") + if store.shouldResetInteractiveRefreshPipeline, + let age = store.staleInteractivePayloadAgeSeconds { + NSLog("CodeBurn: popover opened with %ds stale payload cache - hard recovery", age) + } + Task { [weak self] in + guard let self else { return } + await self.store.recoverFromStuckLoading() + self.refreshStatusButton() + } + } + + private func stopRefreshTimer() { + refreshTimer?.setEventHandler {} + refreshTimer?.cancel() + refreshTimer = nil + } + + private func startNapBackstop() { + let scheduler = NSBackgroundActivityScheduler( + identifier: "org.agentseal.codeburn-menubar.refresh-backstop") + scheduler.repeats = true + scheduler.interval = 180 + scheduler.tolerance = 60 + scheduler.qualityOfService = .utility + scheduler.schedule { [weak self] completion in + Task { @MainActor [weak self] in + self?.runRefreshLoopTick(reason: "nap backstop") + completion(.finished) + } + } + napBackstop = scheduler + } + + private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) { + refreshLoopHeartbeatAt = Date() + if displayAsleep && !forcePayload { return } + let hadForceRefreshInFlight = forceRefreshTask != nil + let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded() + let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded() + let clearedStaleLoading = store.clearStaleLoadingIfNeeded() + let statusPayloadStale = store.needsStatusPayloadRefresh + let sinceLast = Date().timeIntervalSince(lastRefreshTime) + // The timer stays at 30s; the spawn interval follows the user's + // Settings cadence, stretching on battery / Low Power Mode in Auto + // and never auto-spawning in Manual (#647). + let interval = currentRefreshInterval() + let intervalElapsed = interval.map { sinceLast >= $0 } ?? false + // Stuck-refresh cleanup may retrigger a spawn only when the cadence + // allows auto-spawns at all: in Manual mode the stale state is + // cleared and the next user interaction fetches fresh. + let recoveryRespawnAllowed = interval != nil + let shouldForceRefresh = forcePayload || + ((clearedStaleForceRefresh || clearedStaleLoading) && recoveryRespawnAllowed) || + intervalElapsed + + if shouldForceRefresh { + forceRefresh(bypassRateLimit: true, forceQuota: forceQuota) + } + + let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil + if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) { + guard forcePayload || interval != nil else { return } + refreshStatusPayloadIfNeeded(reason: reason, force: forcePayload, minAgeSeconds: interval ?? 0) + } + } + + private func currentRefreshInterval() -> TimeInterval? { + RefreshCadence.interval( + mode: UsageRefreshCadence.current, + popoverOpen: popover?.isShown ?? false, + onBattery: PowerSource.isOnBattery(), + lowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled + ) + } + + private func startRefreshLoop(forceQuotaOnStart: Bool = false) { + stopRefreshTimer() + runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: forceQuotaOnStart) + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule( + deadline: .now() + .seconds(Int(refreshIntervalSeconds)), + repeating: .seconds(Int(refreshIntervalSeconds)), + leeway: .seconds(2) + ) + timer.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + self?.runRefreshLoopTick(reason: "timer") + } + } + refreshTimer = timer + refreshLoopHeartbeatAt = Date() + timer.resume() + } + + @MainActor + func refreshSubscriptionNow() { + manualRefreshTask?.cancel() + manualRefreshGeneration &+= 1 + let generation = manualRefreshGeneration + forceRefreshTask?.cancel() + forceRefreshTask = nil + forceRefreshStartedAt = nil + forceRefreshGeneration &+= 1 + statusPayloadRefreshTask?.cancel() + statusPayloadRefreshTask = nil + statusPayloadRefreshStartedAt = nil + statusPayloadRefreshGeneration &+= 1 + pendingRefreshWork?.cancel() + pendingRefreshWork = nil + stopRefreshTimer() + store.resetRefreshState(clearCache: true) + lastRefreshTime = .distantPast + refreshStatusButton() + + manualRefreshTask = Task { [weak self] in + guard let self else { return } + let activity = ProcessInfo.processInfo.beginActivity( + options: .userInitiated, reason: "CodeBurn manual refresh") + defer { ProcessInfo.processInfo.endActivity(activity) } + // "Refresh Now" should refresh the menubar payload AND every + // connected provider's live quota. The user's intent is "make + // this match reality right now." + async let payload: Void = self.refreshUsagePayloads(force: true, showLoading: true) + async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true) + _ = await payload + guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } + self.lastRefreshTime = Date() + self.refreshStatusButton() + _ = await quotas + guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } + self.manualRefreshTask = nil + if self.refreshTimer == nil { + self.startRefreshLoop() + } + } + } + + /// Reset the cadence anchor so the next loop tick re-evaluates from "now" + /// rather than measuring against a timestamp from the previous connection. + /// Triggered on disconnect of any provider — the cost of clearing both + /// anchors is one extra refresh tick on the unaffected provider, far less + /// disruptive than waiting a full cadence after a reconnect. + @MainActor + func resetSubscriptionCadenceAnchor() { + lastSubscriptionRefreshAt = nil + lastCodexRefreshAt = nil + } + + private func observeStore() { + // Read closure uses [weak self] so the implicit self capture from + // accessing store.* doesn't pin self for the lifetime of an + // unfired observation. withObservationTracking is one-shot per + // call: once any read property changes, onChange fires and the + // registration is consumed, then we re-arm. There is at most one + // active subscription at a time. + withObservationTracking { [weak self] in + guard let self else { return } + _ = self.store.payload + _ = self.store.menubarPeriod + _ = self.store.menubarPayload + // Track currency so the menubar title catches up immediately on + // currency switch instead of waiting for the next 30s payload tick. + _ = self.store.currency + _ = self.store.displayMetric + _ = self.store.dailyBudget + _ = self.store.dailyTokenBudget + // Read the derived flag so the flame re-tints when today's usage + // crosses the budget, not only when the budget value itself changes. + // This also makes the dependency on todayPayload explicit instead of + // relying on payload/menubarPayload happening to touch the same cache. + _ = self.store.isOverDailyBudget + // Track the live-quota state too so the flame icon re-tints on + // every subscription / codex usage update, not just every 30s. + _ = self.store.subscription + _ = self.store.subscriptionLoadState + _ = self.store.codexUsage + _ = self.store.codexLoadState + } onChange: { [weak self] in + DispatchQueue.main.async { + guard let self else { return } + self.pendingRefreshWork?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.refreshStatusButton() + self?.observeStore() + } + self.pendingRefreshWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) + } + } + } + + // MARK: - Status Item + + private var isCompact: Bool { + UserDefaults.standard.bool(forKey: "CodeBurnMenubarCompact") + } + + private func setupStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: statusItemWidth) + guard let button = statusItem.button else { return } + + // Set a simple SF Symbol image immediately to ensure the status item renders. + // On macOS Tahoe, status items may fail to appear if only an attributed title + // is set during initial setup. + let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium) + let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")? + .withSymbolConfiguration(flameConfig) + flame?.isTemplate = true + button.image = flame + button.imagePosition = .imageLeading + + button.target = self + button.action = #selector(handleButtonClick(_:)) + // Left-click drives the popover. We keep .rightMouseUp in the mask so the + // legacy action path below still fires on macOS <= 26; on macOS 27 the + // system consumes the right button entirely and this never fires, so the + // global monitor (below) is what restores the right-click menu there. + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + + // macOS 27 no longer routes any right-mouse event to the status-item + // button's target/action. A global monitor still observes right-mouse-down; + // we hit-test it against our own status-item window and present the menu + // ourselves. Harmless and stable on 15/26 too (the debounce in + // showContextMenu prevents a double-present if the legacy path also fires). + rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in + guard let self, + let button = self.statusItem.button, + let window = button.window, + window.frame.contains(NSEvent.mouseLocation) else { return } + DispatchQueue.main.async { self.showContextMenu(from: button) } + } + + // Defer the full attributed title setup to ensure initial render completes + DispatchQueue.main.async { [weak self] in + self?.refreshStatusButton() + } + } + + /// Composes the menubar title as a single attributed string with the flame as an inline + /// NSTextAttachment. NSStatusItem's separate `image` + `attributedTitle` path leaves a + /// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge + /// of the status item, the title starts at its own baseline), so we inline both so they + /// flow as one typographic unit with a single, controllable gap. + private static func flameTint(for severity: QuotaSummary.Severity) -> NSColor? { + switch severity { + case .normal: return nil // template, auto-adapt + case .warning: return NSColor.systemYellow // 70-90% + case .critical: return NSColor.systemOrange // 90-100% + case .danger: return NSColor.systemRed // 100%+ + } + } + + private func refreshStatusButton() { + guard let button = statusItem.button else { return } + // Skip while the popover is anchored to this button. Rewriting the + // attributedTitle changes the button's intrinsic width, which makes + // macOS reflow the status item in the menubar and detaches the + // anchored popover (it pops to a stale default position). The + // popoverDidClose delegate calls back through here once the popover + // is dismissed so the menubar cost catches up immediately on close. + if popover != nil && popover.isShown { return } + + // Clear any previously-set image so the attachment is the only glyph rendered. + button.image = nil + button.imagePosition = .noImage + + let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium) + let baseConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium) + // Tint the flame based on the worst-affected connected provider's quota. + // Normal (<70%) keeps the template (auto white-on-dark / black-on-light); + // warning/critical/danger override with a fixed palette color so the + // user gets a glanceable signal even when the menu bar is busy. + let aggregate = store.aggregateQuotaStatus + var tint = Self.flameTint(for: aggregate.severity) + if tint == nil, store.isOverDailyBudget { + tint = NSColor.systemYellow + } + let flameConfig: NSImage.SymbolConfiguration + if let tint { + flameConfig = baseConfig.applying(.init(paletteColors: [tint])) + } else { + flameConfig = baseConfig + } + let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")? + .withSymbolConfiguration(flameConfig) + flame?.isTemplate = (tint == nil) + + let attachment = NSTextAttachment() + attachment.image = flame + if let size = flame?.size { + attachment.bounds = CGRect(x: 0, y: -3, width: size.width, height: size.height) + } + + let menubarPeriod = store.menubarPeriod + let menubarPayload = badgePayload() + let hasPayload = menubarPayload != nil + let compact = isCompact + + let composed = NSMutableAttributedString() + composed.append(NSAttributedString(attachment: attachment)) + + if store.displayMetric != .iconOnly { + let suffix = menubarPeriod.menubarSuffix(compact: compact) + let valueText: String + if store.displayMetric == .tokens, let p = menubarPayload?.current { + let out = formatTokensMenubar(Double(p.outputTokens)) + let inp = formatTokensMenubar(Double(p.inputTokens)) + valueText = compact ? "↑\(out)↓\(inp)\(suffix)" : " ↑\(out) ↓\(inp)\(suffix)" + } else if store.displayMetric == .totalTokens, let p = menubarPayload?.current { + let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens)) + valueText = compact ? "\(total)\(suffix)" : " \(total)\(suffix)" + } else if store.displayMetric == .credits, let p = menubarPayload?.current { + let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded()) + valueText = compact ? "\(credits)cr\(suffix)" : " \(credits) credits\(suffix)" + } else { + let fallback = compact ? "$-" : "$—" + let formatted = menubarPayload?.current.cost + valueText = compact + ? (formatted?.asCompactCurrencyWhole() ?? fallback) + suffix + : " " + (formatted?.asCompactCurrency() ?? fallback) + suffix + } + + var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0] + if !hasPayload { + textAttrs[.foregroundColor] = NSColor.secondaryLabelColor + } + composed.append(NSAttributedString(string: valueText, attributes: textAttrs)) + } + + button.attributedTitle = composed + button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" + + persistBadgeStatusFile() + } + + private var lastWrittenBadgeGenerated: String? + + // Mirror the freshest in-memory payload to disk so the badge survives an app + // restart. Skips redundant writes by tracking the last payload's `generated` + // stamp. This is the only writer now that the launchd fetcher is gone. + private func persistBadgeStatusFile() { + // Only persist the unscoped (All) badge. Config selection resets to All + // on relaunch, so a scoped payload on disk would show the wrong config's + // number at next launch. + guard store.selectedClaudeConfigSourceId == nil else { return } + guard let payload = store.menubarPayload else { return } + guard payload.generated != lastWrittenBadgeGenerated else { return } + do { + try MenubarStatusCache.standard().writeStatus(payload) + lastWrittenBadgeGenerated = payload.generated + } catch { + NSLog("CodeBurn: failed to write badge status file: \(error)") + } + } + + // Badge falls back to the on-disk status file (written by a prior app run) + // when the in-app loop has no payload yet; in-memory wins when it's fresher. + // The 10-min bound discards a file too stale to trust. + private func badgePayload() -> MenubarPayload? { + let inMemory = store.menubarPayload + // While scoped to a config, never fall back to the on-disk badge: it + // holds the unscoped All payload and would show the wrong number. + if store.selectedClaudeConfigSourceId != nil { return inMemory } + let inMemoryAge = store.menubarPayloadAgeSeconds.map(TimeInterval.init) + guard let fileRead = MenubarStatusCache.standard().readBadgePayload(maxAgeSeconds: 600) else { + return inMemory + } + if inMemory == nil { return fileRead.payload } + if let age = inMemoryAge, fileRead.ageSeconds < age { return fileRead.payload } + return inMemory + } + + private func formatTokensMenubar(_ n: Double) -> String { + if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) } + if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) } + if n >= 1_000 { return String(format: "%.0fK", n / 1_000) } + return String(format: "%.0f", n) + } + + // MARK: - Popover + + private func setupPopover() { + popover = NSPopover() + popover.contentSize = NSSize(width: popoverWidth, height: popoverHeight) + popover.behavior = .transient // auto-close only on explicit outside click + popover.animates = true + popover.delegate = self + + let content = MenuBarContent() + .environment(store) + .environment(updateChecker) + .frame(width: popoverWidth) + + popover.contentViewController = NSHostingController(rootView: content) + } + + @objc private func handleButtonClick(_ sender: AnyObject?) { + guard let button = statusItem.button, + let event = NSApp.currentEvent else { return } + + // Legacy right-click path for macOS <= 26 (no-op on 27, where the action + // never receives a right-mouse event — the global monitor handles it). + if event.type == .rightMouseUp { + showContextMenu(from: button) + return + } + + if popover.isShown { + popover.performClose(sender) + } else { + // Do NOT call NSApp.activate(ignoringOtherApps:) here. On macOS + // Tahoe an accessory app activating while a popover anchors to + // its NSStatusItem can race with the system menu bar's auto-hide + // logic and leave the user's apple-menu hidden until the popover + // closes. The popover's window takes keyboard focus on its own + // via makeKeyAndOrderFront, which is enough for keystrokes to + // reach the SwiftUI content. + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + if let window = popover.contentViewController?.view.window { + // Pin the popover's window above the status-bar layer but tag + // it as auxiliary so macOS Tahoe does not treat it as an + // app-level focus event — that's what was hiding the system + // menu bar (Terminal's apple-logo / Shell / Edit / View row) + // every time the popover opened. + window.level = .statusBar + window.collectionBehavior.insert(.fullScreenAuxiliary) + window.collectionBehavior.insert(.canJoinAllSpaces) + window.makeKeyAndOrderFront(nil) + } + refreshPayloadForPopoverOpen() + refreshLiveQuotaProgressForPopoverOpen() + } + } + + private func showContextMenu(from button: NSStatusBarButton) { + // Debounce: on macOS <= 26 both the legacy action path and the global + // monitor can fire for a single right-click. Present at most once per click. + let now = Date() + guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return } + lastContextMenuPresentedAt = now + + let menu = NSMenu() + + // Glanceable "today" usage at the top as a single non-interactive (dimmed) + // row. A real .sectionHeader adds section padding (and pulls the actions + // into its group without a separator), so use a plain disabled item. + let usageItem = NSMenuItem(title: contextMenuUsageSummary(), action: nil, keyEquivalent: "") + usageItem.isEnabled = false + menu.addItem(usageItem) + + let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: "") + settingsItem.target = self + settingsItem.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings") + menu.addItem(settingsItem) + + let refreshNow = NSMenuItem(title: "Refresh Now", action: #selector(refreshNowAction), keyEquivalent: "") + refreshNow.target = self + menu.addItem(refreshNow) + + let updateItem = NSMenuItem(title: "Check for Updates", action: #selector(checkForUpdates), keyEquivalent: "") + updateItem.target = self + menu.addItem(updateItem) + + let aboutItem = NSMenuItem(title: "About CodeBurn", action: #selector(openAbout), keyEquivalent: "") + aboutItem.target = self + menu.addItem(aboutItem) + + let quitItem = NSMenuItem(title: "Quit CodeBurn", action: #selector(quitApp), keyEquivalent: "") + quitItem.target = self + menu.addItem(quitItem) + + // Present directly. The previous `statusItem.menu = menu; button.performClick` + // trick relies on the click -> action path that macOS 27 changed; popUp is + // version-stable. Open a few px below the status item so the menu clears the + // menu bar: anchoring flush clips the top edge and makes macOS engage menu + // scrolling (a scroll chevron appears and the first row slides up on hover). + menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 6), in: button) + } + + /// One-line "today" summary for the context menu's usage row. + private func contextMenuUsageSummary() -> String { + guard let current = store.todayPayload?.current else { return "Today · no usage yet" } + let calls = current.calls == 1 ? "1 call" : "\(current.calls) calls" + return "Today · \(current.cost.asCurrency()) · \(calls)" + } + + private var settingsWindowController: NSWindowController? + + @objc private func openAbout() { + store.settingsTab = "about" + openSettings() + } + + @objc private func openSettings() { + // Accessory-policy apps (no Dock icon, no main menu) don't get the + // SwiftUI Settings scene wired into the responder chain reliably, so + // the standard `showSettingsWindow:` selector silently no-ops. We host + // the SwiftUI view in our own NSWindowController instead. + if let controller = settingsWindowController { + NSApp.activate(ignoringOtherApps: true) + controller.window?.makeKeyAndOrderFront(nil) + return + } + + let hosting = NSHostingController( + rootView: SettingsView().environment(store) + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 380), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "CodeBurn Settings" + window.contentViewController = hosting + window.center() + window.isReleasedWhenClosed = false + let controller = NSWindowController(window: window) + settingsWindowController = controller + NSApp.activate(ignoringOtherApps: true) + controller.showWindow(nil) + } + + @objc private func refreshNowAction() { + refreshSubscriptionNow() + } + + private func codeburnAlertIcon() -> NSImage? { + let config = NSImage.SymbolConfiguration(pointSize: 32, weight: .medium) + guard let symbol = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")? + .withSymbolConfiguration(config) else { return nil } + let size = NSSize(width: 64, height: 64) + let img = NSImage(size: size, flipped: false) { rect in + let symbolSize = symbol.size + let x = (rect.width - symbolSize.width) / 2 + let y = (rect.height - symbolSize.height) / 2 + symbol.draw(in: NSRect(x: x, y: y, width: symbolSize.width, height: symbolSize.height)) + return true + } + img.isTemplate = false + return img + } + + @objc private func checkForUpdates() { + Task { + await updateChecker.check() + let alert = NSAlert() + alert.icon = codeburnAlertIcon() + if let error = updateChecker.updateError { + alert.messageText = "Update Check Failed" + alert.informativeText = error + alert.alertStyle = .warning + } else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion { + alert.messageText = "Update Available" + let header = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion)))." + if updateChecker.cliTooOldForUpdate { + alert.informativeText = "\(header) Your codeburn CLI is too old to install it. First run:\n\n\(updateChecker.cliUpdateCommand)\n\nthen:\n\ncodeburn menubar --force" + } else { + alert.informativeText = "\(header) Run:\n\ncodeburn menubar --force" + } + alert.alertStyle = .informational + } else { + alert.messageText = "Up to Date" + alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))." + alert.alertStyle = .informational + } + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + + @objc private func quitApp() { + NSApp.terminate(nil) + } + + // MARK: - NSPopoverDelegate + + func popoverShouldDetach(_ popover: NSPopover) -> Bool { + false + } + + func popoverDidClose(_ notification: Notification) { + // Catch up on any menubar title updates that were skipped while the + // popover was anchored. + refreshStatusButton() + } +} diff --git a/mac/Sources/CodeBurnMenubar/CurrencyState.swift b/mac/Sources/CodeBurnMenubar/CurrencyState.swift new file mode 100644 index 0000000..def6cf3 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/CurrencyState.swift @@ -0,0 +1,281 @@ +import Foundation +import Observation + +private let fxCacheTTLSeconds: TimeInterval = 24 * 3600 +private let frankfurterBaseURL = "https://api.frankfurter.app/latest?from=USD&to=" +/// Defensive bounds on any fetched FX rate. Real-world USD→X rates sit in [0.0001, 200000] +/// for every ISO 4217 pair; anything outside is either a parser bug or a MITM poisoning +/// attempt. We clamp hard so UI can't render NaN, negative, or astronomical numbers. +private let minValidFXRate: Double = 0.0001 +private let maxValidFXRate: Double = 1_000_000 +private let fxFetchTimeoutSeconds: TimeInterval = 10 + +@MainActor @Observable +final class CurrencyState: Sendable { + static let shared = CurrencyState() + + var code: String = "USD" + var rate: Double = 1.0 + var symbol: String = "$" + + private init() {} + + /// Applies a new currency context. Callers must invoke on the main actor so @Observable + /// view updates run on the UI thread. Rejects non-finite or out-of-band rates so a + /// poisoned Frankfurter response can't corrupt displayed costs. + func apply(code: String, rate: Double?, symbol: String) { + self.code = code + self.symbol = symbol + if let r = rate, r.isFinite, r >= minValidFXRate, r <= maxValidFXRate { + self.rate = r + } + } + + nonisolated static func symbolForCode(_ code: String) -> String { + // Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the + // plain glyph form everyone recognises. + if let override = symbolOverrides[code] { return override } + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = code + formatter.locale = Locale(identifier: "en_\(code.prefix(2))") + return formatter.currencySymbol ?? code + } + + nonisolated private static let symbolOverrides: [String: String] = [ + "USD": "$", + "CAD": "$", + "AUD": "$", + "NZD": "$", + "HKD": "$", + "SGD": "$", + "MXN": "$", + "EUR": "\u{20AC}", + "GBP": "\u{00A3}", + "JPY": "\u{00A5}", + "CNY": "\u{00A5}", + "KRW": "\u{20A9}", + "INR": "\u{20B9}", + "BRL": "R$", + "CHF": "CHF", + "SEK": "kr", + "DKK": "kr", + "ZAR": "R", + "RON": "lei" + ] +} + +actor FXRateCache { + static let shared = FXRateCache() + + private struct Entry: Codable { + let rate: Double + let savedAt: TimeInterval + } + + private var entries: [String: Entry] = [:] + private var loaded = false + + private var cacheFilePath: String { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + return base + .appendingPathComponent("codeburn-mac", isDirectory: true) + .appendingPathComponent("fx-rates.json") + .path + } + + private func loadIfNeeded() { + guard !loaded else { return } + loaded = true + do { + let data = try SafeFile.read(from: cacheFilePath) + let decoded = try JSONDecoder().decode([String: Entry].self, from: data) + // Drop any persisted entries whose rate violates the sanity bounds -- covers an + // old cache that was written before the clamp was introduced. + entries = decoded.filter { _, entry in + entry.rate.isFinite && entry.rate >= minValidFXRate && entry.rate <= maxValidFXRate + } + } catch { + entries = [:] + } + } + + private func persist() { + guard let data = try? JSONEncoder().encode(entries) else { return } + try? SafeFile.write(data, to: cacheFilePath) + } + + /// Returns a cached rate regardless of freshness. Nil if never fetched. + func cachedRate(for code: String) -> Double? { + if code == "USD" { return 1.0 } + loadIfNeeded() + return entries[code]?.rate + } + + /// Returns a fresh rate, fetching from Frankfurter when cache is stale or absent. Nil on + /// failure. The returned rate is always finite, positive, and within the sanity bounds. + func rate(for code: String) async -> Double? { + if code == "USD" { return 1.0 } + loadIfNeeded() + + if let entry = entries[code], + Date().timeIntervalSince1970 - entry.savedAt < fxCacheTTLSeconds { + return entry.rate + } + + guard let url = URL(string: "\(frankfurterBaseURL)\(code)") else { return entries[code]?.rate } + + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = fxFetchTimeoutSeconds + config.tlsMinimumSupportedProtocolVersion = .TLSv12 + let session = URLSession(configuration: config) + + do { + let (data, response) = try await session.data(from: url) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + return entries[code]?.rate + } + struct Response: Decodable { let rates: [String: Double] } + let decoded = try JSONDecoder().decode(Response.self, from: data) + guard let fresh = decoded.rates[code], + fresh.isFinite, fresh >= minValidFXRate, fresh <= maxValidFXRate else { + NSLog("CodeBurn: discarding out-of-band FX rate for \(code)") + return entries[code]?.rate + } + entries[code] = Entry(rate: fresh, savedAt: Date().timeIntervalSince1970) + persist() + return fresh + } catch { + return entries[code]?.rate + } + } +} + +/// Reads and writes the CLI's persisted currency config (~/.config/codeburn/config.json). +/// Uses an on-disk flock so a concurrent `codeburn currency ...` invocation from a terminal +/// can't race the menubar and silently drop each other's writes (TOCTOU on config.json). +enum CLICurrencyConfig { + private static var configDir: String { + (NSHomeDirectory() as NSString).appendingPathComponent(".config/codeburn") + } + private static var configPath: String { + (configDir as NSString).appendingPathComponent("config.json") + } + private static var lockPath: String { + (configDir as NSString).appendingPathComponent(".config.lock") + } + + static func loadCode() -> String? { + guard + let data = try? SafeFile.read(from: configPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let currency = json["currency"] as? [String: Any], + let code = currency["code"] as? String + else { + return nil + } + return code.uppercased() + } + + static func persist(code: String) { + do { + try SafeFile.withExclusiveLock(at: lockPath) { + var existing: [String: Any] = [:] + if let data = try? SafeFile.read(from: configPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + existing = parsed + } + + if code == "USD" { + existing.removeValue(forKey: "currency") + } else { + existing["currency"] = [ + "code": code, + "symbol": CurrencyState.symbolForCode(code) + ] + } + + guard let data = try? JSONSerialization.data( + withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys] + ) else { + return + } + try SafeFile.write(data, to: configPath, mode: 0o600) + } + } catch { + NSLog("CodeBurn: failed to persist currency config: \(error)") + } + } +} + +struct CodeburnCLIConfigStore { + let homeDirectory: String + + init(homeDirectory: String = NSHomeDirectory()) { + self.homeDirectory = homeDirectory + } + + private var configDir: String { + (homeDirectory as NSString).appendingPathComponent(".config/codeburn") + } + private var configPath: String { + (configDir as NSString).appendingPathComponent("config.json") + } + private var lockPath: String { + (configDir as NSString).appendingPathComponent(".config.lock") + } + + func loadDevinAcuUsdRate() -> Double? { + guard + let data = try? SafeFile.read(from: configPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let devin = json["devin"] as? [String: Any], + let rate = devin["acuUsdRate"] as? Double, + rate.isFinite, + rate > 0 + else { + return nil + } + return rate + } + + func persistDevinAcuUsdRate(_ rate: Double) { + guard rate.isFinite, rate > 0 else { return } + do { + try SafeFile.withExclusiveLock(at: lockPath) { + var existing: [String: Any] = [:] + if let data = try? SafeFile.read(from: configPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + existing = parsed + } + + var devin = existing["devin"] as? [String: Any] ?? [:] + devin["acuUsdRate"] = rate + existing["devin"] = devin + + guard let data = try? JSONSerialization.data( + withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys] + ) else { + return + } + try SafeFile.write(data, to: configPath, mode: 0o600) + } + } catch { + NSLog("CodeBurn: failed to persist Devin ACU config: \(error)") + } + } +} + +enum CLIDevinConfig { + private static let store = CodeburnCLIConfigStore() + + static func loadAcuUsdRate() -> Double? { + store.loadDevinAcuUsdRate() + } + + static func persistAcuUsdRate(_ rate: Double) { + store.persistDevinAcuUsdRate(rate) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CLIClaudeConfig.swift b/mac/Sources/CodeBurnMenubar/Data/CLIClaudeConfig.swift new file mode 100644 index 0000000..c6ef272 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CLIClaudeConfig.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Reads and writes the CLI's `claudeConfigDirs` list in +/// `~/.config/codeburn/config.json`. The menubar is a GUI app that doesn't +/// inherit the user's shell environment, so it can't rely on `CLAUDE_CONFIG_DIRS` +/// to aggregate usage across multiple Claude config directories (work / personal +/// accounts). Persisting to the shared config file instead means every `codeburn` +/// invocation honors the list — whether the menubar spawns it directly or the +/// user launches `codeburn report` in a terminal — without injecting environment +/// variables through the shell/AppleScript paths (which only accept a strict +/// metacharacter-free allowlist that arbitrary directory paths can't satisfy). +/// +/// Shares the same on-disk flock as `CLICurrencyConfig` so a concurrent +/// `codeburn` write from a terminal can't race the menubar and drop the other's +/// changes (TOCTOU on config.json). +enum CLIClaudeConfig { + private static var configDir: String { + (NSHomeDirectory() as NSString).appendingPathComponent(".config/codeburn") + } + private static var configPath: String { + (configDir as NSString).appendingPathComponent("config.json") + } + private static var lockPath: String { + (configDir as NSString).appendingPathComponent(".config.lock") + } + + /// Returns the persisted config directories, or an empty array when none are + /// set (the CLI then falls back to `~/.claude`). + static func load() -> [String] { + guard + let data = try? SafeFile.read(from: configPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dirs = json["claudeConfigDirs"] as? [Any] + else { + return [] + } + return dirs.compactMap { $0 as? String }.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + } + + /// Persists the given directories. An empty list removes the key entirely so + /// the CLI reverts to its default `~/.claude` behavior. Entries are trimmed + /// and blanks dropped; order is preserved. + static func persist(dirs: [String]) { + let cleaned = dirs + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + do { + try SafeFile.withExclusiveLock(at: lockPath) { + var existing: [String: Any] = [:] + if let data = try? SafeFile.read(from: configPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + existing = parsed + } + + if cleaned.isEmpty { + existing.removeValue(forKey: "claudeConfigDirs") + } else { + existing["claudeConfigDirs"] = cleaned + } + + guard let data = try? JSONSerialization.data( + withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys] + ) else { + return + } + try SafeFile.write(data, to: configPath, mode: 0o600) + } + } catch { + NSLog("CodeBurn: failed to persist claudeConfigDirs config: \(error)") + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CapacityEstimator.swift b/mac/Sources/CodeBurnMenubar/Data/CapacityEstimator.swift new file mode 100644 index 0000000..446d0e7 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CapacityEstimator.swift @@ -0,0 +1,127 @@ +import Foundation + +public struct CapacitySnapshot: Sendable, Equatable { + public let percent: Double // 0..100, Anthropic-reported utilization + public let effectiveTokens: Double // weighted sum of input/output/cache tokens consumed at capture + public let capturedAt: Date + + public init(percent: Double, effectiveTokens: Double, capturedAt: Date) { + self.percent = percent + self.effectiveTokens = effectiveTokens + self.capturedAt = capturedAt + } +} + +public enum CapacityConfidence: String, Sendable { + case low, medium, solid +} + +public struct CapacityEstimate: Sendable, Equatable { + public let capacity: Double // tokens equivalent to 100% + public let confidence: CapacityConfidence + public let sampleSize: Int // post-decorrelation count + public let nonLinearityWarning: Bool + + public init(capacity: Double, confidence: CapacityConfidence, sampleSize: Int, nonLinearityWarning: Bool) { + self.capacity = capacity + self.confidence = confidence + self.sampleSize = sampleSize + self.nonLinearityWarning = nonLinearityWarning + } +} + +public enum CapacityEstimator { + private static let minSampleSize = 5 + private static let minPercentRange = 15.0 + private static let recencyHalfLifeSeconds: Double = 30 * 86400 + private static let solidR2 = 0.97 + private static let mediumR2 = 0.85 + private static let solidSampleThreshold = 15 + private static let mediumSampleThreshold = 6 + private static let nonLinearityRunLengthThreshold = 0.7 + + public static func estimate(_ snapshots: [CapacitySnapshot], asOf now: Date = Date()) -> CapacityEstimate? { + guard snapshots.count >= minSampleSize else { return nil } + let percents = snapshots.map(\.percent) + let range = (percents.max() ?? 0) - (percents.min() ?? 0) + guard range >= minPercentRange else { return nil } + + let weighted = snapshots.map { snap -> (p: Double, t: Double, w: Double) in + let ageSeconds = now.timeIntervalSince(snap.capturedAt) + let weight = pow(0.5, max(0, ageSeconds) / recencyHalfLifeSeconds) + return (snap.percent, snap.effectiveTokens, weight) + } + + // Weighted least squares through origin: minimize sum(w * (t - p * cap/100)^2) + // Solution: cap = 100 * sum(w * t * p) / sum(w * p * p) + let numerator = weighted.reduce(0.0) { $0 + $1.w * $1.t * $1.p } + let denominator = weighted.reduce(0.0) { $0 + $1.w * $1.p * $1.p } + guard denominator > 0 else { return nil } + let capacity = 100.0 * numerator / denominator + guard capacity > 0 else { return nil } + + // Weighted R^2 against the through-origin fit. + let weightedTokenSum = weighted.reduce(0.0) { $0 + $1.w * $1.t } + let weightSum = weighted.reduce(0.0) { $0 + $1.w } + let weightedMeanT = weightedTokenSum / max(weightSum, .ulpOfOne) + let ssRes = weighted.reduce(0.0) { acc, s in + let predicted = s.p * capacity / 100 + let diff = s.t - predicted + return acc + s.w * diff * diff + } + let ssTot = weighted.reduce(0.0) { acc, s in + let diff = s.t - weightedMeanT + return acc + s.w * diff * diff + } + let r2 = ssTot > 0 ? max(0.0, 1.0 - ssRes / ssTot) : 0.0 + + let n = snapshots.count + let confidence: CapacityConfidence = { + if n >= solidSampleThreshold && r2 >= solidR2 { return .solid } + if n >= mediumSampleThreshold && r2 >= mediumR2 { return .medium } + return .low + }() + + let nonLinearityWarning = detectNonLinearity(snapshots: weighted, capacity: capacity) + + return CapacityEstimate( + capacity: capacity, + confidence: confidence, + sampleSize: n, + nonLinearityWarning: nonLinearityWarning + ) + } + + /// Sign-test on residuals across the percent range. If residuals form a long monotonic run + /// (e.g. all-negative in low percents then all-positive at high), the relationship isn't linear. + private static func detectNonLinearity( + snapshots: [(p: Double, t: Double, w: Double)], + capacity: Double + ) -> Bool { + let sorted = snapshots.sorted { $0.p < $1.p } + let signs = sorted.map { s -> Int in + let predicted = s.p * capacity / 100 + let diff = s.t - predicted + if abs(diff) < .ulpOfOne { return 0 } + return diff > 0 ? 1 : -1 + }.filter { $0 != 0 } + guard signs.count >= minSampleSize else { return false } + + // Longest single-sign run length / total + var longestRun = 0 + var currentRun = 0 + var currentSign = 0 + for s in signs { + if s == currentSign { + currentRun += 1 + } else { + longestRun = max(longestRun, currentRun) + currentSign = s + currentRun = 1 + } + } + longestRun = max(longestRun, currentRun) + let runFraction = Double(longestRun) / Double(signs.count) + return runFraction >= nonLinearityRunLengthThreshold + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift new file mode 100644 index 0000000..002e861 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -0,0 +1,351 @@ +import Foundation +import Security + +/// Owns the lifecycle of Claude OAuth credentials, mirroring CodexBar's pattern: +/// +/// 1. **Bootstrap is user-initiated.** The first read of Claude's keychain +/// entry — which triggers a macOS keychain prompt — only happens when +/// the user clicks "Connect" in the Plan tab. The menubar does not +/// touch Claude's keychain on launch. +/// +/// 2. **The Claude CLI owns the grant; we never refresh it ourselves.** +/// Claude's refresh token is single-use and rotates on every refresh, and +/// the CLI is refreshing the same grant. If the menubar spent that token +/// it would invalidate the CLI's own login. So on expiry/401 we re-read +/// the CLI's store for a token it has already rotated rather than calling +/// the refresh endpoint. If the CLI hasn't rotated yet we report a +/// transient staleness (`sourceTokenStale`) and recover on its next use. +/// +/// 3. **In-memory + file cache** so back-to-back reads in the same refresh +/// cycle don't re-hit the source, and we keep serving the last good token +/// across launches. +enum ClaudeCredentialStore { + private static let bootstrapCompletedKey = "codeburn.claude.bootstrapCompleted" + private static let inMemoryTTL: TimeInterval = 5 * 60 + private static let proactiveRefreshMargin: TimeInterval = 5 * 60 + + private static let claudeKeychainService = "Claude Code-credentials" + private static let credentialsRelativePath = ".claude/.credentials.json" + private static let maxCredentialBytes = 64 * 1024 + + /// Legacy local cache file. New writes use the macOS Keychain; this path is + private static let cacheFilename = "claude-credentials.v1.json" + + private static let lock = NSLock() + private nonisolated(unsafe) static var memoryCache: CachedRecord? + + struct CachedRecord { + let record: CredentialRecord + let cachedAt: Date + + var isFresh: Bool { Date().timeIntervalSince(cachedAt) < ClaudeCredentialStore.inMemoryTTL } + } + + struct CredentialRecord: Codable, Equatable { + let accessToken: String + let refreshToken: String? + let expiresAt: Date? + let rateLimitTier: String? + } + + enum StoreError: Error, LocalizedError { + case bootstrapNoSource // neither file nor Claude keychain has credentials + case bootstrapDecodeFailed + case keychainWriteFailed(OSStatus) + case keychainReadFailed(OSStatus) + case noRefreshToken + case sourceTokenStale // CLI hasn't rotated yet; transient, not a re-auth + + var errorDescription: String? { + switch self { + case .bootstrapNoSource: + return "No Claude credentials found. Sign in with `claude` first." + case .bootstrapDecodeFailed: + return "Claude credentials are malformed." + case let .keychainWriteFailed(status): + return "Could not write to keychain (status \(status))." + case let .keychainReadFailed(status): + return "Could not read from keychain (status \(status))." + case .noRefreshToken: + return "No refresh token available; reconnect required." + case .sourceTokenStale: + return "Waiting for the Claude CLI to refresh its token." + } + } + + /// True when the failure means the user must re-authenticate (re-run + /// `claude` or click Reconnect). Used by the UI to distinguish between + /// "try again later" and "you must act". `sourceTokenStale` is the CLI + /// not having rotated yet — transient, recovers on its next use. + var isTerminal: Bool { + if case .noRefreshToken = self { return true } + return false + } + } + + // MARK: - Bootstrap state + + /// True once the user has explicitly connected (clicked Connect in the Plan + /// tab AND we successfully read their credentials). Persists across launches. + static var isBootstrapCompleted: Bool { + get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) } + set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) } + } + + /// Reset bootstrap state. Used when the user explicitly wants to disconnect + /// or when the refresh token has been revoked terminally. + static func resetBootstrap() { + lock.withLock { memoryCache = nil } + deleteOurCache() + isBootstrapCompleted = false + } + + // MARK: - Public API + + /// User-initiated entry point. Reads from Claude's source (PROMPTS for the + /// keychain on first use), writes to our own keychain item, marks bootstrap + /// as completed. + @discardableResult + static func bootstrap() throws -> CredentialRecord { + let record = try readClaudeSource() + try writeOurCache(record: record) + isBootstrapCompleted = true + cacheInMemory(record) + return record + } + + /// Silent read for background refresh cycles. Reads only from our cache / + /// keychain item — never prompts. Returns nil if not bootstrapped. + static func currentRecord() throws -> CredentialRecord? { + guard isBootstrapCompleted else { return nil } + // Honour the in-memory TTL: a stale cached record can mask a token + // that another process (e.g. claude /login again) has just rotated + // on disk. Re-read the file when the cache passes the TTL. + if let cached = lock.withLock({ memoryCache }), cached.isFresh { + return cached.record + } + if let stored = try readOurCache() { + cacheInMemory(stored) + return stored + } + // Bootstrap flag is set but our cache file is missing — most likely + // a fresh install resetting state, or the user manually deleted the + // file. Force re-bootstrap on next user action. + isBootstrapCompleted = false + return nil + } + + /// Returns the current token, adopting a fresher one from the CLI's store if + /// ours is near expiry. Never spends the refresh token — see the type doc. + static func freshAccessToken() async throws -> String? { + guard let record = try currentRecord() else { return nil } + if let expiresAt = record.expiresAt, expiresAt.timeIntervalSinceNow < proactiveRefreshMargin { + if let live = adoptFresherSource(than: record) { + return live.accessToken + } + } + return record.accessToken + } + + /// Called after an explicit 401. Delegates to the CLI: re-reads its store + /// (silently, no prompt) for a token it has already rotated. If none is + /// available yet, throws the transient `sourceTokenStale` rather than + /// spending the shared refresh token, which would break the CLI's login. + static func refreshAfter401() async throws -> String { + guard let record = try currentRecord() else { throw StoreError.noRefreshToken } + if let live = adoptFresherSource(than: record) { + return live.accessToken + } + throw StoreError.sourceTokenStale + } + + /// Re-reads Claude's own store (file, then keychain with a no-UI query) and + /// adopts it when it holds a different access token than `record` — i.e. the + /// CLI rotated since we last read. Returns nil when nothing fresher exists. + private static func adoptFresherSource(than record: CredentialRecord) -> CredentialRecord? { + guard let live = readClaudeSourceSilently(), live.accessToken != record.accessToken else { + return nil + } + cacheInMemory(live) + try? writeOurCache(record: live) + return live + } + + private static func readClaudeSourceSilently() -> CredentialRecord? { + if let fromFile = try? readClaudeFile() { return fromFile } + if let fromKeychain = try? readClaudeKeychain(allowUI: false) { return fromKeychain } + return nil + } + + static func subscriptionTier() throws -> String? { + try currentRecord()?.rateLimitTier + } + + // MARK: - Bootstrap source + + private static func readClaudeSource() throws -> CredentialRecord { + if let fromFile = try? readClaudeFile() { return fromFile } + if let fromKeychain = try readClaudeKeychain(allowUI: true) { return fromKeychain } + throw StoreError.bootstrapNoSource + } + + private static func readClaudeFile() throws -> CredentialRecord? { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) + return try parseClaudeBlob(data: sanitizeClaudeBlob(data)) + } + + /// Reads Claude's keychain credentials. The CLI has historically written + /// entries under different account names — older versions used "agentseal" + /// (a hardcoded company-style identifier) while Claude Code 2.1.x writes + /// under `$USER` (NSUserName()). After a user re-runs `/login`, both + /// entries can coexist and a service-only lookup often returns the older + /// stale one. We try the user-keyed entry first (the modern format), then + /// fall back to the unscoped query for older installations. + /// + /// Silent background reads go through the `security` CLI rather than the + /// Security framework. The Apple-signed `security` binary sits in the + /// keychain item's `apple-tool:` partition, so it never raises the + /// partition-list prompt. The framework API does — and re-prompts every + /// time Claude Code rotates its credential and resets the item's partition + /// list, dropping our app from the allowed set (issue #490). Only the + /// user-initiated bootstrap still reads through the framework, where a + /// single consent prompt is expected. + private static func readClaudeKeychain(allowUI: Bool) throws -> CredentialRecord? { + if !allowUI { + return readClaudeKeychainSilently(account: NSUserName()) + ?? readClaudeKeychainSilently(account: nil) + } + if let record = try readClaudeKeychainPrompting(account: NSUserName()) { + return record + } + return try readClaudeKeychainPrompting(account: nil) + } + + private static func readClaudeKeychainPrompting(account: String?) throws -> CredentialRecord? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: claudeKeychainService, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + if let account { query[kSecAttrAccount as String] = account } + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw StoreError.keychainReadFailed(status) + } + return try parseClaudeBlob(data: sanitizeClaudeBlob(data)) + } + + /// Reads Claude's keychain entry via `/usr/bin/security`, which never raises + /// the partition-list prompt. Returns nil on any failure so the caller falls + /// back to the cached token. + private static func readClaudeKeychainSilently(account: String?) -> CredentialRecord? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/security") + var args = ["find-generic-password", "-s", claudeKeychainService] + if let account { args += ["-a", account] } + args.append("-w") + process.arguments = args + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return try? parseClaudeBlob(data: sanitizeClaudeBlob(data)) + } catch { + return nil + } + } + + /// Claude Code's keychain writer line-wraps long values (newline + leading + /// spaces) mid-token, producing JSON with literal control chars inside string + /// values. Strip those plus pretty-print indentation between fields so the + /// JSON parser succeeds. + private static func sanitizeClaudeBlob(_ data: Data) -> Data { + guard var s = String(data: data, encoding: .utf8) else { return data } + s = s.replacingOccurrences(of: "\r", with: "") + if let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: []) { + let range = NSRange(s.startIndex.. CredentialRecord { + struct Root: Decodable { let claudeAiOauth: OAuth? } + struct OAuth: Decodable { + let accessToken: String? + let refreshToken: String? + let expiresAt: Double? + let rateLimitTier: String? + } + do { + let root = try JSONDecoder().decode(Root.self, from: data) + guard let oauth = root.claudeAiOauth, + let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + else { throw StoreError.bootstrapDecodeFailed } + return CredentialRecord( + accessToken: token, + refreshToken: oauth.refreshToken, + expiresAt: oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) }, + rateLimitTier: oauth.rateLimitTier + ) + } catch { + throw StoreError.bootstrapDecodeFailed + } + } + + // MARK: - Local cache file (no keychain involvement) + + private static func cacheFileURL() -> URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + return support + .appendingPathComponent("CodeBurn", isDirectory: true) + .appendingPathComponent(cacheFilename) + } + + private static func readOurCache() throws -> CredentialRecord? { + let url = cacheFileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) + guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil } + return record + } + + private static func writeOurCache(record: CredentialRecord) throws { + try writeOurFileCache(record: record) + } + + private static func writeOurFileCache(record: CredentialRecord) throws { + let url = cacheFileURL() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONEncoder().encode(record) + try data.write(to: url, options: [.atomic, .completeFileProtection]) + } + + private static func deleteOurCache() { + try? FileManager.default.removeItem(at: cacheFileURL()) + } + + private static func cacheInMemory(_ record: CredentialRecord) { + lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) } + } +} + +private extension NSLock { + func withLock(_ body: () throws -> T) rethrows -> T { + lock(); defer { unlock() } + return try body() + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift new file mode 100644 index 0000000..4516dd0 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift @@ -0,0 +1,285 @@ +import Foundation + +/// Orchestrates "given a credential record, fetch live quota from Anthropic +/// and surface a result the UI can render". All token persistence lives in +/// `ClaudeCredentialStore`; the only state this service holds is the +/// 429 backoff window for the usage endpoint. +enum ClaudeSubscriptionService { + private static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")! + private static let betaHeader = "oauth-2025-04-20" + private static let userAgent = "claude-code/2.1.0" + private static let usageBlockedUntilKey = "codeburn.claude.usage.blockedUntil" + + enum FetchError: Error, LocalizedError { + case notBootstrapped + case bootstrapFailed(ClaudeCredentialStore.StoreError) + case rateLimited(retryAt: Date) + case usageHTTPError(Int, String?) + case usageDecodeFailed + case network(Error) + case credential(ClaudeCredentialStore.StoreError) + + var errorDescription: String? { + switch self { + case .notBootstrapped: + return "Connect Claude in the Plan tab to start tracking quota." + case let .bootstrapFailed(err): + return err.errorDescription + case let .rateLimited(retryAt): + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return "Anthropic rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))." + case let .usageHTTPError(code, body): + return "Quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")" + case .usageDecodeFailed: + return "Quota response was malformed." + case let .network(err): + return "Network error: \(err.localizedDescription)" + case let .credential(err): + return err.errorDescription + } + } + + /// True when the user must take action (re-run claude/login or click + /// Reconnect). Drives the red "Reconnect" UI path. + var isTerminal: Bool { + if case let .credential(err) = self { return err.isTerminal } + if case let .bootstrapFailed(err) = self { return err.isTerminal } + return false + } + + var rateLimitRetryAt: Date? { + if case let .rateLimited(retryAt) = self { return retryAt } + return nil + } + } + + // MARK: - Public API + + /// User-initiated. Reads Claude's keychain (PROMPTS), copies to our keychain, + /// then fetches usage. Idempotent — safe to call again to "reconnect". + static func bootstrap() async throws -> SubscriptionUsage { + // Honour the same 429 backoff that refreshIfBootstrapped respects. + // Without this, a user spamming Reconnect during a sustained + // rate-limit window hammers Anthropic on every click — exactly the + // pattern that escalates the backoff. + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + let record: ClaudeCredentialStore.CredentialRecord + do { + record = try ClaudeCredentialStore.bootstrap() + } catch let err as ClaudeCredentialStore.StoreError { + throw FetchError.bootstrapFailed(err) + } + return try await fetchWithRecord(initial: record) + } + + /// Background refresh. Never prompts. Returns nil if not yet bootstrapped. + static func refreshIfBootstrapped() async throws -> SubscriptionUsage? { + guard ClaudeCredentialStore.isBootstrapCompleted else { + return nil + } + + // Honour an outstanding rate-limit window — we recorded a 429 recently + // and Anthropic told us when to come back. + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + + do { + let token = try await ClaudeCredentialStore.freshAccessToken() + guard let token else { throw FetchError.notBootstrapped } + return try await fetch(token: token, allowOne401Recovery: true) + } catch let err as ClaudeCredentialStore.StoreError { + throw FetchError.credential(err) + } catch let err as FetchError { + throw err + } + } + + /// Reset everything — used on user-initiated disconnect. + static func disconnect() { + ClaudeCredentialStore.resetBootstrap() + clearUsageBlock() + } + + // MARK: - Internal + + private static func fetchWithRecord(initial record: ClaudeCredentialStore.CredentialRecord) async throws -> SubscriptionUsage { + do { + return try await fetch(token: record.accessToken, allowOne401Recovery: true) + } catch let err as FetchError { + throw err + } catch let err as ClaudeCredentialStore.StoreError { + throw FetchError.credential(err) + } + } + + private static func fetch(token: String, allowOne401Recovery: Bool) async throws -> SubscriptionUsage { + var request = URLRequest(url: usageURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw FetchError.network(error) + } + guard let http = response as? HTTPURLResponse else { + throw FetchError.usageHTTPError(-1, nil) + } + + switch http.statusCode { + case 200: + clearUsageBlock() + do { + let tier = try ClaudeCredentialStore.subscriptionTier() + return try parseUsage(data, rawTier: tier) + } catch { + throw FetchError.usageDecodeFailed + } + case 401: + if allowOne401Recovery { + let newToken = try await ClaudeCredentialStore.refreshAfter401() + return try await fetch(token: newToken, allowOne401Recovery: false) + } + throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8)) + case 429: + let body = String(data: data, encoding: .utf8) + let retryAfter = parseRetryAfter(body: body) + let until = recordUsageRateLimit(retryAfterSeconds: retryAfter) + throw FetchError.rateLimited(retryAt: until) + default: + throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8)) + } + } + + // MARK: - 429 backoff + + private static func usageBlockedUntil() -> Date? { + UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date + } + + private static func clearUsageBlock() { + UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey) + } + + @discardableResult + private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date { + let seconds = max(retryAfterSeconds ?? 300, 60) + let until = Date().addingTimeInterval(TimeInterval(seconds)) + UserDefaults.standard.set(until, forKey: usageBlockedUntilKey) + return until + } + + private static func parseRetryAfter(body: String?) -> Int? { + guard let body, let data = body.data(using: .utf8) else { return nil } + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let n = json["retry_after"] as? Int { return n } + if let s = json["retry_after"] as? String, let n = Int(s) { return n } + } + return nil + } + + // 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 + } + } + + private struct Window: Decodable { + let utilization: Double? + let resetsAt: String? + enum CodingKeys: String, CodingKey { + case utilization + case resetsAt = "resets_at" + } + } + + /// 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" + } + } + } + } + + private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> 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, + fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt), + sevenDayPercent: r.sevenDay?.utilization, + sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt), + sevenDayOpusPercent: r.sevenDayOpus?.utilization, + sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt), + sevenDaySonnetPercent: r.sevenDaySonnet?.utilization, + sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt), + scopedWeekly: scopedWeekly, + fetchedAt: Date() + ) + } + + private static func parseDate(_ s: String?) -> Date? { + guard let s, !s.isEmpty else { return nil } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = f.date(from: s) { return d } + f.formatOptions = [.withInternetDateTime] + return f.date(from: s) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift new file mode 100644 index 0000000..9f7ae18 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift @@ -0,0 +1,364 @@ +import Foundation +import Security + +/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors +/// ClaudeCredentialStore but reads from ~/.codex/auth.json — Codex CLI +/// already stores its tokens as plaintext JSON in the home directory, so +/// no keychain prompt is involved on bootstrap. After the user clicks +/// Connect we cache a copy under ~/Library/Application Support/CodeBurn so +/// we keep using rotated tokens after refresh. +enum CodexCredentialStore { + private static let bootstrapCompletedKey = "codeburn.codex.bootstrapCompleted" + private static let inMemoryTTL: TimeInterval = 5 * 60 + // Codex refresh tokens are single-use and rotate on every refresh. The CLI + // owns the grant via ~/.codex/auth.json; we only refresh ourselves when its + // last_refresh is older than this, mirroring the Codex CLI's own cadence. + // Refreshing more eagerly races the CLI and burns its rotating token. + private static let staleRefreshInterval: TimeInterval = 8 * 24 * 60 * 60 + + private static let oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + private static let refreshURL = URL(string: "https://auth.openai.com/oauth/token")! + private static let codexAuthPath = ".codex/auth.json" + private static let maxCredentialBytes = 64 * 1024 + + private static let cacheFilename = "codex-credentials.v1.json" + + private static let lock = NSLock() + private nonisolated(unsafe) static var memoryCache: CachedRecord? + + struct CachedRecord { + let record: CredentialRecord + let cachedAt: Date + + var isFresh: Bool { Date().timeIntervalSince(cachedAt) < CodexCredentialStore.inMemoryTTL } + } + + struct CredentialRecord: Codable, Equatable { + let accessToken: String + let refreshToken: String + let idToken: String? + let accountId: String? + let expiresAt: Date? + let lastRefresh: Date? + } + + enum StoreError: Error, LocalizedError { + case bootstrapNoSource + case bootstrapDecodeFailed + case bootstrapNotChatGPT // user is on API-key mode; we need ChatGPT mode for quota + case fileWriteFailed(String) + case refreshHTTPError(Int, String?) + case refreshNetworkError(Error) + case refreshDecodeFailed + case noRefreshToken + + var errorDescription: String? { + switch self { + case .bootstrapNoSource: + return "No Codex credentials found at ~/.codex/auth.json. Run `codex` to sign in." + case .bootstrapDecodeFailed: + return "Codex credentials are malformed." + case .bootstrapNotChatGPT: + return "Codex is in API-key mode; live quota tracking is only available for ChatGPT subscriptions." + case let .fileWriteFailed(message): + return "Could not write to local cache: \(message)" + case let .refreshHTTPError(code, body): + return "Codex token refresh failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")" + case let .refreshNetworkError(err): + return "Codex token refresh network error: \(err.localizedDescription)" + case .refreshDecodeFailed: + return "Codex token refresh response was malformed." + case .noRefreshToken: + return "No refresh token available; reconnect required." + } + } + + /// True when the user must take action: rerun `codex` to re-authenticate + /// or switch from API-key to ChatGPT mode. Drives the red Reconnect path. + var isTerminal: Bool { + if case let .refreshHTTPError(code, body) = self, code >= 400, code < 500 { + let lower = body?.lowercased() ?? "" + if lower.contains("refresh_token_expired") || + lower.contains("refresh_token_reused") || + lower.contains("refresh_token_invalidated") || + lower.contains("invalid_grant") + { + return true + } + return true + } + switch self { + case .noRefreshToken, .bootstrapNotChatGPT, .bootstrapNoSource: return true + default: return false + } + } + } + + // MARK: - Bootstrap state + + static var isBootstrapCompleted: Bool { + get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) } + set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) } + } + + static func resetBootstrap() { + lock.withLock { memoryCache = nil } + deleteOurCache() + isBootstrapCompleted = false + } + + // MARK: - Public API + + @discardableResult + static func bootstrap() throws -> CredentialRecord { + let record = try readCodexAuth() + try writeOurCache(record: record) + isBootstrapCompleted = true + cacheInMemory(record) + return record + } + + static func currentRecord() throws -> CredentialRecord? { + guard isBootstrapCompleted else { return nil } + // The Codex CLI's auth.json is the source of truth. Read it fresh each + // call so we always serve the CLI's current token rather than racing it + // with a stale private copy. Our cache is only a fallback for when the + // file is briefly unreadable. + if let live = try? readCodexAuth() { + cacheInMemory(live) + try? writeOurCache(record: live) + return live + } + if let cached = lock.withLock({ memoryCache }), cached.isFresh { + return cached.record + } + if let stored = try readOurCache() { + cacheInMemory(stored) + return stored + } + return nil + } + + static func freshAccessToken() async throws -> String? { + guard let record = try currentRecord() else { return nil } + if needsRefresh(record) { + let updated = try await refreshAndPersist(record: record) + return updated.accessToken + } + return record.accessToken + } + + static func refreshAfter401(failedToken: String) async throws -> String { + // Source of truth first: the CLI may have already rotated the token out + // from under us. Re-read auth.json before spending our single-use refresh + // token, which would race the CLI and can invalidate its login. + if let live = try? readCodexAuth(), live.accessToken != failedToken { + cacheInMemory(live) + try? writeOurCache(record: live) + return live.accessToken + } + guard let record = try currentRecord() else { throw StoreError.noRefreshToken } + let updated = try await refreshAndPersist(record: record) + return updated.accessToken + } + + private static func needsRefresh(_ record: CredentialRecord) -> Bool { + guard let last = record.lastRefresh else { return true } + return Date().timeIntervalSince(last) > staleRefreshInterval + } + + // MARK: - Bootstrap source: ~/.codex/auth.json + + private static func readCodexAuth() throws -> CredentialRecord { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath) + guard FileManager.default.fileExists(atPath: url.path) else { + throw StoreError.bootstrapNoSource + } + let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) + struct Root: Decodable { + let auth_mode: String? + let tokens: Tokens? + let last_refresh: String? + } + struct Tokens: Decodable { + let access_token: String? + let refresh_token: String? + let id_token: String? + let account_id: String? + } + do { + let root = try JSONDecoder().decode(Root.self, from: data) + // Live quota is only meaningful for ChatGPT-mode auth. API-key users + // have a different billing surface (/v1/usage) which we do not yet + // implement here. + guard root.auth_mode == "chatgpt" else { + throw StoreError.bootstrapNotChatGPT + } + guard let tokens = root.tokens, + let access = tokens.access_token?.trimmingCharacters(in: .whitespacesAndNewlines), + let refresh = tokens.refresh_token?.trimmingCharacters(in: .whitespacesAndNewlines), + !access.isEmpty, !refresh.isEmpty + else { + throw StoreError.bootstrapDecodeFailed + } + return CredentialRecord( + accessToken: access, + refreshToken: refresh, + idToken: tokens.id_token, + accountId: tokens.account_id, + expiresAt: nil, // Codex CLI does not record expiresAt in auth.json + lastRefresh: root.last_refresh.flatMap(parseISO8601) + ) + } catch let err as StoreError { + throw err + } catch { + throw StoreError.bootstrapDecodeFailed + } + } + + private static func parseISO8601(_ s: String) -> Date? { + // auth.json records fractional seconds (e.g. ...:12.010758Z); the plain + // ISO8601 formatter rejects those, so try the fractional variant first. + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = withFraction.date(from: s) { return d } + return ISO8601DateFormatter().date(from: s) + } + + // MARK: - Write rotated tokens back to ~/.codex/auth.json + + /// Atomic read-modify-write of auth.json that preserves every other top-level + /// key (OPENAI_API_KEY, auth_mode, ...) and only rewrites the tokens dict and + /// last_refresh. Keeps the CLI and the menubar on the same rotated grant. + private static func writeBackToCodexAuth(record: CredentialRecord) { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath) + var json: [String: Any] = [:] + if let data = try? SafeFile.read(from: url.path, maxBytes: maxCredentialBytes), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + json = existing + } + var tokens: [String: Any] = [ + "access_token": record.accessToken, + "refresh_token": record.refreshToken, + ] + if let idToken = record.idToken { tokens["id_token"] = idToken } + if let accountId = record.accountId { tokens["account_id"] = accountId } + json["tokens"] = tokens + let stamp = ISO8601DateFormatter() + stamp.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + json["last_refresh"] = stamp.string(from: record.lastRefresh ?? Date()) + guard let out = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) else { + return + } + try? out.write(to: url, options: .atomic) + } + + // MARK: - Local cache file + + private static func cacheFileURL() -> URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + return support + .appendingPathComponent("CodeBurn", isDirectory: true) + .appendingPathComponent(cacheFilename) + } + + private static func readOurCache() throws -> CredentialRecord? { + let url = cacheFileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) + guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil } + return record + } + + private static func writeOurCache(record: CredentialRecord) throws { + try writeOurFileCache(record: record) + } + + private static func writeOurFileCache(record: CredentialRecord) throws { + let url = cacheFileURL() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONEncoder().encode(record) + try data.write(to: url, options: [.atomic, .completeFileProtection]) + } + + private static func deleteOurCache() { + try? FileManager.default.removeItem(at: cacheFileURL()) + } + + private static func cacheInMemory(_ record: CredentialRecord) { + lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) } + } + + // MARK: - Refresh + + private static func refreshAndPersist(record: CredentialRecord) async throws -> CredentialRecord { + guard !record.refreshToken.isEmpty else { throw StoreError.noRefreshToken } + + var request = URLRequest(url: refreshURL) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: String] = [ + "client_id": oauthClientID, + "grant_type": "refresh_token", + "refresh_token": record.refreshToken, + "scope": "openid profile email", + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw StoreError.refreshNetworkError(error) + } + guard let http = response as? HTTPURLResponse else { + throw StoreError.refreshHTTPError(-1, nil) + } + guard http.statusCode == 200 else { + // A 4xx here usually means the CLI already rotated the shared grant + // out from under us (single-use refresh token). Re-read the source: + // if it now holds a different token, the CLI healed it and we adopt + // that instead of surfacing a terminal "disconnected". + if http.statusCode >= 400, http.statusCode < 500, + let live = try? readCodexAuth(), live.refreshToken != record.refreshToken { + cacheInMemory(live) + try? writeOurCache(record: live) + return live + } + let body = String(data: data, encoding: .utf8) + throw StoreError.refreshHTTPError(http.statusCode, body) + } + + struct RefreshResponse: Decodable { + let access_token: String + let refresh_token: String? + let id_token: String? + let expires_in: Int? + } + guard let decoded = try? JSONDecoder().decode(RefreshResponse.self, from: data) else { + throw StoreError.refreshDecodeFailed + } + + let updated = CredentialRecord( + accessToken: decoded.access_token, + refreshToken: decoded.refresh_token ?? record.refreshToken, + idToken: decoded.id_token ?? record.idToken, + accountId: record.accountId, + expiresAt: decoded.expires_in.map { Date().addingTimeInterval(TimeInterval($0)) } ?? record.expiresAt, + lastRefresh: Date() + ) + cacheInMemory(updated) + // Write the rotated grant back to the CLI's store first so the CLI keeps + // working, then mirror it into our fallback cache. + writeBackToCodexAuth(record: updated) + do { + try writeOurCache(record: updated) + } catch { + NSLog("CodeBurn: codex cache write failed during refresh rotation: %@", String(describing: error)) + } + return updated + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift new file mode 100644 index 0000000..e83bd32 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift @@ -0,0 +1,243 @@ +import Foundation + +/// Mirror of ClaudeSubscriptionService for Codex (ChatGPT-mode). Hits +/// /backend-api/wham/usage with the bearer token from CodexCredentialStore, +/// applies an independent 429 backoff, and surfaces terminal vs transient +/// failures to the UI. +enum CodexSubscriptionService { + private static let usageURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! + private static let usageBlockedUntilKey = "codeburn.codex.usage.blockedUntil" + + enum FetchError: Error, LocalizedError { + case notBootstrapped + case bootstrapFailed(CodexCredentialStore.StoreError) + case rateLimited(retryAt: Date) + case usageHTTPError(Int, String?) + case usageDecodeFailed + case network(Error) + case credential(CodexCredentialStore.StoreError) + + var errorDescription: String? { + switch self { + case .notBootstrapped: + return "Connect Codex in Settings to start tracking quota." + case let .bootstrapFailed(err): return err.errorDescription + case let .rateLimited(retryAt): + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return "ChatGPT rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))." + case let .usageHTTPError(code, body): + return "Codex quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")" + case .usageDecodeFailed: return "Codex quota response was malformed." + case let .network(err): return "Network error: \(err.localizedDescription)" + case let .credential(err): return err.errorDescription + } + } + + var isTerminal: Bool { + if case let .credential(err) = self { return err.isTerminal } + if case let .bootstrapFailed(err) = self { return err.isTerminal } + return false + } + + var rateLimitRetryAt: Date? { + if case let .rateLimited(retryAt) = self { return retryAt } + return nil + } + } + + static func bootstrap() async throws -> CodexUsage { + // Honour the same 429 backoff that refreshIfBootstrapped respects. + // A user clicking Reconnect during a sustained ChatGPT rate-limit + // window would otherwise re-hit /wham/usage on every click and keep + // the backoff window pegged. + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + let record: CodexCredentialStore.CredentialRecord + do { + record = try CodexCredentialStore.bootstrap() + } catch let err as CodexCredentialStore.StoreError { + throw FetchError.bootstrapFailed(err) + } + return try await fetchWithToken(record.accessToken, allowOne401Recovery: true) + } + + static func refreshIfBootstrapped() async throws -> CodexUsage? { + guard CodexCredentialStore.isBootstrapCompleted else { return nil } + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + do { + let token = try await CodexCredentialStore.freshAccessToken() + guard let token else { throw FetchError.notBootstrapped } + return try await fetchWithToken(token, allowOne401Recovery: true) + } catch let err as CodexCredentialStore.StoreError { + throw FetchError.credential(err) + } + } + + static func disconnect() { + CodexCredentialStore.resetBootstrap() + clearUsageBlock() + } + + private static func fetchWithToken(_ token: String, allowOne401Recovery: Bool) async throws -> CodexUsage { + var request = URLRequest(url: usageURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent") + // chatgpt.com routes the rate_limit envelope per ChatGPT account. Without + // this header the response often comes back as a guest-shape document + // missing rate_limit entirely, which our decoder then fails on. + if let accountId = try? CodexCredentialStore.currentRecord()?.accountId, !accountId.isEmpty { + request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw FetchError.network(error) + } + guard let http = response as? HTTPURLResponse else { + throw FetchError.usageHTTPError(-1, nil) + } + + switch http.statusCode { + case 200: + clearUsageBlock() + do { + return try decodeUsage(data: data) + } catch { + // Do not log the response body — it's user-account data from + // chatgpt.com and is readable by other local users via + // `log stream`. The decode error type alone is enough to + // bisect schema drift if needed. + NSLog("CodeBurn: codex usage decode failed: %@", String(describing: error)) + throw FetchError.usageDecodeFailed + } + case 401: + if allowOne401Recovery { + let newToken = try await CodexCredentialStore.refreshAfter401(failedToken: token) + return try await fetchWithToken(newToken, allowOne401Recovery: false) + } + throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8)) + case 429: + // Honour the RFC Retry-After header when present — ChatGPT's quota + // endpoint sometimes sets it to a window shorter than our 5-min + // floor, and ignoring it forced users to wait longer than the + // server actually wanted. + let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After")) + let until = recordUsageRateLimit(retryAfterSeconds: retryAfter) + throw FetchError.rateLimited(retryAt: until) + default: + throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8)) + } + } + + private struct UsageDTO: Decodable { + let plan_type: String? + let rate_limit: RateLimit? + let additional_rate_limits: [AdditionalLimitDTO]? + let credits: Credits? + + struct RateLimit: Decodable { + let primary_window: WindowDTO? + let secondary_window: WindowDTO? + } + struct AdditionalLimitDTO: Decodable { + let limit_name: String? + let rate_limit: RateLimit? + } + struct WindowDTO: Decodable { + let used_percent: Double? + let reset_at: Int? + let limit_window_seconds: Int? + } + // chatgpt.com sometimes serializes balance as a Double ("balance": 0.0) + // and other times as a String ("balance": "0.00"). Mirror CodexBar's + // resilient decode so a schema drift on either shape doesn't blow up + // the whole quota fetch. + struct Credits: Decodable { + let balance: Double? + enum CodingKeys: String, CodingKey { case balance } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + if let n = try? c.decode(Double.self, forKey: .balance) { + balance = n + } else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) { + balance = n + } else { + balance = nil + } + } + } + } + + private static func decodeUsage(data: Data) throws -> CodexUsage { + let root = try JSONDecoder().decode(UsageDTO.self, from: data) + let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in + guard let name = dto.limit_name, !name.isEmpty else { return nil } + return CodexUsage.AdditionalLimit( + name: name, + primary: makeWindow(dto.rate_limit?.primary_window), + secondary: makeWindow(dto.rate_limit?.secondary_window) + ) + } + return CodexUsage( + plan: CodexUsage.planType(from: root.plan_type), + primary: makeWindow(root.rate_limit?.primary_window), + secondary: makeWindow(root.rate_limit?.secondary_window), + additionalLimits: additional, + creditsBalance: root.credits?.balance, + fetchedAt: Date() + ) + } + + private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? { + guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else { + return nil + } + let resetsAt = dto.reset_at.map { Date(timeIntervalSince1970: TimeInterval($0)) } + return CodexUsage.Window(usedPercent: used, resetsAt: resetsAt, limitWindowSeconds: windowSeconds) + } + + // MARK: - 429 backoff + + private static func usageBlockedUntil() -> Date? { + UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date + } + + private static func clearUsageBlock() { + UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey) + } + + @discardableResult + /// RFC 7231 says Retry-After is either a delta-seconds or an HTTP-date. + /// chatgpt.com appears to send delta-seconds today; we still parse both + /// shapes defensively so a future change to HTTP-date doesn't drop us + /// onto the silent 5-minute floor. + private static func parseRetryAfterHeader(_ value: String?) -> Int? { + guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil } + if let seconds = Int(value), seconds >= 0 { return seconds } + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + if let date = f.date(from: value) { + return max(0, Int(date.timeIntervalSinceNow)) + } + return nil + } + + private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date { + let seconds = max(retryAfterSeconds ?? 300, 60) + let until = Date().addingTimeInterval(TimeInterval(seconds)) + UserDefaults.standard.set(until, forKey: usageBlockedUntilKey) + return until + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift new file mode 100644 index 0000000..719b117 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Codex (ChatGPT-mode) live quota snapshot returned by /backend-api/wham/usage. +/// Two windows are exposed: primary (typically the 5-hour rolling window) and +/// secondary (typically the weekly window). Window size is dynamic per +/// account — `limitWindowSeconds` tells us whether it's a 5-hour or 7-day +/// boundary so we can label correctly. +struct CodexUsage: Sendable, Equatable { + enum PlanType: Sendable, Equatable { + case guest, free, go, plus, pro, prolite, freeWorkspace, team + case business, education, quorum, k12, enterprise, edu + /// Captures any plan_type string OpenAI ships that we haven't enumerated + /// yet, so the Settings/Plan UI can still show "Plan: " instead of + /// a generic "Subscription" placeholder. Preserves forward compatibility + /// without requiring a CodeBurn update for every new tier. + case unknown(String) + + var displayName: String { + switch self { + case .guest: "Guest" + case .free: "Free" + case .go: "Go" + case .plus: "Plus" + case .pro: "Pro" + case .prolite: "Pro Lite" + case .freeWorkspace: "Free Workspace" + case .team: "Team" + case .business: "Business" + case .education: "Education" + case .quorum: "Quorum" + case .k12: "K-12" + case .enterprise: "Enterprise" + case .edu: "Edu" + case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized + } + } + } + + struct Window: Sendable, Equatable { + let usedPercent: Double // 0.0 ... 100.0 + let resetsAt: Date? + let limitWindowSeconds: Int + + /// Human label inferred from window size: 5h, 1d, 7d, etc. + var windowLabel: String { + switch limitWindowSeconds { + case 0..<3600: return "Hourly" + case 3600..<7200: return "Hour" + case 18000..<19000: return "5-hour" + case 86400..<87000: return "Daily" + case 604800..<605000: return "Weekly" + default: + let hours = limitWindowSeconds / 3600 + if hours < 24 { return "\(hours)-hour" } + return "\(hours / 24)-day" + } + } + } + + /// Additional per-model / per-feature quotas exposed by ChatGPT alongside + /// the main rate_limit (e.g. "GPT-5.3-Codex-Spark"). Each entry has its + /// own primary/secondary windows. Only ones with non-zero utilization are + /// surfaced in the popover so users on plans that don't touch these + /// features don't see clutter. + struct AdditionalLimit: Sendable, Equatable { + let name: String + let primary: Window? + let secondary: Window? + } + + let plan: PlanType + let primary: Window? + let secondary: Window? + let additionalLimits: [AdditionalLimit] + let creditsBalance: Double? + let fetchedAt: Date + + static func planType(from raw: String?) -> PlanType { + guard let raw = raw?.lowercased() else { return .unknown("") } + switch raw { + case "guest": return .guest + case "free": return .free + case "go": return .go + case "plus": return .plus + case "pro": return .pro + case "prolite", "pro_lite", "pro-lite": return .prolite + case "free_workspace": return .freeWorkspace + case "team": return .team + case "business": return .business + case "education": return .education + case "quorum": return .quorum + case "k12": return .k12 + case "enterprise": return .enterprise + case "edu": return .edu + default: return .unknown(raw) + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift new file mode 100644 index 0000000..81d049b --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -0,0 +1,294 @@ +import Foundation + +/// Upper bound on payload + stderr bytes read from the CLI. Real payloads top out near 500 KB +/// (365 days of history with dozens of models); anything larger is pathological and truncating +/// prevents unbounded memory growth. Hard timeout guards against a hung CLI keeping Process and +/// Pipe file descriptors pinned forever. +private let maxPayloadBytes = 20 * 1024 * 1024 +private let maxStderrBytes = 256 * 1024 +private let spawnTimeoutSeconds: UInt64 = 45 +private let maxConcurrentSpawns = 6 + +enum DataClientError: Error { + case spawn(String) + case nonZeroExit(code: Int32, stderr: String) + case decode(Error) + case timeout + 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 { + static func fetch(period: Period, + day: String? = nil, + days: Set = [], + provider: ProviderFilter, + includeOptimize: Bool, + scope: MenubarScope = .local, + claudeConfigSourceId: String? = nil) async throws -> MenubarPayload { + let subcommand = statusSubcommand( + period: period, + day: day, + days: days, + provider: provider, + includeOptimize: includeOptimize, + scope: scope, + claudeConfigSourceId: claudeConfigSourceId + ) + let result = try await runCLI(subcommand: subcommand) + guard result.exitCode == 0 else { + throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr) + } + do { + return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout) + } catch { + 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 + )) + } + } + + static func statusSubcommand(period: Period, + day: String? = nil, + days: Set = [], + provider: ProviderFilter, + includeOptimize: Bool, + scope: MenubarScope = .local, + claudeConfigSourceId: String? = nil) -> [String] { + let effectiveScope: MenubarScope = days.count > 1 ? .local : scope + let effectiveProvider: ProviderFilter = effectiveScope == .combined ? .all : provider + var subcommand = [ + "status", + "--format", "menubar-json", + "--provider", effectiveProvider.cliArg, + ] + if effectiveScope == .combined { + subcommand.append(contentsOf: ["--scope", effectiveScope.cliArg]) + } + if effectiveScope == .local, let claudeConfigSourceId, !claudeConfigSourceId.isEmpty { + subcommand.append(contentsOf: ["--claude-config-source", claudeConfigSourceId]) + } + if days.count > 1 { + subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")]) + } else if let day { + subcommand.append(contentsOf: ["--day", day]) + } else if let d = days.first { + subcommand.append(contentsOf: ["--day", d]) + } else { + subcommand.append(contentsOf: ["--period", period.cliArg]) + } + if !includeOptimize { + subcommand.append("--no-optimize") + } + return subcommand + } + + struct ProcessResult { + let stdout: Data + let stderr: String + let exitCode: Int32 + } + + /// Caps concurrent CLI spawns so a wake-burst of refreshes can't fan out into + /// dozens of node processes at once. + private static let spawnLimiter = AsyncSemaphore(maxConcurrentSpawns) + + private static func runCLI(subcommand: [String]) async throws -> ProcessResult { + await spawnLimiter.acquire() + defer { Task { await spawnLimiter.release() } } + let process = CodeburnCLI.makeProcess(subcommand: subcommand) + return try await runProcess(process, + timeoutSeconds: spawnTimeoutSeconds, + label: subcommand.joined(separator: " ")) + } + + /// Runs an already-configured process to completion, draining its output and + /// enforcing a hard timeout. + /// + /// CRITICAL: nothing here may block a worker thread waiting for the process. + /// `process.waitUntilExit()` is a blocking syscall. An earlier fix moved it + /// onto a global(qos:.utility) queue with the timeout on that SAME queue — but + /// under sustained load every utility worker ended up blocked in waitUntilExit, + /// so the timeout could never be scheduled to kill them and the menubar wedged + /// on "Loading…" forever (confirmed via sample: threads parked in + /// waitUntilExit, timeout never firing). Instead we await + /// `process.terminationHandler`, which fires on a Foundation-managed queue and + /// blocks nothing, so the timeout always has a free thread to fire on. + static func runProcess(_ process: Process, + timeoutSeconds: UInt64, + label: String) async throws -> ProcessResult { + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + + // Bridge the process exit to an async signal set up BEFORE run(), so the + // exit can never be missed and the wait never blocks a worker thread. + let exitSignal = ProcessExitSignal() + process.terminationHandler = { _ in exitSignal.fulfill() } + + do { + try process.run() + } catch { + throw DataClientError.spawn(error.localizedDescription) + } + + let timeoutTimer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility)) + timeoutTimer.schedule(deadline: .now() + .seconds(Int(timeoutSeconds))) + timeoutTimer.setEventHandler { + if process.isRunning { + NSLog("CodeBurn: CLI subprocess timed out after %llus for %@ — terminating", + timeoutSeconds, label) + terminateWithEscalation(process) + } + } + timeoutTimer.resume() + defer { timeoutTimer.cancel() } + + let outHandle = outPipe.fileHandleForReading + let errHandle = errPipe.fileHandleForReading + let (out, err) = await withTaskCancellationHandler { + async let stdoutData = drain(outHandle, limit: maxPayloadBytes) + async let stderrData = drain(errHandle, limit: maxStderrBytes) + return await (stdoutData, stderrData) + } onCancel: { + terminateWithEscalation(process) + } + try? outHandle.close() + try? errHandle.close() + // Wait for exit via terminationHandler, never by parking a worker thread + // in waitUntilExit (see the doc comment above for why that wedged). + await exitSignal.wait() + + if out.count >= maxPayloadBytes { + throw DataClientError.outputTooLarge + } + + let stderrString = String(data: err, encoding: .utf8) ?? "" + return ProcessResult(stdout: out, stderr: stderrString, exitCode: process.terminationStatus) + } + + private static func terminateWithEscalation(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + let pid = process.processIdentifier + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) { + if process.isRunning { kill(pid, SIGKILL) } + } + } + + private static func drain(_ handle: FileHandle, limit: Int) async -> Data { + let fd = handle.fileDescriptor + let flags = Darwin.fcntl(fd, F_GETFL) + if flags >= 0 { + _ = Darwin.fcntl(fd, F_SETFL, flags | O_NONBLOCK) + } else { + NSLog("CodeBurn: fcntl F_GETFL failed on fd %d, drain may block", fd) + } + + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 65_536) + + while buffer.count < limit && !Task.isCancelled { + let toRead = min(chunk.count, limit - buffer.count) + let n = chunk.withUnsafeMutableBufferPointer { ptr in + Darwin.read(fd, ptr.baseAddress!, toRead) + } + if n > 0 { + buffer.append(contentsOf: chunk.prefix(n)) + } else if n == 0 { + break + } else if errno == EAGAIN || errno == EWOULDBLOCK { + try? await Task.sleep(nanoseconds: 5_000_000) + } else if errno == EINTR { + continue + } else { + NSLog("CodeBurn: drain read() failed on fd %d: errno %d", fd, errno) + break + } + } + return buffer + } +} + +/// One-shot async signal that bridges `Process.terminationHandler` (invoked on a +/// Foundation-internal queue) to an awaiting task without blocking a worker +/// thread. Safe against fulfill-before-wait. +final class ProcessExitSignal: @unchecked Sendable { + private let lock = NSLock() + private var fulfilled = false + private var continuation: CheckedContinuation? + + func fulfill() { + lock.lock() + if fulfilled { lock.unlock(); return } + fulfilled = true + let cont = continuation + continuation = nil + lock.unlock() + cont?.resume() + } + + func wait() async { + await withCheckedContinuation { (cont: CheckedContinuation) in + lock.lock() + if fulfilled { + lock.unlock() + cont.resume() + } else { + continuation = cont + lock.unlock() + } + } + } +} + +/// Minimal actor-based async semaphore. Caps concurrency without blocking a +/// thread (unlike DispatchSemaphore.wait()). +actor AsyncSemaphore { + private var available: Int + private var waiters: [CheckedContinuation] = [] + + init(_ count: Int) { available = count } + + func acquire() async { + if available > 0 { + available -= 1 + return + } + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + if waiters.isEmpty { + available += 1 + } else { + waiters.removeFirst().resume() + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift new file mode 100644 index 0000000..14bb548 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -0,0 +1,468 @@ +import Foundation + +/// Shape of `codeburn status --format menubar-json --period `. +/// `current` is scoped to the requested period; the whole payload reflects that slice. +struct MenubarPayload: Codable, Sendable { + let generated: String + let current: CurrentBlock + let optimize: OptimizeBlock + let history: HistoryBlock + let combined: CombinedUsage? + let claudeConfigs: ClaudeConfigSelector? + + init(generated: String, + current: CurrentBlock, + optimize: OptimizeBlock, + history: HistoryBlock, + combined: CombinedUsage?, + claudeConfigs: ClaudeConfigSelector? = nil) { + self.generated = generated + self.current = current + self.optimize = optimize + self.history = history + self.combined = combined + self.claudeConfigs = claudeConfigs + } + + enum CodingKeys: String, CodingKey { + case generated, current, optimize, history, combined, claudeConfigs + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + generated = try c.decode(String.self, forKey: .generated) + current = try c.decode(CurrentBlock.self, forKey: .current) + optimize = try c.decode(OptimizeBlock.self, forKey: .optimize) + history = try c.decode(HistoryBlock.self, forKey: .history) + combined = try c.decodeIfPresent(CombinedUsage.self, forKey: .combined) + claudeConfigs = try c.decodeIfPresent(ClaudeConfigSelector.self, forKey: .claudeConfigs) + } +} + +struct ClaudeConfigSelector: Codable, Sendable { + let selectedId: String? + let options: [ClaudeConfigOption] +} + +struct ClaudeConfigOption: Codable, Identifiable, Hashable, Sendable { + let id: String + let label: String + let path: String +} + +struct CombinedUsage: Codable, Sendable { + let perDevice: [CombinedDeviceUsage] + let combined: CombinedUsageTotals +} + +struct CombinedDeviceUsage: Codable, Sendable { + let id: String + let name: String + let local: Bool + let error: String? + let cost: Double + let calls: Int + let sessions: Int + let inputTokens: Int + let outputTokens: Int + let cacheCreateTokens: Int + let cacheReadTokens: Int + let totalTokens: Int +} + +struct CombinedUsageTotals: Codable, Sendable { + let cost: Double + let calls: Int + let sessions: Int + let inputTokens: Int + let outputTokens: Int + let cacheCreateTokens: Int + let cacheReadTokens: Int + let totalTokens: Int + let deviceCount: Int + let reachableCount: Int +} + +struct HistoryBlock: Codable, Sendable { + let daily: [DailyHistoryEntry] +} + +struct DailyModelBreakdown: Codable, Sendable { + let name: String + let cost: Double + let savingsUSD: Double + let calls: Int + let inputTokens: Int + let outputTokens: Int + + var totalTokens: Int { inputTokens + outputTokens } + + enum CodingKeys: String, CodingKey { + case name, cost, savingsUSD, calls, inputTokens, outputTokens + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + cost = try c.decode(Double.self, forKey: .cost) + savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 + calls = try c.decode(Int.self, forKey: .calls) + inputTokens = try c.decode(Int.self, forKey: .inputTokens) + outputTokens = try c.decode(Int.self, forKey: .outputTokens) + } +} + +struct DailyHistoryEntry: Codable, Sendable { + let date: String + let cost: Double + let savingsUSD: Double + let calls: Int + let inputTokens: Int + let outputTokens: Int + let cacheReadTokens: Int + let cacheWriteTokens: Int + let topModels: [DailyModelBreakdown] + + /// Pricing-ratio prior: input + 5x output + cache_creation + 0.1x cache_read. + /// Matches Anthropic's published per-token pricing on Sonnet/Opus closely enough to be a useful proxy. + var effectiveTokens: Double { + Double(inputTokens) + 5.0 * Double(outputTokens) + Double(cacheWriteTokens) + 0.1 * Double(cacheReadTokens) + } +} + +extension DailyHistoryEntry { + /// Required for legacy payloads (no topModels emitted yet). + enum CodingKeys: String, CodingKey { + case date, cost, savingsUSD, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + date = try c.decode(String.self, forKey: .date) + cost = try c.decode(Double.self, forKey: .cost) + savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 + calls = try c.decode(Int.self, forKey: .calls) + inputTokens = try c.decode(Int.self, forKey: .inputTokens) + outputTokens = try c.decode(Int.self, forKey: .outputTokens) + cacheReadTokens = try c.decode(Int.self, forKey: .cacheReadTokens) + cacheWriteTokens = try c.decode(Int.self, forKey: .cacheWriteTokens) + topModels = try c.decodeIfPresent([DailyModelBreakdown].self, forKey: .topModels) ?? [] + } +} + +struct RetryTaxModelEntry: Codable, Sendable { + let name: String + let taxUSD: Double + let retries: Int + let retriesPerEdit: Double? +} + +struct RetryTax: Codable, Sendable { + let totalUSD: Double + let retries: Int + let editTurns: Int + let byModel: [RetryTaxModelEntry] +} + +struct RoutingWasteModelEntry: Codable, Sendable { + let name: String + let costPerEdit: Double + let editTurns: Int + let actualUSD: Double + let counterfactualUSD: Double + let savingsUSD: Double +} + +struct RoutingWaste: Codable, Sendable { + let totalSavingsUSD: Double + let baselineModel: String + let baselineCostPerEdit: Double + let byModel: [RoutingWasteModelEntry] +} + +struct CurrentBlock: Codable, Sendable { + let label: String + let cost: Double + let calls: Int + let sessions: Int + let oneShotRate: Double? + let inputTokens: Int + let outputTokens: Int + let cacheHitPercent: Double + /// Codex credits consumed in the period (nil on payloads from older builds). + let codexCredits: Double? + let topActivities: [ActivityEntry] + let topModels: [ModelEntry] + let localModelSavings: LocalModelSavings + let providers: [String: Double] + let topProjects: [ProjectEntry] + let modelEfficiency: [ModelEfficiencyEntry] + let topSessions: [TopSessionEntry] + let retryTax: RetryTax + let routingWaste: RoutingWaste + let tools: [ToolEntry] + let skills: [SkillEntry] + let subagents: [SubagentEntry] + let mcpServers: [McpServerEntry] +} + +extension CurrentBlock { + enum CodingKeys: String, CodingKey { + case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens, + cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects, + modelEfficiency, topSessions, retryTax, routingWaste, + tools, skills, subagents, mcpServers + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + label = try c.decode(String.self, forKey: .label) + cost = try c.decode(Double.self, forKey: .cost) + calls = try c.decode(Int.self, forKey: .calls) + sessions = try c.decode(Int.self, forKey: .sessions) + oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate) + inputTokens = try c.decode(Int.self, forKey: .inputTokens) + outputTokens = try c.decode(Int.self, forKey: .outputTokens) + cacheHitPercent = try c.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0 + codexCredits = try c.decodeIfPresent(Double.self, forKey: .codexCredits) + topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? [] + topModels = try c.decodeIfPresent([ModelEntry].self, forKey: .topModels) ?? [] + localModelSavings = try c.decodeIfPresent(LocalModelSavings.self, forKey: .localModelSavings) ?? LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []) + providers = try c.decodeIfPresent([String: Double].self, forKey: .providers) ?? [:] + topProjects = try c.decodeIfPresent([ProjectEntry].self, forKey: .topProjects) ?? [] + modelEfficiency = try c.decodeIfPresent([ModelEfficiencyEntry].self, forKey: .modelEfficiency) ?? [] + topSessions = try c.decodeIfPresent([TopSessionEntry].self, forKey: .topSessions) ?? [] + retryTax = try c.decodeIfPresent(RetryTax.self, forKey: .retryTax) ?? RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []) + routingWaste = try c.decodeIfPresent(RoutingWaste.self, forKey: .routingWaste) ?? RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []) + tools = try c.decodeIfPresent([ToolEntry].self, forKey: .tools) ?? [] + skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? [] + subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? [] + mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? [] + } +} + +struct LocalModelSavingsByModel: Codable, Sendable { + let name: String + let calls: Int + let actualUSD: Double + let savingsUSD: Double + let baselineModel: String + let inputTokens: Int + let outputTokens: Int +} + +struct LocalModelSavingsByProvider: Codable, Sendable { + let name: String + let calls: Int + let savingsUSD: Double +} + +struct LocalModelSavings: Codable, Sendable { + let totalUSD: Double + let calls: Int + let byModel: [LocalModelSavingsByModel] + let byProvider: [LocalModelSavingsByProvider] +} + +struct ActivityEntry: Codable, Sendable { + let name: String + let cost: Double + let savingsUSD: Double + let turns: Int + let oneShotRate: Double? + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + cost = try c.decode(Double.self, forKey: .cost) + savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 + turns = try c.decode(Int.self, forKey: .turns) + oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate) + } + + private enum CodingKeys: String, CodingKey { + case name, cost, savingsUSD, turns, oneShotRate + } +} + +struct ModelEntry: Codable, Sendable { + let name: String + let cost: Double + let savingsUSD: Double + let savingsBaselineModel: String + let calls: Int + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + cost = try c.decode(Double.self, forKey: .cost) + savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 + savingsBaselineModel = try c.decodeIfPresent(String.self, forKey: .savingsBaselineModel) ?? "" + calls = try c.decode(Int.self, forKey: .calls) + } + + private enum CodingKeys: String, CodingKey { + case name, cost, savingsUSD, savingsBaselineModel, calls + } +} + +struct SessionModelEntry: Codable, Sendable { + let name: String + let cost: Double + let savingsUSD: Double + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + cost = try c.decode(Double.self, forKey: .cost) + savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 + } + + private enum CodingKeys: String, CodingKey { + case name, cost, savingsUSD + } +} + +struct SessionDetailEntry: Codable, Sendable { + let cost: Double + let savingsUSD: Double + let calls: Int + let inputTokens: Int + let outputTokens: Int + let date: String + let models: [SessionModelEntry] + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + cost = try c.decode(Double.self, forKey: .cost) + savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 + calls = try c.decode(Int.self, forKey: .calls) + inputTokens = try c.decode(Int.self, forKey: .inputTokens) + outputTokens = try c.decode(Int.self, forKey: .outputTokens) + date = try c.decode(String.self, forKey: .date) + models = try c.decodeIfPresent([SessionModelEntry].self, forKey: .models) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case cost, savingsUSD, calls, inputTokens, outputTokens, date, models + } +} + +struct ProjectEntry: Codable, Sendable { + let name: String + let cost: Double + let savingsUSD: Double + let sessions: Int + let avgCostPerSession: Double + let sessionDetails: [SessionDetailEntry] + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + cost = try c.decode(Double.self, forKey: .cost) + savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 + sessions = try c.decode(Int.self, forKey: .sessions) + avgCostPerSession = try c.decode(Double.self, forKey: .avgCostPerSession) + sessionDetails = try c.decodeIfPresent([SessionDetailEntry].self, forKey: .sessionDetails) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case name, cost, savingsUSD, sessions, avgCostPerSession, sessionDetails + } +} + +struct ModelEfficiencyEntry: Codable, Sendable { + let name: String + let costPerEdit: Double? + let oneShotRate: Double? +} + +struct TopSessionEntry: Codable, Sendable { + let project: String + let cost: Double + let savingsUSD: Double + let calls: Int + let date: String + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + project = try c.decode(String.self, forKey: .project) + cost = try c.decode(Double.self, forKey: .cost) + savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 + calls = try c.decode(Int.self, forKey: .calls) + date = try c.decode(String.self, forKey: .date) + } + + private enum CodingKeys: String, CodingKey { + case project, cost, savingsUSD, calls, date + } +} + +struct ToolEntry: Codable, Sendable { + let name: String + let calls: Int +} + +struct SkillEntry: Codable, Sendable { + let name: String + let turns: Int + let cost: Double +} + +struct SubagentEntry: Codable, Sendable { + let name: String + let calls: Int + let cost: Double +} + +struct McpServerEntry: Codable, Sendable { + let name: String + let calls: Int +} + +struct OptimizeBlock: Codable, Sendable { + let findingCount: Int + let savingsUSD: Double + let topFindings: [FindingEntry] +} + +struct FindingEntry: Codable, Sendable { + let title: String + let impact: String + let savingsUSD: Double +} + +// MARK: - Empty fallback + +extension MenubarPayload { + /// Strictly-empty payload. Used as the fallback before real data arrives, so no + /// plausible-looking fake numbers leak into the UI. + static let empty = MenubarPayload( + generated: "", + current: CurrentBlock( + label: "", + cost: 0, + calls: 0, + sessions: 0, + oneShotRate: nil, + inputTokens: 0, + outputTokens: 0, + cacheHitPercent: 0, + codexCredits: nil, + topActivities: [], + topModels: [], + localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []), + providers: [:], + topProjects: [], + modelEfficiency: [], + topSessions: [], + retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []), + routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []), + tools: [], + skills: [], + subagents: [], + mcpServers: [] + ), + optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), + history: HistoryBlock(daily: []), + combined: nil, + claudeConfigs: nil + ) +} diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift new file mode 100644 index 0000000..ef5f217 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift @@ -0,0 +1,43 @@ +import Foundation + +/// On-disk badge backstop. The app writes `menubar-status.json` on each successful +/// refresh and reads it back as a badge fallback after a restart, before the live +/// loop has produced a payload. Shares the `MenubarPayload` model with the live +/// path — no separate data model. +struct MenubarStatusCache { + let statusPath: String + + /// Default location under `~/.cache/codeburn/`. + static func standard() -> MenubarStatusCache { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return MenubarStatusCache(statusPath: "\(home)/.cache/codeburn/menubar-status.json") + } + + struct BadgeRead { + let payload: MenubarPayload + let ageSeconds: TimeInterval + } + + /// Decodes the status file and returns it with its age (from the file's + /// mtime). Returns nil when the file is missing, corrupt, unreadable, or + /// older than `maxAgeSeconds` — every failure mode silently falls back to + /// the in-memory payload, never crashes. + func readBadgePayload(maxAgeSeconds: TimeInterval) -> BadgeRead? { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: statusPath), + let mtime = attrs[.modificationDate] as? Date else { + return nil + } + let age = Date().timeIntervalSince(mtime) + guard age >= 0, age <= maxAgeSeconds else { return nil } + guard let data = try? SafeFile.read(from: statusPath), + let payload = try? JSONDecoder().decode(MenubarPayload.self, from: data) else { + return nil + } + return BadgeRead(payload: payload, ageSeconds: age) + } + + func writeStatus(_ payload: MenubarPayload) throws { + let data = try JSONEncoder().encode(payload) + try SafeFile.write(data, to: statusPath, mode: 0o600) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift new file mode 100644 index 0000000..c76f6ba --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Per-provider live-quota snapshot consumed by the AgentTab progress bar +/// and the hover-detail popover. Today only Claude has a real quota source +/// (Anthropic /api/oauth/usage); future providers (Cursor, Copilot, etc.) +/// will plug in by producing the same struct from their own auth path. +struct QuotaSummary: Equatable { + enum Connection: Equatable { + case connected + case disconnected // no credentials present + case loading + case stale // had data once, current fetch is in flight + case transientFailure // backing off; show last-known data dimmed + case terminalFailure(reason: String?) // user must reconnect + } + + let providerFilter: ProviderFilter + let connection: Connection + let primary: Window? // weekly utilization, the headline bar + let details: [Window] // 5h, weekly, opus, sonnet — full hover card + /// Display label for the user's plan (e.g. "Max 20x", "Pro Lite"). Shown + /// in the top-right corner of the hover detail popover so users can + /// confirm at a glance which subscription is feeding the bar. + let planLabel: String? + /// Optional footer rows that the popover renders below the window list. + /// Used today only by Codex to surface the on-account credits balance, + /// but kept generic so future providers can add provider-specific facts + /// (e.g. "Anthropic incident in progress", "Cursor team seat"). + let footerLines: [String] + + struct Window: Equatable { + let label: String + let percent: Double // 0..1 + let resetsAt: Date? + } + + /// Color band thresholds for the inline chip bar and aggregate menubar + /// flame tint. Four tiers so the icon can step from "you're approaching + /// your limit" (yellow) through "you're about to hit the wall" (orange) + /// to "you're over" (red) — matches what the user expects from a warning + /// indicator in the menu bar. + static func severity(for percent: Double) -> Severity { + if percent >= 1.0 { return .danger } + if percent >= 0.9 { return .critical } + if percent >= 0.7 { return .warning } + return .normal + } + + enum Severity { + case normal // <70% + case warning // 70-90% + case critical // 90-100% + case danger // >=100% + } +} + +extension QuotaSummary.Window { + /// Human-readable countdown like "2h 11m" or "3d 14h" or "now". + var resetsInLabel: String { + guard let resetsAt else { return "" } + let seconds = max(0, resetsAt.timeIntervalSinceNow) + if seconds < 60 { return "now" } + let minutes = Int(seconds / 60) + let hours = minutes / 60 + let days = hours / 24 + if days > 0 { return "\(days)d \(hours % 24)h" } + if hours > 0 { return "\(hours)h \(minutes % 60)m" } + return "\(minutes)m" + } + + var percentLabel: String { + let pct = Int((percent * 100).rounded()) + return "\(pct)%" + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift new file mode 100644 index 0000000..3701d25 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionRefreshCadence.swift @@ -0,0 +1,42 @@ +import Foundation + +/// User-configurable cadence for /api/oauth/usage polling. Mirrors CodexBar's +/// "manual / 1m / 2m / 5m / 15m" preset set so users on tight rate-limit +/// budgets can dial it down and power users can dial it up. Stored as the raw +/// number of seconds in UserDefaults; `manual = 0` means "never auto-refresh". +enum SubscriptionRefreshCadence: Int, CaseIterable, Identifiable { + case manual = 0 + case oneMinute = 60 + case twoMinutes = 120 + case fiveMinutes = 300 + case fifteenMinutes = 900 + + var id: Int { rawValue } + + var label: String { + switch self { + case .manual: return "Manual" + case .oneMinute: return "1 minute" + case .twoMinutes: return "2 minutes" + case .fiveMinutes: return "5 minutes" + case .fifteenMinutes: return "15 minutes" + } + } + + static let defaultsKey = "codeburn.claude.refreshCadenceSeconds" + static let `default`: SubscriptionRefreshCadence = .twoMinutes + + static var current: SubscriptionRefreshCadence { + get { + // UserDefaults.integer returns 0 when the key is missing — that + // happens to alias `manual`, which is wrong for a fresh install. + // Probe with object(forKey:) so we can distinguish "never set" + // from "set to manual" and seed the default on first run. + if UserDefaults.standard.object(forKey: defaultsKey) == nil { + return .default + } + return SubscriptionRefreshCadence(rawValue: UserDefaults.standard.integer(forKey: defaultsKey)) ?? .default + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: defaultsKey) } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift new file mode 100644 index 0000000..9357ee9 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Persisted snapshot of a single utilization reading. We capture one per window every time +/// SubscriptionClient.fetch() succeeds so we can answer "what did the prior 7-day cycle finish at?" +/// when the current window has no usable data yet (just reset). +struct SubscriptionSnapshot: Codable, Sendable { + let windowKey: String // "five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet" + let percent: Double // 0..100 + let resetsAt: Date // resets_at active at capture (identifies which window cycle this belongs to) + let capturedAt: Date // when the snapshot was recorded + let effectiveTokens: Double? // tokens consumed in window at capture (nil if not computed) +} + +private let snapshotFilename = "subscription-snapshots.json" +private let pruneOlderThanSeconds: TimeInterval = 30 * 24 * 3600 + +private func snapshotsCacheDir() -> String { + return ProcessInfo.processInfo.environment["CODEBURN_CACHE_DIR"] + ?? (NSHomeDirectory() as NSString).appendingPathComponent(".cache/codeburn") +} + +private func snapshotsPath() -> String { + return (snapshotsCacheDir() as NSString).appendingPathComponent(snapshotFilename) +} + +private actor SnapshotLock { + static let shared = SnapshotLock() + func run(_ fn: () throws -> T) rethrows -> T { try fn() } +} + +enum SubscriptionSnapshotStore { + /// Append a snapshot. Auto-prunes entries older than 30 days. Idempotent: if a snapshot + /// with the same windowKey + resetsAt already exists, only update percent if new is higher + /// (so "final" reading near reset is preserved). + static func record(_ snapshot: SubscriptionSnapshot) async { + await SnapshotLock.shared.run { + do { + var all = loadAll() + let key = "\(snapshot.windowKey)|\(snapshot.resetsAt.timeIntervalSince1970)" + if let idx = all.firstIndex(where: { "\($0.windowKey)|\($0.resetsAt.timeIntervalSince1970)" == key }) { + if snapshot.percent > all[idx].percent { + all[idx] = snapshot + } + } else { + all.append(snapshot) + } + let cutoff = Date().addingTimeInterval(-pruneOlderThanSeconds) + all = all.filter { $0.capturedAt >= cutoff } + try save(all) + } catch { + NSLog("CodeBurn: snapshot record failed: \(error)") + } + } + } + + /// Returns the final percent of the immediately-prior cycle for this window, or nil if no + /// prior data is available. Logic: among snapshots whose resetsAt < currentResetsAt, pick + /// the group with the largest resetsAt (most recent prior cycle), then return the max + /// percent in that group (the closest-to-final reading we have). + static func previousWindowFinal(windowKey: String, currentResetsAt: Date) async -> Double? { + await SnapshotLock.shared.run { + let all = loadAll() + let priors = all.filter { $0.windowKey == windowKey && $0.resetsAt < currentResetsAt } + guard let mostRecentPriorReset = priors.map({ $0.resetsAt }).max() else { return nil } + let priorWindow = priors.filter { $0.resetsAt == mostRecentPriorReset } + return priorWindow.map(\.percent).max() + } + } + + /// Return all snapshots for a given window key, useful for capacity estimation. + static func snapshots(for windowKey: String) async -> [SubscriptionSnapshot] { + await SnapshotLock.shared.run { + loadAll().filter { $0.windowKey == windowKey } + } + } + + /// Test seam: clear all snapshots. + static func resetForTesting() async { + await clearAll() + } + + /// Wipe all snapshots from disk. Called when the user disconnects so the + /// "Based on last cycle" projections do not contaminate a reconnect under + /// a different account or tier. + static func clearAll() async { + await SnapshotLock.shared.run { + try? FileManager.default.removeItem(atPath: snapshotsPath()) + } + } + + // MARK: - Internals + + private static func loadAll() -> [SubscriptionSnapshot] { + let path = snapshotsPath() + guard FileManager.default.fileExists(atPath: path) else { return [] } + guard let data = try? SafeFile.read(from: path) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return (try? decoder.decode([SubscriptionSnapshot].self, from: data)) ?? [] + } + + private static func save(_ snapshots: [SubscriptionSnapshot]) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshots) + // SafeFile.write refuses symlinked targets and does the tmp+rename atomic dance. + try SafeFile.write(data, to: snapshotsPath(), mode: 0o600) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift new file mode 100644 index 0000000..211e7f1 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift @@ -0,0 +1,56 @@ +import Foundation + +struct SubscriptionUsage: Sendable, Equatable { + enum Tier: String, Sendable, Equatable { + case pro + case max5x + case max20x + case team + case enterprise + case unknown + + var displayName: String { + switch self { + case .pro: "Pro" + case .max5x: "Max 5x" + case .max20x: "Max 20x" + case .team: "Team" + case .enterprise: "Enterprise" + case .unknown: "Subscription" + } + } + } + + /// 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? + let fiveHourResetsAt: Date? + let sevenDayPercent: Double? + let sevenDayResetsAt: Date? + let sevenDayOpusPercent: Double? + let sevenDayOpusResetsAt: Date? + let sevenDaySonnetPercent: Double? + let sevenDaySonnetResetsAt: Date? + let scopedWeekly: [ScopedWindow] + let fetchedAt: Date + + static func tier(from raw: String?) -> Tier { + guard let raw = raw?.lowercased() else { return .unknown } + if raw.contains("max_20x") || raw.contains("max20x") || raw.contains("max-20x") { return .max20x } + if raw.contains("max_5x") || raw.contains("max5x") || raw.contains("max-5x") { return .max5x } + if raw.contains("max") { return .max5x } + if raw.contains("pro") { return .pro } + if raw.contains("team") { return .team } + if raw.contains("enterprise") { return .enterprise } + return .unknown + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift new file mode 100644 index 0000000..0d87a34 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -0,0 +1,258 @@ +import Foundation +import Observation + +private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20" +private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60 +private let lastCheckKey = "UpdateChecker.lastCheckDate" +private let cachedVersionKey = "UpdateChecker.latestVersion" +private let cachedCliVersionKey = "UpdateChecker.latestCliVersion" +private let updateTimeoutSeconds: UInt64 = 120 +private let maxUpdateStderrBytes = 64 * 1024 +// The installer that scans `mac-v*` releases for the menubar zip (instead of +// `/releases/latest`, which can resolve to a CLI release that carries no menubar +// asset) landed in CLI 0.9.9 (commit 909efcf). Older CLIs cannot perform a correct +// `menubar --force`, so we refuse to run them and ask the user to upgrade the CLI first. +private let minCliVersionForUpdate = "0.9.9" + +private final class LockedDataBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ chunk: Data, limit: Int) { + lock.withLock { + guard data.count < limit else { return } + data.append(Data(chunk.prefix(limit - data.count))) + } + } + + func snapshot() -> Data { + lock.withLock { data } + } +} + +@MainActor +@Observable +final class UpdateChecker { + var latestVersion: String? + var latestCliVersion: String? + var installedCliVersion: String? + var isUpdating = false + var updateError: String? + + var updateAvailable: Bool { + guard let latest = latestVersion else { return false } + let current = currentVersion + let normalizedLatest = AppVersion.normalize(latest) + let normalizedCurrent = AppVersion.normalize(current) + guard !normalizedCurrent.isEmpty && normalizedCurrent != "dev" else { return false } + return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending + } + + var cliUpdateAvailable: Bool { + guard let latest = latestCliVersion, let installed = installedCliVersion else { return false } + let normalizedLatest = AppVersion.normalize(latest) + let normalizedInstalled = AppVersion.normalize(installed) + guard !normalizedInstalled.isEmpty else { return false } + return normalizedLatest.compare(normalizedInstalled, options: .numeric) == .orderedDescending + } + + /// True when the installed CLI predates the `menubar --force` fix and would fail to + /// install the new app. Distinct from `cliUpdateAvailable`: a CLI can be behind the + /// latest release yet still new enough (>= 0.9.9) to update the menubar correctly. + var cliTooOldForUpdate: Bool { + Self.isCliTooOld(installed: installedCliVersion) + } + + var cliUpdateCommand: String { + let argv = CodeburnCLI.baseArgv() + let path = argv.first ?? "" + if path.contains("/homebrew/") { return "brew upgrade codeburn" } + return "npm update -g codeburn" + } + + var currentVersion: String { + AppVersion.normalizedBundleShortVersion + } + + func checkIfNeeded() async { + installedCliVersion = Self.queryInstalledCliVersion() + let lastCheck = UserDefaults.standard.double(forKey: lastCheckKey) + let now = Date().timeIntervalSince1970 + if now - lastCheck < checkIntervalSeconds { + latestVersion = UserDefaults.standard.string(forKey: cachedVersionKey) + latestCliVersion = UserDefaults.standard.string(forKey: cachedCliVersionKey) + return + } + await check() + } + + func check() async { + updateError = nil + installedCliVersion = Self.queryInstalledCliVersion() + guard let url = URL(string: releasesAPI) else { return } + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent") + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw UpdateCheckError.http(status) + } + let releases = try JSONDecoder().decode([GitHubRelease].self, from: data) + guard let resolved = Self.resolveLatestMenubarRelease(in: releases) else { + throw UpdateCheckError.missingMenubarAsset + } + + let version = resolved.asset.name + .replacingOccurrences(of: "CodeBurnMenubar-", with: "") + .replacingOccurrences(of: ".zip", with: "") + + let cliVersion = Self.resolveLatestCliVersion(in: releases) + + latestVersion = version + latestCliVersion = cliVersion + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey) + UserDefaults.standard.set(version, forKey: cachedVersionKey) + if let cliVersion { UserDefaults.standard.set(cliVersion, forKey: cachedCliVersionKey) } + } catch { + updateError = "Update check failed: \(error.localizedDescription)" + NSLog("CodeBurn: update check failed: \(error)") + } + } + + nonisolated static func resolveLatestCliVersion(in releases: [GitHubRelease]) -> String? { + for release in releases where release.tag_name.hasPrefix("v") && !release.tag_name.hasPrefix("mac-v") { + return AppVersion.normalize(release.tag_name) + } + return nil + } + + nonisolated static func queryInstalledCliVersion() -> String? { + let process = CodeburnCLI.makeProcess(subcommand: ["--version"]) + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return output.isEmpty ? nil : output + } catch { + return nil + } + } + + nonisolated static func resolveLatestMenubarRelease(in releases: [GitHubRelease]) -> (release: GitHubRelease, asset: GitHubAsset)? { + for release in releases where release.tag_name.hasPrefix("mac-v") { + guard let asset = release.assets.first(where: { + $0.name.hasPrefix("CodeBurnMenubar-v") && $0.name.hasSuffix(".zip") + }) else { continue } + guard release.assets.contains(where: { $0.name == "\(asset.name).sha256" }) else { continue } + return (release, asset) + } + return nil + } + + nonisolated static func isCliTooOld(installed: String?) -> Bool { + guard let installed else { return false } + let normalizedInstalled = AppVersion.normalize(installed) + guard !normalizedInstalled.isEmpty else { return false } + return AppVersion.normalize(minCliVersionForUpdate).compare(normalizedInstalled, options: .numeric) == .orderedDescending + } + + func performUpdate() { + installedCliVersion = Self.queryInstalledCliVersion() + if cliTooOldForUpdate { + updateError = "Your codeburn CLI (\(AppVersion.display(installedCliVersion ?? ""))) is too old to update the menubar. Run “\(cliUpdateCommand)” first, then try again." + return + } + isUpdating = true + updateError = nil + + let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"]) + let errPipe = Pipe() + let errBuffer = LockedDataBuffer() + process.standardOutput = FileHandle.nullDevice + process.standardError = errPipe + errPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + errBuffer.append(chunk, limit: maxUpdateStderrBytes) + } + + let timeoutTask = Task.detached(priority: .utility) { + try? await Task.sleep(nanoseconds: updateTimeoutSeconds * 1_000_000_000) + if process.isRunning { + NSLog("CodeBurn: update subprocess timed out after %llus - terminating", updateTimeoutSeconds) + process.terminate() + } + } + + process.terminationHandler = { [weak self] proc in + timeoutTask.cancel() + errPipe.fileHandleForReading.readabilityHandler = nil + let stderrData = errBuffer.snapshot() + let stderr = Self.sanitizeForDisplay(String(data: stderrData, encoding: .utf8) ?? "") + Task { @MainActor in + guard let self else { return } + self.isUpdating = false + if proc.terminationStatus != 0 { + self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr + NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)") + } else { + self.latestVersion = nil + } + } + } + + do { + try process.run() + } catch { + isUpdating = false + updateError = error.localizedDescription + NSLog("CodeBurn: update spawn failed: \(error)") + } + } + + nonisolated private static func sanitizeForDisplay(_ value: String) -> String { + var cleaned = value.replacingOccurrences(of: "\u{0000}", with: "") + let patterns: [(String, String)] = [ + (#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"), + (#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"), + (#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"), + (#"(?i)Bearer\s+\S+"#, "Bearer ***"), + ] + for (pattern, replacement) in patterns { + cleaned = cleaned.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) + } + if cleaned.count > 1_000 { cleaned = String(cleaned.prefix(1_000)) + "..." } + return cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +enum UpdateCheckError: LocalizedError { + case http(Int) + case missingMenubarAsset + + var errorDescription: String? { + switch self { + case let .http(status): "GitHub returned HTTP \(status)." + case .missingMenubarAsset: "No mac-v release with a menubar zip and checksum was found." + } + } +} + +struct GitHubRelease: Decodable { + let tag_name: String + let assets: [GitHubAsset] +} + +struct GitHubAsset: Decodable { + let name: String + let browser_download_url: String +} 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/MenubarScope.swift b/mac/Sources/CodeBurnMenubar/MenubarScope.swift new file mode 100644 index 0000000..0ffe6bb --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/MenubarScope.swift @@ -0,0 +1,39 @@ +import Foundation + +private let menubarScopeDefaultsKey = "CodeBurnMenubarScope" + +enum MenubarScope: String, CaseIterable, Identifiable, Sendable { + case local = "Local" + case combined = "Combined" + + var id: String { rawValue } + + var cliArg: String { + switch self { + case .local: "local" + case .combined: "combined" + } + } + + var menubarDefaultsValue: String { + switch self { + case .local: "local" + case .combined: "combined" + } + } + + init(menubarDefaultsValue: String?) { + switch menubarDefaultsValue { + case "combined": self = .combined + default: self = .local + } + } + + static func savedMenubarScope(defaults: UserDefaults = .standard) -> MenubarScope { + MenubarScope(menubarDefaultsValue: defaults.string(forKey: menubarScopeDefaultsKey)) + } + + func persistAsMenubarDefault(defaults: UserDefaults = .standard) { + defaults.set(menubarDefaultsValue, forKey: menubarScopeDefaultsKey) + } +} diff --git a/mac/Sources/CodeBurnMenubar/RefreshCadence.swift b/mac/Sources/CodeBurnMenubar/RefreshCadence.swift new file mode 100644 index 0000000..0cf532c --- /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 via refreshPayloadForPopoverOpen, 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 new file mode 100644 index 0000000..82d6e26 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Single entry point for spawning the `codeburn` CLI. All callers route through here so the +/// binary argv is validated once and no code path ever passes user-influenced strings through +/// a shell (`/bin/zsh -c`, `open --args`, AppleScript). This closes the shell-injection attack +/// surface end-to-end. +enum CodeburnCLI { + /// Matches a plain file path / program name: alphanumerics, dot, underscore, slash, hyphen, + /// space. Deliberately excludes shell metacharacters (`$`, `;`, `&`, `|`, quotes, backticks, + /// newlines) so a malicious `CODEBURN_BIN="codeburn; rm -rf ~"` can't slip through. + private static let safeArgPattern = try! NSRegularExpression(pattern: "^[A-Za-z0-9 ._/\\-]+$") + + /// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses + /// Homebrew and npm global installs. + private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"] + + private static let userNodePaths: [String] = { + let home = FileManager.default.homeDirectoryForCurrentUser.path + var paths: [String] = [] + for dir in ["\(home)/.volta/bin", "\(home)/.npm-global/bin", "\(home)/.asdf/shims"] { + paths.append(dir) + } + let nvmDir = ProcessInfo.processInfo.environment["NVM_DIR"] ?? "\(home)/.nvm" + let versionsDir = "\(nvmDir)/versions/node" + if let entries = try? FileManager.default.contentsOfDirectory(atPath: versionsDir) { + for entry in entries.sorted().reversed() { + let bin = "\(versionsDir)/\(entry)/bin" + if FileManager.default.isExecutableFile(atPath: "\(bin)/codeburn") { + paths.append(bin) + break + } + } + } + return paths + }() + private static let persistedPathFilename = "codeburn-cli-path.v1" + + /// Returns the argv that launches the CLI. Dev override via `CODEBURN_BIN` is honoured only + /// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the + /// plain `codeburn` name (resolved via PATH). + static func baseArgv() -> [String] { + if ProcessInfo.processInfo.environment["CODEBURN_ALLOW_DEV_BIN"] == "1", + let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], + !raw.isEmpty + { + let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + guard parts.allSatisfy(isSafe) else { + NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using installed codeburn") + return installedArgv() + } + return parts + } + + return installedArgv() + } + + private static func installedArgv() -> [String] { + if let persisted = persistedCLIPath(), isSafe(persisted), FileManager.default.isExecutableFile(atPath: persisted) { + return [persisted] + } + for candidate in (additionalPathEntries + userNodePaths).map({ "\($0)/codeburn" }) { + if isSafe(candidate), FileManager.default.isExecutableFile(atPath: candidate) { + return [candidate] + } + } + return ["codeburn"] + } + + private static func persistedCLIPath() -> String? { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + let url = support + .appendingPathComponent("CodeBurn", isDirectory: true) + .appendingPathComponent(persistedPathFilename) + guard let value = try? String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + value.hasPrefix("/") + else { return nil } + return value + } + + /// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env` + /// so PATH lookup happens without involving a shell, and augments PATH with Homebrew + /// defaults. Caller sets stdout/stderr pipes and calls `run()`. + static func makeProcess(subcommand: [String]) -> Process { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = augmentedPath(environment["PATH"] ?? "") + process.environment = environment + // `env --` treats everything following as argv, not VAR=val pairs -- guards against an + // argument accidentally resembling an env assignment. + process.arguments = ["--"] + baseArgv() + subcommand + // 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 30s refresh cadence on large corpora. + process.qualityOfService = .userInitiated + return process + } + + static func isSafe(_ s: String) -> Bool { + let range = NSRange(s.startIndex.. String { + var parts = existing.split(separator: ":", omittingEmptySubsequences: true).map(String.init) + for extra in additionalPathEntries + userNodePaths where !parts.contains(extra) { + parts.append(extra) + } + return parts.joined(separator: ":") + } +} diff --git a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift new file mode 100644 index 0000000..3d6bda5 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift @@ -0,0 +1,128 @@ +import Foundation + +/// Symlink-safe file I/O with atomic writes and optional cross-process flock. +/// +/// Every cache file we touch (`~/Library/Caches/codeburn-mac/fx-rates.json`, +/// `~/.cache/codeburn/subscription-snapshots.json`, `~/.config/codeburn/config.json`) is a +/// legitimate target for a local-symlink attack: if an attacker plants a symlink from one of +/// those paths to, say, `~/.ssh/config`, a naive `Data.write(to:)` blindly follows the link and +/// clobbers the real file. `O_NOFOLLOW` on the write() refuses the operation instead. +enum SafeFile { + enum Error: Swift.Error { + case symlinkDetected(String) + case openFailed(String, Int32) + case writeFailed(String, Int32) + case renameFailed(String, Int32) + case readFailed(String, Int32) + case sizeLimitExceeded(String, Int) + } + + /// Default max bytes when reading untrusted cache files. Prevents a malicious cache file + /// from exhausting memory in the Swift process. + static let defaultReadLimit = 8 * 1024 * 1024 + + /// Refuses to follow symlinks and writes atomically via a tmp file + rename. `mode` is the + /// final file permission (0o600 by default so cache files stay user-private). + static func write(_ data: Data, to path: String, mode: mode_t = 0o600) throws { + let parent = (path as NSString).deletingLastPathComponent + try FileManager.default.createDirectory( + atPath: parent, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: 0o700)] + ) + + // Reject if the existing file is a symlink. We use lstat so the link itself is + // inspected, not its target. + var linkInfo = stat() + if lstat(path, &linkInfo) == 0, (linkInfo.st_mode & S_IFMT) == S_IFLNK { + throw Error.symlinkDetected(path) + } + + let tmpPath = parent + "/.codeburn-" + UUID().uuidString + ".tmp" + let flags: Int32 = O_CREAT | O_WRONLY | O_EXCL | O_NOFOLLOW + let fd = Darwin.open(tmpPath, flags, mode) + guard fd >= 0 else { + throw Error.openFailed(tmpPath, errno) + } + + let writeResult: Int = data.withUnsafeBytes { buffer -> Int in + guard let base = buffer.baseAddress else { return 0 } + return Darwin.write(fd, base, buffer.count) + } + let writeErrno = errno + fsync(fd) + Darwin.close(fd) + + guard writeResult == data.count else { + unlink(tmpPath) + throw Error.writeFailed(tmpPath, writeErrno) + } + + if rename(tmpPath, path) != 0 { + let renameErrno = errno + unlink(tmpPath) + throw Error.renameFailed(path, renameErrno) + } + } + + /// Refuses to read through a symlink. `maxBytes` bounds the read so a tampered cache file + /// can't balloon the process. + static func read(from path: String, maxBytes: Int = defaultReadLimit) throws -> Data { + var linkInfo = stat() + guard lstat(path, &linkInfo) == 0 else { + throw Error.readFailed(path, errno) + } + if (linkInfo.st_mode & S_IFMT) == S_IFLNK { + throw Error.symlinkDetected(path) + } + + let fd = Darwin.open(path, O_RDONLY | O_NOFOLLOW) + guard fd >= 0 else { + throw Error.readFailed(path, errno) + } + defer { Darwin.close(fd) } + + let size = Int(linkInfo.st_size) + if size > maxBytes { + throw Error.sizeLimitExceeded(path, size) + } + + var data = Data(count: size) + let readBytes: Int = data.withUnsafeMutableBytes { buffer -> Int in + guard let base = buffer.baseAddress else { return 0 } + return Darwin.read(fd, base, buffer.count) + } + guard readBytes >= 0 else { + throw Error.readFailed(path, errno) + } + if readBytes < size { + data = data.prefix(readBytes) + } + return data + } + + /// Runs `body` while holding an exclusive POSIX advisory lock on `path`. The lock file is + /// created if missing (with 0o600 permissions) and released on scope exit, so other + /// codeburn processes (the CLI running in a terminal, say) block on the same file instead + /// of racing on a shared config. + static func withExclusiveLock(at path: String, body: () throws -> T) throws -> T { + let parent = (path as NSString).deletingLastPathComponent + try FileManager.default.createDirectory( + atPath: parent, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: 0o700)] + ) + let fd = Darwin.open(path, O_CREAT | O_RDWR | O_NOFOLLOW, 0o600) + guard fd >= 0 else { + throw Error.openFailed(path, errno) + } + defer { Darwin.close(fd) } + + guard flock(fd, LOCK_EX) == 0 else { + throw Error.openFailed(path, errno) + } + defer { _ = flock(fd, LOCK_UN) } + + return try body() + } +} diff --git a/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift b/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift new file mode 100644 index 0000000..9d0b3e7 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift @@ -0,0 +1,65 @@ +import AppKit +import Foundation + +/// Runs commands in the user's Terminal. Every string that reaches AppleScript `do script` +/// must be whitespace-joined argv where each token passes `CodeburnCLI.isSafe` (regex allowlist +/// that excludes shell metacharacters), OR a hardcoded literal defined here. The private +/// `runInTerminal` re-validates any non-literal input defensively so a future caller can't +/// bypass the invariant. +/// Falls back to a detached headless spawn on machines without Terminal.app (iTerm/Ghostty/Warp +/// users) so the subcommand still runs. +enum TerminalLauncher { + private static let terminalPaths = [ + "/System/Applications/Utilities/Terminal.app", + "/Applications/Utilities/Terminal.app", + ] + + static func open(subcommand: [String]) { + let argv = CodeburnCLI.baseArgv() + subcommand + guard argv.allSatisfy(CodeburnCLI.isSafe) else { + NSLog("CodeBurn: refusing to open terminal with unsafe argv") + return + } + let command = argv.joined(separator: " ") + + if terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) { + runInTerminal(command: command, preValidated: true) + return + } + + let headless = CodeburnCLI.makeProcess(subcommand: subcommand) + try? headless.run() + } + + /// Launches `claude login` in Terminal.app so the user can complete the OAuth flow + /// without leaving CodeBurn. The command is a hardcoded literal -- no user input is + /// interpolated, so there's no injection surface. + static func openClaudeLogin() -> Bool { + guard terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) else { + NSLog("CodeBurn: Terminal.app not present; user must run `claude login` manually") + return false + } + runInTerminal(command: "claude login", preValidated: true) + return true + } + + private static func runInTerminal(command: String, preValidated: Bool) { + if !preValidated { + let tokens = command.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + guard tokens.allSatisfy(CodeburnCLI.isSafe) else { + NSLog("CodeBurn: refusing to run unvalidated command in Terminal") + return + } + } + let script = """ + tell application "Terminal" + activate + do script "\(command)" + end tell + """ + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + try? process.run() + } +} diff --git a/mac/Sources/CodeBurnMenubar/Theme/Theme.swift b/mac/Sources/CodeBurnMenubar/Theme/Theme.swift new file mode 100644 index 0000000..07fe146 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Theme/Theme.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// Design tokens. Accent colors are driven by ThemeState so the user can switch palettes. +@MainActor +enum Theme { + static let brandEmber = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0) + + static var brandAccent: Color { ThemeState.shared.preset.base } + static var brandAccentLight: Color { ThemeState.shared.preset.light } + static var brandAccentDeep: Color { ThemeState.shared.preset.deep } + static var brandAccentGlow: Color { ThemeState.shared.preset.glow } + + static let warmSurface = Color(red: 0xFA/255.0, green: 0xF7/255.0, blue: 0xF3/255.0) + static let warmSurfaceDark = Color(red: 0x1C/255.0, green: 0x18/255.0, blue: 0x16/255.0) + + static let categoricalClaude = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0) + static let categoricalCursor = Color(red: 0x3F/255.0, green: 0x6B/255.0, blue: 0x8C/255.0) + static let categoricalCodex = Color(red: 0x4A/255.0, green: 0x7D/255.0, blue: 0x5C/255.0) + + static let oneShotGood = Color(red: 0x30/255.0, green: 0xD1/255.0, blue: 0x58/255.0) + static let oneShotMid = Color(red: 0xFF/255.0, green: 0x9F/255.0, blue: 0x0A/255.0) + static let oneShotLow = Color(red: 0xFF/255.0, green: 0x45/255.0, blue: 0x3A/255.0) + + // Semantic colors -- tuned to sit alongside the terracotta accent without clashing. + static let semanticDanger = Color(red: 0xC8/255.0, green: 0x3F/255.0, blue: 0x2C/255.0) // brick-red, terracotta-leaning + static let semanticWarning = Color(red: 0xD9/255.0, green: 0x8F/255.0, blue: 0x29/255.0) // amber, warmer than vanilla + static let semanticSuccess = Color(red: 0x4E/255.0, green: 0xA8/255.0, blue: 0x65/255.0) // muted green that holds against terracotta +} + +extension Font { + /// SF Mono for currency values -- developer-tool identity. + static func codeMono(size: CGFloat, weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift b/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift new file mode 100644 index 0000000..9e0444e --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift @@ -0,0 +1,88 @@ +import SwiftUI +import Observation + +enum AccentPreset: String, CaseIterable, Identifiable { + case ember = "Ember" + case blue = "Blue" + case purple = "Purple" + case pink = "Pink" + case red = "Red" + case orange = "Orange" + case yellow = "Yellow" + case green = "Green" + case graphite = "Graphite" + + var id: String { rawValue } + + /// Apple macOS dark-mode system accent colors (NSColor.system*). + var base: Color { + switch self { + case .ember: Color(red: 0xC9/255, green: 0x52/255, blue: 0x1D/255) + case .blue: Color(red: 0x0A/255, green: 0x84/255, blue: 0xFF/255) + case .purple: Color(red: 0xBF/255, green: 0x5A/255, blue: 0xF2/255) + case .pink: Color(red: 0xFF/255, green: 0x37/255, blue: 0x5F/255) + case .red: Color(red: 0xFF/255, green: 0x45/255, blue: 0x3A/255) + case .orange: Color(red: 0xFF/255, green: 0x9F/255, blue: 0x0A/255) + case .yellow: Color(red: 0xFF/255, green: 0xD6/255, blue: 0x0A/255) + case .green: Color(red: 0x30/255, green: 0xD1/255, blue: 0x58/255) + case .graphite: Color(red: 0x98/255, green: 0x98/255, blue: 0x9D/255) + } + } + + var light: Color { + switch self { + case .ember: Color(red: 0xE8/255, green: 0x77/255, blue: 0x4A/255) + case .blue: Color(red: 0x40/255, green: 0x9C/255, blue: 0xFF/255) + case .purple: Color(red: 0xDA/255, green: 0x8F/255, blue: 0xF7/255) + case .pink: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x8C/255) + case .red: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x63/255) + case .orange: Color(red: 0xFF/255, green: 0xBD/255, blue: 0x4A/255) + case .yellow: Color(red: 0xFF/255, green: 0xE0/255, blue: 0x4A/255) + case .green: Color(red: 0x5A/255, green: 0xE0/255, blue: 0x78/255) + case .graphite: Color(red: 0xAE/255, green: 0xAE/255, blue: 0xB2/255) + } + } + + var deep: Color { + switch self { + case .ember: Color(red: 0x8B/255, green: 0x3E/255, blue: 0x13/255) + case .blue: Color(red: 0x06/255, green: 0x52/255, blue: 0xB3/255) + case .purple: Color(red: 0x7C/255, green: 0x38/255, blue: 0xA8/255) + case .pink: Color(red: 0xB3/255, green: 0x26/255, blue: 0x42/255) + case .red: Color(red: 0xB3/255, green: 0x30/255, blue: 0x28/255) + case .orange: Color(red: 0xB3/255, green: 0x6F/255, blue: 0x06/255) + case .yellow: Color(red: 0xB3/255, green: 0x96/255, blue: 0x06/255) + case .green: Color(red: 0x20/255, green: 0x92/255, blue: 0x3D/255) + case .graphite: Color(red: 0x5E/255, green: 0x5E/255, blue: 0x62/255) + } + } + + var glow: Color { + switch self { + case .ember: Color(red: 0xF0/255, green: 0xA0/255, blue: 0x70/255) + case .blue: Color(red: 0x80/255, green: 0xC0/255, blue: 0xFF/255) + case .purple: Color(red: 0xE0/255, green: 0xB8/255, blue: 0xFA/255) + case .pink: Color(red: 0xFF/255, green: 0x99/255, blue: 0xB0/255) + case .red: Color(red: 0xFF/255, green: 0x99/255, blue: 0x90/255) + case .orange: Color(red: 0xFF/255, green: 0xD0/255, blue: 0x80/255) + case .yellow: Color(red: 0xFF/255, green: 0xEA/255, blue: 0x80/255) + case .green: Color(red: 0x80/255, green: 0xF0/255, blue: 0x98/255) + case .graphite: Color(red: 0xC8/255, green: 0xC8/255, blue: 0xCC/255) + } + } +} + +@MainActor +@Observable +final class ThemeState { + static let shared = ThemeState() + + var preset: AccentPreset { + didSet { UserDefaults.standard.set(preset.rawValue, forKey: "CodeBurnAccentPreset") } + } + + private init() { + let saved = UserDefaults.standard.string(forKey: "CodeBurnAccentPreset") ?? "" + self.preset = AccentPreset(rawValue: saved) ?? .ember + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift new file mode 100644 index 0000000..3189717 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct ActivitySection: View { + @Environment(AppStore.self) private var store + @State private var isExpanded: Bool = true + + var body: some View { + CollapsibleSection( + caption: "Activity", + isExpanded: $isExpanded, + trailing: { + HStack(spacing: 8) { + Text("Cost").frame(minWidth: 54, alignment: .trailing) + Text("Turns").frame(minWidth: 52, alignment: .trailing) + Text("1-shot").frame(minWidth: 44, alignment: .trailing) + } + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + .tracking(-0.05) + } + ) { + VStack(alignment: .leading, spacing: 7) { + let maxCost = max(store.payload.current.topActivities.map(\.cost).max() ?? 1, 0.01) + ForEach(store.payload.current.topActivities, id: \.name) { activity in + ActivityRow(activity: activity, maxCost: maxCost) + } + } + } + } +} + +struct ActivityRow: View { + let activity: ActivityEntry + let maxCost: Double + + var body: some View { + HStack(spacing: 8) { + FixedBar(fraction: activity.cost / maxCost) + .frame(width: 56, height: 6) + + Text(activity.name) + .font(.system(size: 12.5, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(activity.cost.asCompactCurrency()) + .font(.codeMono(size: 12, weight: .medium)) + .tracking(-0.2) + .frame(minWidth: 54, alignment: .trailing) + + Text("\(activity.turns)") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 52, alignment: .trailing) + + Text(oneShotText) + .font(.system(size: 10.5)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 44, alignment: .trailing) + } + .padding(.horizontal, 2) + .padding(.vertical, 1) + } + + private var oneShotText: String { + guard let rate = activity.oneShotRate else { return "—" } + return "\(Int(rate * 100))%" + } +} + +/// Fixed-width horizontal bar that shows a fill fraction. +struct FixedBar: View { + let fraction: Double + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(.secondary.opacity(0.15)) + RoundedRectangle(cornerRadius: 2) + .fill(Theme.brandAccent) + .frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(fraction)))) + } + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift new file mode 100644 index 0000000..e7bd948 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -0,0 +1,511 @@ +import AppKit +import SwiftUI + +/// Shared state read by the NSEvent local monitor closure. The closure +/// snapshots its captured environment at install time, so SwiftUI @State +/// can't be used directly — a reference-type holder keeps the latest hover +/// status visible to the monitor across SwiftUI updates. +@MainActor +final class AgentTabStripScrollState { + static let shared = AgentTabStripScrollState() + var isStripHovered: Bool = false +} + +struct AgentTabStrip: View { + @Environment(AppStore.self) private var store + @State private var stripViewportWidth: CGFloat = 0 + @State private var stripContentWidth: CGFloat = 0 + @State private var scrollWheelMonitor: Any? + + var body: some View { + GeometryReader { viewportGeo in + ScrollViewReader { proxy in + HStack(spacing: 4) { + if isOverflowing { + Button { + selectAdjacentProvider(direction: -1, proxy: proxy) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .semibold)) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .foregroundStyle(canMoveBackward ? Color.primary : Color.secondary.opacity(0.35)) + .disabled(!canMoveBackward) + .help("Show previous providers") + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 5) { + ForEach(visibleFilters) { filter in + AgentTab( + filter: filter, + cost: cost(for: filter), + isActive: store.selectedProvider == filter, + quota: store.quotaSummary(for: filter) + ) { + store.switchTo(provider: filter) + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo(filter.id, anchor: .center) + } + } + .id(filter.id) + } + } + .background( + GeometryReader { contentGeo in + Color.clear + .onAppear { + stripContentWidth = contentGeo.size.width + } + .onChange(of: contentGeo.size.width) { _, newWidth in + stripContentWidth = newWidth + } + } + ) + } + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 4) + .onHover { hovering in + AgentTabStripScrollState.shared.isStripHovered = hovering + } + + if isOverflowing { + Button { + selectAdjacentProvider(direction: 1, proxy: proxy) + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .foregroundStyle(canMoveForward ? Color.primary : Color.secondary.opacity(0.35)) + .disabled(!canMoveForward) + .help("Show next providers") + } + } + .onAppear { + stripViewportWidth = viewportGeo.size.width + installScrollWheelMonitorIfNeeded() + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo(store.selectedProvider.id, anchor: .center) + } + } + .onChange(of: viewportGeo.size.width) { _, newWidth in + stripViewportWidth = newWidth + } + .onChange(of: store.selectedProvider) { _, newProvider in + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo(newProvider.id, anchor: .center) + } + } + .onDisappear { + removeScrollWheelMonitorIfNeeded() + } + } + } + .frame(height: 38) + } + + private var periodAll: MenubarPayload { + store.periodAllPayload ?? store.payload + } + + private var visibleFilters: [ProviderFilter] { + // Tabs reflect the SELECTED range: every provider with usage (cost > 0) + // in the period, ordered by usage. Providers with no usage in the range + // are omitted. `.all` always leads. cost(for:) reads periodAll, so this + // updates as the user switches periods. + let costs = Dictionary(uniqueKeysWithValues: ProviderFilter.allCases.map { ($0, cost(for: $0) ?? 0) }) + let detected = ProviderFilter.allCases.filter { filter in + filter == .all || (costs[filter] ?? 0) > 0 + } + return detected.sorted { a, b in + if a == .all { return true } + if b == .all { return false } + let ca = costs[a, default: 0] + let cb = costs[b, default: 0] + if ca != cb { return ca > cb } + return a.rawValue < b.rawValue + } + } + + private func cost(for filter: ProviderFilter) -> Double? { + let data = periodAll + if filter == .all { return data.current.cost } + let providers = Dictionary( + data.current.providers.map { ($0.key.lowercased(), $0.value) }, + uniquingKeysWith: + + ) + return filter.providerKeys.reduce(0.0) { sum, key in + sum + (providers[key] ?? 0) + } + } + + private var currentFilterIndex: Int { + visibleFilters.firstIndex(of: store.selectedProvider) ?? 0 + } + + private var canMoveBackward: Bool { currentFilterIndex > 0 } + private var canMoveForward: Bool { currentFilterIndex < visibleFilters.count - 1 } + private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) } + + private func selectAdjacentProvider(direction: Int, proxy: ScrollViewProxy) { + guard !visibleFilters.isEmpty else { return } + let targetIndex = min(max(currentFilterIndex + direction, 0), visibleFilters.count - 1) + let target = visibleFilters[targetIndex] + store.switchTo(provider: target) + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo(target.id, anchor: .center) + } + } + + /// Standard mouse wheels emit vertical-only scroll deltas, which a horizontal + /// `ScrollView` ignores. While the cursor is over the strip we transpose + /// vertical-axis scroll fields onto the horizontal axis so the underlying + /// NSScrollView receives a real horizontal delta. Trackpad events (precise + /// deltas, with native horizontal component) are passed through untouched + /// so vertical scrolling elsewhere in the popover is unaffected. + private func installScrollWheelMonitorIfNeeded() { + guard scrollWheelMonitor == nil else { return } + scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in + guard AgentTabStripScrollState.shared.isStripHovered, + !event.hasPreciseScrollingDeltas, + abs(event.scrollingDeltaX) < 0.001, + abs(event.scrollingDeltaY) > 0, + let cg = event.cgEvent?.copy() else { + return event + } + let lineDeltaY = cg.getIntegerValueField(.scrollWheelEventDeltaAxis1) + let pointDeltaY = cg.getDoubleValueField(.scrollWheelEventPointDeltaAxis1) + let fixedDeltaY = cg.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1) + cg.setIntegerValueField(.scrollWheelEventDeltaAxis1, value: 0) + cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: 0) + cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0) + cg.setIntegerValueField(.scrollWheelEventDeltaAxis2, value: lineDeltaY) + cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: pointDeltaY) + cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: fixedDeltaY) + return NSEvent(cgEvent: cg) ?? event + } + } + + private func removeScrollWheelMonitorIfNeeded() { + if let monitor = scrollWheelMonitor { + NSEvent.removeMonitor(monitor) + scrollWheelMonitor = nil + } + } +} + +private struct AgentTab: View { + let filter: ProviderFilter + let cost: Double? + let isActive: Bool + let quota: QuotaSummary? + let onTap: () -> Void + + @State private var hoverPopoverShown = false + @State private var hoverEnterTask: DispatchWorkItem? + @State private var hoverExitTask: DispatchWorkItem? + @State private var clickDismissed = false + + /// Providers whose AgentTab chip reserves a 3pt bar slot underneath the + /// label, even when not yet connected. Driven by which providers we + /// actually implement live-quota fetching for in AppStore.quotaSummary. + static func providerSupportsQuota(_ filter: ProviderFilter) -> Bool { + switch filter { + case .claude, .codex: return true + default: return false + } + } + + var body: some View { + VStack(spacing: 3) { + HStack(spacing: 5) { + Text(filter.rawValue) + .font(.system(size: 11.5, weight: .medium)) + .tracking(-0.05) + if let cost, cost > 0 { + Text(cost.asCompactCurrency()) + .font(.codeMono(size: 10.5, weight: .medium)) + .foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary)) + .tracking(-0.2) + } + } + if quota != nil { + AgentTabQuotaBar(quota: quota, isActive: isActive) + .frame(height: 3) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isActive ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.08))) + ) + .foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)) + .contentShape(Rectangle()) + .onTapGesture { + hoverPopoverShown = false + hoverEnterTask?.cancel() + clickDismissed = true + onTap() + } + .onHover { hovering in + hoverEnterTask?.cancel() + hoverExitTask?.cancel() + if !hovering { + clickDismissed = false + let task = DispatchWorkItem { hoverPopoverShown = false } + hoverExitTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task) + } else if !clickDismissed, quota != nil { + let task = DispatchWorkItem { hoverPopoverShown = true } + hoverEnterTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task) + } + } + .popover(isPresented: $hoverPopoverShown) { + if let quota { + QuotaDetailPopover(quota: quota) + } + } + } +} + +/// Thin progress bar drawn inside an AgentTab chip when that provider has a live quota +/// source. Width matches the chip; color shifts green → amber → red at 70% / 90%. +private struct AgentTabQuotaBar: View { + let quota: QuotaSummary? + let isActive: Bool + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(trackColor) + if let percent = filledFraction { + Capsule() + .fill(barColor) + .frame(width: max(2, geo.size.width * CGFloat(percent))) + .animation(.easeOut(duration: 0.25), value: percent) + } + if case .terminalFailure = quota?.connection { + // Hatched/red strip to telegraph "broken; reconnect needed". + Capsule() + .fill(Color.red.opacity(0.7)) + } + } + } + } + + private var filledFraction: Double? { + guard let pct = quota?.primary?.percent else { return nil } + return min(max(pct, 0), 1) + } + + private var barColor: Color { + guard let pct = quota?.primary?.percent else { return .clear } + switch QuotaSummary.severity(for: pct) { + case .normal: return isActive ? Color.white : Color.green.opacity(0.85) + case .warning: return Color.yellow + case .critical: return Color.orange + case .danger: return Color.red + } + } + + private var trackColor: Color { + isActive ? Color.white.opacity(0.20) : Color.secondary.opacity(0.18) + } +} + +private struct QuotaDetailPopover: View { + let quota: QuotaSummary + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + switch quota.connection { + case .terminalFailure(let reason): + terminalFailureCard(reason: reason) + case .disconnected: + Text(disconnectedMessage) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + case .loading where quota.details.isEmpty: + Text("Loading…") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + default: + rowsCard + } + } + .padding(12) + .frame(width: 260) + } + + private var disconnectedMessage: String { + switch quota.providerFilter { + case .codex: return "Sign in with `codex` (ChatGPT mode) to track quota." + case .claude: return "Sign in to Claude Code to track quota." + default: return "Sign in to track quota." + } + } + + private var rowsCard: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text("\(quota.providerFilter.rawValue) usage") + .font(.system(size: 11, weight: .semibold)) + if case .stale = quota.connection { + Text("stale") + .font(.system(size: 9.5)) + .foregroundStyle(.secondary) + } else if case .transientFailure = quota.connection { + Text("retrying") + .font(.system(size: 9.5)) + .foregroundStyle(.orange) + } + Spacer() + if let plan = quota.planLabel, !plan.isEmpty { + Text(plan) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.12)) + ) + // Size to content. Plan names are bounded short strings + // ("Max 20x", "Pro Lite", "Free Workspace"); a forced + // maxWidth was making short labels look stretched. + .fixedSize(horizontal: true, vertical: false) + } + } + ForEach(Array(quota.details.enumerated()), id: \.offset) { _, w in + QuotaDetailRow(window: w) + } + if !quota.footerLines.isEmpty { + Divider() + .padding(.top, 2) + ForEach(Array(quota.footerLines.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + } + } + + private func terminalFailureCard(reason: String?) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(reconnectTitle) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(.red) + Text(reason ?? defaultReconnectReason) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(2) + Text(reconnectInstruction) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + + private var reconnectTitle: String { + switch quota.providerFilter { + case .codex: return "Reconnect Codex" + default: return "Reconnect Claude" + } + } + + private var defaultReconnectReason: String { + switch quota.providerFilter { + case .codex: return "Refresh token rejected by OpenAI." + default: return "Refresh token rejected by Anthropic." + } + } + + private var reconnectInstruction: String { + switch quota.providerFilter { + case .codex: return "Run `codex login` in your terminal, then click Reconnect." + default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect." + } + } +} + +private struct QuotaDetailRow: View { + let window: QuotaSummary.Window + + var body: some View { + HStack(spacing: 8) { + Text(window.label) + .font(.system(size: 10.5)) + .frame(width: 92, alignment: .leading) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.18)) + Capsule() + .fill(barColor) + .frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1)))) + } + } + .frame(height: 4) + Text(window.percentLabel) + .font(.codeMono(size: 10.5, weight: .medium)) + .frame(width: 36, alignment: .trailing) + if !window.resetsInLabel.isEmpty { + Text(window.resetsInLabel) + .font(.codeMono(size: 10)) + .foregroundStyle(.secondary) + .frame(width: 50, alignment: .trailing) + } + } + } + + private var barColor: Color { + switch QuotaSummary.severity(for: window.percent) { + case .normal: return Color.green.opacity(0.85) + case .warning: return Color.yellow + case .critical: return Color.orange + case .danger: return Color.red + } + } +} + +extension ProviderFilter { + @MainActor var color: Color { + switch self { + case .all: return Theme.brandAccent + case .claude: return Theme.categoricalClaude + case .cline: return Color(red: 0x23/255.0, green: 0x8A/255.0, blue: 0x7E/255.0) + case .codex: return Theme.categoricalCodex + case .cursor: return Theme.categoricalCursor + case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0) + case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) + case .devin: return Color(red: 0x25/255.0, green: 0xA0/255.0, blue: 0x8D/255.0) + case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0) + case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0) + case .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0) + case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0) + case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0) + case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0) + case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0) + case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0) + case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0) + case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0) + case .qwen: return Color(red: 0x61/255.0, green: 0x5E/255.0, blue: 0xEB/255.0) + case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0) + case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0) + case .crush: return Color(red: 0xE0/255.0, green: 0x6C/255.0, blue: 0x9F/255.0) + case .antigravity: return Color(red: 0xFF/255.0, green: 0x7A/255.0, blue: 0x45/255.0) + case .goose: return Color(red: 0xB7/255.0, green: 0x8D/255.0, blue: 0x52/255.0) + case .grok: return Color(red: 0x8E/255.0, green: 0x8E/255.0, blue: 0x93/255.0) + case .hermes: return Color(red: 0xC7/255.0, green: 0x52/255.0, blue: 0x3E/255.0) + case .zcode: return Color(red: 0x52/255.0, green: 0x6E/255.0, blue: 0xD6/255.0) + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift new file mode 100644 index 0000000..aff1e83 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift @@ -0,0 +1,287 @@ +import SwiftUI + + +/// Three-category insights panel: wins, improvements, risks. +/// Wins/risks are derived from current + history; improvements come from the optimize findings. +struct FindingsSection: View { + @Environment(AppStore.self) private var store + @State private var isExpanded: Bool = true + + var body: some View { + let groups = computeTipGroups(payload: store.payload) + if groups.allSatisfy({ $0.items.isEmpty }) { return AnyView(EmptyView()) } + + return AnyView( + VStack(alignment: .leading, spacing: 8) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() } + } label: { + HStack(alignment: .firstTextBaseline) { + HStack(spacing: 6) { + Image(systemName: "lightbulb.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.brandAccent) + Text("Tips for you") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(.primary) + } + Spacer() + Text("\(groups.flatMap { $0.items }.count) signals") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .opacity(0.55) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + VStack(alignment: .leading, spacing: 10) { + ForEach(groups) { group in + if !group.items.isEmpty { + TipsGroup(group: group) + } + } + + if store.payload.optimize.findingCount > 0 { + Button { + openOptimize() + } label: { + HStack(spacing: 4) { + Text("Open Full Optimize") + .font(.system(size: 11.5, weight: .semibold)) + Image(systemName: "arrow.forward") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(Theme.brandAccent) + } + .buttonStyle(.plain) + } + } + .transition(.opacity) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.06)) + ) + .padding(.horizontal, 14) + .padding(.vertical, 8) + ) + } + + private func openOptimize() { + TerminalLauncher.open(subcommand: ["optimize"]) + } +} + +private struct TipsGroup: View { + let group: TipGroup + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 5) { + Image(systemName: group.icon) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(group.color) + Text(group.label) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(group.color) + .textCase(.uppercase) + .tracking(0.4) + } + VStack(alignment: .leading, spacing: 4) { + ForEach(group.items) { item in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Circle().fill(group.color).frame(width: 3, height: 3).padding(.top, 4) + Text(item.text) + .font(.system(size: 11.5)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + if let trailing = item.trailing { + Text(trailing) + .font(.codeMono(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .tracking(-0.2) + } + } + } + } + } + } +} + +private struct TipGroup: Identifiable { + let id = UUID() + let label: String + let icon: String + let color: Color + let items: [TipItem] +} + +private struct TipItem: Identifiable { + let id = UUID() + let text: String + let trailing: String? +} + +@MainActor private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] { + let stats = computeHistoryStats(history: payload.history.daily) + + // What's working + var wins: [TipItem] = [] + let cacheHit = payload.current.cacheHitPercent + if cacheHit >= 80 { + wins.append(TipItem( + text: "Cache hit at \(Int(cacheHit))% — most prompts reuse cache", + trailing: nil + )) + } + if let oneShot = payload.current.oneShotRate, oneShot >= 0.75 { + wins.append(TipItem( + text: "\(Int(oneShot * 100))% one-shot — edits landing first try", + trailing: nil + )) + } + if let delta = stats.weekDeltaPercent, delta < -10 { + wins.append(TipItem( + text: "Spend down \(Int(abs(delta)))% vs last 7 days", + trailing: nil + )) + } + if stats.activeStreakDays >= 5 { + wins.append(TipItem( + text: "\(stats.activeStreakDays)-day usage streak", + trailing: nil + )) + } + + // What to improve (existing optimize findings) + var improvements: [TipItem] = [] + for finding in payload.optimize.topFindings.prefix(3) { + improvements.append(TipItem( + text: finding.title, + trailing: finding.savingsUSD.asCompactCurrency() + )) + } + + // Risks + var risks: [TipItem] = [] + if let delta = stats.weekDeltaPercent, delta > 25 { + risks.append(TipItem( + text: "Spend up \(Int(delta))% vs prior 7 days", + trailing: nil + )) + } + if cacheHit > 0 && cacheHit < 50 { + risks.append(TipItem( + text: "Cache hit only \(Int(cacheHit))% — paying for cold prompts", + trailing: nil + )) + } + if let oneShot = payload.current.oneShotRate, oneShot < 0.5 { + risks.append(TipItem( + text: "\(Int(oneShot * 100))% one-shot — lots of iteration", + trailing: nil + )) + } + if let projected = stats.projectedMonth, let prevMonth = stats.previousMonthTotal, projected > prevMonth * 1.3 { + risks.append(TipItem( + text: "On pace for \(projected.asCompactCurrency()) this month (+\(Int(((projected - prevMonth) / prevMonth) * 100))% vs last)", + trailing: nil + )) + } + + return [ + TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: Theme.brandAccent, items: wins), + TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: Theme.brandAccent, items: improvements), + TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: Theme.brandAccent, items: risks), + ] +} + +private struct HistoryStats { + let weekDeltaPercent: Double? + let activeStreakDays: Int + let projectedMonth: Double? + let previousMonthTotal: Double? +} + +private func computeHistoryStats(history: [DailyHistoryEntry]) -> HistoryStats { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = .current + return f + }() + let now = Date() + let today = calendar.startOfDay(for: now) + let costByDate = Dictionary(history.map { ($0.date, $0.cost) }, uniquingKeysWith: +) + + let lastWeekStart = calendar.date(byAdding: .day, value: -6, to: today) + let priorWeekStart = calendar.date(byAdding: .day, value: -13, to: today) + let priorWeekEnd = calendar.date(byAdding: .day, value: -7, to: today) + var weekDeltaPercent: Double? = nil + if let lws = lastWeekStart, let pws = priorWeekStart, let pwe = priorWeekEnd { + let lwsStr = formatter.string(from: lws) + let pwsStr = formatter.string(from: pws) + let pweStr = formatter.string(from: pwe) + let thisWeek = history.filter { $0.date >= lwsStr }.reduce(0.0) { $0 + $1.cost } + let prior = history.filter { $0.date >= pwsStr && $0.date <= pweStr }.reduce(0.0) { $0 + $1.cost } + if prior > 0 { + weekDeltaPercent = ((thisWeek - prior) / prior) * 100 + } + } + + var streak = 0 + for offset in 0..<60 { + guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break } + let key = formatter.string(from: d) + if (costByDate[key] ?? 0) > 0 { streak += 1 } else { break } + } + + var projectedMonth: Double? = nil + var previousMonthTotal: Double? = nil + let comps = calendar.dateComponents([.year, .month, .day], from: now) + if + let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)), + let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth) + { + let firstStr = formatter.string(from: firstOfMonth) + let mtd = history.filter { $0.date >= firstStr }.reduce(0.0) { $0 + $1.cost } + let dayOfMonth = comps.day ?? 1 + if dayOfMonth > 0 { + projectedMonth = (mtd / Double(dayOfMonth)) * Double(rangeOfMonth.count) + } + + if + let prevMonth = calendar.date(byAdding: .month, value: -1, to: firstOfMonth), + let prevRange = calendar.range(of: .day, in: .month, for: prevMonth), + let prevFirst = calendar.date(from: DateComponents( + year: calendar.component(.year, from: prevMonth), + month: calendar.component(.month, from: prevMonth), + day: 1 + )), + let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst) + { + let prevFirstStr = formatter.string(from: prevFirst) + let prevLastStr = formatter.string(from: prevLast) + let prevTotal = history.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr } + .reduce(0.0) { $0 + $1.cost } + if prevTotal > 0 { previousMonthTotal = prevTotal } + } + } + + return HistoryStats( + weekDeltaPercent: weekDeltaPercent, + activeStreakDays: streak, + projectedMonth: projectedMonth, + previousMonthTotal: previousMonthTotal + ) +} diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift new file mode 100644 index 0000000..9e7cd70 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -0,0 +1,2226 @@ +import SwiftUI + +private let trendChartHeight: CGFloat = 90 + +// Cached formatters and a calendar to avoid allocating fresh ones on every +// SwiftUI body re-eval. Hover scrubbing on the trend bars triggers many +// re-evals per second; a fresh DateFormatter / Calendar each time was a +// measurable hot spot. +private let yyyymmdd: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = .current + return f +}() + +private let prettyDayFormat: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEE MMM d" + f.locale = Locale(identifier: "en_US_POSIX") + return f +}() + +private let mmmDayFormat: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM d" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = .current + return f +}() + +private let gregorianCalendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = .current + return c +}() + +/// Switchable insight visualizations: trend, calendar, forecast, pulse, stats, +/// optimize, plus provider-specific plan views. +struct HeatmapSection: View { + @Environment(AppStore.self) private var store + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + InsightPillSwitcher(selected: bindingMode, visibleModes: visibleModes) + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { ensureValidSelection() } + .onChange(of: store.selectedProvider) { _, _ in ensureValidSelection() } + } + + private var bindingMode: Binding { + Binding(get: { store.selectedInsight }, set: { store.selectedInsight = $0 }) + } + + private var visibleModes: [InsightMode] { + // Plan sources from a provider's OAuth usage endpoint. Currently + // implemented for Claude (Anthropic) and Codex (ChatGPT). Hidden on + // All / Cursor / Droid / Gemini / Copilot until those providers ship + // their own quota data sources. + InsightMode.allCases.filter { mode in + if mode == .plan { + return store.selectedProvider == .claude || store.selectedProvider == .codex + } + return true + } + } + + private func ensureValidSelection() { + if !visibleModes.contains(store.selectedInsight) { + store.selectedInsight = visibleModes.first ?? .trend + } + } + + @ViewBuilder + private var content: some View { + switch store.selectedInsight { + case .plan: + if store.selectedProvider == .codex { + CodexPlanInsight() + } else { + PlanInsight(usage: store.subscription) + } + case .trend: TrendInsight(days: store.payload.history.daily, period: store.trendPeriod) + case .calendar: ContributionHeatmapInsight(days: store.payload.history.daily) + case .forecast: ForecastInsight(days: store.payload.history.daily) + case .pulse: PulseInsight(payload: store.payload) + case .stats: StatsInsight(payload: store.payload) + case .optimize: OptimizeInsight(payload: store.payload) + } + } +} + +// MARK: - Pill Switcher + +private struct InsightPillSwitcher: View { + @Binding var selected: InsightMode + let visibleModes: [InsightMode] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(visibleModes) { mode in + Button { + selected = mode + } label: { + Text(mode.rawValue) + .font(.system(size: 11, weight: .medium)) + .fixedSize() + .foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10))) + ) + } + .buttonStyle(.plain) + } + } + } + } +} + +// MARK: - Trend (14-day bar chart with peak + average) + +private struct TrendInsight: View { + let days: [DailyHistoryEntry] + let period: Period + + private var trendDayCount: Int { + switch period { + case .today, .sevenDays: return 19 + case .thirtyDays: return 30 + case .month: return 31 + case .all: return min(days.count, 90) + } + } + + private var barGap: CGFloat { + trendDayCount > 45 ? 2 : 4 + } + + var body: some View { + let dayCount = trendDayCount + let bars = buildTrendBars(from: days, dayCount: dayCount) + let stats = computeTrendStats(bars: bars, allDays: days, dayCount: dayCount) + // Tokens are real for the .all-providers view; per-provider history doesn't carry + // token breakdown yet, so fall back to $ when no tokens are present. + let totalTokens = bars.reduce(0.0) { $0 + $1.tokens } + let useTokens = totalTokens > 0 + let metric: (TrendBar) -> Double = useTokens ? { $0.tokens } : { $0.cost } + let maxValue = max(bars.map(metric).max() ?? 1, 0.01) + let avgValue = bars.isEmpty ? 0 : bars.map(metric).reduce(0, +) / Double(bars.count) + let peakValue = bars.filter({ metric($0) > 0 }).max(by: { metric($0) < metric($1) }) + let yesterdayValue = stats.yesterdayBar.map(metric) + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 1) { + Text("Last \(dayCount) days") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Text(formatHero(useTokens: useTokens, tokens: totalTokens, dollars: stats.totalThisWindow)) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.primary) + } + Spacer() + if let delta = stats.deltaPercent { + HStack(spacing: 3) { + Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 9, weight: .bold)) + Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(dayCount)d") + .font(.system(size: 10.5)) + .monospacedDigit() + } + .foregroundStyle(Theme.brandAccent) + } + } + + TrendChart( + bars: bars, + maxValue: maxValue, + avgValue: avgValue, + metric: metric, + formatValue: { formatValue($0, useTokens: useTokens) }, + barGap: barGap + ) + .zIndex(1) + + HStack(spacing: 14) { + MiniStat(label: "Avg/day", value: formatValue(avgValue, useTokens: useTokens)) + MiniStat(label: "Peak", value: peakLabel(peakValue, metric: metric, useTokens: useTokens)) + MiniStat(label: "Yesterday", value: yesterdayValue.map { formatValue($0, useTokens: useTokens) } ?? "—") + } + } + } + + private func formatHero(useTokens: Bool, tokens: Double, dollars: Double) -> String { + useTokens ? "\(formatTokens(tokens)) tokens" : dollars.asCurrency() + } + + private func formatValue(_ v: Double, useTokens: Bool) -> String { + useTokens ? "\(formatTokens(v)) tok" : v.asCompactCurrency() + } + + private func peakLabel(_ peak: TrendBar?, metric: (TrendBar) -> Double, useTokens: Bool) -> String { + guard let peak, metric(peak) > 0 else { return "—" } + return "\(formatValue(metric(peak), useTokens: useTokens)) on \(shortDate(peak.date))" + } + + private func formatTokens(_ n: Double) -> String { + if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) } + if n >= 1_000 { return String(format: "%.0fK", n / 1_000) } + return String(format: "%.0f", n) + } + + private func shortDate(_ ymd: String) -> String { + let parts = ymd.split(separator: "-") + guard parts.count == 3 else { return ymd } + return "\(parts[1])/\(parts[2])" + } +} + +private struct TrendChart: View { + let bars: [TrendBar] + let maxValue: Double + let avgValue: Double + let metric: (TrendBar) -> Double + let formatValue: (Double) -> String + let barGap: CGFloat + + @State private var hoveredBarID: TrendBar.ID? + + private var peakBarID: TrendBar.ID? { + bars.filter { metric($0) > 0 }.max(by: { metric($0) < metric($1) })?.id + } + + var body: some View { + let avgFraction = maxValue > 0 ? CGFloat(min(avgValue / maxValue, 1.0)) : 0 + + ZStack(alignment: .bottomLeading) { + HStack(alignment: .bottom, spacing: barGap) { + ForEach(bars) { bar in + BarColumn( + bar: bar, + value: metric(bar), + maxValue: maxValue, + isHovered: hoveredBarID == bar.id, + isPeak: bar.id == peakBarID + ) + .onHover { hovering in + hoveredBarID = hovering ? bar.id : (hoveredBarID == bar.id ? nil : hoveredBarID) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: trendChartHeight, alignment: .bottom) + + GeometryReader { geo in + Path { p in + let y = geo.size.height - (geo.size.height * avgFraction) + p.move(to: CGPoint(x: 0, y: y)) + p.addLine(to: CGPoint(x: geo.size.width, y: y)) + } + .stroke(Color.secondary.opacity(0.5), style: StrokeStyle(lineWidth: 1, dash: [3, 3])) + } + .frame(height: trendChartHeight) + .allowsHitTesting(false) + } + .frame(height: trendChartHeight) + .overlay(alignment: .bottomLeading) { + if let hoveredBar { + BarTooltipCard(bar: hoveredBar, value: metric(hoveredBar), formatValue: formatValue) + .padding(.top, 6) + .offset(y: 92) + .transition(.opacity) + .allowsHitTesting(false) + .zIndex(10) + } + } + .animation(.easeInOut(duration: 0.12), value: hoveredBarID) + } + + private var hoveredBar: TrendBar? { + guard let id = hoveredBarID else { return nil } + return bars.first { $0.id == id } + } +} + +private struct BarColumn: View { + let bar: TrendBar + let value: Double + let maxValue: Double + let isHovered: Bool + let isPeak: Bool + + var body: some View { + let fraction = maxValue > 0 ? CGFloat(value / maxValue) : 0 + let height = max(2, trendChartHeight * fraction) + + VStack(spacing: 0) { + Spacer(minLength: 0) + if isPeak && value > 0 { + RoundedRectangle(cornerRadius: 2) + .fill(Color.yellow.opacity(0.85)) + .frame(maxWidth: .infinity) + .frame(height: max(2, trendChartHeight * 0.05)) + } + RoundedRectangle(cornerRadius: 2) + .fill(barColor) + .frame(maxWidth: .infinity) + .frame(height: height) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Theme.brandAccent.opacity(isHovered ? 0.9 : 0), lineWidth: 1) + ) + .scaleEffect(x: isHovered ? 1.08 : 1.0, y: 1.0, anchor: .bottom) + .animation(.easeOut(duration: 0.12), value: isHovered) + } + .contentShape(Rectangle()) + } + + private var barColor: Color { + if bar.isToday { return Theme.brandAccent } + if value <= 0 { return Color.secondary.opacity(0.15) } + if isHovered { return Theme.brandAccent.opacity(0.85) } + let ratio = maxValue > 0 ? value / maxValue : 0 + return Theme.brandAccent.opacity(0.42 + ratio * 0.48) + } +} + +private struct BarTooltipCard: View { + let bar: TrendBar + /// Value to display in the tooltip header. Matches the metric the trend chart + /// is currently using (tokens when the .all-providers view has token data, + /// cost when provider-filtered views force a $ fallback). Passing this in keeps + /// the tooltip in sync with the chart instead of always reading bar.tokens, + /// which is zero for provider-filtered days. + let value: Double + let formatValue: (Double) -> String + @Environment(\.colorScheme) private var colorScheme + + private var backgroundFill: Color { + colorScheme == .dark ? Color.white : Color.black + } + + private var primaryText: Color { + colorScheme == .dark ? Color.black : Color.white + } + + private var secondaryText: Color { + colorScheme == .dark ? Color.black.opacity(0.7) : Color.white.opacity(0.72) + } + + private var tertiaryText: Color { + colorScheme == .dark ? Color.black.opacity(0.5) : Color.white.opacity(0.52) + } + + private var borderStroke: Color { + colorScheme == .dark ? Color.black.opacity(0.12) : Color.white.opacity(0.12) + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline) { + Text(prettyDate(bar.date)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(primaryText) + Spacer() + Text("\(formatValue(value))") + .font(.codeMono(size: 10.5, weight: .semibold)) + .foregroundStyle(Theme.brandAccent) + } + + if !bar.topModels.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(bar.topModels.prefix(4).enumerated()), id: \.offset) { idx, m in + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 1) + .fill(Theme.brandAccent.opacity(0.75 - Double(idx) * 0.12)) + .frame(width: 3, height: 12) + Text(m.name) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(primaryText) + .lineLimit(1) + Spacer() + if m.cost > 0 { + Text(m.cost.asCompactCurrency()) + .font(.codeMono(size: 9.5, weight: .semibold)) + .foregroundStyle(secondaryText) + } + Text("\(formatTokensCompact(Double(m.totalTokens))) tok") + .font(.codeMono(size: 9.5, weight: .medium)) + .foregroundStyle(tertiaryText) + } + } + } + } + } + .padding(11) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(backgroundFill) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(borderStroke, lineWidth: 0.5) + ) + .shadow(color: Color.black.opacity(0.35), radius: 10, y: 4) + } + + private func formatTokensCompact(_ n: Double) -> String { + if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) } + if n >= 1_000 { return String(format: "%.0fK", n / 1_000) } + return String(format: "%.0f", n) + } +} + +private func prettyDate(_ ymd: String) -> String { + guard let date = yyyymmdd.date(from: ymd) else { return ymd } + return prettyDayFormat.string(from: date) +} + +private struct MiniStat: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.tertiary) + Text(value) + .font(.system(size: 11.5, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.primary) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .separatorColor).opacity(0.35)) + ) + } +} + +private struct TrendBar: Identifiable { + var id: String { date } + let date: String + let cost: Double + let inputTokens: Double + let outputTokens: Double + let isToday: Bool + let topModels: [DailyModelBreakdown] + + var tokens: Double { inputTokens + outputTokens } +} + +private struct TrendStats { + let totalThisWindow: Double + let avgPerDay: Double + let peak: TrendBar? + let activeDays: Int + let deltaPercent: Double? + let yesterdayBar: TrendBar? +} + +private func buildTrendBars(from days: [DailyHistoryEntry], dayCount: Int) -> [TrendBar] { + let calendar = gregorianCalendar + let formatter = yyyymmdd + let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new }) + let today = calendar.startOfDay(for: Date()) + let todayKey = formatter.string(from: today) + + var bars: [TrendBar] = [] + for offset in (0.. TrendStats { + let total = bars.reduce(0.0) { $0 + $1.cost } + let active = bars.filter { $0.cost > 0 }.count + let avg = bars.isEmpty ? 0 : total / Double(bars.count) + let peak = bars.filter { $0.cost > 0 }.max(by: { $0.cost < $1.cost }) + + let calendar = gregorianCalendar + let formatter = yyyymmdd + let today = calendar.startOfDay(for: Date()) + let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * dayCount - 1), to: today) + let thisWindowStart = calendar.date(byAdding: .day, value: -(dayCount - 1), to: today) + var deltaPercent: Double? = nil + if let priorStart = priorWindowStart, let thisStart = thisWindowStart { + let priorStartStr = formatter.string(from: priorStart) + let thisStartStr = formatter.string(from: thisStart) + let priorTotal = allDays + .filter { $0.date >= priorStartStr && $0.date < thisStartStr } + .reduce(0.0) { $0 + $1.cost } + if priorTotal > 0 { + deltaPercent = ((total - priorTotal) / priorTotal) * 100 + } + } + + let yesterdayDate = calendar.date(byAdding: .day, value: -1, to: today) + let yesterdayKey = yesterdayDate.map { formatter.string(from: $0) } + let yesterdayBar = bars.first(where: { $0.date == yesterdayKey }) + + return TrendStats( + totalThisWindow: total, + avgPerDay: avg, + peak: peak, + activeDays: active, + deltaPercent: deltaPercent, + yesterdayBar: yesterdayBar + ) +} + +// MARK: - Calendar + +private struct ContributionHeatmapInsight: View { + let days: [DailyHistoryEntry] + + private let cellSize: CGFloat = 8 + private let cellGap: CGFloat = 3 + private let weekdayLabelWidth: CGFloat = 26 + @State private var hoveredDayID: ContributionDay.ID? + + var body: some View { + GeometryReader { geo in + let weekCount = adaptiveWeekCount(for: geo.size.width) + let weeks = buildContributionWeeks(from: days, weekCount: weekCount) + let stats = computeContributionStats(weeks: weeks) + let hoveredDay = weeks.flatMap(\.days).first { $0.id == hoveredDayID } + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 1) { + Text("Daily activity") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Text(stats.total.asCurrency()) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.primary) + } + Spacer() + Text("\(stats.activeDays) active days") + .font(.system(size: 10.5, weight: .medium)) + .monospacedDigit() + .foregroundStyle(Theme.brandAccent) + } + + HStack(alignment: .top, spacing: 6) { + VStack(alignment: .trailing, spacing: cellGap) { + ForEach(0..<7, id: \.self) { idx in + Text(weekdayLabel(for: idx)) + .font(.system(size: 8.5, weight: .medium)) + .foregroundStyle(.tertiary) + .frame(width: weekdayLabelWidth, height: cellSize, alignment: .trailing) + } + } + + HStack(alignment: .top, spacing: cellGap) { + ForEach(weeks) { week in + VStack(spacing: cellGap) { + ForEach(week.days) { day in + ContributionCell( + day: day, + size: cellSize, + isHovered: hoveredDayID == day.id + ) + .onHover { hovering in + hoveredDayID = hovering ? day.id : (hoveredDayID == day.id ? nil : hoveredDayID) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + ContributionDayDetail(day: hoveredDay, fallbackStats: stats) + .animation(.easeInOut(duration: 0.12), value: hoveredDayID) + + HStack(spacing: 14) { + MiniStat(label: "Peak day", value: stats.peakLabel) + MiniStat(label: "Avg active", value: stats.avgActive.asCompactCurrency()) + MiniStat(label: "Streak", value: "\(stats.currentStreak)d") + } + } + } + .frame(height: 216) + } + + private func adaptiveWeekCount(for width: CGFloat) -> Int { + let available = max(0, width - weekdayLabelWidth - 10) + let raw = Int(floor((available + cellGap) / (cellSize + cellGap))) + return min(max(raw, 1), 52) + } + + private func weekdayLabel(for index: Int) -> String { + switch index { + case 0: return "Mon" + case 2: return "Wed" + case 4: return "Fri" + case 6: return "Sun" + default: return "" + } + } +} + +private struct ContributionCell: View { + let day: ContributionDay + let size: CGFloat + let isHovered: Bool + + var body: some View { + RoundedRectangle(cornerRadius: 2) + .fill(fillColor) + .frame(width: size, height: size) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(strokeColor, lineWidth: isHovered || day.isToday ? 1 : 0) + ) + .scaleEffect(isHovered ? 1.35 : 1.0) + .animation(.easeOut(duration: 0.10), value: isHovered) + .help(day.helpText) + .accessibilityLabel(day.helpText) + } + + private var strokeColor: Color { + if isHovered { return Theme.brandAccent.opacity(0.95) } + if day.isToday { return Theme.brandAccent.opacity(0.95) } + return Color.clear + } + + private var fillColor: Color { + if day.isFuture { return Color.secondary.opacity(0.06) } + if isHovered { return Theme.brandAccent.opacity(0.95) } + switch day.level { + case 0: return Color.secondary.opacity(0.14) + case 1: return Theme.brandAccent.opacity(0.30) + case 2: return Theme.brandAccent.opacity(0.48) + case 3: return Theme.brandAccent.opacity(0.66) + default: return Theme.brandAccent.opacity(0.88) + } + } +} + +private struct ContributionDayDetail: View { + let day: ContributionDay? + let fallbackStats: ContributionStats + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Text(value) + .font(.system(size: 13, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.primary) + .lineLimit(1) + } + + Spacer(minLength: 8) + + DetailMetric(label: "Calls", value: calls) + DetailMetric(label: "Tokens", value: tokens) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .separatorColor).opacity(0.25)) + ) + } + + private var title: String { + // The header already shows the period total and active-day count, so + // the resting state is a short hover hint — not a duplicate of those + // (and not the full sentence that previously overflowed and truncated). + guard let day else { return "Daily detail" } + return prettyDate(day.date) + } + + private var value: String { + guard let day else { return "Hover a day" } + if day.isFuture { return "Future day" } + if day.cost <= 0 && day.calls == 0 { return "No tracked usage" } + return day.cost.asCompactCurrency() + } + + private var calls: String { + guard let day, !day.isFuture else { return "—" } + return "\(day.calls)" + } + + private var tokens: String { + guard let day, !day.isFuture else { return "—" } + return formatTokensForContribution(day.totalTokens) + } +} + +private struct DetailMetric: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .trailing, spacing: 2) { + Text(label) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.tertiary) + Text(value) + .font(.codeMono(size: 11, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } + .frame(minWidth: 42, alignment: .trailing) + } +} + +struct ContributionWeek: Identifiable, Equatable { + let startDate: String + let days: [ContributionDay] + + var id: String { startDate } +} + +struct ContributionDay: Identifiable, Equatable { + let date: String + let cost: Double + let calls: Int + let inputTokens: Int + let outputTokens: Int + let level: Int + let isToday: Bool + let isFuture: Bool + + var id: String { date } + var totalTokens: Int { inputTokens + outputTokens } + + @MainActor var helpText: String { + if isFuture { return "\(prettyDate(date)): future day" } + if cost <= 0 && calls == 0 { return "\(prettyDate(date)): no tracked usage" } + return "\(prettyDate(date)): \(cost.asCompactCurrency()), \(calls) calls, \(formatTokensForContribution(totalTokens)) tokens" + } +} + +struct ContributionStats: Equatable { + let total: Double + let activeDays: Int + let avgActive: Double + let peakLabel: String + let currentStreak: Int +} + +func contributionLevel(value: Double, maxValue: Double) -> Int { + guard value > 0, maxValue > 0 else { return 0 } + let ratio = min(max(value / maxValue, 0), 1) + if ratio < 0.25 { return 1 } + if ratio < 0.50 { return 2 } + if ratio < 0.75 { return 3 } + return 4 +} + +func buildContributionWeeks( + from days: [DailyHistoryEntry], + weekCount: Int, + now: Date = Date(), + calendar: Calendar = gregorianCalendar, + formatter: DateFormatter = yyyymmdd +) -> [ContributionWeek] { + let today = calendar.startOfDay(for: now) + let todayKey = formatter.string(from: today) + let visibleWeekCount = min(max(weekCount, 1), 52) + let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new }) + guard + let thisWeekStart = startOfContributionWeek(containing: today, calendar: calendar), + let firstWeekStart = calendar.date(byAdding: .weekOfYear, value: -(visibleWeekCount - 1), to: thisWeekStart) + else { + return [] + } + + var visibleKeys: [String] = [] + for offset in 0..<(visibleWeekCount * 7) { + guard let date = calendar.date(byAdding: .day, value: offset, to: firstWeekStart) else { continue } + if date <= today { visibleKeys.append(formatter.string(from: date)) } + } + let maxCost = visibleKeys.compactMap { entryByDate[$0]?.cost }.max() ?? 0 + + var weeks: [ContributionWeek] = [] + for weekOffset in 0.. today + let cost = isFuture ? 0 : (entry?.cost ?? 0) + contributionDays.append(ContributionDay( + date: key, + cost: cost, + calls: isFuture ? 0 : (entry?.calls ?? 0), + inputTokens: isFuture ? 0 : (entry?.inputTokens ?? 0), + outputTokens: isFuture ? 0 : (entry?.outputTokens ?? 0), + level: isFuture ? 0 : contributionLevel(value: cost, maxValue: maxCost), + isToday: key == todayKey, + isFuture: isFuture + )) + } + + weeks.append(ContributionWeek(startDate: weekStartKey, days: contributionDays)) + } + + return weeks +} + +@MainActor func computeContributionStats(weeks: [ContributionWeek]) -> ContributionStats { + let days = weeks.flatMap(\.days).filter { !$0.isFuture } + let active = days.filter { $0.cost > 0 } + let total = active.reduce(0.0) { $0 + $1.cost } + let avg = active.isEmpty ? 0 : total / Double(active.count) + let peak = active.max(by: { $0.cost < $1.cost }) + let peakLabel = peak.map { "\($0.cost.asCompactCurrency()) on \(shortContributionDate($0.date))" } ?? "—" + + var streak = 0 + for day in days.reversed() { + if day.cost > 0 { + streak += 1 + } else { + break + } + } + + return ContributionStats( + total: total, + activeDays: active.count, + avgActive: avg, + peakLabel: peakLabel, + currentStreak: streak + ) +} + +private func startOfContributionWeek(containing date: Date, calendar: Calendar) -> Date? { + let start = calendar.startOfDay(for: date) + let weekday = calendar.component(.weekday, from: start) + let daysFromMonday = (weekday + 5) % 7 + return calendar.date(byAdding: .day, value: -daysFromMonday, to: start) +} + +private func shortContributionDate(_ ymd: String) -> String { + guard let date = yyyymmdd.date(from: ymd) else { return ymd } + return mmmDayFormat.string(from: date) +} + +private func formatTokensForContribution(_ n: Int) -> String { + if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) } + if n >= 1_000 { return String(format: "%.0fK", Double(n) / 1_000) } + return "\(n)" +} + +// MARK: - Forecast + +private struct ForecastInsight: View { + let days: [DailyHistoryEntry] + + var body: some View { + let stats = computeForecast(days: days) + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("Month-to-date") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Text(stats.mtd.asCurrency()) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Theme.brandAccent) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("On pace for") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Text(stats.projection.asCurrency()) + .font(.system(size: 16, weight: .semibold)) + .monospacedDigit() + } + } + + HStack(spacing: 14) { + ForecastStat(label: "Avg/day (this wk)", value: stats.weekAvg.asCompactCurrency()) + ForecastStat(label: "Yesterday", value: stats.yesterday.asCompactCurrency()) + ForecastStat(label: "Last 7d", value: stats.weekTotal.asCompactCurrency()) + } + + if let prevTotal = stats.previousMonthTotal { + HStack(spacing: 4) { + Image(systemName: stats.projection > prevTotal ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 9, weight: .bold)) + Text(comparisonText(projection: stats.projection, previous: prevTotal)) + .font(.system(size: 10.5)) + .monospacedDigit() + } + .foregroundStyle(Theme.brandAccent) + } + } + } + + private func comparisonText(projection: Double, previous: Double) -> String { + guard previous > 0 else { return "no prior month" } + let diff = ((projection - previous) / previous) * 100 + let sign = diff >= 0 ? "+" : "" + return "\(sign)\(String(format: "%.0f", diff))% vs last month (\(previous.asCompactCurrency()))" + } +} + +private struct ForecastStat: View { + let label: String + let value: String + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.tertiary) + Text(value) + .font(.system(size: 12, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.primary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct ForecastStats { + let mtd: Double + let projection: Double + let weekAvg: Double + let weekTotal: Double + let yesterday: Double + let previousMonthTotal: Double? +} + +private func computeForecast(days: [DailyHistoryEntry]) -> ForecastStats { + let calendar = gregorianCalendar + let formatter = yyyymmdd + let now = Date() + let comps = calendar.dateComponents([.year, .month, .day], from: now) + guard + let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)), + let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth) + else { + return ForecastStats(mtd: 0, projection: 0, weekAvg: 0, weekTotal: 0, yesterday: 0, previousMonthTotal: nil) + } + + let firstStr = formatter.string(from: firstOfMonth) + let totalDays = rangeOfMonth.count + let dayOfMonth = comps.day ?? 1 + + let mtdEntries = days.filter { $0.date >= firstStr } + let mtd = mtdEntries.reduce(0.0) { $0 + $1.cost } + let avgPerElapsedDay = dayOfMonth > 0 ? mtd / Double(dayOfMonth) : 0 + let projection = avgPerElapsedDay * Double(totalDays) + + let weekStart = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: now)) + let weekStartStr = weekStart.map { formatter.string(from: $0) } ?? "" + let weekEntries = days.filter { $0.date >= weekStartStr } + let weekTotal = weekEntries.reduce(0.0) { $0 + $1.cost } + let weekAvg = weekTotal / 7.0 + + let yesterdayDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now)) + let yesterdayStr = yesterdayDate.map { formatter.string(from: $0) } ?? "" + let yesterday = days.first(where: { $0.date == yesterdayStr })?.cost ?? 0 + + var previousMonthTotal: Double? = nil + if + let prevMonthDate = calendar.date(byAdding: .month, value: -1, to: firstOfMonth), + let prevRange = calendar.range(of: .day, in: .month, for: prevMonthDate), + let prevFirst = calendar.date(from: DateComponents(year: calendar.component(.year, from: prevMonthDate), month: calendar.component(.month, from: prevMonthDate), day: 1)), + let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst) + { + let prevFirstStr = formatter.string(from: prevFirst) + let prevLastStr = formatter.string(from: prevLast) + let prevEntries = days.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr } + if !prevEntries.isEmpty { + previousMonthTotal = prevEntries.reduce(0.0) { $0 + $1.cost } + } + } + + return ForecastStats( + mtd: mtd, + projection: projection, + weekAvg: weekAvg, + weekTotal: weekTotal, + yesterday: yesterday, + previousMonthTotal: previousMonthTotal + ) +} + +// MARK: - Pulse + +private struct PulseInsight: View { + let payload: MenubarPayload + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent) + PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor) + PulseTile( + label: "Cost / session", + value: payload.current.sessions > 0 + ? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency() + : "—", + color: .secondary + ) + } + CostPerEditCaption(models: payload.current.modelEfficiency) + } + } + + private var cacheHitText: String { + let v = payload.current.cacheHitPercent + return v <= 0 ? "—" : String(format: "%.0f%%", v) + } + + private var oneShotText: String { + guard let r = payload.current.oneShotRate else { return "—" } + return String(format: "%.0f%%", r * 100) + } + + private var oneShotColor: Color { + payload.current.oneShotRate == nil ? .secondary : Theme.brandAccent + } +} + +private struct PulseTile: View { + let label: String + let value: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Text(value) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(color) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.secondary.opacity(0.06)) + ) + } +} + +private struct CostPerEditCaption: View { + let models: [ModelEfficiencyEntry] + + var body: some View { + let valid = models.compactMap { m -> (String, Double)? in + guard let cpe = m.costPerEdit, cpe > 0 else { return nil } + return (m.name, cpe) + }.sorted(by: { $0.1 < $1.1 }) + + if let best = valid.first { + HStack(spacing: 4) { + Image(systemName: "pencil.line") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.tertiary) + Text("Cost/edit") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Text(formatCPE(best.1)) + .font(.codeMono(size: 10.5, weight: .semibold)) + .foregroundStyle(Theme.brandAccent) + Text(best.0) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + if valid.count > 1, let worst = valid.last, worst.0 != best.0 { + Text("—") + .font(.system(size: 9)) + .foregroundStyle(.quaternary) + Text(formatCPE(worst.1)) + .font(.codeMono(size: 10.5, weight: .semibold)) + .foregroundStyle(.primary) + Text(worst.0) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + } + } + } + + private func formatCPE(_ v: Double) -> String { + if v < 0.01 { return String(format: "$%.3f", v) } + return String(format: "$%.2f", v) + } +} + +/// Connects optimize findings directly to plan utilization: "address N findings to recover X +/// tokens" framed as the same currency the rest of the Plan view uses (effective tokens). +/// Scoped to whatever period the user selected (today / 7d / 30d / month / all). +private struct OptimizeSavingsBadge: View { + let payload: MenubarPayload + + var body: some View { + let findingCount = payload.optimize.findingCount + let savingsUSD = payload.optimize.savingsUSD + if findingCount == 0 || savingsUSD <= 0 { + EmptyView() + } else { + Button { openOptimize() } label: { + HStack(spacing: 6) { + Image(systemName: "lightbulb.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.brandAccent) + Text(captionText(findingCount: findingCount, savingsUSD: savingsUSD)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Theme.brandAccent.opacity(0.10)) + ) + } + .buttonStyle(.plain) + .padding(.top, 2) + } + } + + private func captionText(findingCount: Int, savingsUSD: Double) -> String { + let tokens = savingsUSD / 9.0 * 1_000_000 // ~$9/M effective tokens (Sonnet-weighted approx) + let tokensLabel = formatTokens(tokens) + let plural = findingCount == 1 ? "finding" : "findings" + return "Save ~\(savingsUSD.asCompactCurrency()) / ~\(tokensLabel) tokens · \(findingCount) \(plural)" + } + + private func openOptimize() { + TerminalLauncher.open(subcommand: ["optimize"]) + } + + private func formatTokens(_ n: Double) -> String { + if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) } + if n >= 1_000 { return String(format: "%.0fK", n / 1_000) } + return String(format: "%.0f", n) + } +} + +// MARK: - Stats + +private struct StatsInsight: View { + let payload: MenubarPayload + + var body: some View { + let stats = computeAllStats(payload: payload) + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 8) { + StatRow(label: "Favorite model", value: stats.favoriteModel) + StatRow(label: "Active days (month)", value: stats.activeDaysFraction) + StatRow(label: "Most active day", value: stats.mostActiveDay) + StatRow(label: "Peak day spend", value: stats.peakDaySpend) + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 8) { + StatRow(label: "Sessions today", value: "\(payload.current.sessions)") + StatRow(label: "Calls today", value: payload.current.calls.asThousandsSeparated()) + StatRow(label: "Current streak", value: stats.currentStreak) + StatRow(label: "Longest streak", value: stats.longestStreak) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let lifetime = stats.lifetimeTotal { + Divider().opacity(0.5) + HStack { + Text("Tracked spend (last \(stats.historyDayCount) days)") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(.tertiary) + Spacer() + Text(lifetime.asCurrency()) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Theme.brandAccent) + } + } + + if !payload.current.topProjects.isEmpty { + Divider().opacity(0.5) + TopProjectsList(projects: payload.current.topProjects) + } + + if let top = payload.current.topSessions.first, top.cost > 0 { + HStack(spacing: 4) { + Image(systemName: "flame") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(Theme.brandAccent) + Text("Costliest session") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Spacer() + Text(top.cost.asCompactCurrency()) + .font(.codeMono(size: 10.5, weight: .semibold)) + .foregroundStyle(.secondary) + .monospacedDigit() + Text("· \(projectDisplayName(top.project))") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + + } + } +} + +private struct RetryTaxSection: View { + let retryTax: RetryTax + let totalCost: Double + @State private var expanded = false + + var body: some View { + if retryTax.totalUSD > 0 { + Divider().opacity(0.5) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: "arrow.2.squarepath") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.orange) + Text("Retry tax") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Spacer() + Text(retryTax.totalUSD.asCompactCurrency()) + .font(.codeMono(size: 11, weight: .bold)) + .foregroundStyle(.orange) + .monospacedDigit() + if totalCost > 0 { + Text("(\(Int((retryTax.totalUSD / totalCost * 100).rounded()))%)") + .font(.system(size: 9.5)) + .foregroundStyle(.tertiary) + } + Image(systemName: "chevron.right") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.quaternary) + .rotationEffect(.degrees(expanded ? 90 : 0)) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + expanded.toggle() + } + } + + Text("\(retryTax.retries) retries across \(retryTax.editTurns) edits") + .font(.system(size: 9.5)) + .foregroundStyle(.quaternary) + + if expanded { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(retryTax.byModel.enumerated()), id: \.offset) { idx, model in + HStack(spacing: 0) { + Text(model.name) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + if let rpe = model.retriesPerEdit { + Text(String(format: "%.1f ret/edit", rpe)) + .font(.system(size: 9)) + .foregroundStyle(.quaternary) + .padding(.trailing, 8) + } + Text(model.taxUSD.asCompactCurrency()) + .font(.codeMono(size: 10, weight: .semibold)) + .foregroundStyle(.orange.opacity(0.85)) + .monospacedDigit() + } + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(RoundedRectangle(cornerRadius: 4).fill(.orange.opacity(0.05))) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)) + .animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)), + removal: .opacity.animation(.easeOut(duration: 0.12)) + ) + ) + } + } + .padding(.top, 2) + } + } + } + } +} + +private struct StatRow: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.tertiary) + Text(value) + .font(.system(size: 12, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.primary) + } + } +} + +private func projectDisplayName(_ path: String) -> String { + path.split(separator: "/").last.map(String.init) ?? path +} + +private struct TopProjectsList: View { + let projects: [ProjectEntry] + @State private var expanded: String? + + var body: some View { + let top = Array(projects.prefix(3)) + let maxCost = top.first?.cost ?? 1 + + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(top.enumerated()), id: \.offset) { idx, project in + let expandKey = "\(idx):\(project.name)" + let isOpen = expanded == expandKey + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.quaternary) + .rotationEffect(.degrees(isOpen ? 90 : 0)) + Text(projectDisplayName(project.name)) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + Text("\(project.sessions) sess") + .font(.system(size: 9.5)) + .foregroundStyle(.quaternary) + Text(project.cost.asCompactCurrency()) + .font(.codeMono(size: 10.5, weight: .semibold)) + .foregroundStyle(.secondary) + .monospacedDigit() + RoundedRectangle(cornerRadius: 2) + .fill(Theme.brandAccent.opacity(0.5)) + .frame( + width: max(2, 40 * CGFloat(project.cost / max(maxCost, 0.01))), + height: 6 + ) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + expanded = isOpen ? nil : expandKey + } + } + + if isOpen, !project.sessionDetails.isEmpty { + SessionDetailsList(sessions: project.sessionDetails) + .padding(.top, 6) + .padding(.leading, 14) + } + } + } + } + } +} + +private struct SessionDetailsList: View { + let sessions: [SessionDetailEntry] + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(sessions.prefix(5).enumerated()), id: \.offset) { idx, sess in + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 0) { + Text(sess.cost.asCompactCurrency()) + .font(.codeMono(size: 10, weight: .semibold)) + .foregroundStyle(.primary) + .monospacedDigit() + .frame(width: 52, alignment: .trailing) + Text(" \(sess.calls) calls") + .font(.system(size: 9)) + .foregroundStyle(.quaternary) + Spacer() + HStack(spacing: 3) { + Image(systemName: "arrow.down") + .font(.system(size: 7, weight: .semibold)) + Text(compactTokens(sess.inputTokens)) + } + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + HStack(spacing: 3) { + Image(systemName: "arrow.up") + .font(.system(size: 7, weight: .semibold)) + Text(compactTokens(sess.outputTokens)) + } + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + .padding(.leading, 4) + } + HStack(spacing: 4) { + ForEach(Array(sess.models.prefix(3).enumerated()), id: \.offset) { _, model in + Text(model.name) + .font(.system(size: 8.5, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background(Theme.brandAccent.opacity(0.1)) + .clipShape(Capsule()) + } + } + .padding(.leading, 52) + } + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(.primary.opacity(0.03)) + ) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)) + .animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)), + removal: .opacity.animation(.easeOut(duration: 0.15)) + ) + ) + } + } + } + + private func compactTokens(_ n: Int) -> String { + let d = Double(n) + if d >= 1_000_000 { return String(format: "%.1fM", d / 1_000_000) } + if d >= 1_000 { return String(format: "%.0fK", d / 1_000) } + return "\(n)" + } +} + +// MARK: - Optimize + +private struct OptimizeInsight: View { + let payload: MenubarPayload + + var body: some View { + let totalWaste = payload.current.retryTax.totalUSD + payload.current.routingWaste.totalSavingsUSD + let cost = payload.current.cost + + VStack(alignment: .leading, spacing: 12) { + if totalWaste > 0, cost > 0 { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("Potential savings") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Text(totalWaste.asCompactCurrency()) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.orange) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("\(Int((totalWaste / cost * 100).rounded()))% of spend") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.orange.opacity(0.8)) + Text("could be optimized") + .font(.system(size: 9.5)) + .foregroundStyle(.quaternary) + } + } + .padding(.bottom, 2) + } + + RetryTaxSection(retryTax: payload.current.retryTax, totalCost: cost) + + RoutingWasteSection(routingWaste: payload.current.routingWaste, totalCost: cost) + } + } +} + +private struct RoutingWasteSection: View { + let routingWaste: RoutingWaste + let totalCost: Double + @State private var expanded = false + + var body: some View { + if routingWaste.totalSavingsUSD > 0 { + Divider().opacity(0.5) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.swap") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.purple) + Text("Routing waste") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + Spacer() + Text(routingWaste.totalSavingsUSD.asCompactCurrency()) + .font(.codeMono(size: 11, weight: .bold)) + .foregroundStyle(.purple) + .monospacedDigit() + if totalCost > 0 { + Text("(\(Int((routingWaste.totalSavingsUSD / totalCost * 100).rounded()))%)") + .font(.system(size: 9.5)) + .foregroundStyle(.tertiary) + } + Image(systemName: "chevron.right") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.quaternary) + .rotationEffect(.degrees(expanded ? 90 : 0)) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + expanded.toggle() + } + } + + if !routingWaste.baselineModel.isEmpty { + Text("vs \(routingWaste.baselineModel) @ \(routingWaste.baselineCostPerEdit.asCompactCurrency())/edit") + .font(.system(size: 9.5)) + .foregroundStyle(.quaternary) + } + + if expanded { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(routingWaste.byModel.enumerated()), id: \.offset) { idx, model in + HStack(spacing: 0) { + Text(model.name) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Text(String(format: "$%.2f/edit", model.costPerEdit)) + .font(.system(size: 9)) + .foregroundStyle(.quaternary) + .padding(.trailing, 8) + Text(model.savingsUSD.asCompactCurrency()) + .font(.codeMono(size: 10, weight: .semibold)) + .foregroundStyle(.purple.opacity(0.85)) + .monospacedDigit() + } + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(RoundedRectangle(cornerRadius: 4).fill(.purple.opacity(0.05))) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)) + .animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)), + removal: .opacity.animation(.easeOut(duration: 0.12)) + ) + ) + } + } + .padding(.top, 2) + } + } + } + } +} + +private struct AllStats { + let favoriteModel: String + let activeDaysFraction: String + let mostActiveDay: String + let peakDaySpend: String + let currentStreak: String + let longestStreak: String + let lifetimeTotal: Double? + let historyDayCount: Int +} + +@MainActor private func computeAllStats(payload: MenubarPayload) -> AllStats { + let history = payload.history.daily + let favoriteModel = payload.current.topModels.first?.name ?? "—" + + let calendar = gregorianCalendar + let formatter = yyyymmdd + let displayFormatter = mmmDayFormat + + let now = Date() + let today = calendar.startOfDay(for: now) + let comps = calendar.dateComponents([.year, .month, .day], from: now) + + var activeDaysFraction = "—" + if + let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)), + let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth) + { + let firstStr = formatter.string(from: firstOfMonth) + let mtdActive = history.filter { $0.date >= firstStr && $0.cost > 0 }.count + activeDaysFraction = "\(mtdActive)/\(rangeOfMonth.count)" + } + + let peak = history.max(by: { $0.cost < $1.cost }) + let mostActiveDay: String + let peakDaySpend: String + if let peak, peak.cost > 0, let date = formatter.date(from: peak.date) { + mostActiveDay = displayFormatter.string(from: date) + peakDaySpend = peak.cost.asCompactCurrency() + } else { + mostActiveDay = "—" + peakDaySpend = "—" + } + + let costByDate = Dictionary(history.map { ($0.date, $0.cost) }, uniquingKeysWith: +) + + var currentStreak = 0 + for offset in 0..<400 { + guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break } + let key = formatter.string(from: d) + if (costByDate[key] ?? 0) > 0 { currentStreak += 1 } else { break } + } + + var longestStreak = 0 + var running = 0 + if let firstDate = history.map(\.date).min(), + let lastDate = history.map(\.date).max(), + let start = formatter.date(from: firstDate), + let end = formatter.date(from: lastDate) { + var cursor = start + while cursor <= end { + let key = formatter.string(from: cursor) + if (costByDate[key] ?? 0) > 0 { + running += 1 + longestStreak = max(longestStreak, running) + } else { + running = 0 + } + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = next + } + } + + let lifetimeTotal: Double? = history.isEmpty ? nil : history.reduce(0.0) { $0 + $1.cost } + + return AllStats( + favoriteModel: favoriteModel, + activeDaysFraction: activeDaysFraction, + mostActiveDay: mostActiveDay, + peakDaySpend: peakDaySpend, + currentStreak: currentStreak == 0 ? "—" : "\(currentStreak) days", + longestStreak: longestStreak == 0 ? "—" : "\(longestStreak) days", + lifetimeTotal: lifetimeTotal, + historyDayCount: history.count + ) +} + +// MARK: - Plan (subscription) + +private struct PlanInsight: View { + @Environment(AppStore.self) private var store + let usage: SubscriptionUsage? + + private static let fiveHourSeconds: TimeInterval = 5 * 3600 + private static let sevenDaySeconds: TimeInterval = 7 * 86400 + private static let freshWindowThreshold: Double = 0.05 + + @State private var projections: [String: WindowProjection] = [:] + + var body: some View { + Group { + switch store.subscriptionLoadState { + case .notBootstrapped, .dormant: + 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 .bootstrapping: + PlanLoadingView() + case .loading: + if let usage { + loadedBody(usage: usage) + } else { + PlanLoadingView() + } + case .noCredentials: + 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: + if let usage { + loadedBody(usage: usage) + } else { + PlanFailedView(error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying.") + } + case let .terminalFailure(reason): + 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) + } else { + PlanLoadingView() + } + } + } + } + + @ViewBuilder + private func loadedBody(usage: SubscriptionUsage) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(usage.tier.displayName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.brandAccent) + Spacer() + if let resets = headlineReset(usage: usage) { + Text("Resets \(resets)") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + + VStack(spacing: 8) { + if let p = usage.fiveHourPercent { + UtilizationRow(label: "5-hour window", percent: p, resetsAt: usage.fiveHourResetsAt, projection: projections["five_hour"]) + } + if let p = usage.sevenDayPercent { + UtilizationRow(label: "7-day total", percent: p, resetsAt: usage.sevenDayResetsAt, projection: projections["seven_day"]) + } + if let p = usage.sevenDayOpusPercent { + UtilizationRow(label: "7-day Opus", percent: p, resetsAt: usage.sevenDayOpusResetsAt, projection: projections["seven_day_opus"]) + } + 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.payload) + } + .task(id: usage.fetchedAt) { + await recomputeProjections(usage: usage) + } + } + + private func recomputeProjections(usage: SubscriptionUsage) async { + var result: [String: WindowProjection] = [:] + 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 + } + } + projections = result + } + + /// Linear extrapolation when window is past the freshness threshold; otherwise falls back to + /// the prior cycle's final percent from the snapshot store. + private func project(key: String, percent: Double?, resetsAt: Date?, windowSeconds: TimeInterval) async -> WindowProjection? { + guard let percent, let resetsAt else { return nil } + let windowStart = resetsAt.addingTimeInterval(-windowSeconds) + let elapsed = Date().timeIntervalSince(windowStart) + let elapsedFraction = elapsed / windowSeconds + + if elapsedFraction > Self.freshWindowThreshold, percent > 0 { + let projectedPercent = percent / elapsedFraction + var hitDate: Date? = nil + if projectedPercent > 100, percent < 100 { + let remainingPercent = 100 - percent + let percentPerSecond = percent / elapsed + if percentPerSecond > 0 { + hitDate = Date().addingTimeInterval(remainingPercent / percentPerSecond) + } + } + return WindowProjection(percent: projectedPercent, willOverflow: projectedPercent > 100, hitsLimitAt: hitDate, source: .linear) + } + + // Window too fresh OR percent exactly zero -- use the prior cycle's final reading. + if let prior = await SubscriptionSnapshotStore.previousWindowFinal(windowKey: key, currentResetsAt: resetsAt) { + return WindowProjection(percent: prior, willOverflow: prior > 100, hitsLimitAt: nil, source: .historicalBaseline) + } + return nil + } + + private func headlineReset(usage: SubscriptionUsage) -> String? { + let candidates = [ + usage.fiveHourResetsAt, + usage.sevenDayResetsAt, + usage.sevenDayOpusResetsAt, + usage.sevenDaySonnetResetsAt, + ].compactMap { $0 } + guard let earliest = candidates.min() else { return nil } + return relativeReset(earliest) + } +} + +// MARK: - Plan empty/loading/failure states + +private struct PlanLoadingView: View { + var body: some View { + VStack(spacing: 8) { + ProgressView().scaleEffect(0.8) + Text("Reading Claude credentials...") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } +} + +private struct PlanNoCredentialsView: View { + 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(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + Text(.init(message)) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + Button("Try Again", action: onRetry) + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(Theme.brandAccent) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } +} + +private struct PlanFailedView: View { + @Environment(AppStore.self) private var store + let error: String? + + var body: some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 18)) + .foregroundStyle(Theme.brandAccent) + Text("Couldn't load plan data") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + if let error { + Text(error) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + .lineLimit(3) + } + Button("Retry") { + Task { await store.refreshSubscription() } + } + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(Theme.brandAccent) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } +} + +/// Shown the very first time a user opens the Plan tab. Clicking Connect is the +/// 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 { + VStack(spacing: 10) { + Image(systemName: "link.circle") + .font(.system(size: 26)) + .foregroundStyle(Theme.brandAccent) + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + Text(.init(message)) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + Button("Connect", action: onConnect) + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(Theme.brandAccent) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } +} + +/// 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 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 { + VStack(spacing: 10) { + Image(systemName: "arrow.triangle.2.circlepath.circle") + .font(.system(size: 24)) + .foregroundStyle(.red) + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + Text(reason ?? fallback) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + .lineLimit(3) + Button("Reconnect", action: onReconnect) + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(.red) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } +} + +/// Plan tab for Codex. Mirrors PlanInsight's layout but reads from +/// store.codexUsage / store.codexLoadState. We deliberately skip the +/// "On pace at reset" projection here — that math is fed by local +/// per-message Claude spend extrapolated against the API quota windows; +/// our local Codex spend isn't an apples-to-apples signal for the +/// ChatGPT-subscription rate windows reported by wham/usage. Add when +/// we wire a comparable extrapolator. +private struct CodexPlanInsight: View { + @Environment(AppStore.self) private var store + + var body: some View { + Group { + switch store.codexLoadState { + case .notBootstrapped, .dormant: + 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 .bootstrapping: + PlanLoadingView() + case .loading: + if let usage = store.codexUsage { + loadedBody(usage: usage) + } else { + PlanLoadingView() + } + case .noCredentials: + 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: + if let usage = store.codexUsage { + loadedBody(usage: usage) + } else { + PlanFailedView(error: store.codexError ?? "ChatGPT temporarily unreachable — retrying.") + } + case let .terminalFailure(reason): + 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) + } else { + PlanLoadingView() + } + } + } + } + + @ViewBuilder + private func loadedBody(usage: CodexUsage) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(usage.plan.displayName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt { + Text("Resets \(relativeReset(resetsAt))") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + if let primary = usage.primary { + UtilizationRow( + label: "\(primary.windowLabel) window", + percent: primary.usedPercent, + resetsAt: primary.resetsAt, + projection: nil + ) + } + if let secondary = usage.secondary { + UtilizationRow( + label: "\(secondary.windowLabel) window", + percent: secondary.usedPercent, + resetsAt: secondary.resetsAt, + projection: nil + ) + } + // Surface non-zero per-model rate limits (Codex Spark, etc.) so + // power users see them; idle ones stay collapsed. + ForEach(Array(usage.additionalLimits.enumerated()), id: \.offset) { _, limit in + if let p = limit.primary, p.usedPercent > 0 { + UtilizationRow( + label: "\(limit.name) · \(p.windowLabel)", + percent: p.usedPercent, + resetsAt: p.resetsAt, + projection: nil + ) + } + if let s = limit.secondary, s.usedPercent > 0 { + UtilizationRow( + label: "\(limit.name) · \(s.windowLabel)", + percent: s.usedPercent, + resetsAt: s.resetsAt, + projection: nil + ) + } + } + } + .padding(.horizontal, 14) + .padding(.top, 4) + .padding(.bottom, 8) + } + + private func relativeReset(_ date: Date) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return f.localizedString(for: date, relativeTo: Date()) + } +} + +private struct WindowProjection { + enum Source { case linear, historicalBaseline } + let percent: Double + let willOverflow: Bool + let hitsLimitAt: Date? + let source: Source +} + +private struct UtilizationRow: View { + let label: String + /// API returns utilization as 0..100 (a percentage value, not a fraction). + let percent: Double + let resetsAt: Date? + let projection: WindowProjection? + + var body: some View { + VStack(spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Text(String(format: "%.0f%%", clampedPercent)) + .font(.codeMono(size: 11, weight: .semibold)) + .foregroundStyle(barColor) + .monospacedDigit() + } + UtilizationBar( + fraction: clampedPercent / 100, + color: barColor, + markerFraction: projection.map { min(max($0.percent, 0), 100) / 100 } + ) + .frame(height: 6) + if let projection { + ProjectionCaption(projection: projection) + } + } + } + + private var clampedPercent: Double { min(max(percent, 0), 100) } + + /// Single-color brand palette decision (see session notes): the number is the signal, not + /// the color. Keeping this as a computed property so a future threshold-based palette + /// reintroduction stays scoped to one place. + private var barColor: Color { Theme.brandAccent } +} + +private struct ProjectionCaption: View { + let projection: WindowProjection + + var body: some View { + HStack(spacing: 3) { + if projection.willOverflow { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(Theme.brandAccent) + } else { + Image(systemName: "arrow.up.right") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.tertiary) + } + Text(captionText) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(projection.willOverflow + ? AnyShapeStyle(Theme.brandAccent) + : AnyShapeStyle(.tertiary)) + Spacer() + } + } + + private var captionText: String { + let projected = String(format: "%.0f%%", projection.percent) + switch projection.source { + case .linear: + if projection.willOverflow, let hit = projection.hitsLimitAt { + return "On pace: \(projected) at reset · hits 100% \(relativeReset(hit))" + } + return "On pace: \(projected) at reset" + case .historicalBaseline: + return "Based on last cycle: \(projected)" + } + } +} + +private struct UtilizationBar: View { + /// 0..1 fraction of the bar to fill. + let fraction: Double + let color: Color + /// Optional 0..1 marker position for projected utilization at reset. + let markerFraction: Double? + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3).fill(Color.secondary.opacity(0.12)) + RoundedRectangle(cornerRadius: 3) + .fill(color) + .frame(width: max(0, geo.size.width * CGFloat(fraction))) + if let m = markerFraction { + Rectangle() + .fill(Color.primary.opacity(0.55)) + .frame(width: 1.5) + .offset(x: max(0, geo.size.width * CGFloat(m)) - 0.75) + } + } + } + } +} + +private func relativeReset(_ date: Date) -> String { + let interval = date.timeIntervalSinceNow + if interval <= 0 { return "now" } + let hours = interval / 3600 + if hours < 1 { + let minutes = Int(ceil(interval / 60)) + return "in \(minutes)m" + } + if hours < 24 { return "in \(Int(ceil(hours)))h" } + let days = Int(ceil(hours / 24)) + return "in \(days)d" +} diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift new file mode 100644 index 0000000..e05adfe --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -0,0 +1,234 @@ +import SwiftUI + +struct HeroSection: View { + @Environment(AppStore.self) private var store + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + SectionCaption(text: caption) + + HStack(alignment: .firstTextBaseline) { + Text(heroText) + .font(.system(size: 32, weight: .semibold, design: .rounded)) + .monospacedDigit() + .tracking(-1) + .foregroundStyle( + LinearGradient( + colors: [Theme.brandAccent, Theme.brandAccentDeep], + startPoint: .top, + endPoint: .bottom + ) + ) + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + if store.displayMetric == .tokens { + HStack(spacing: 2) { + Image(systemName: "arrow.up") + .font(.system(size: 9, weight: .semibold)) + Text(formatTokens(Double(totals.outputTokens))) + } + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.secondary) + HStack(spacing: 2) { + Image(systemName: "arrow.down") + .font(.system(size: 9, weight: .semibold)) + Text(formatTokens(Double(totals.inputTokens))) + } + .font(.system(size: 10.5)) + .monospacedDigit() + .foregroundStyle(.tertiary) + } else { + Text("\(totals.calls.asThousandsSeparated()) calls") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.secondary) + Text("\(totals.sessions) sessions") + .font(.system(size: 10.5)) + .monospacedDigit() + .foregroundStyle(.tertiary) + } + } + } + + if !store.isDayMode, + store.selectedPeriod == .today, + store.shouldShowDailyBudgetWarning { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + Text("Daily budget of \(store.dailyBudgetLabel) exceeded") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.orange) + .padding(.top, 2) + } + + if let usage = combinedUsage { + CombinedDeviceBreakdown(usage: usage, formatTokens: formatTokens) + } else if store.activeScope == .combined, store.lastError != nil { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + Text("Combined unavailable · showing local") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.secondary) + } + + if let savingsCaption { + HStack(spacing: 4) { + Image(systemName: "leaf.fill") + .font(.system(size: 10)) + Text(savingsCaption) + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.green) + } + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 12) + } + + private var heroText: String { + if store.displayMetric == .tokens || store.displayMetric == .totalTokens { + let total = Double(totals.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) + } + return totals.cost.asCurrency() + } + + private var combinedUsage: CombinedUsage? { + guard store.activeScope == .combined else { return nil } + return store.payload.combined + } + + private var totals: HeroTotals { + HeroTotals(payload: store.payload, activeScope: store.activeScope) + } + + private func formatTokens(_ n: Double) -> String { + if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) } + if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) } + if n >= 1_000 { return String(format: "%.0fK", n / 1_000) } + return String(format: "%.0f", n) + } + + private var caption: String { + let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label + if combinedUsage != nil { + return "Combined · \(label)" + } + if !store.isDayMode && store.selectedPeriod == .today { + return "\(label) · \(todayDate)" + } + return label + } + + /// Local-model savings caption shown beneath the hero amount when the + /// user has mapped any local model to a paid baseline via + /// `codeburn model-savings`. Kept as a separate line so actual spend + /// (above) and hypothetical avoided spend (below) never get summed + /// into a misleading "real cost" by the reader. + private var savingsCaption: String? { + guard combinedUsage == nil else { return nil } + let savings = store.payload.current.localModelSavings.totalUSD + guard savings > 0 else { return nil } + return "Saved \(savings.asCurrency()) with local models" + } + + private var todayDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEE MMM d" + return formatter.string(from: Date()) + } +} + +struct HeroTotals: Equatable { + let cost: Double + let calls: Int + let sessions: Int + let inputTokens: Int + let outputTokens: Int + let totalTokens: Int + + init(cost: Double, calls: Int, sessions: Int, inputTokens: Int, outputTokens: Int, totalTokens: Int) { + self.cost = cost + self.calls = calls + self.sessions = sessions + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + + init(payload: MenubarPayload, activeScope: MenubarScope) { + if activeScope == .combined, let combined = payload.combined?.combined { + self.init( + cost: combined.cost, + calls: combined.calls, + sessions: combined.sessions, + inputTokens: combined.inputTokens, + outputTokens: combined.outputTokens, + totalTokens: combined.inputTokens + combined.outputTokens + ) + return + } + + let current = payload.current + self.init( + cost: current.cost, + calls: current.calls, + sessions: current.sessions, + inputTokens: current.inputTokens, + outputTokens: current.outputTokens, + totalTokens: current.inputTokens + current.outputTokens + ) + } +} + +private struct CombinedDeviceBreakdown: View { + let usage: CombinedUsage + let formatTokens: (Double) -> String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 4) { + Image(systemName: "desktopcomputer") + .font(.system(size: 10)) + Text("\(usage.combined.reachableCount) of \(usage.combined.deviceCount) devices") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.secondary) + + VStack(spacing: 3) { + ForEach(usage.perDevice, id: \.id) { device in + HStack(spacing: 6) { + Image(systemName: device.error == nil ? "circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: device.error == nil ? 5 : 9, weight: .semibold)) + .foregroundStyle(device.error == nil ? Color.secondary.opacity(0.75) : Theme.semanticWarning) + .frame(width: 10) + Text(device.local ? "\(device.name) · local" : device.name) + .font(.system(size: 10.5, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 6) + Text(device.error == nil ? device.cost.asCurrency() : "Unavailable") + .font(.system(size: 10.5)) + .monospacedDigit() + .foregroundStyle(.secondary) + Text(formatTokens(Double(device.totalTokens))) + .font(.system(size: 10)) + .monospacedDigit() + .foregroundStyle(.tertiary) + } + } + } + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift new file mode 100644 index 0000000..a1c945c --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -0,0 +1,817 @@ +import AppKit +import SwiftUI + +/// Popover root. Assembles all sections matching the HTML design spec. +struct MenuBarContent: View { + @Environment(AppStore.self) private var store + + var body: some View { + VStack(spacing: 0) { + Header() + + Divider() + + if showAgentTabs { + AgentTabStrip() + Divider() + } + + ZStack { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + HeroSection() + Divider().opacity(0.5) + PeriodSegmentedControl() + ScopeSegmentedControl() + Divider().opacity(0.5) + if isFilteredEmpty { + EmptyProviderState(provider: store.selectedProvider, periodLabel: store.selectionLabel) + } else { + HeatmapSection() + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 10) + .zIndex(10) + Divider().opacity(0.5) + ActivitySection() + Divider().opacity(0.5) + ModelsSection() + Divider().opacity(0.5) + ToolingSection() + Divider().opacity(0.5) + FindingsSection() + } + } + } + + // Overlay fires only on cold cache for the current key. This + // avoids the 1-frame `$0.00` flash on first-time period/provider + // switches. When the fetch fails (CLI subprocess timeout, parse + // error, etc.), surface a retry card instead of leaving the + // user stuck on a perpetual "Loading..." spinner. + if !store.hasCachedData { + if let err = store.lastError { + FetchErrorOverlay( + error: err, + periodLabel: store.selectionLabel, + retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } } + ) + .transition(.opacity) + } else { + BurnLoadingOverlay(periodLabel: store.selectionLabel) + .transition(.opacity) + .task { + var delay: Duration = .seconds(8) + let maxDelay: Duration = .seconds(60) + let maxAttempts = 6 + for attempt in 1...maxAttempts { + try? await Task.sleep(for: delay) + guard !Task.isCancelled, !store.hasCachedData else { return } + await store.recoverFromStuckLoading() + if attempt < maxAttempts { delay = min(delay * 2, maxDelay) } + } + guard !Task.isCancelled, !store.hasCachedData else { return } + store.setRecoveryExhausted(for: store.selectionLabel) + } + } + } + } + .frame(height: 520) + .animation(.easeInOut(duration: 0.2), value: store.isLoading) + + Divider() + + FooterBar() + + CLIUpdateBanner() + + StarBanner() + } + } + + private var isFilteredEmpty: Bool { + guard store.selectedProvider != .all else { return false } + if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false } + if providerHasCostInAllPayload { return false } + return true + } + + private var providerHasCostInAllPayload: Bool { + guard let allPayload = store.periodAllPayload else { return false } + let providers = Dictionary( + allPayload.current.providers.map { ($0.key.lowercased(), $0.value) }, + uniquingKeysWith: + + ) + return store.selectedProvider.providerKeys.contains { key in + (providers[key] ?? 0) > 0 + } + } + + /// Show the tab row whenever the CLI detected at least one AI coding tool installed + /// on this machine. Hidden only when nothing is detected, which means there's + /// nothing to filter by anyway. + private var showAgentTabs: Bool { + // Sticky: once any cached payload has reported providers, keep the tab strip + // visible. Without this, the strip disappears for one frame on a period + // switch when the new key's payload is still empty. + if store.hasAnyProvidersInCache { return true } + let payload = store.todayPayload ?? store.payload + return !payload.current.providers.isEmpty + } + +} + +private struct ScopeSegmentedControl: View { + @Environment(AppStore.self) private var store + + var body: some View { + HStack(spacing: 8) { + HStack(spacing: 1) { + ForEach(MenubarScope.allCases) { scope in + let isActive = store.activeScope == scope + Button { + store.switchTo(scope: scope) + } label: { + Text(scope.rawValue) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear) + .shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5) + ) + } + } + .padding(2) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color.secondary.opacity(0.08)) + ) + .frame(maxWidth: .infinity) + + if store.shouldShowClaudeConfigSelector { + ClaudeConfigPicker() + } + } + .padding(.horizontal, 12) + .padding(.bottom, 10) + } +} + +private struct ClaudeConfigPicker: View { + @Environment(AppStore.self) private var store + + private var selectedLabel: String { + guard let selected = store.selectedClaudeConfigSourceId, + let option = store.claudeConfigOptions.first(where: { $0.id == selected }) else { + return "All" + } + return option.label + } + + var body: some View { + Menu { + Button { + store.switchTo(claudeConfigSourceId: nil) + } label: { + HStack { + if store.selectedClaudeConfigSourceId == nil { + Image(systemName: "checkmark") + } + Text("All") + } + } + + Divider() + + ForEach(store.claudeConfigOptions) { option in + Button { + store.switchTo(claudeConfigSourceId: option.id) + } label: { + HStack { + if store.selectedClaudeConfigSourceId == option.id { + Image(systemName: "checkmark") + } + Text(option.label) + } + } + } + } label: { + HStack(spacing: 5) { + Image(systemName: "person.crop.circle") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + Text(selectedLabel) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + } + .foregroundStyle(.primary) + .frame(width: 118, height: 26) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color.secondary.opacity(0.08)) + ) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .fixedSize(horizontal: true, vertical: false) + .help("Claude config") + } +} + +private struct EmptyProviderState: View { + let provider: ProviderFilter + let periodLabel: String + + var body: some View { + VStack(spacing: 10) { + Image(systemName: "tray") + .font(.system(size: 26)) + .foregroundStyle(.tertiary) + Text("No \(provider.rawValue) data for \(periodLabel)") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } + +} + +/// Shown when a fetch failed and the cache is still empty for this key. The +/// user previously sat on the "Loading…" spinner forever — the popover had +/// no path to recover beyond the next 30s tick (which would just re-fail). +/// Now they see what broke and can retry directly. +private struct FetchErrorOverlay: View { + let error: String + let periodLabel: String + let retry: () -> Void + + var body: some View { + ZStack { + Rectangle().fill(.ultraThinMaterial) + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 28)) + .foregroundStyle(Theme.brandAccent) + Text("Couldn't load \(periodLabel)") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(.primary) + Text(displayError) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + .lineLimit(3) + Button("Retry", action: retry) + .buttonStyle(.borderedProminent) + .tint(Theme.brandAccent) + .controlSize(.small) + } + .padding(.horizontal, 20) + } + } + + /// Strip the leading subprocess noise that creeps into NSError descriptions + /// so the visible message is the actual cause, not the framework wrapper. + private var displayError: String { + let trimmed = error.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count <= 240 { return trimmed } + return String(trimmed.prefix(240)) + "…" + } +} + +/// Translucent overlay that blurs whatever's behind it (the previous tab/period content) +/// and centers an animated burning flame -- the brand mark filling up bottom-to-top in +/// yellow→orange→red, looping. +private struct BurnLoadingOverlay: View { + let periodLabel: String + @State private var fillProgress: CGFloat = 0 + @State private var glowing: Bool = false + + private let flameSize: CGFloat = 64 + + var body: some View { + ZStack { + // Blur backdrop -- ultraThinMaterial uses live blur of underlying content. + Rectangle() + .fill(.ultraThinMaterial) + + VStack(spacing: 14) { + BurnFlame(size: flameSize, fillProgress: fillProgress, glowing: glowing) + Text("Loading \(periodLabel)…") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(.secondary) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) { + fillProgress = 1.0 + } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + glowing = true + } + } + } +} + +private struct BurnFlame: View { + let size: CGFloat + let fillProgress: CGFloat + let glowing: Bool + + var body: some View { + ZStack { + // Soft outer glow that pulses, matching the brand terracotta palette. + Image(systemName: "flame.fill") + .font(.system(size: size, weight: .regular)) + .foregroundStyle(Theme.brandAccentGlow.opacity(glowing ? 0.55 : 0.20)) + .blur(radius: glowing ? 14 : 6) + + // Empty (cool) flame as base + Image(systemName: "flame") + .font(.system(size: size, weight: .regular)) + .foregroundStyle(Theme.brandAccent.opacity(0.25)) + + // Burning gradient (brand orange) masked by an animated bottom-up rectangle + Image(systemName: "flame.fill") + .font(.system(size: size, weight: .regular)) + .foregroundStyle( + LinearGradient( + colors: [ + Theme.brandAccentGlow, + Theme.brandAccentLight, + Theme.brandAccent, + Theme.brandAccentDeep + ], + startPoint: .bottom, + endPoint: .top + ) + ) + .mask( + GeometryReader { geo in + Rectangle() + .frame(height: geo.size.height * fillProgress) + .frame(maxHeight: .infinity, alignment: .bottom) + } + ) + } + .frame(width: size, height: size) + } +} + +private struct Header: View { + @Environment(UpdateChecker.self) private var updateChecker + @Environment(AppStore.self) private var store + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 1) { + ( + Text("Code").foregroundStyle(.primary) + + Text("Burn").foregroundStyle(Theme.brandEmber) + ) + .font(.system(size: 13, weight: .semibold)) + .tracking(-0.15) + Text("AI Coding Cost Tracker") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + Spacer() + if updateChecker.updateAvailable || updateChecker.updateError != nil { + UpdateBadge() + } + AccentPicker() + } + // Compact warning row when any connected provider crosses 70%. + // Lists all warning providers with their worst-window percent so + // the user knows whether to slow down on Claude, Codex, or both. + QuotaWarningRow(status: store.aggregateQuotaStatus) + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 8) + } +} + +private struct QuotaWarningRow: View { + let status: AppStore.AggregateQuotaStatus + + var body: some View { + if !status.warnings.isEmpty { + HStack(spacing: 6) { + Image(systemName: severityIcon) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(severityColor) + Text(message) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(severityColor) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(severityColor.opacity(0.12)) + ) + } + } + + private var message: String { + let parts = status.warnings.map { "\($0.name) \(Int($0.percent.rounded()))%" } + if parts.count == 1 { + // Reads "Claude over limit (105%)" when any provider exceeds the + // quota cap, instead of the awkward "Claude 105% of quota used". + if case .danger = status.severity { + return "\(status.warnings[0].name) over limit (\(Int(status.warnings[0].percent.rounded()))%)" + } + return "\(parts[0]) of quota used" + } + return parts.joined(separator: " · ") + } + + private var severityColor: Color { + switch status.severity { + case .normal: return .secondary + case .warning: return .yellow + case .critical: return .orange + case .danger: return .red + } + } + + private var severityIcon: String { + switch status.severity { + case .normal: return "info.circle" + case .warning: return "exclamationmark.circle" + case .critical: return "exclamationmark.triangle" + case .danger: return "octagon" + } + } +} + +private struct AccentPicker: View { + @Environment(AppStore.self) private var store + + var body: some View { + HStack(spacing: 0) { + if store.showingAccentPicker { + HStack(spacing: 5) { + ForEach(AccentPreset.allCases) { preset in + Button { + withAnimation(.easeInOut(duration: 0.15)) { + store.accentPreset = preset + } + } label: { + Circle() + .fill(preset.base) + .frame(width: 12, height: 12) + .overlay( + Circle() + .stroke(.white.opacity(store.accentPreset == preset ? 0.9 : 0), lineWidth: 1.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(preset.rawValue) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.08)) + ) + .transition(.opacity.combined(with: .move(edge: .trailing))) + } + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + store.showingAccentPicker.toggle() + } + } label: { + Circle() + .fill(store.accentPreset.base) + .frame(width: 14, height: 14) + .overlay( + Circle() + .stroke(.white.opacity(0.3), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Change accent color") + .padding(.leading, 4) + } + } +} + +private struct UpdateBadge: View { + @Environment(UpdateChecker.self) private var updateChecker + + var body: some View { + Button { + if updateChecker.updateAvailable { + updateChecker.performUpdate() + } else { + Task { await updateChecker.check() } + } + } label: { + HStack(spacing: 4) { + if updateChecker.isUpdating { + ProgressView() + .controlSize(.mini) + .scaleEffect(0.7) + } else if updateChecker.updateError != nil { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + } else { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 10)) + } + Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed")) + .font(.system(size: 10, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .tint(Theme.brandAccent) + .controlSize(.mini) + .disabled(updateChecker.isUpdating) + .help(updateChecker.updateError ?? "Install the latest menubar build") + } +} + +struct FlameMark: View { + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 5) + .fill( + LinearGradient( + colors: [Theme.brandAccentLight, Theme.brandAccentDeep], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .shadow(color: .black.opacity(0.2), radius: 1, y: 0.5) + Image(systemName: "flame.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + } + } +} + +struct CLIUpdateBanner: View { + @Environment(UpdateChecker.self) private var updateChecker + + var body: some View { + if updateChecker.cliUpdateAvailable { + HStack(spacing: 6) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.blue) + + Text("CLI \(updateChecker.latestCliVersion ?? "") available") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(.primary) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(updateChecker.cliUpdateCommand, forType: .string) + } label: { + HStack(spacing: 3) { + Text(updateChecker.cliUpdateCommand) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + Image(systemName: "doc.on.doc") + .font(.system(size: 8)) + } + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + .help("Copy update command to clipboard") + + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.06)) + .overlay(alignment: .top) { + Rectangle() + .fill(Color.secondary.opacity(0.18)) + .frame(height: 0.5) + } + } + } +} + +private let starBannerGitHubURL = URL(string: "https://github.com/getagentseal/codeburn")! + +/// Shown at the very bottom on first launch. A small terracotta strip nudges users to star the +/// repo; clicking opens GitHub, clicking the close icon hides it forever (persisted to +/// UserDefaults so it never returns across launches). +struct StarBanner: View { + @AppStorage("codeburn.starBannerDismissed") private var dismissed: Bool = false + + var body: some View { + if !dismissed { + HStack(spacing: 8) { + Image(systemName: "star.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.brandAccent) + + Button { + NSWorkspace.shared.open(starBannerGitHubURL) + } label: { + HStack(spacing: 4) { + Text("Enjoying CodeBurn?") + .foregroundStyle(.primary) + Text("Star us on GitHub") + .foregroundStyle(Theme.brandAccent) + .underline(true, pattern: .solid) + } + .font(.system(size: 10.5, weight: .medium)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Spacer() + + Button { + dismissed = true + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Hide this banner") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Theme.brandAccent.opacity(0.08)) + .overlay(alignment: .top) { + Rectangle() + .fill(Color.secondary.opacity(0.18)) + .frame(height: 0.5) + } + } + } +} + +struct FooterBar: View { + @Environment(AppStore.self) private var store + + var body: some View { + HStack(spacing: 6) { + Menu { + ForEach(SupportedCurrency.allCases) { currency in + Button { + applyCurrency(code: currency.rawValue) + } label: { + if currency.rawValue == store.currency { + Label("\(currency.displayName) (\(currency.rawValue))", systemImage: "checkmark") + } else { + Text("\(currency.displayName) (\(currency.rawValue))") + } + } + } + } label: { + Label(store.currency, systemImage: "dollarsign.circle") + .font(.system(size: 11, weight: .medium)) + .labelStyle(.titleAndIcon) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.bordered) + .controlSize(.small) + .fixedSize() + + Button { + refreshNow() + } label: { + Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(store.isLoading) + + Menu { + Button("CSV (folder)") { runExport(format: .csv) } + Button("JSON") { runExport(format: .json) } + } label: { + Label("Export", systemImage: "square.and.arrow.down") + .font(.system(size: 11, weight: .medium)) + .labelStyle(.titleAndIcon) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.bordered) + .controlSize(.small) + .fixedSize() + + Spacer() + + Text(AppVersion.displayBundleShortVersion) + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(.tertiary) + + Button { openReport() } label: { + Label("Full Report", systemImage: "terminal") + .font(.system(size: 11, weight: .semibold)) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(Theme.brandAccent) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func openReport() { + TerminalLauncher.open(subcommand: ["report"]) + } + + private func refreshNow() { + if let delegate = NSApp.delegate as? AppDelegate { + delegate.refreshSubscriptionNow() + } else { + Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } + } + } + + private enum ExportFormat { + case csv, json + var cliName: String { self == .csv ? "csv" : "json" } + var suffix: String { self == .csv ? "" : ".json" } + } + + /// Runs `codeburn export` directly into ~/Downloads and reveals the result in Finder. CSV + /// produces a folder of clean one-table-per-file CSVs; JSON produces a single structured + /// file. The CLI is spawned with argv (no shell interpretation), so the output path cannot + /// be abused to inject shell commands even if a pathological value slips through. + private func runExport(format: ExportFormat) { + Task { + let downloads = (NSHomeDirectory() as NSString).appendingPathComponent("Downloads") + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + let base = "codeburn-\(formatter.string(from: Date()))" + let outputPath = (downloads as NSString).appendingPathComponent(base + format.suffix) + + let process = CodeburnCLI.makeProcess(subcommand: [ + "export", "-f", format.cliName, "-o", outputPath + ]) + + do { + let fmt = format + process.terminationHandler = { proc in + Task { @MainActor in + if proc.terminationStatus == 0 { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)]) + } else { + NSLog("CodeBurn: \(fmt.cliName.uppercased()) export exited with status \(proc.terminationStatus)") + } + } + } + try process.run() + } catch { + NSLog("CodeBurn: \(format.cliName.uppercased()) export failed: \(error)") + } + } + } + + /// Instant-feeling currency switch. Updates the symbol and any cached FX rate on the main + /// thread right away so the UI redraws the next frame, then fetches a fresh rate in the + /// background. CLI config is persisted so other codeburn commands stay in sync. + private func applyCurrency(code: String) { + let symbol = CurrencyState.symbolForCode(code) + + Task { + let cached = await FXRateCache.shared.cachedRate(for: code) + if let cached { + store.currency = code + CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol) + } + + let fresh = await FXRateCache.shared.rate(for: code) + if let rate = fresh ?? cached { + store.currency = code + CurrencyState.shared.apply(code: code, rate: rate, symbol: symbol) + } + } + + CLICurrencyConfig.persist(code: code) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift new file mode 100644 index 0000000..4cc90d4 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct ModelsSection: View { + @Environment(AppStore.self) private var store + @State private var isExpanded: Bool = true + + // Only surface the Saved column when something was actually saved by a + // local-model mapping. With no mapping it would be an unlabeled column of + // dashes, so we drop it entirely and keep the plain Cost / Calls layout. + private var showSavings: Bool { + store.payload.current.topModels.contains { $0.savingsUSD > 0 } + } + + var body: some View { + CollapsibleSection( + caption: "Models", + isExpanded: $isExpanded, + trailing: { + HStack(spacing: 8) { + Text("Cost").frame(minWidth: 54, alignment: .trailing) + if showSavings { + Text("Saved").frame(minWidth: 54, alignment: .trailing) + } + Text("Calls").frame(minWidth: 52, alignment: .trailing) + } + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + .tracking(-0.05) + } + ) { + VStack(alignment: .leading, spacing: 7) { + let maxCost = max(store.payload.current.topModels.map(\.cost).max() ?? 1, 0.01) + ForEach(store.payload.current.topModels, id: \.name) { model in + ModelRow(model: model, maxCost: maxCost, showSavings: showSavings) + } + + TokensLine() + .padding(.top, 5) + } + } + } +} + +private struct ModelRow: View { + let model: ModelEntry + let maxCost: Double + let showSavings: Bool + + var body: some View { + HStack(spacing: 8) { + // Bar tracks actual cost; for local models the cost is $0 and the + // bar will be empty. Saved counterfactual (if any) renders as + // green text in the saved column, never summed into the bar. + FixedBar(fraction: model.cost / maxCost) + .frame(width: 56, height: 6) + + Text(model.name) + .font(.system(size: 12.5, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(model.cost.asCompactCurrency()) + .font(.codeMono(size: 12, weight: .medium)) + .tracking(-0.2) + .frame(minWidth: 54, alignment: .trailing) + + if showSavings { + Text(model.savingsUSD > 0 ? model.savingsUSD.asCompactCurrency() : "—") + .font(.codeMono(size: 12)) + .tracking(-0.2) + .foregroundStyle(model.savingsUSD > 0 ? Color.green : Color.secondary) + .frame(minWidth: 54, alignment: .trailing) + } + + Text("\(model.calls)") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 52, alignment: .trailing) + } + .padding(.horizontal, 2) + .padding(.vertical, 1) + } +} + +private struct TokensLine: View { + @Environment(AppStore.self) private var store + + var body: some View { + let t = store.payload.current + let cacheHit = String(format: "%.0f", t.cacheHitPercent) + + HStack(spacing: 4) { + Text("Tokens") + .foregroundStyle(.tertiary) + Text(formatTokens(t.inputTokens) + " in") + .foregroundStyle(.secondary) + Text("·") + .foregroundStyle(.tertiary) + Text(formatTokens(t.outputTokens) + " out") + .foregroundStyle(.secondary) + Text("·") + .foregroundStyle(.tertiary) + Text(cacheHit + "% cache hit") + .foregroundStyle(.secondary) + Spacer() + } + .font(.system(size: 10.5)) + .monospacedDigit() + } + + private func formatTokens(_ n: Int) -> String { + if n >= 1_000_000 { + return String(format: "%.1fM", Double(n) / 1_000_000) + } else if n >= 1_000 { + return String(format: "%.1fK", Double(n) / 1_000) + } + return "\(n)" + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift new file mode 100644 index 0000000..5c2307d --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift @@ -0,0 +1,304 @@ +import SwiftUI + +struct PeriodSegmentedControl: View { + @Environment(AppStore.self) private var store + @State private var showingCalendar = false + + var body: some View { + HStack(spacing: 1) { + ForEach(Period.allCases) { period in + let isActive = !store.isDayMode && store.selectedPeriod == period + Button { + store.switchTo(period: period) + } label: { + Text(period.rawValue) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear) + .shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5) + ) + } + + Button { + showingCalendar.toggle() + } label: { + Image(systemName: "calendar") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(store.isDayMode ? Theme.brandAccent : .secondary) + .frame(width: 28) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(store.isDayMode ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear) + .shadow(color: .black.opacity(store.isDayMode ? 0.06 : 0), radius: 1, y: 0.5) + ) + .popover(isPresented: $showingCalendar, arrowEdge: .bottom) { + CalendarPopover(isPresented: $showingCalendar) + .environment(store) + } + } + .padding(2) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color.secondary.opacity(0.08)) + ) + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, 10) + } +} + +private struct CalendarPopover: View { + @Environment(AppStore.self) private var store + @Binding var isPresented: Bool + @State private var displayMonth = Date() + @State private var pending: Set = [] + + private let calendar = Calendar.current + private let weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + private let cellSize: CGFloat = 30 + + var body: some View { + VStack(spacing: 0) { + HStack { + Button { shiftMonth(-1) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Spacer() + + Text(monthYearLabel) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + + Spacer() + + Button { shiftMonth(1) } label: { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(canGoForward ? .secondary : Color.secondary.opacity(0.3)) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canGoForward) + } + .padding(.horizontal, 10) + .padding(.top, 10) + .padding(.bottom, 6) + + HStack(spacing: 0) { + ForEach(weekdays, id: \.self) { day in + Text(day) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.tertiary) + .frame(width: cellSize, height: 16) + } + } + .padding(.bottom, 2) + + LazyVGrid(columns: Array(repeating: GridItem(.fixed(cellSize), spacing: 0), count: 7), spacing: 2) { + ForEach(dayCells, id: \.id) { cell in + DayCellView( + cell: cell, + isSelected: pending.contains(cell.dateString), + isToday: cell.dateString == todayString, + isFuture: cell.dateString > todayString + ) { + toggleDay(cell.dateString) + } + } + } + .padding(.horizontal, 6) + + HStack(spacing: 8) { + if !pending.isEmpty { + Button("Clear") { + pending = [] + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .buttonStyle(.plain) + } + + Spacer() + + Text(selectionSummary) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .lineLimit(1) + + Spacer() + + Button { + if !pending.isEmpty { + store.switchTo(days: pending) + } else { + store.switchTo(period: store.selectedPeriod) + } + isPresented = false + } label: { + Text("Done") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(pending.isEmpty ? Color.secondary.opacity(0.3) : Theme.brandAccent) + ) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 10) + .padding(.top, 8) + .padding(.bottom, 10) + } + .frame(width: cellSize * 7 + 24) + .onAppear { + pending = store.selectedDays + if let first = store.selectedDays.sorted().first, + let date = AppStore.dayFormatter.date(from: first) { + displayMonth = date + } + } + } + + private var todayString: String { + AppStore.dayString(from: Date()) + } + + private var monthYearLabel: String { + let f = DateFormatter() + f.dateFormat = "MMMM yyyy" + return f.string(from: displayMonth) + } + + private var canGoForward: Bool { + let nextMonth = calendar.date(byAdding: .month, value: 1, to: displayMonth) ?? displayMonth + return calendar.startOfDay(for: nextMonth) <= calendar.startOfDay(for: Date()) + } + + private var selectionSummary: String { + if pending.isEmpty { return "Pick dates" } + if pending.count == 1 { return "1 day" } + return "\(pending.count) days" + } + + private func shiftMonth(_ delta: Int) { + if let next = calendar.date(byAdding: .month, value: delta, to: displayMonth) { + displayMonth = next + } + } + + private func toggleDay(_ day: String) { + guard day <= todayString else { return } + if pending.contains(day) { + pending.remove(day) + } else { + pending.insert(day) + } + } + + var dayCells: [DayCell] { + let comps = calendar.dateComponents([.year, .month], from: displayMonth) + guard let firstOfMonth = calendar.date(from: comps), + let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { return [] } + + var weekdayOfFirst = calendar.component(.weekday, from: firstOfMonth) - 2 + if weekdayOfFirst < 0 { weekdayOfFirst += 7 } + + var cells: [DayCell] = [] + + for offset in stride(from: -weekdayOfFirst, to: 0, by: 1) { + if let date = calendar.date(byAdding: .day, value: offset, to: firstOfMonth) { + let d = calendar.component(.day, from: date) + cells.append(DayCell(id: "prev-\(offset)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false)) + } + } + + for day in range { + if let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) { + cells.append(DayCell(id: "cur-\(day)", day: day, dateString: AppStore.dayString(from: date), isCurrentMonth: true)) + } + } + + let remainder = (7 - cells.count % 7) % 7 + if let lastOfMonth = calendar.date(byAdding: .day, value: range.count - 1, to: firstOfMonth) { + for i in 1...max(remainder, 1) { + if let date = calendar.date(byAdding: .day, value: i, to: lastOfMonth) { + let d = calendar.component(.day, from: date) + cells.append(DayCell(id: "next-\(i)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false)) + } + } + } + if cells.count % 7 != 0 { + cells = Array(cells.prefix(cells.count - cells.count % 7)) + } + + return cells + } +} + +private struct DayCell: Identifiable { + let id: String + let day: Int + let dateString: String + let isCurrentMonth: Bool +} + +private struct DayCellView: View { + let cell: DayCell + let isSelected: Bool + let isToday: Bool + let isFuture: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text("\(cell.day)") + .font(.system(size: 11, weight: isToday ? .bold : .regular)) + .foregroundStyle(foregroundColor) + .frame(width: 28, height: 28) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(backgroundColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isToday && !isSelected ? Theme.brandAccent.opacity(0.5) : .clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isFuture) + } + + private var foregroundColor: Color { + if isFuture { return Color.secondary.opacity(0.25) } + if isSelected { return .white } + if !cell.isCurrentMonth { return Color.secondary.opacity(0.4) } + if isToday { return Theme.brandAccent } + return .primary + } + + private var backgroundColor: Color { + if isSelected { return Theme.brandAccent } + return .clear + } +} + diff --git a/mac/Sources/CodeBurnMenubar/Views/SectionCaption.swift b/mac/Sources/CodeBurnMenubar/Views/SectionCaption.swift new file mode 100644 index 0000000..1c4db4c --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/SectionCaption.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct SectionCaption: View { + let text: String + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(Theme.brandAccent.opacity(0.7)) + .frame(width: 3, height: 3) + Text(text) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(.secondary) + .tracking(-0.1) + } + } +} + +/// Collapsible section shell with a clickable caption, optional inline trailing +/// view (e.g. column headers), and a chevron. +struct CollapsibleSection: View { + let caption: String + @Binding var isExpanded: Bool + let trailing: Trailing + let content: Content + + init( + caption: String, + isExpanded: Binding, + @ViewBuilder trailing: () -> Trailing, + @ViewBuilder content: () -> Content + ) { + self.caption = caption + self._isExpanded = isExpanded + self.trailing = trailing() + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + HStack(spacing: 5) { + Circle() + .fill(Theme.brandAccent.opacity(0.7)) + .frame(width: 3, height: 3) + Text(caption) + .font(.system(size: 11.5, weight: .medium)) + .tracking(-0.1) + } + Spacer() + trailing + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .opacity(0.55) + } + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + content + .transition(.opacity) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + } +} + +extension CollapsibleSection where Trailing == EmptyView { + init( + caption: String, + isExpanded: Binding, + @ViewBuilder content: () -> Content + ) { + self.init(caption: caption, isExpanded: isExpanded, trailing: { EmptyView() }, content: content) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift new file mode 100644 index 0000000..494e1f9 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -0,0 +1,715 @@ +import AppKit +import SwiftUI + +/// macOS-standard tabbed Settings window. New per-provider sections (Codex, +/// Cursor, Copilot, etc.) plug in as additional tabs. Each tab owns its own +/// concerns; this top-level view only hosts the TabView shell. +struct SettingsView: View { + @Environment(AppStore.self) private var store + + var body: some View { + TabView(selection: Binding(get: { store.settingsTab }, set: { store.settingsTab = $0 })) { + GeneralSettingsTab() + .tabItem { Label("General", systemImage: "gearshape") } + .tag("general") + + ClaudeSettingsTab() + .tabItem { Label("Claude", systemImage: "brain") } + .tag("claude") + + CodexSettingsTab() + .tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") } + .tag("codex") + + DevinSettingsTab() + .tabItem { Label("Devin", systemImage: "flame.fill") } + .tag("devin") + + AboutSettingsTab() + .tabItem { Label("About", systemImage: "info.circle") } + .tag("about") + } + .frame(width: 520, height: 430) + } +} + +// MARK: - General + +private struct GeneralSettingsTab: View { + @Environment(AppStore.self) private var store + + // "Custom…" budget entry state, one per metric (cost in dollars, tokens in + // millions). When custom is active the picker shows "Custom…" and a field + // appears for an exact amount. + @State private var costCustom = false + @State private var tokenCustom = false + @State private var costText = "" + @State private var tokenText = "" + + // 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 + + private let costPresets: Set = [25, 50, 100, 200, 500] + private let tokenPresets: Set = [1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 100_000_000] + + private func applyCostBudget() { + store.dailyBudget = max(0, Double(costText.trimmingCharacters(in: .whitespaces)) ?? 0) + } + + private func applyTokenBudget() { + let millions = Double(tokenText.trimmingCharacters(in: .whitespaces)) ?? 0 + store.dailyTokenBudget = max(0, millions * 1_000_000) + } + + private func trimNumber(_ v: Double) -> String { + v == v.rounded() ? String(Int(v)) : String(v) + } + + // Help text under the budget picker. When "Custom…" is selected but no amount + // has been entered, the budget is effectively 0 (off); call that out so the + // alert does not look armed when it isn't. + private var alertHelpText: String { + let customEmpty = store.isTokenMetric + ? (tokenCustom && store.dailyTokenBudget == 0) + : (costCustom && store.dailyBudget == 0) + if customEmpty { return "Enter an amount above, or the alert stays off." } + return "Flame icon turns yellow when today's \(store.isTokenMetric ? "tokens" : "cost") pass the daily budget." + } + + var body: some View { + Form { + Section("Display") { + Picker("Currency", selection: Binding( + get: { store.currency }, + set: { applyCurrency(code: $0) } + )) { + ForEach(SupportedCurrency.allCases) { currency in + Text("\(currency.rawValue) — \(currency.displayName)").tag(currency.rawValue) + } + } + Picker("Metric", selection: Binding( + get: { store.displayMetric }, + set: { store.displayMetric = $0 } + )) { + Text("Cost ($)").tag(DisplayMetric.cost) + Text("Tokens (↑↓)").tag(DisplayMetric.tokens) + Text("Total Tokens").tag(DisplayMetric.totalTokens) + Text("Credits (Codex)").tag(DisplayMetric.credits) + Text("Icon Only").tag(DisplayMetric.iconOnly) + } + Picker("Period", selection: Binding( + get: { store.menubarPeriod }, + set: { store.setMenubarPeriod($0) } + )) { + ForEach(Period.menubarMetricCases) { period in + Text(period.menubarMetricLabel).tag(period) + } + } + .pickerStyle(.menu) + Picker("Scope", selection: Binding( + get: { store.menubarScope }, + set: { store.setMenubarScope($0) } + )) { + ForEach(MenubarScope.allCases) { scope in + Text(scope.rawValue).tag(scope) + } + } + .pickerStyle(.menu) + Picker("Accent", selection: Binding( + get: { store.accentPreset }, + set: { store.accentPreset = $0 } + )) { + ForEach(AccentPreset.allCases) { preset in + Text(preset.rawValue).tag(preset) + } + } + } + + 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") { + // The budget tracks whatever the menubar metric shows: dollars for + // the Cost metric, tokens for the Tokens / Total Tokens metrics. + // "Custom…" reveals a field for an exact amount. + if store.isTokenMetric { + Picker("Daily budget", selection: Binding( + get: { tokenCustom ? -1.0 : store.dailyTokenBudget }, + set: { sel in + if sel < 0 { + tokenCustom = true + tokenText = store.dailyTokenBudget > 0 ? trimNumber(store.dailyTokenBudget / 1_000_000) : "" + } else { + tokenCustom = false + store.dailyTokenBudget = sel + } + } + )) { + Text("Off").tag(0.0) + Text("1M").tag(1_000_000.0) + Text("5M").tag(5_000_000.0) + Text("10M").tag(10_000_000.0) + Text("25M").tag(25_000_000.0) + Text("50M").tag(50_000_000.0) + Text("100M").tag(100_000_000.0) + Text("Custom…").tag(-1.0) + } + if tokenCustom { + HStack { + TextField("Amount", text: $tokenText) + .multilineTextAlignment(.trailing) + .onSubmit { applyTokenBudget() } + .onChange(of: tokenText) { _, _ in applyTokenBudget() } + Text("M tokens").foregroundStyle(.secondary) + } + } + } else { + Picker("Daily budget", selection: Binding( + get: { costCustom ? -1.0 : store.dailyBudget }, + set: { sel in + if sel < 0 { + costCustom = true + costText = store.dailyBudget > 0 ? trimNumber(store.dailyBudget) : "" + } else { + costCustom = false + store.dailyBudget = sel + } + } + )) { + Text("Off").tag(0.0) + Text("$25").tag(25.0) + Text("$50").tag(50.0) + Text("$100").tag(100.0) + Text("$200").tag(200.0) + Text("$500").tag(500.0) + Text("Custom…").tag(-1.0) + } + if costCustom { + HStack { + Text("$").foregroundStyle(.secondary) + TextField("Amount", text: $costText) + .multilineTextAlignment(.trailing) + .onSubmit { applyCostBudget() } + .onChange(of: costText) { _, _ in applyCostBudget() } + } + } + } + Text(alertHelpText) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .onAppear { + costCustom = store.dailyBudget > 0 && !costPresets.contains(store.dailyBudget) + if costCustom { costText = trimNumber(store.dailyBudget) } + tokenCustom = store.dailyTokenBudget > 0 && !tokenPresets.contains(store.dailyTokenBudget) + if tokenCustom { tokenText = trimNumber(store.dailyTokenBudget / 1_000_000) } + } + } + .formStyle(.grouped) + .padding() + } + + private func applyCurrency(code: String) { + let symbol = CurrencyState.symbolForCode(code) + Task { + let cached = await FXRateCache.shared.cachedRate(for: code) + if let cached { + store.currency = code + CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol) + } + let fresh = await FXRateCache.shared.rate(for: code) + store.currency = code + CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol) + } + CLICurrencyConfig.persist(code: code) + } +} + +// MARK: - Claude + +private struct ClaudeSettingsTab: View { + @Environment(AppStore.self) private var store + + var body: some View { + Form { + Section("Connection") { + ClaudeConnectionRow() + } + Section { + ClaudeConfigDirsSection() + } header: { + Text("Config Directories") + } footer: { + Text("Aggregate usage across multiple Claude config directories (e.g. work and personal accounts). Leave empty to track just the default `~/.claude`. The `CLAUDE_CONFIG_DIRS` environment variable, if set, overrides this list.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Section("Quota Refresh") { + Picker("Update every", selection: Binding( + get: { SubscriptionRefreshCadence.current }, + set: { SubscriptionRefreshCadence.current = $0 } + )) { + ForEach(SubscriptionRefreshCadence.allCases) { cadence in + Text(cadence.label).tag(cadence) + } + } + .pickerStyle(.menu) + Text("Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Button("Refresh Now") { + Task { await store.refreshSubscription() } + } + } + } + .formStyle(.grouped) + .padding() + } +} + +private struct ClaudeConnectionRow: View { + @Environment(AppStore.self) private var store + @State private var showDisconnectConfirm = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: stateIcon) + .font(.system(size: 18)) + .foregroundStyle(stateTint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(stateTitle) + .font(.system(size: 12, weight: .semibold)) + Text(stateDetail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + actionButton + } + .padding(.vertical, 4) + } + + private var stateIcon: String { + switch store.subscriptionLoadState { + case .loaded: return "checkmark.circle.fill" + case .terminalFailure: return "exclamationmark.triangle.fill" + case .transientFailure: return "clock.arrow.circlepath" + case .bootstrapping, .loading: return "ellipsis.circle" + case .notBootstrapped, .dormant, .noCredentials: return "link.circle" + case .failed: return "xmark.circle" + } + } + + private var stateTint: Color { + switch store.subscriptionLoadState { + case .loaded: return .green + case .terminalFailure, .failed: return .red + case .transientFailure: return .orange + default: return .secondary + } + } + + private var stateTitle: String { + switch store.subscriptionLoadState { + case .loaded: return "Connected" + case let .terminalFailure(reason): return reason ?? "Reconnect required" + case .transientFailure: return "Backing off" + case .bootstrapping: return "Connecting…" + case .loading: return "Refreshing…" + case .dormant: return "Ready" + case .notBootstrapped, .noCredentials: return "Not connected" + case .failed: return "Couldn't load plan data" + } + } + + private var stateDetail: String { + switch store.subscriptionLoadState { + case .loaded: + if let tier = store.subscription?.tier.displayName { + return "Plan: \(tier)" + } + return "Live quota tracked from Anthropic." + case .terminalFailure: return "Open Claude Code in your terminal and type `/login`, then click Reconnect." + case .transientFailure: return store.subscriptionError ?? "Anthropic rate-limited; auto-retrying." + case .bootstrapping: return "macOS may ask permission to read your credentials." + case .loading: return "Background refresh in progress." + case .dormant: return "Tap Load Quota to fetch live usage from Anthropic." + case .notBootstrapped, .noCredentials: return "Click Connect to read your Claude Code credentials and start tracking quota." + case .failed: return store.subscriptionError ?? "" + } + } + + @ViewBuilder + private var actionButton: some View { + switch store.subscriptionLoadState { + case .loaded, .transientFailure, .loading: + Button("Disconnect") { showDisconnectConfirm = true } + .confirmationDialog( + "Disconnect Claude?", + isPresented: $showDisconnectConfirm + ) { + Button("Disconnect", role: .destructive) { + store.disconnectSubscription() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("CodeBurn will stop tracking quota and delete its local copy of your Claude credentials. Your Claude Code keychain entry is untouched — Claude Code keeps working.") + } + case .terminalFailure, .noCredentials, .failed: + Button("Reconnect") { Task { await store.bootstrapSubscription() } } + .buttonStyle(.borderedProminent) + case .dormant: + Button("Load Quota") { Task { await store.activateClaudeFromDormant() } } + .buttonStyle(.borderedProminent) + case .notBootstrapped: + Button("Connect") { Task { await store.bootstrapSubscription() } } + .buttonStyle(.borderedProminent) + case .bootstrapping: + ProgressView().controlSize(.small) + } + } +} + +// MARK: - Claude config directories + +private struct ClaudeConfigDirsSection: View { + @Environment(AppStore.self) private var store + @State private var dirs: [String] = CLIClaudeConfig.load() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if dirs.isEmpty { + Text("No extra directories — tracking the default `~/.claude`.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } else { + ForEach(Array(dirs.enumerated()), id: \.offset) { index, dir in + HStack(spacing: 8) { + Image(systemName: "folder") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text(dir) + .font(.system(size: 12)) + .truncationMode(.middle) + .lineLimit(1) + .help(dir) + Spacer() + Button { + remove(at: index) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Remove") + } + } + } + + Button { + addDirectory() + } label: { + Label("Add Directory…", systemImage: "plus") + } + .controlSize(.small) + } + .padding(.vertical, 2) + } + + private func addDirectory() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = true + panel.prompt = "Add" + panel.message = "Choose one or more Claude config directories (each containing a `projects` folder)." + guard panel.runModal() == .OK else { return } + + let added = panel.urls.map { $0.path } + var next = dirs + for path in added where !next.contains(path) { + next.append(path) + } + apply(next) + } + + private func remove(at index: Int) { + guard dirs.indices.contains(index) else { return } + var next = dirs + next.remove(at: index) + apply(next) + } + + /// Persists the new list and kicks a forced refresh so the dashboard + /// reflects the changed aggregation immediately. + private func apply(_ next: [String]) { + dirs = next + CLIClaudeConfig.persist(dirs: next) + Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } + } +} + +// MARK: - Codex + +private struct CodexSettingsTab: View { + @Environment(AppStore.self) private var store + + var body: some View { + Form { + Section("Connection") { + CodexConnectionRow() + } + Section { + Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } header: { + Text("How it works") + } + } + .formStyle(.grouped) + .padding() + } +} + +private struct CodexConnectionRow: View { + @Environment(AppStore.self) private var store + @State private var showDisconnectConfirm = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: stateIcon) + .font(.system(size: 18)) + .foregroundStyle(stateTint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(stateTitle) + .font(.system(size: 12, weight: .semibold)) + Text(stateDetail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + actionButton + } + .padding(.vertical, 4) + } + + private var stateIcon: String { + switch store.codexLoadState { + case .loaded: return "checkmark.circle.fill" + case .terminalFailure: return "exclamationmark.triangle.fill" + case .transientFailure: return "clock.arrow.circlepath" + case .bootstrapping, .loading: return "ellipsis.circle" + case .notBootstrapped, .dormant, .noCredentials: return "link.circle" + case .failed: return "xmark.circle" + } + } + + private var stateTint: Color { + switch store.codexLoadState { + case .loaded: return .green + case .terminalFailure, .failed: return .red + case .transientFailure: return .orange + default: return .secondary + } + } + + private var stateTitle: String { + switch store.codexLoadState { + case .loaded: return "Connected" + case let .terminalFailure(reason): return reason ?? "Reconnect required" + case .transientFailure: return "Backing off" + case .bootstrapping: return "Connecting…" + case .loading: return "Refreshing…" + case .dormant: return "Ready" + case .notBootstrapped, .noCredentials: return "Not connected" + case .failed: return "Couldn't load Codex quota" + } + } + + private var stateDetail: String { + switch store.codexLoadState { + case .loaded: + if let plan = store.codexUsage?.plan.displayName { + return "Plan: \(plan)" + } + return "Live quota tracked from chatgpt.com." + case .terminalFailure: + // Be specific about the cause: the message we already surface in + // codexError will say "API-key mode" if that's the situation, so + // the generic "run codex login" hint covers both cases. + if let err = store.codexError, err.lowercased().contains("api-key") { + return "Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking." + } + return "Run `codex login` in your terminal to sign in again, then click Reconnect." + case .transientFailure: return store.codexError ?? "ChatGPT rate-limited; auto-retrying." + case .bootstrapping: return "Reading ~/.codex/auth.json." + case .loading: return "Background refresh in progress." + case .dormant: return "Tap Load Quota to fetch live usage from chatgpt.com." + case .notBootstrapped, .noCredentials: + return "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json." + case .failed: return store.codexError ?? "" + } + } + + @ViewBuilder + private var actionButton: some View { + switch store.codexLoadState { + case .loaded, .transientFailure, .loading: + Button("Disconnect") { showDisconnectConfirm = true } + .confirmationDialog( + "Disconnect Codex?", + isPresented: $showDisconnectConfirm + ) { + Button("Disconnect", role: .destructive) { + store.disconnectCodex() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("CodeBurn will stop tracking quota and delete its local copy of your Codex credentials. Your ~/.codex/auth.json is untouched — Codex CLI keeps working.") + } + case .terminalFailure, .noCredentials, .failed: + Button("Reconnect") { Task { await store.bootstrapCodex() } } + .buttonStyle(.borderedProminent) + case .dormant: + Button("Load Quota") { Task { await store.activateCodexFromDormant() } } + .buttonStyle(.borderedProminent) + case .notBootstrapped: + Button("Connect") { Task { await store.bootstrapCodex() } } + .buttonStyle(.borderedProminent) + case .bootstrapping: + ProgressView().controlSize(.small) + } + } +} + +// MARK: - Devin + +private struct DevinSettingsTab: View { + @State private var rateText: String = "" + @State private var statusText: String = "" + + private var parsedRate: Double? { + let trimmed = rateText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value > 0 else { return nil } + return value + } + + var body: some View { + Form { + Section("ACU Conversion") { + HStack(alignment: .center, spacing: 10) { + Text("USD per ACU") + Spacer() + TextField("", text: $rateText) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + .frame(width: 96) + .accessibilityLabel("USD per ACU") + Text("USD") + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .leading) + } + + Button("Save") { + saveRate() + } + .buttonStyle(.borderedProminent) + .disabled(parsedRate == nil) + + if !statusText.isEmpty { + Text(statusText) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + + Section { + Text("CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } header: { + Text("How it works") + } + } + .formStyle(.grouped) + .padding() + .onAppear { + if let rate = CLIDevinConfig.loadAcuUsdRate() { + rateText = Self.format(rate) + } + } + } + + private func saveRate() { + guard let rate = parsedRate else { return } + CLIDevinConfig.persistAcuUsdRate(rate) + rateText = Self.format(rate) + statusText = "Saved. Refresh CodeBurn to recalculate Devin cost." + } + + private static func format(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 6 + return formatter.string(from: NSNumber(value: value)) ?? String(value) + } +} + +// MARK: - About + +private struct AboutSettingsTab: View { + private let appVersion: String = AppVersion.normalizedBundleShortVersion + private let buildVersion: String = AppVersion.normalizedBundleBuildVersion + + var body: some View { + VStack(spacing: 14) { + Image(systemName: "flame.fill") + .font(.system(size: 40)) + .foregroundStyle(Theme.brandAccent) + Text("CodeBurn") + .font(.system(size: 18, weight: .semibold)) + Text("AI Coding Cost Tracker") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text("Version \(appVersion) (\(buildVersion))") + .font(.codeMono(size: 11)) + .foregroundStyle(.secondary) + Link(destination: URL(string: "https://github.com/getagentseal/codeburn")!) { + Label("Star on GitHub", systemImage: "star.fill") + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.borderedProminent) + .tint(Theme.brandAccent) + HStack(spacing: 10) { + Link("GitHub", destination: URL(string: "https://github.com/getagentseal/codeburn")!) + Link("Issues", destination: URL(string: "https://github.com/getagentseal/codeburn/issues")!) + Link("Sponsor", destination: URL(string: "https://github.com/sponsors/iamtoruk")!) + } + .font(.system(size: 12)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/SparklineView.swift b/mac/Sources/CodeBurnMenubar/Views/SparklineView.swift new file mode 100644 index 0000000..db7d7cc --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/SparklineView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct SparklineView: View { + let points: [Double] + + var body: some View { + GeometryReader { geo in + let cgPoints = makePoints(in: geo.size) + let smooth = smoothPath(cgPoints) + + ZStack { + // Gradient fill under the curve + let fill = closedPath(smooth, width: geo.size.width, height: geo.size.height) + fill.fill( + LinearGradient( + colors: [Theme.brandAccent.opacity(0.25), .clear], + startPoint: .top, + endPoint: .bottom + ) + ) + + // Smooth accent stroke + smooth.stroke( + Theme.brandAccent.opacity(0.85), + style: StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round) + ) + + // Highlighted current-day point + if let last = cgPoints.last { + Circle() + .fill(Theme.brandAccent) + .frame(width: 6, height: 6) + .overlay( + Circle() + .stroke(Color(NSColor.windowBackgroundColor).opacity(0.9), lineWidth: 1.3) + ) + .position(last) + } + } + } + } + + // MARK: - Geometry + + private func makePoints(in size: CGSize) -> [CGPoint] { + guard !points.isEmpty else { return [] } + let w = size.width + let h = size.height + let maxV = points.max() ?? 1 + let minV = points.min() ?? 0 + let range = max(maxV - minV, 1) + let count = max(points.count - 1, 1) + let topPad: CGFloat = 5 + let bottomPad: CGFloat = 5 + let usable = max(h - topPad - bottomPad, 1) + + return points.enumerated().map { idx, v in + CGPoint( + x: w * CGFloat(idx) / CGFloat(count), + y: h - bottomPad - usable * CGFloat(v - minV) / CGFloat(range) + ) + } + } + + /// Catmull-Rom → cubic bezier. Standard smooth interpolation, no overshoot. + private func smoothPath(_ pts: [CGPoint]) -> Path { + var path = Path() + guard pts.count >= 2 else { return path } + path.move(to: pts[0]) + + let tension: CGFloat = 0.5 + for i in 0..<(pts.count - 1) { + let p0 = i > 0 ? pts[i - 1] : pts[i] + let p1 = pts[i] + let p2 = pts[i + 1] + let p3 = i + 2 < pts.count ? pts[i + 2] : p2 + + let cp1 = CGPoint( + x: p1.x + (p2.x - p0.x) * tension / 3, + y: p1.y + (p2.y - p0.y) * tension / 3 + ) + let cp2 = CGPoint( + x: p2.x - (p3.x - p1.x) * tension / 3, + y: p2.y - (p3.y - p1.y) * tension / 3 + ) + path.addCurve(to: p2, control1: cp1, control2: cp2) + } + return path + } + + /// Close the path along the bottom to form a fill region. + private func closedPath(_ line: Path, width: CGFloat, height: CGFloat) -> Path { + var p = line + p.addLine(to: CGPoint(x: width, y: height)) + p.addLine(to: CGPoint(x: 0, y: height)) + p.closeSubpath() + return p + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift b/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift new file mode 100644 index 0000000..1bcf9de --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift @@ -0,0 +1,137 @@ +import SwiftUI + +struct ToolingSection: View { + @Environment(AppStore.self) private var store + @State private var isExpanded: Bool = false + + private var skillsAndAgents: [CostRowData] { + let current = store.payload.current + var merged: [String: (uses: Int, cost: Double)] = [:] + for s in current.skills { + let e = merged[s.name, default: (0, 0)] + merged[s.name] = (e.uses + s.turns, e.cost + s.cost) + } + for a in current.subagents { + let e = merged[a.name, default: (0, 0)] + merged[a.name] = (e.uses + a.calls, e.cost + a.cost) + } + return merged + .map { CostRowData(name: $0.key, uses: $0.value.uses, cost: $0.value.cost) } + .sorted { $0.cost > $1.cost } + } + + var body: some View { + let current = store.payload.current + let combined = skillsAndAgents + let hasAny = !current.tools.isEmpty || !combined.isEmpty || !current.mcpServers.isEmpty + if hasAny { + CollapsibleSection(caption: "Tooling", isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 12) { + if !current.tools.isEmpty { + ToolingSubsection(title: "Tools") { + let maxCalls = current.tools.map(\.calls).max() ?? 1 + ForEach(current.tools, id: \.name) { t in + CallsRow(name: t.name, calls: t.calls, maxCalls: maxCalls) + } + } + } + if !combined.isEmpty { + ToolingSubsection(title: "Skills & Agents") { + let maxCost = max(combined.map(\.cost).max() ?? 0.01, 0.01) + ForEach(combined, id: \.name) { d in + CostRow(name: d.name, cost: d.cost, count: d.uses, countLabel: "uses", maxCost: maxCost) + } + } + } + if !current.mcpServers.isEmpty { + ToolingSubsection(title: "MCP Servers") { + let maxCalls = current.mcpServers.map(\.calls).max() ?? 1 + ForEach(current.mcpServers, id: \.name) { m in + CallsRow(name: m.name, calls: m.calls, maxCalls: maxCalls) + } + } + } + } + } + } + } +} + +private struct CostRowData { + let name: String + let uses: Int + let cost: Double +} + +private struct ToolingSubsection: View { + let title: String + let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + .tracking(0.5) + content + } + } +} + +private struct CallsRow: View { + let name: String + let calls: Int + let maxCalls: Int + + var body: some View { + HStack(spacing: 8) { + FixedBar(fraction: Double(calls) / Double(max(maxCalls, 1))) + .frame(width: 40, height: 5) + Text(name) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Text("\(calls)") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 36, alignment: .trailing) + } + .padding(.vertical, 1) + } +} + +private struct CostRow: View { + let name: String + let cost: Double + let count: Int + let countLabel: String + let maxCost: Double + + var body: some View { + HStack(spacing: 8) { + FixedBar(fraction: cost / max(maxCost, 0.01)) + .frame(width: 40, height: 5) + Text(name) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Text("\(count)") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 30, alignment: .trailing) + Text(cost.asCompactCurrency()) + .font(.codeMono(size: 11, weight: .medium)) + .tracking(-0.2) + .frame(minWidth: 46, alignment: .trailing) + } + .padding(.vertical, 1) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift new file mode 100644 index 0000000..74d0ec8 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -0,0 +1,389 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private func combinedUsage(cost: Double = 12.5) -> CombinedUsage { + CombinedUsage( + perDevice: [ + CombinedDeviceUsage( + id: "local", + name: "MacBook", + local: true, + error: nil, + cost: cost, + calls: 3, + sessions: 2, + inputTokens: 100, + outputTokens: 50, + cacheCreateTokens: 10, + cacheReadTokens: 20, + totalTokens: 180 + ) + ], + combined: CombinedUsageTotals( + cost: cost, + calls: 3, + sessions: 2, + inputTokens: 100, + outputTokens: 50, + cacheCreateTokens: 10, + cacheReadTokens: 20, + totalTokens: 180, + deviceCount: 1, + reachableCount: 1 + ) + ) +} + +private func claudeConfigSelector(selectedId: String? = nil) -> ClaudeConfigSelector { + ClaudeConfigSelector( + selectedId: selectedId, + options: [ + ClaudeConfigOption(id: "claude-config:work", label: "claude-work", path: "/tmp/claude-work"), + ClaudeConfigOption(id: "claude-config:personal", label: "claude-personal", path: "/tmp/claude-personal") + ] + ) +} + +private func menubarPayload(cost: Double, + combined: CombinedUsage? = nil, + claudeConfigs: ClaudeConfigSelector? = nil) -> MenubarPayload { + MenubarPayload( + generated: "test", + current: CurrentBlock( + label: "Today", + cost: cost, + calls: 1, + sessions: 1, + oneShotRate: nil, + inputTokens: 1, + outputTokens: 1, + cacheHitPercent: 0, + codexCredits: nil, + topActivities: [], + topModels: [], + localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []), + providers: ["claude": cost], + topProjects: [], + modelEfficiency: [], + topSessions: [], + retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []), + routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []), + tools: [], + skills: [], + subagents: [], + mcpServers: [] + ), + optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), + history: HistoryBlock(daily: []), + combined: combined, + claudeConfigs: claudeConfigs + ) +} + +@Suite("AppStore refresh recovery") +@MainActor +struct AppStoreRefreshRecoveryTests { + @Test("stale visible payload triggers hard recovery without clearing cache") + func stalePayloadTriggersHardRecoveryWithoutClearingCache() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 92.33), + period: .today, + provider: .all, + fetchedAt: Date().addingTimeInterval(-180) + ) + + #expect(store.todayPayload?.current.cost == 92.33) + #expect(store.needsInteractivePayloadRefresh) + #expect(store.needsStatusPayloadRefresh) + #expect(store.hasStaleInteractivePayload) + #expect(store.shouldResetInteractiveRefreshPipeline) + + store.resetRefreshState(clearCache: false) + + #expect(store.todayPayload?.current.cost == 92.33) + } + + @Test("fresh visible payload does not trigger hard recovery") + func freshPayloadDoesNotTriggerHardRecovery() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 164.06), + period: .today, + provider: .all, + fetchedAt: Date() + ) + + #expect(!store.needsInteractivePayloadRefresh) + #expect(!store.needsStatusPayloadRefresh) + #expect(!store.hasStaleInteractivePayload) + #expect(!store.shouldResetInteractiveRefreshPipeline) + } + + @Test("payload cache partitions local and combined scope") + func payloadCachePartitionsByScope() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 10), + scope: .local, + period: .today, + provider: .all, + fetchedAt: Date() + ) + store.setCachedPayloadForTesting( + menubarPayload(cost: 99, combined: combinedUsage(cost: 42)), + scope: .combined, + period: .today, + provider: .all, + fetchedAt: Date() + ) + + #expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all)?.current.cost == 10) + #expect(store.cachedPayloadForTesting(scope: .combined, period: .today, provider: .all)?.current.cost == 99) + + store.selectedScope = .combined + + #expect(store.payload.current.cost == 10) + #expect(store.payload.combined?.combined.cost == 42) + } + + @Test("multi-day combined selection uses local cache path") + func multiDayCombinedSelectionUsesLocalCachePath() { + let store = AppStore() + let days: Set = ["2026-06-01", "2026-06-02"] + store.selectedScope = .combined + store.selectedDays = days + + store.setCachedPayloadForTesting( + menubarPayload(cost: 18), + scope: .local, + period: .today, + provider: .all, + days: days, + fetchedAt: Date() + ) + store.setCachedPayloadForTesting( + menubarPayload(cost: 99, combined: combinedUsage(cost: 44)), + scope: .combined, + period: .today, + provider: .all, + days: days, + fetchedAt: Date() + ) + + #expect(store.activeScope == .local) + #expect(store.payload.current.cost == 18) + #expect(store.payload.combined == nil) + } + + @Test("combined failure state does not invalidate local badge payload") + func combinedFailureDoesNotInvalidateLocalBadgePayload() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 31), + scope: .local, + period: .today, + provider: .all, + fetchedAt: Date() + ) + store.selectedScope = .combined + store.setLastErrorForTesting( + "timeout", + scope: .combined, + period: .today, + provider: .all + ) + + #expect(store.lastError == "timeout") + #expect(store.menubarPayload?.current.cost == 31) + #expect(!store.needsStatusPayloadRefresh) + #expect(store.payload.current.cost == 31) + #expect(store.payload.combined == nil) + } + + @Test("switching to combined resets selected provider to all") + func switchingToCombinedResetsSelectedProviderToAll() { + let store = AppStore() + store.suppressRefreshesForTesting() + store.selectedScope = .local + store.selectedProvider = .claude + + store.switchTo(scope: .combined) + + #expect(store.selectedScope == .combined) + #expect(store.selectedProvider == .all) + } + + @Test("selected Claude config partitions payload cache") + func selectedClaudeConfigPartitionsPayloadCache() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 10, claudeConfigs: claudeConfigSelector()), + scope: .local, + period: .today, + provider: .all, + fetchedAt: Date() + ) + store.setCachedPayloadForTesting( + menubarPayload(cost: 4, claudeConfigs: claudeConfigSelector(selectedId: "claude-config:work")), + scope: .local, + period: .today, + provider: .all, + claudeConfigSourceId: "claude-config:work", + fetchedAt: Date() + ) + + #expect(store.payload.current.cost == 10) + + store.selectedClaudeConfigSourceId = "claude-config:work" + + #expect(store.payload.current.cost == 4) + #expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all)?.current.cost == 10) + #expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all, claudeConfigSourceId: "claude-config:work")?.current.cost == 4) + } + + @Test("Claude config selector is hidden until multiple configs are available") + func claudeConfigSelectorVisibilityRequiresMultipleConfigs() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 1), + scope: .local, + period: .today, + provider: .all, + fetchedAt: Date() + ) + #expect(!store.shouldShowClaudeConfigSelector) + + store.setCachedPayloadForTesting( + menubarPayload(cost: 2, claudeConfigs: claudeConfigSelector()), + scope: .local, + period: .today, + provider: .all, + fetchedAt: Date() + ) + + #expect(store.shouldShowClaudeConfigSelector) + #expect(store.claudeConfigOptions.map(\.label) == ["claude-work", "claude-personal"]) + } + + @Test("selecting Claude config resets provider and combined scope") + func selectingClaudeConfigResetsProviderAndCombinedScope() { + let store = AppStore() + store.suppressRefreshesForTesting() + store.selectedScope = .combined + store.selectedProvider = .codex + + store.switchTo(claudeConfigSourceId: "claude-config:work") + + #expect(store.selectedClaudeConfigSourceId == "claude-config:work") + #expect(store.selectedScope == .local) + #expect(store.selectedProvider == .all) + } + + @Test("daily budget warning is suppressed for combined scope") + func dailyBudgetWarningIsSuppressedForCombinedScope() { + let defaults = UserDefaults.standard + let previousDisplayMetric = defaults.object(forKey: "CodeBurnDisplayMetric") + let previousDailyBudget = defaults.object(forKey: "CodeBurnDailyBudget") + defer { + if let previousDisplayMetric { + defaults.set(previousDisplayMetric, forKey: "CodeBurnDisplayMetric") + } else { + defaults.removeObject(forKey: "CodeBurnDisplayMetric") + } + if let previousDailyBudget { + defaults.set(previousDailyBudget, forKey: "CodeBurnDailyBudget") + } else { + defaults.removeObject(forKey: "CodeBurnDailyBudget") + } + } + + let store = AppStore() + store.selectedScope = .local + store.selectedDays = [] + store.displayMetric = .cost + store.dailyBudget = 10 + store.setCachedPayloadForTesting( + menubarPayload(cost: 12.5), + scope: .local, + period: .today, + provider: .all, + fetchedAt: Date() + ) + + #expect(store.isOverDailyBudget) + #expect(store.shouldShowDailyBudgetWarning) + + store.selectedScope = .combined + + #expect(store.isOverDailyBudget) + #expect(!store.shouldShowDailyBudgetWarning) + } + + @Test("missing today status payload needs status refresh") + func missingTodayStatusPayloadNeedsStatusRefresh() { + let store = AppStore() + + #expect(store.todayPayload == nil) + #expect(store.needsStatusPayloadRefresh) + } + + @Test("missing unattempted payload triggers hard recovery") + func missingUnattemptedPayloadTriggersHardRecovery() { + let store = AppStore() + + #expect(!store.hasCachedData) + #expect(!store.hasAttemptedCurrentKeyLoad) + #expect(store.needsInteractivePayloadRefresh) + #expect(store.hasMissingInteractivePayloadWithoutAttempt) + #expect(store.shouldResetInteractiveRefreshPipeline) + } + + @Test("orphaned stale in-flight entry does not block stuck-loading recovery") + func staleInFlightDoesNotBlockRecovery() { + let store = AppStore() + // A quiet refresh torn down across sleep/wake can leave an in-flight + // entry behind for the current key with no cache and no active loading + // counter, far older than the watchdog window. Recovery must clear it + // and proceed instead of bailing on the in-flight guard forever. + store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date().addingTimeInterval(-3600)) + + #expect(store.isInFlightForTesting(period: .today, provider: .all)) + + let canRecover = store.prepareStuckLoadingRecovery() + + #expect(canRecover) + #expect(!store.isInFlightForTesting(period: .today, provider: .all)) + } + + @Test("healthy in-flight fetch is not killed by recovery") + func healthyInFlightFetchSurvivesRecovery() { + let store = AppStore() + store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date()) + + let canRecover = store.prepareStuckLoadingRecovery() + + #expect(!canRecover) + #expect(store.isInFlightForTesting(period: .today, provider: .all)) + } + + @Test("prepareStuckLoadingRecovery clears stale loading bookkeeping for the current key") + func popoverRecoveryClearsStuckLoading() { + let store = AppStore() + // Seed an orphaned in-flight entry older than the 60s watchdog so the + // stale-clear path runs, mimicking a fetch torn down across sleep/wake. + store.seedInFlightForTesting( + period: .today, + provider: .all, + insertedAt: Date().addingTimeInterval(-120) + ) + #expect(store.isInFlightForTesting(period: .today, provider: .all)) + + let willFetch = store.prepareStuckLoadingRecovery() + + #expect(willFetch) + #expect(!store.isInFlightForTesting(period: .today, provider: .all)) + } + +} diff --git a/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift b/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift new file mode 100644 index 0000000..898f5e0 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import CodeBurnMenubar + +@Suite("AppVersion") +struct AppVersionTests { + @Test("display avoids duplicate v prefix") + func displayAvoidsDuplicatePrefix() { + #expect(AppVersion.display("0.9.8") == "v0.9.8") + #expect(AppVersion.display("v0.9.8") == "v0.9.8") + #expect(AppVersion.display("mac-v0.9.8") == "v0.9.8") + } + + @Test("bundle metadata stores unprefixed semver") + func normalizeBundleVersion() { + #expect(AppVersion.normalize("v0.9.8") == "0.9.8") + #expect(AppVersion.normalize("mac-v0.9.8") == "0.9.8") + #expect(AppVersion.normalize("dev") == "dev") + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift b/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift new file mode 100644 index 0000000..69e9f55 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("CLI Devin config", .serialized) +struct CLIDevinConfigTests { + private func withTemporaryStore(_ body: (URL, CodeburnCLIConfigStore) throws -> Void) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codeburn-devin-config-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: root) + } + try body(root, CodeburnCLIConfigStore(homeDirectory: root.path)) + } + + private func configURL(in home: URL) -> URL { + home + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("codeburn", isDirectory: true) + .appendingPathComponent("config.json") + } + + private func writeConfig(_ object: [String: Any], in home: URL) throws { + let url = configURL(in: home) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url) + } + + private func readConfig(in home: URL) throws -> [String: Any] { + let data = try Data(contentsOf: configURL(in: home)) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test("missing config has no ACU rate") + func missingConfigHasNoRate() throws { + try withTemporaryStore { _, store in + #expect(store.loadDevinAcuUsdRate() == nil) + } + } + + @Test("persists and loads ACU rate") + func persistsAndLoadsRate() throws { + try withTemporaryStore { _, store in + store.persistDevinAcuUsdRate(2.25) + + #expect(store.loadDevinAcuUsdRate() == 2.25) + } + } + + @Test("preserves existing config while adding Devin rate") + func preservesExistingConfig() throws { + try withTemporaryStore { home, store in + try writeConfig([ + "currency": [ + "code": "EUR", + "symbol": "\u{20AC}" + ] + ], in: home) + + store.persistDevinAcuUsdRate(3.5) + + let json = try readConfig(in: home) + let currency = try #require(json["currency"] as? [String: Any]) + let devin = try #require(json["devin"] as? [String: Any]) + #expect(currency["code"] as? String == "EUR") + #expect(devin["acuUsdRate"] as? Double == 3.5) + } + } + + @Test("ignores invalid rates") + func ignoresInvalidRates() throws { + try withTemporaryStore { _, store in + store.persistDevinAcuUsdRate(1.75) + store.persistDevinAcuUsdRate(0) + store.persistDevinAcuUsdRate(-2) + store.persistDevinAcuUsdRate(.infinity) + + #expect(store.loadDevinAcuUsdRate() == 1.75) + } + } + + @Test("loads only positive finite numeric rates") + func loadsOnlyPositiveFiniteNumericRates() throws { + try withTemporaryStore { home, store in + try writeConfig(["devin": ["acuUsdRate": 0]], in: home) + #expect(store.loadDevinAcuUsdRate() == nil) + + try writeConfig(["devin": ["acuUsdRate": "2.25"]], in: home) + #expect(store.loadDevinAcuUsdRate() == nil) + } + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityEstimatorTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityEstimatorTests.swift new file mode 100644 index 0000000..b23ba5f --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CapacityEstimatorTests.swift @@ -0,0 +1,158 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private let now = Date(timeIntervalSince1970: 1_734_000_000) + +private func snap(_ percent: Double, _ tokens: Double, ageDays: Double = 0) -> CapacitySnapshot { + CapacitySnapshot( + percent: percent, + effectiveTokens: tokens, + capturedAt: now.addingTimeInterval(-ageDays * 86400) + ) +} + +@Suite("CapacityEstimator -- gating") +struct CapacityEstimatorGatingTests { + @Test("returns nil with no snapshots") + func emptyReturnsNil() { + #expect(CapacityEstimator.estimate([], asOf: now) == nil) + } + + @Test("returns nil with fewer than 5 snapshots") + func tooFewReturnsNil() { + let snaps = (1...4).map { snap(Double($0 * 10), Double($0) * 100_000) } + #expect(CapacityEstimator.estimate(snaps, asOf: now) == nil) + } + + @Test("returns nil when percent range is below 15 points") + func tooNarrowReturnsNil() { + let snaps = [ + snap(40, 4_000_000), + snap(42, 4_200_000), + snap(44, 4_400_000), + snap(46, 4_600_000), + snap(48, 4_800_000), + snap(50, 5_000_000), + ] + #expect(CapacityEstimator.estimate(snaps, asOf: now) == nil) + } +} + +@Suite("CapacityEstimator -- recovery") +struct CapacityEstimatorRecoveryTests { + @Test("recovers capacity from 10 noise-free snapshots within 0.5%") + func recoverFromCleanData() { + let trueCapacity: Double = 10_000_000 + let percents = [5.0, 12, 20, 28, 35, 47, 55, 68, 80, 92] + let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) } + let est = CapacityEstimator.estimate(snaps, asOf: now) + #expect(est != nil) + #expect(est!.capacity > trueCapacity * 0.995) + #expect(est!.capacity < trueCapacity * 1.005) + // 10 perfect samples is below the solid sample threshold (15) but easily medium. + #expect(est!.confidence == .medium || est!.confidence == .solid) + } + + @Test("recovers capacity within 5% from 30 noisy snapshots") + func recoverFromNoisyData() { + let trueCapacity: Double = 8_000_000 + var rng = LinearCongruentialGenerator(seed: 42) + let snaps: [CapacitySnapshot] = (0..<30).map { i in + let p = 5.0 + Double(i) * 3.0 // 5..92, spanning enough + let noise = (rng.nextDouble() - 0.5) * 0.10 // ±5% + let tokens = (p / 100) * trueCapacity * (1 + noise) + return snap(p, tokens) + } + let est = CapacityEstimator.estimate(snaps, asOf: now) + #expect(est != nil) + let ratio = est!.capacity / trueCapacity + #expect(ratio > 0.95 && ratio < 1.05) + #expect(est!.confidence == .solid || est!.confidence == .medium) + } +} + +@Suite("CapacityEstimator -- confidence tiers") +struct CapacityEstimatorConfidenceTests { + @Test("six clean snapshots span sufficient range -> at least medium") + func sixCleanSnapshotsMedium() { + let trueCapacity: Double = 5_000_000 + let percents = [5.0, 18, 32, 51, 70, 88] + let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) } + let est = CapacityEstimator.estimate(snaps, asOf: now) + #expect(est != nil) + #expect(est!.confidence == .medium || est!.confidence == .solid) + } + + @Test("noisy small-sample data falls to low confidence") + func noisySmallSampleLow() { + let trueCapacity: Double = 5_000_000 + var rng = LinearCongruentialGenerator(seed: 7) + let percents = [5.0, 22, 40, 60, 80, 95] + let snaps: [CapacitySnapshot] = percents.map { p in + let noise = (rng.nextDouble() - 0.5) * 1.6 // ±80% noise -> drops R^2 below medium gate + return snap(p, p / 100 * trueCapacity * (1 + noise)) + } + let est = CapacityEstimator.estimate(snaps, asOf: now) + #expect(est != nil) + #expect(est!.confidence == .low) + } +} + +@Suite("CapacityEstimator -- recency weighting") +struct CapacityEstimatorRecencyTests { + @Test("recent snapshots dominate over old ones with different capacity") + func recencyShiftsEstimate() { + // Old data: capacity = 5M (45 days ago) + // New data: capacity = 10M (today) + // With 30-day half-life, recent data should win. + let oldSnaps = (0..<10).map { i -> CapacitySnapshot in + let p = 10.0 + Double(i) * 8 + return snap(p, p / 100 * 5_000_000, ageDays: 45) + } + let newSnaps = (0..<10).map { i -> CapacitySnapshot in + let p = 10.0 + Double(i) * 8 + return snap(p, p / 100 * 10_000_000, ageDays: 1) + } + let est = CapacityEstimator.estimate(oldSnaps + newSnaps, asOf: now) + #expect(est != nil) + // Recent capacity is 10M; estimate should be closer to 10M than 5M. + #expect(est!.capacity > 7_500_000) + } +} + +@Suite("CapacityEstimator -- non-linearity") +struct CapacityEstimatorNonLinearityTests { + @Test("flags non-linearity when residuals show systematic sign pattern") + func detectsKneePattern() { + // Data follows a knee: linear up to 60%, then flatter (Anthropic capping). + let snaps: [CapacitySnapshot] = (0..<20).map { i in + let p = 5.0 + Double(i) * 5 + let tokens: Double = p < 60 ? p / 100 * 8_000_000 : 0.6 * 8_000_000 + (p - 60) / 100 * 4_000_000 + return snap(p, tokens) + } + let est = CapacityEstimator.estimate(snaps, asOf: now) + #expect(est != nil) + #expect(est!.nonLinearityWarning == true) + } + + @Test("does not flag clean linear data") + func cleanLinearNoFlag() { + let trueCapacity: Double = 6_000_000 + let percents = stride(from: 5.0, to: 95.0, by: 5.0).map { $0 } + let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) } + let est = CapacityEstimator.estimate(snaps, asOf: now) + #expect(est != nil) + #expect(est!.nonLinearityWarning == false) + } +} + +// Lightweight deterministic RNG for reproducible noise in tests. +struct LinearCongruentialGenerator { + private var state: UInt64 + init(seed: UInt64) { self.state = seed } + mutating func nextDouble() -> Double { + state = state &* 6364136223846793005 &+ 1442695040888963407 + return Double(state >> 11) / Double(1 << 53) + } +} 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/CodexTerminalFailureTests.swift b/mac/Tests/CodeBurnMenubarTests/CodexTerminalFailureTests.swift new file mode 100644 index 0000000..a7f50a0 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CodexTerminalFailureTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("Codex terminal failure classification") +struct CodexTerminalFailureTests { + @Test("expired refresh token (4xx) is terminal") + func expiredRefreshTokenIsTerminal() { + let err = CodexCredentialStore.StoreError.refreshHTTPError(400, "invalid_grant: refresh_token_expired") + #expect(err.isTerminal) + } + + @Test("missing refresh token is terminal") + func missingRefreshTokenIsTerminal() { + #expect(CodexCredentialStore.StoreError.noRefreshToken.isTerminal) + } + + @Test("a 5xx refresh error is not terminal") + func serverErrorIsNotTerminal() { + let err = CodexCredentialStore.StoreError.refreshHTTPError(503, "service unavailable") + #expect(!err.isTerminal) + } + + @Test("a network error is not terminal") + func networkErrorIsNotTerminal() { + let err = CodexCredentialStore.StoreError.refreshNetworkError(URLError(.timedOut)) + #expect(!err.isTerminal) + } + + @Test("FetchError wrapping a terminal credential error is terminal") + func fetchErrorPropagatesTerminal() { + let store = CodexCredentialStore.StoreError.refreshHTTPError(401, "invalid_grant") + let fetch = CodexSubscriptionService.FetchError.credential(store) + #expect(fetch.isTerminal) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/ContributionHeatmapTests.swift b/mac/Tests/CodeBurnMenubarTests/ContributionHeatmapTests.swift new file mode 100644 index 0000000..1bdd586 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/ContributionHeatmapTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private let heatmapNow: Date = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar.date(from: DateComponents(year: 2026, month: 6, day: 3, hour: 12))! +}() + +private let heatmapCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar +}() + +private let heatmapFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0)! + return formatter +}() + +private func historyEntry( + _ date: String, + cost: Double, + calls: Int = 1, + inputTokens: Int = 100, + outputTokens: Int = 20 +) -> DailyHistoryEntry { + DailyHistoryEntry( + date: date, + cost: cost, + savingsUSD: 0, + calls: calls, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: [] + ) +} + +@Suite("Contribution heatmap") +struct ContributionHeatmapTests { + @Test("builds Monday-start weeks ending with the current week") + func buildsMondayStartWeeks() { + let weeks = buildContributionWeeks( + from: [historyEntry("2026-06-03", cost: 10)], + weekCount: 2, + now: heatmapNow, + calendar: heatmapCalendar, + formatter: heatmapFormatter + ) + + #expect(weeks.map(\.startDate) == ["2026-05-25", "2026-06-01"]) + #expect(weeks.allSatisfy { $0.days.count == 7 }) + #expect(weeks[1].days[0].date == "2026-06-01") + #expect(weeks[1].days[2].date == "2026-06-03") + #expect(weeks[1].days[2].isToday) + } + + @Test("marks future cells after today") + func marksFutureCells() { + let weeks = buildContributionWeeks( + from: [ + historyEntry("2026-06-03", cost: 10), + historyEntry("2026-06-04", cost: 99), + ], + weekCount: 1, + now: heatmapNow, + calendar: heatmapCalendar, + formatter: heatmapFormatter + ) + + let currentWeek = weeks[0].days + #expect(currentWeek[2].date == "2026-06-03") + #expect(currentWeek[2].cost == 10) + #expect(!currentWeek[2].isFuture) + #expect(currentWeek[3].date == "2026-06-04") + #expect(currentWeek[3].cost == 0) + #expect(currentWeek[3].isFuture) + } + + @Test("maps costs to four nonzero intensity levels") + func mapsIntensityLevels() { + #expect(contributionLevel(value: 0, maxValue: 100) == 0) + #expect(contributionLevel(value: 10, maxValue: 100) == 1) + #expect(contributionLevel(value: 25, maxValue: 100) == 2) + #expect(contributionLevel(value: 50, maxValue: 100) == 3) + #expect(contributionLevel(value: 75, maxValue: 100) == 4) + #expect(contributionLevel(value: 100, maxValue: 100) == 4) + } + + @MainActor + @Test("computes total, active days, average, and current streak") + func computesStats() { + let weeks = buildContributionWeeks( + from: [ + historyEntry("2026-06-01", cost: 3), + historyEntry("2026-06-02", cost: 0), + historyEntry("2026-06-03", cost: 9), + ], + weekCount: 1, + now: heatmapNow, + calendar: heatmapCalendar, + formatter: heatmapFormatter + ) + + let stats = computeContributionStats(weeks: weeks) + #expect(stats.total == 12) + #expect(stats.activeDays == 2) + #expect(stats.avgActive == 6) + #expect(stats.currentStreak == 1) + #expect(stats.peakLabel.contains("Jun 3")) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift new file mode 100644 index 0000000..93d4ed6 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift @@ -0,0 +1,224 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("DataClient process") +struct DataClientProcessTests { + @Test("status argv local omits scope") + func statusSubcommandLocalOmitsScope() { + let args = DataClient.statusSubcommand( + period: .today, + provider: .claude, + includeOptimize: false, + scope: .local + ) + + #expect(!args.contains("--scope")) + #expect(value(after: "--provider", in: args) == "claude") + #expect(args.contains("--no-optimize")) + } + + @Test("status argv combined adds scope and forces provider all") + func statusSubcommandCombinedAddsScopeAndForcesAllProvider() { + let args = DataClient.statusSubcommand( + period: .today, + provider: .codex, + includeOptimize: true, + scope: .combined + ) + + #expect(value(after: "--scope", in: args) == "combined") + #expect(value(after: "--provider", in: args) == "all") + #expect(!args.contains("--no-optimize")) + } + + @Test("status argv combined multi-day coerces to local") + func statusSubcommandCombinedMultiDayCoercesToLocal() { + let args = DataClient.statusSubcommand( + period: .today, + days: ["2026-06-01", "2026-06-02"], + provider: .codex, + includeOptimize: false, + scope: .combined + ) + + #expect(!args.contains("--scope")) + #expect(value(after: "--provider", in: args) == "codex") + #expect(value(after: "--days", in: args) == "2026-06-01,2026-06-02") + } + + @Test("status argv local includes Claude config source") + func statusSubcommandLocalIncludesClaudeConfigSource() { + let args = DataClient.statusSubcommand( + period: .today, + provider: .all, + includeOptimize: false, + scope: .local, + claudeConfigSourceId: "claude-config:work" + ) + + #expect(value(after: "--claude-config-source", in: args) == "claude-config:work") + } + + @Test("status argv combined omits Claude config source") + func statusSubcommandCombinedOmitsClaudeConfigSource() { + let args = DataClient.statusSubcommand( + period: .today, + provider: .all, + includeOptimize: false, + scope: .combined, + claudeConfigSourceId: "claude-config:work" + ) + + #expect(value(after: "--scope", in: args) == "combined") + #expect(!args.contains("--claude-config-source")) + } + + @Test("status argv supports LingTai TUI provider") + func statusSubcommandSupportsLingTaiTUI() { + let args = DataClient.statusSubcommand( + period: .month, + provider: .lingtaiTui, + includeOptimize: false, + scope: .local + ) + + #expect(value(after: "--provider", in: args) == "lingtai-tui") + #expect(value(after: "--period", in: args) == "month") + } + + /// Concurrency + timeout smoke test: launch more hung subprocesses than + /// there are cooperative threads, all at once, with a short timeout, and + /// assert every call returns once the timeout kills its sleep. + /// + /// NOTE: this does NOT reproduce the production permanent deadlock (16/16 + /// cooperative threads parked in waitUntilExit). In a short-lived unit-test + /// process libdispatch spins up replacement threads for blocked workers, so + /// even the old blocking-on-the-pool code completes here. The real deadlock + /// built up over ~2 days under the @MainActor refresh loop and is confirmed + /// by the live `sample`, not by this test. Kept as a guard that the + /// off-pool wait + timeout path stays correct under concurrency. + @Test("concurrent timed-out processes all complete") + func concurrentTimedOutProcessesAllComplete() { + let count = ProcessInfo.processInfo.activeProcessorCount * 2 + 4 + let done = DispatchSemaphore(value: 0) + + Task { + await withTaskGroup(of: Void.self) { group in + for _ in 0..")) + } + + /// A normally-exiting process returns its real output and exit code through + /// the off-pool wait path. + @Test("process returns output and exit code") + func processReturnsOutputAndExitCode() async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/echo") + process.arguments = ["hello"] + let result = try await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo hello") + #expect(result.exitCode == 0) + #expect(String(data: result.stdout, encoding: .utf8) == "hello\n") + } + + /// Many NORMALLY-exiting processes, all at once, must every one complete + /// through the terminationHandler wait path. Guards against the wait path + /// leaking or wedging under concurrency (the production bug was the wait and + /// its timeout sharing one queue that saturated under sustained load). + @Test("many normal processes all complete") + func manyNormalProcessesAllComplete() async { + let count = 50 + let codes = await withTaskGroup(of: Int32?.self) { group -> [Int32?] in + for _ in 0.. 0) + } +} + +private func value(after flag: String, in args: [String]) -> String? { + guard let index = args.firstIndex(of: flag), args.indices.contains(index + 1) else { + return nil + } + return args[index + 1] +} + +private actor PeakCounter { + private var current = 0 + private(set) var peak = 0 + func enter() { current += 1; peak = max(peak, current) } + func leave() { current -= 1 } +} diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift new file mode 100644 index 0000000..3b616cc --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("MenubarPayload combined") +struct MenubarPayloadCombinedTests { + @Test("decodes combined block") + func decodesCombinedBlock() throws { + let json = combinedPayloadJSON() + + let payload = try JSONDecoder().decode(MenubarPayload.self, from: json) + + #expect(payload.combined?.perDevice.count == 2) + #expect(payload.combined?.perDevice.first?.id == "local-device") + #expect(payload.combined?.perDevice.first?.local == true) + #expect(payload.combined?.perDevice.last?.error == "offline") + #expect(payload.combined?.combined.cost == 4.5) + #expect(payload.combined?.combined.calls == 7) + #expect(payload.combined?.combined.deviceCount == 2) + #expect(payload.combined?.combined.reachableCount == 1) + } + + @Test("combined hero token total excludes cache tokens") + func combinedHeroTokenTotalExcludesCacheTokens() throws { + let payload = try JSONDecoder().decode(MenubarPayload.self, from: combinedPayloadJSON()) + let totals = HeroTotals(payload: payload, activeScope: .combined) + + #expect(payload.combined?.combined.totalTokens == 2000) + #expect(totals.inputTokens == 1000) + #expect(totals.outputTokens == 500) + #expect(totals.totalTokens == 1500) + } + + @Test("combined block is nil when absent") + func combinedBlockIsNilWhenAbsent() throws { + let json = Data(""" + { + "generated": "2026-06-24T00:00:00Z", + "current": { + "label": "Today", + "cost": 1.25, + "calls": 2, + "sessions": 1, + "inputTokens": 100, + "outputTokens": 50 + }, + "optimize": { + "findingCount": 0, + "savingsUSD": 0, + "topFindings": [] + }, + "history": { + "daily": [] + } + } + """.utf8) + + let payload = try JSONDecoder().decode(MenubarPayload.self, from: json) + + #expect(payload.combined == nil) + } +} + +private func combinedPayloadJSON() -> Data { + Data(""" + { + "generated": "2026-06-24T00:00:00Z", + "current": { + "label": "Today", + "cost": 1.25, + "calls": 2, + "sessions": 1, + "inputTokens": 100, + "outputTokens": 50 + }, + "optimize": { + "findingCount": 0, + "savingsUSD": 0, + "topFindings": [] + }, + "history": { + "daily": [] + }, + "combined": { + "perDevice": [ + { + "id": "local-device", + "name": "MacBook Pro", + "local": true, + "error": null, + "cost": 4.5, + "calls": 7, + "sessions": 3, + "inputTokens": 1000, + "outputTokens": 500, + "cacheCreateTokens": 200, + "cacheReadTokens": 300, + "totalTokens": 2000 + }, + { + "id": "remote-device", + "name": "Studio", + "local": false, + "error": "offline", + "cost": 0, + "calls": 0, + "sessions": 0, + "inputTokens": 0, + "outputTokens": 0, + "cacheCreateTokens": 0, + "cacheReadTokens": 0, + "totalTokens": 0 + } + ], + "combined": { + "cost": 4.5, + "calls": 7, + "sessions": 3, + "inputTokens": 1000, + "outputTokens": 500, + "cacheCreateTokens": 200, + "cacheReadTokens": 300, + "totalTokens": 2000, + "deviceCount": 2, + "reachableCount": 1 + } + } + } + """.utf8) +} diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift new file mode 100644 index 0000000..e52a987 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("Menubar period settings") +struct MenubarPeriodSettingsTests { + @Test("settings picker exposes requested periods") + func settingsPickerExposesRequestedPeriods() { + #expect(Period.menubarMetricCases == [.today, .sevenDays, .month, .all]) + } + + @Test("defaults values map to periods") + func defaultsValuesMapToPeriods() { + #expect(Period(menubarDefaultsValue: "today") == .today) + #expect(Period(menubarDefaultsValue: "week") == .sevenDays) + #expect(Period(menubarDefaultsValue: "month") == .month) + #expect(Period(menubarDefaultsValue: "sixMonths") == .all) + #expect(Period(menubarDefaultsValue: "all") == .all) + #expect(Period(menubarDefaultsValue: "30days") == .today) + #expect(Period(menubarDefaultsValue: "bogus") == .today) + #expect(Period(menubarDefaultsValue: nil) == .today) + } + + @Test("periods persist canonical defaults values") + func periodsPersistCanonicalDefaultsValues() { + let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + Period.sevenDays.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "week") + #expect(Period.savedMenubarPeriod(defaults: defaults) == .sevenDays) + + Period.all.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "sixMonths") + #expect(Period.savedMenubarPeriod(defaults: defaults) == .all) + + Period.thirtyDays.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "today") + #expect(Period.savedMenubarPeriod(defaults: defaults) == .today) + } + + @Test("menubar scope persistence defaults to local and round-trips") + func menubarScopePersistenceDefaultsToLocalAndRoundTrips() { + let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect(MenubarScope.savedMenubarScope(defaults: defaults) == .local) + + MenubarScope.combined.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarScope") == "combined") + #expect(MenubarScope.savedMenubarScope(defaults: defaults) == .combined) + + MenubarScope.local.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarScope") == "local") + #expect(MenubarScope.savedMenubarScope(defaults: defaults) == .local) + #expect(MenubarScope(menubarDefaultsValue: "bogus") == .local) + } + + @Test("non-today periods render compact and regular suffixes") + func nonTodayPeriodsRenderCompactAndRegularSuffixes() { + #expect(Period.today.menubarSuffix(compact: false) == "") + #expect(Period.sevenDays.menubarSuffix(compact: false) == " / wk") + #expect(Period.month.menubarSuffix(compact: false) == " / mo") + #expect(Period.all.menubarSuffix(compact: false) == " / 6mo") + #expect(Period.sevenDays.menubarSuffix(compact: true) == "/wk") + #expect(Period.month.menubarSuffix(compact: true) == "/mo") + #expect(Period.all.menubarSuffix(compact: true) == "/6mo") + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift new file mode 100644 index 0000000..8c769ab --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("MenubarStatusCache") +struct MenubarStatusCacheTests { + private func tempDir() -> String { + let dir = NSTemporaryDirectory() + "menubar-status-test-" + UUID().uuidString + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + return dir + } + + private func validPayloadJSON(cost: Double) -> Data { + let p = MenubarPayload( + generated: "2026-05-29T00:00:00Z", + current: CurrentBlock( + label: "Today", cost: cost, calls: 1, sessions: 1, oneShotRate: nil, + inputTokens: 1, outputTokens: 1, cacheHitPercent: 0, + codexCredits: nil, + topActivities: [], topModels: [], localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []), providers: ["claude": cost], + topProjects: [], modelEfficiency: [], topSessions: [], + retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []), + routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []), + tools: [], skills: [], subagents: [], mcpServers: [] + ), + optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), + history: HistoryBlock(daily: []), + combined: nil + ) + return try! JSONEncoder().encode(p) + } + + @Test("reads a fresh, valid status file") + func readsValidFile() throws { + let dir = tempDir() + let path = dir + "/menubar-status.json" + try validPayloadJSON(cost: 42.5).write(to: URL(fileURLWithPath: path)) + let cache = MenubarStatusCache(statusPath: path) + + let result = cache.readBadgePayload(maxAgeSeconds: 3600) + + #expect(result?.payload.current.cost == 42.5) + #expect((result?.ageSeconds ?? .infinity) < 60) + } + + @Test("returns nil for a missing file") + func missingFileReturnsNil() { + let dir = tempDir() + let cache = MenubarStatusCache(statusPath: dir + "/nope.json") + #expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil) + } + + @Test("returns nil for a corrupt file") + func corruptFileReturnsNil() throws { + let dir = tempDir() + let path = dir + "/menubar-status.json" + try Data("{ not json".utf8).write(to: URL(fileURLWithPath: path)) + let cache = MenubarStatusCache(statusPath: path) + #expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil) + } + + @Test("returns nil for an over-age file") + func overAgeFileReturnsNil() throws { + let dir = tempDir() + let path = dir + "/menubar-status.json" + let url = URL(fileURLWithPath: path) + try validPayloadJSON(cost: 7).write(to: url) + try FileManager.default.setAttributes( + [.modificationDate: Date().addingTimeInterval(-7200)], ofItemAtPath: path + ) + let cache = MenubarStatusCache(statusPath: path) + #expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil) + } + + @Test("writeStatus round-trips through readBadgePayload") + func writeStatusRoundTrips() throws { + let dir = tempDir() + let path = dir + "/menubar-status.json" + let cache = MenubarStatusCache(statusPath: path) + + let payload = try JSONDecoder().decode(MenubarPayload.self, from: validPayloadJSON(cost: 13.5)) + try cache.writeStatus(payload) + + let result = cache.readBadgePayload(maxAgeSeconds: 3600) + #expect(result?.payload.current.cost == 13.5) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift b/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift new file mode 100644 index 0000000..ef27985 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/RefreshCadenceTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import CodeBurnMenubar + +final class RefreshCadenceTests: XCTestCase { + func testAutoPopoverOpenAlwaysUsesActiveCadence() { + XCTAssertEqual( + RefreshCadence.interval(mode: .auto, popoverOpen: true, onBattery: true, lowPowerMode: true), + RefreshCadence.activeSeconds + ) + } + + func testAutoIdleOnACStaysActive() { + XCTAssertEqual( + RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: false, lowPowerMode: false), + RefreshCadence.activeSeconds + ) + } + + func testAutoIdleOnBatteryBacksOff() { + XCTAssertEqual( + RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: true, lowPowerMode: false), + RefreshCadence.batteryIdleSeconds + ) + } + + func testAutoLowPowerModeBacksOffFurthest() { + XCTAssertEqual( + RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: true, lowPowerMode: true), + RefreshCadence.lowPowerIdleSeconds + ) + XCTAssertEqual( + RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: false, lowPowerMode: true), + RefreshCadence.lowPowerIdleSeconds + ) + } + + func testManualNeverAutoSpawns() { + XCTAssertNil(RefreshCadence.interval(mode: .manual, popoverOpen: false, onBattery: false, lowPowerMode: false)) + XCTAssertNil(RefreshCadence.interval(mode: .manual, popoverOpen: true, onBattery: false, lowPowerMode: false)) + } + + func testFixedCadenceIgnoresPowerState() { + XCTAssertEqual( + RefreshCadence.interval(mode: .fiveMinutes, popoverOpen: false, onBattery: true, lowPowerMode: true), + 300 + ) + XCTAssertEqual( + RefreshCadence.interval(mode: .fifteenMinutes, popoverOpen: false, onBattery: false, lowPowerMode: false), + 900 + ) + } + + func testFixedCadenceGoesActiveWhilePopoverOpen() { + XCTAssertEqual( + RefreshCadence.interval(mode: .fiveMinutes, popoverOpen: true, onBattery: true, lowPowerMode: false), + RefreshCadence.activeSeconds + ) + } + + func testBackoffOrdering() { + XCTAssertLessThan(RefreshCadence.activeSeconds, RefreshCadence.batteryIdleSeconds) + XCTAssertLessThan(RefreshCadence.batteryIdleSeconds, RefreshCadence.lowPowerIdleSeconds) + } + + func testCadenceDefaultsToAutoWhenUnset() { + let key = UsageRefreshCadence.defaultsKey + let saved = UserDefaults.standard.object(forKey: key) + defer { + if let saved { UserDefaults.standard.set(saved, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + UserDefaults.standard.removeObject(forKey: key) + XCTAssertEqual(UsageRefreshCadence.current, .auto) + + UsageRefreshCadence.current = .manual + XCTAssertEqual(UsageRefreshCadence.current, .manual) + UsageRefreshCadence.current = .fiveMinutes + XCTAssertEqual(UsageRefreshCadence.current, .fiveMinutes) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..63084d9 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift @@ -0,0 +1,60 @@ +import Testing +@testable import CodeBurnMenubar + +@Suite("UpdateChecker") +struct UpdateCheckerTests { + @Test("selects newest mac release with zip and checksum") + func selectsNewestMacReleaseWithChecksum() { + let releases = [ + GitHubRelease( + tag_name: "v0.9.9", + assets: [GitHubAsset(name: "codeburn-0.9.9.tgz", browser_download_url: "https://example.test/cli")] + ), + GitHubRelease( + tag_name: "mac-v0.9.8", + assets: [ + GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app"), + GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip.sha256", browser_download_url: "https://example.test/app.sha256"), + ] + ), + ] + + let resolved = UpdateChecker.resolveLatestMenubarRelease(in: releases) + + #expect(resolved?.release.tag_name == "mac-v0.9.8") + #expect(resolved?.asset.name == "CodeBurnMenubar-v0.9.8.zip") + } + + @Test("ignores mac release missing checksum") + func ignoresMacReleaseMissingChecksum() { + let releases = [ + GitHubRelease( + tag_name: "mac-v0.9.8", + assets: [GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app")] + ), + ] + + #expect(UpdateChecker.resolveLatestMenubarRelease(in: releases) == nil) + } + + @Test("flags CLI older than the menubar-update fix as too old") + func flagsCliBelowMinimumAsTooOld() { + #expect(UpdateChecker.isCliTooOld(installed: "0.9.8")) + #expect(UpdateChecker.isCliTooOld(installed: "v0.9.8")) + #expect(UpdateChecker.isCliTooOld(installed: "0.8.12")) + } + + @Test("accepts CLI at or above the menubar-update fix version") + func acceptsCliAtOrAboveMinimum() { + #expect(!UpdateChecker.isCliTooOld(installed: "0.9.9")) + #expect(!UpdateChecker.isCliTooOld(installed: "0.9.10")) + #expect(!UpdateChecker.isCliTooOld(installed: "0.9.14")) + #expect(!UpdateChecker.isCliTooOld(installed: "1.0.0")) + } + + @Test("does not flag when the CLI version is unknown") + func ignoresUnknownCliVersion() { + #expect(!UpdateChecker.isCliTooOld(installed: nil)) + #expect(!UpdateChecker.isCliTooOld(installed: "")) + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e649491 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4084 @@ +{ + "name": "codeburn", + "version": "0.9.15", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codeburn", + "version": "0.9.15", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "bonjour-service": "^1.4.1", + "chalk": "^5.4.1", + "commander": "^13.1.0", + "ink": "^7.0.0", + "react": "^19.2.5", + "selfsigned": "^5.5.0", + "strip-ansi": "^7.2.0", + "undici": "^7.27.2", + "zod": "^3.25.76" + }, + "bin": { + "codeburn": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.19.17", + "@types/react": "^19.2.14", + "@types/selfsigned": "^2.0.4", + "tsup": "^8.4.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0", + "vitest": "^3.1.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "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/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/@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/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "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/@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/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "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/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "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", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bonjour-service": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.1.tgz", + "integrity": "sha512-9KM4QMPKnaJqaja1v7gYO/+TXZGLtzPA05NmUTqDAJjcsWeVoOXKMvU9g0gfuuoYTQqJZ924hivICd5R/bCJbA==", + "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", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", + "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.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/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "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/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "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/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ink": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.0.0.tgz", + "integrity": "sha512-fMie5/VwIYXofMyND0s+fOVhwVBBPYx+uuqJ6V6rUBGjui+2UYp+0fWtvhSeKT4z+X1uH98a4ge5Vj3aTlL6mg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "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==", + "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", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "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", + "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/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "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/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "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/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/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "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/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "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", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "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", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "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/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f3f59c0 --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "codeburn", + "version": "0.9.15", + "description": "See where your AI spend goes - by task, tool, model, and project", + "type": "module", + "main": "./dist/cli.js", + "bin": { + "codeburn": "dist/cli.js" + }, + "files": [ + "dist" + ], + "scripts": { + "bundle-litellm": "node scripts/bundle-litellm.mjs", + "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); 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": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts", + "test": "vitest", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "claude-code", + "cursor", + "codex", + "kimi", + "devin", + "ibm-bob", + "opencode", + "pi", + "codebuff", + "ai-coding", + "token-usage", + "cost-tracking", + "observability", + "developer-tools" + ], + "engines": { + "node": ">=22.13.0" + }, + "author": "AgentSeal ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/getagentseal/codeburn.git" + }, + "bugs": { + "url": "https://github.com/getagentseal/codeburn/issues" + }, + "homepage": "https://github.com/getagentseal/codeburn#readme", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "bonjour-service": "^1.4.1", + "chalk": "^5.4.1", + "commander": "^13.1.0", + "ink": "^7.0.0", + "react": "^19.2.5", + "selfsigned": "^5.5.0", + "strip-ansi": "^7.2.0", + "undici": "^7.27.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^22.19.17", + "@types/react": "^19.2.14", + "@types/selfsigned": "^2.0.4", + "tsup": "^8.4.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0", + "vitest": "^3.1.0" + } +} diff --git a/scripts/bundle-litellm.mjs b/scripts/bundle-litellm.mjs new file mode 100644 index 0000000..6d5f495 --- /dev/null +++ b/scripts/bundle-litellm.mjs @@ -0,0 +1,168 @@ +import { writeFileSync, mkdirSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +// Pricing sources, in priority order: +// 1. LiteLLM - broad, maintained, tracks provider list prices. +// 2. MANUAL_ENTRIES - hand-curated overrides for the primary snapshot. +// 3. models.dev - only FIRST-PARTY maker providers (not the 100+ +// gateways/resellers): official direct price for models +// LiteLLM hasn't added yet (e.g. MiniMax-M3). +// 4. OpenRouter - resale rates, one clean price per canonical model; +// a coverage backstop for makers not in models.dev. +// +// Output is TWO files: +// litellm-snapshot.json - primary (LiteLLM + MANUAL_ENTRIES). Used for the +// exact / canonical / prefix lookups. +// pricing-fallback.json - gap-fill (models.dev + OpenRouter). Consulted ONLY +// as a last resort, so a reseller variant name can +// never shadow an existing canonical/alias match. +const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' +const MODELS_DEV_URL = 'https://models.dev/api.json' +const OPENROUTER_URL = 'https://openrouter.ai/api/v1/models' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const dataDir = join(__dirname, '..', 'src', 'data') +const snapshotPath = join(dataDir, 'litellm-snapshot.json') +const fallbackPath = join(dataDir, 'pricing-fallback.json') + +// models.dev provider ids that are the actual model MAKERS (publish official +// list prices), as opposed to gateways/resellers (openrouter, nano-gpt, vercel, +// poe, novita, etc.) that mark up or discount. An id missing here just means +// that maker's models fall through to OpenRouter; an unknown id is ignored. +const MODELS_DEV_FIRST_PARTY = new Set([ + 'openai', 'anthropic', 'google', 'google-vertex', 'mistral', 'deepseek', + 'xai', 'minimax', 'minimax-cn', 'moonshotai', 'zhipuai', 'alibaba', + 'alibaba-cn', 'cohere', 'perplexity', 'inception', 'morph', +]) + +const MANUAL_ENTRIES = { + 'MiniMax-M2.7': [0.3e-6, 1.2e-6, 0.375e-6, 0.06e-6], + 'MiniMax-M2.7-highspeed': [0.6e-6, 2.4e-6, 0.375e-6, 0.06e-6], + // LiteLLM PR #27056 is not merged yet. Source: https://api-docs.deepseek.com/quick_start/pricing + 'deepseek-v4-flash': [1.4e-7, 2.8e-7, 0, 2.8e-9], + 'deepseek-v4-pro': [4.35e-7, 8.7e-7, 0, 3.625e-9], + // Mythos 5 launch pricing; not yet in LiteLLM or the models.dev/OpenRouter gap-fill (Fable is). + 'claude-mythos-5': [10e-6, 50e-6, 12.5e-6, 1e-6], +} + +const snapshot = {} + +// --- Pass 1+2: LiteLLM (primary) --- +const res = await fetch(LITELLM_URL) +if (!res.ok) throw new Error(`HTTP ${res.status}`) +const data = await res.json() +const entries = Object.entries(data).filter(([k]) => k !== 'sample_spec') + +function toVal(entry) { + const inp = entry.input_cost_per_token + const out = entry.output_cost_per_token + if (inp == null || out == null) return null + return [inp, out, entry.cache_creation_input_token_cost ?? null, entry.cache_read_input_token_cost ?? null, entry.provider_specific_entry?.fast ?? null] +} + +// Pass 1: direct entries (no prefix) get priority +for (const [name, entry] of entries) { + if (name.includes('/')) continue + const val = toVal(entry) + if (val) snapshot[name] = val +} +// Pass 2: prefixed entries - store full key + stripped (first-write-wins) +for (const [name, entry] of entries) { + if (!name.includes('/')) continue + const val = toVal(entry) + if (!val) continue + if (!snapshot[name]) snapshot[name] = val + const stripped = name.replace(/^[^/]+\//, '') + if (stripped !== name && !snapshot[stripped]) snapshot[stripped] = val +} + +// A MANUAL_ENTRY that LiteLLM now ships is a candidate to delete (the override +// would otherwise shadow upstream forever with a possibly-stale hand value). +for (const k of Object.keys(MANUAL_ENTRIES)) { + if (snapshot[k]) console.log(`note: MANUAL_ENTRIES['${k}'] is now in LiteLLM - candidate to remove`) +} +Object.assign(snapshot, MANUAL_ENTRIES) + +// --- Gap fill into a SEPARATE fallback map (last-resort only) --- +const fallback = {} +// Strip the vendor prefix to the last path segment, then the @pin and trailing +// -YYYYMMDD date that the runtime's getCanonicalName also strips, so a fallback +// key lines up with the canonical form actually queried (otherwise e.g. +// `vendor/claude-3-5-sonnet@20241022` becomes a key the lookup can never reach). +const bareKey = (name) => name.replace(/^.*\//, '').replace(/@.*$/, '').replace(/-\d{8}$/, '') +// `seen` holds every primary key AND its bareKey form (both lowercased) so we +// never re-add a model LiteLLM/MANUAL already covers under either shape; fallback +// keys are added too so the first source wins (models.dev before OpenRouter). +const seen = new Set() +for (const k of Object.keys(snapshot)) { + seen.add(k.toLowerCase()) + seen.add(bareKey(k).toLowerCase()) +} +const finite = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null } +// A rate pair is usable only if both sides are non-negative and not both zero. +// OpenRouter uses -1 as a "variable / BYOK price" sentinel; without this guard a +// negative per-token cost would ship and subtract from a user's spend totals. +const validRates = (inp, out) => inp != null && out != null && inp >= 0 && out >= 0 && !(inp === 0 && out === 0) +// Drop the same negative sentinel on optional cache fields. +const nonNeg = (v) => (v != null && v >= 0 ? v : null) +function addGap(key, val) { + if (!key || !val) return false + const lk = key.toLowerCase() + if (seen.has(lk)) return false + fallback[key] = val + seen.add(lk) + return true +} + +// --- Pass 3: models.dev first-party makers (official list prices) --- +try { + const md = await (await fetch(MODELS_DEV_URL)).json() + // Surface drift in our hand-maintained maker allowlist: if an id we classify + // as first-party is gone from the API, it was renamed/removed and the set is + // stale (its models would silently fall through to OpenRouter resale rates). + for (const id of MODELS_DEV_FIRST_PARTY) { + if (!md[id]) console.warn(`note: models.dev no longer lists first-party id '${id}' - allowlist may be stale`) + } + let added = 0 + for (const pid of Object.keys(md).sort()) { + if (!MODELS_DEV_FIRST_PARTY.has(pid)) continue + const models = md[pid].models ?? {} + for (const mid of Object.keys(models).sort()) { + const c = models[mid].cost + if (!c) continue + const inp = finite(c.input), out = finite(c.output) + if (!validRates(inp, out)) continue + // models.dev cost is per MILLION tokens; snapshot is per token. + const cw = nonNeg(c.cache_write != null ? finite(c.cache_write) : null) + const cr = nonNeg(c.cache_read != null ? finite(c.cache_read) : null) + if (addGap(bareKey(mid), [inp / 1e6, out / 1e6, cw != null ? cw / 1e6 : null, cr != null ? cr / 1e6 : null, null])) added++ + } + } + console.log(`models.dev (first-party): +${added} models`) +} catch (e) { + console.warn(`models.dev skipped: ${e.message}`) +} + +// --- Pass 4: OpenRouter (resale backstop) --- +try { + const or = (await (await fetch(OPENROUTER_URL)).json()).data ?? [] + let added = 0 + for (const m of or) { + const p = m.pricing ?? {} + const inp = finite(p.prompt), out = finite(p.completion) + if (!validRates(inp, out)) continue + // OpenRouter pricing fields are already per-token. + const cw = nonNeg(p.input_cache_write != null ? finite(p.input_cache_write) : null) + const cr = nonNeg(p.input_cache_read != null ? finite(p.input_cache_read) : null) + if (addGap(bareKey(m.id ?? ''), [inp, out, cw, cr, null])) added++ + } + console.log(`openrouter (backstop): +${added} models`) +} catch (e) { + console.warn(`openrouter skipped: ${e.message}`) +} + +mkdirSync(dataDir, { recursive: true }) +writeFileSync(snapshotPath, JSON.stringify(snapshot)) +writeFileSync(fallbackPath, JSON.stringify(fallback)) +console.log(`Bundled ${Object.keys(snapshot).length} primary + ${Object.keys(fallback).length} fallback models`) 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/antigravity-statusline.ts b/src/antigravity-statusline.ts new file mode 100644 index 0000000..15f4909 --- /dev/null +++ b/src/antigravity-statusline.ts @@ -0,0 +1,185 @@ +import { mkdir, open, readFile, rename, unlink } from 'fs/promises' +import { randomBytes } from 'crypto' +import { dirname, join } from 'path' +import { homedir } from 'os' + +import { + recordAntigravityStatusLinePayload, + snapshotAntigravityStatusLinePayload, +} from './providers/antigravity.js' +import { + buildPersistentCodeburnLookupPath, + resolvePersistentCodeburnPathFromPath, +} from './persistent-codeburn.js' + +export { buildPersistentCodeburnLookupPath as buildAntigravityHookLookupPath } from './persistent-codeburn.js' +export { resolvePersistentCodeburnPathFromPath } from './persistent-codeburn.js' + +type Settings = Record & { + statusLine?: { + type?: string + command?: string + padding?: number + } +} + +type StatusLineSettings = NonNullable + +const PERSISTENT_CLI_REQUIRED_MESSAGE = + 'The Antigravity hook needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn' + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isCodeBurnHook(command: unknown): boolean { + return typeof command === 'string' && /(?:^|\s)agy-statusline-hook$/.test(command.trim()) +} + +function shellQuote(value: string): string { + if (process.platform === 'win32') return `"${value.replace(/(["\\])/g, '\\$1')}"` + return `'${value.replace(/'/g, `'\\''`)}'` +} + +async function hookCommand(): Promise { + const codeburnPath = await resolvePersistentCodeburnPathFromPath( + buildPersistentCodeburnLookupPath(), + PERSISTENT_CLI_REQUIRED_MESSAGE, + ) + return `${shellQuote(codeburnPath)} agy-statusline-hook` +} + +function settingsPath(): string { + return process.env['CODEBURN_ANTIGRAVITY_SETTINGS_PATH'] + ?? join(homedir(), '.gemini', 'antigravity-cli', 'settings.json') +} + +function codeburnCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function previousStatusLinePath(): string { + return join(codeburnCacheDir(), 'antigravity-statusline-previous.json') +} + +async function readSettings(): Promise { + try { + const raw = await readFile(settingsPath(), 'utf-8') + const parsed = JSON.parse(raw) + return isObject(parsed) ? parsed as Settings : {} + } catch { + return {} + } +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }) + const tempPath = `${path}.${randomBytes(8).toString('hex')}.tmp` + const handle = await open(tempPath, 'w', 0o600) + try { + await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf-8' }) + await handle.sync() + } finally { + await handle.close() + } + try { + await rename(tempPath, path) + } catch (err) { + try { await unlink(tempPath) } catch { /* cleanup */ } + throw err + } +} + +async function writeSettings(settings: Settings): Promise { + await writeJsonAtomic(settingsPath(), settings) +} + +async function savePreviousStatusLine(statusLine: StatusLineSettings): Promise { + await writeJsonAtomic(previousStatusLinePath(), { + savedAt: new Date().toISOString(), + statusLine, + }) +} + +async function readPreviousStatusLine(): Promise { + try { + const raw = await readFile(previousStatusLinePath(), 'utf-8') + const parsed = JSON.parse(raw) + if (!isObject(parsed) || !isObject(parsed.statusLine)) return null + return parsed.statusLine as StatusLineSettings + } catch { + return null + } +} + +async function clearPreviousStatusLine(): Promise { + try { + await unlink(previousStatusLinePath()) + } catch { /* no previous hook backup */ } +} + +export async function installAntigravityStatusLineHook(force = false): Promise<'installed' | 'already-installed'> { + const settings = await readSettings() + const existing = settings.statusLine + if (existing && !isCodeBurnHook(existing.command) && !force) { + throw new Error( + 'Antigravity CLI already has a custom statusLine command. Re-run with --force to replace it.' + ) + } + + const command = await hookCommand() + if (isCodeBurnHook(existing?.command) && existing?.command === command && existing.type === 'command' && existing.padding === 0) { + return 'already-installed' + } + if (existing && !isCodeBurnHook(existing.command)) await savePreviousStatusLine(existing) + + settings.statusLine = { + type: 'command', + command, + padding: 0, + } + await writeSettings(settings) + return 'installed' +} + +export async function uninstallAntigravityStatusLineHook(): Promise<'removed' | 'restored' | 'not-installed'> { + const settings = await readSettings() + if (!isCodeBurnHook(settings.statusLine?.command)) return 'not-installed' + + const previous = await readPreviousStatusLine() + if (previous) settings.statusLine = previous + else delete settings.statusLine + + await writeSettings(settings) + await clearPreviousStatusLine() + return previous ? 'restored' : 'removed' +} + +const MAX_STDIN_BYTES = 1024 * 1024 + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let input = '' + let bytes = 0 + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { + bytes += Buffer.byteLength(chunk, 'utf8') + if (bytes > MAX_STDIN_BYTES) { process.stdin.destroy(); reject(new Error('stdin too large')); return } + input += chunk + }) + process.stdin.on('end', () => resolve(input)) + process.stdin.on('error', reject) + }) +} + +export async function runAgyStatusLineHook(): Promise { + try { + const input = await readStdin() + const payload = input.trim() ? JSON.parse(input) : null + await recordAntigravityStatusLinePayload(payload) + await snapshotAntigravityStatusLinePayload(payload) + } catch { + // Status line hooks run inside the user's terminal UI. Never surface parser + // or transient RPC failures there; the next status line update can retry. + } +} 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 new file mode 100644 index 0000000..06a4f6d --- /dev/null +++ b/src/bash-utils.ts @@ -0,0 +1,64 @@ +import { basename } from 'path' +import stripAnsi from 'strip-ansi' + +function stripQuotedStrings(command: string): string { + return command.replace(/"[^"]*"|'[^']*'/g, match => ' '.repeat(match.length)) +} + +const COMMAND_PREFIXES = new Set([ + 'sudo', 'doas', + 'npx', 'bunx', + 'time', + 'nice', 'nohup', 'stdbuf', + 'rtk', +]) + +export function extractBashCommands(rawCommand: string): string[] { + if (!rawCommand || !rawCommand.trim()) return [] + + const command = stripAnsi(rawCommand) + const stripped = stripQuotedStrings(command) + + const separatorRegex = /\s*(?:&&|;|\|)\s*/g + const separators: Array<{ start: number; end: number }> = [] + let match: RegExpExecArray | null + + while ((match = separatorRegex.exec(stripped)) !== null) { + separators.push({ start: match.index, end: match.index + match[0].length }) + } + + const ranges: Array<[number, number]> = [] + let cursor = 0 + for (const sep of separators) { + ranges.push([cursor, sep.start]) + cursor = sep.end + } + ranges.push([cursor, command.length]) + + const commands: string[] = [] + for (const [start, end] of ranges) { + const segment = command.slice(start, end).trim() + if (!segment) continue + + const tokens = segment.split(/\s+/) + let i = 0 + 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') { + commands.push(base) + } + } + + return commands +} diff --git a/src/classifier.ts b/src/classifier.ts new file mode 100644 index 0000000..ca7854b --- /dev/null +++ b/src/classifier.ts @@ -0,0 +1,217 @@ +import type { ClassifiedTurn, ParsedTurn, TaskCategory, ToolCall } from './types.js' + +const TEST_PATTERNS = /\b(test|pytest|vitest|jest|mocha|spec|coverage|npm\s+test|npx\s+vitest|npx\s+jest)\b/i +const GIT_PATTERNS = /\bgit\s+(push|pull|commit|merge|rebase|checkout|branch|stash|log|diff|status|add|reset|cherry-pick|tag)\b/i +const BUILD_PATTERNS = /\b(npm\s+run\s+build|npm\s+publish|pip\s+install|docker|deploy|make\s+build|npm\s+run\s+dev|npm\s+start|pm2|systemctl|brew|cargo\s+build)\b/i +const INSTALL_PATTERNS = /\b(npm\s+install|pip\s+install|brew\s+install|apt\s+install|cargo\s+add)\b/i + +const DEBUG_KEYWORDS = /\b(fix|bug|error|broken|failing|crash|issue|debug|traceback|exception|stack\s*trace|not\s+working|wrong|unexpected|status\s+code|404|500|401|403)\b/i +const FEATURE_KEYWORDS = /\b(add|create|implement|new|build|feature|introduce|set\s*up|scaffold|generate|make\s+(?:a|me|the)|write\s+(?:a|me|the))\b/i +const REFACTOR_KEYWORDS = /\b(refactor|clean\s*up|rename|reorganize|simplify|extract|restructure|move|migrate|split)\b/i +const BRAINSTORM_KEYWORDS = /\b(brainstorm|idea|what\s+if|explore|think\s+about|approach|strategy|design|consider|how\s+should|what\s+would|opinion|suggest|recommend)\b/i +const RESEARCH_KEYWORDS = /\b(research|investigate|look\s+into|find\s+out|check|search|analyze|review|understand|explain|how\s+does|what\s+is|show\s+me|list|compare)\b/i + +const FILE_PATTERNS = /\.(py|js|ts|tsx|jsx|json|yaml|yml|toml|sql|sh|go|rs|java|rb|php|css|html|md|csv|xml)\b/i +const SCRIPT_PATTERNS = /\b(run\s+\S+\.\w+|execute|scrip?t|curl|api\s+\S+|endpoint|request\s+url|fetch\s+\S+|query|database|db\s+\S+)\b/i +const URL_PATTERN = /https?:\/\/\S+/i + +export const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit']) +const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool']) +export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool']) +const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TodoWrite']) +const SEARCH_TOOLS = new Set(['WebSearch', 'WebFetch', 'ToolSearch']) + +function hasEditTools(tools: string[]): boolean { + return tools.some(t => EDIT_TOOLS.has(t)) +} + +function hasReadTools(tools: string[]): boolean { + return tools.some(t => READ_TOOLS.has(t)) +} + +function hasBashTool(tools: string[]): boolean { + return tools.some(t => BASH_TOOLS.has(t)) +} + +function hasTaskTools(tools: string[]): boolean { + return tools.some(t => TASK_TOOLS.has(t)) +} + +function hasSearchTools(tools: string[]): boolean { + return tools.some(t => SEARCH_TOOLS.has(t)) +} + +function hasMcpTools(tools: string[]): boolean { + return tools.some(t => t.startsWith('mcp__')) +} + +function hasSkillTool(tools: string[]): boolean { + return tools.some(t => t === 'Skill') +} + +function getAllTools(turn: ParsedTurn): string[] { + return turn.assistantCalls.flatMap(c => c.tools) +} + +function getAllSkills(turn: ParsedTurn): string[] { + return turn.assistantCalls.flatMap(c => c.skills ?? []) +} + +function classifyByToolPattern(turn: ParsedTurn): TaskCategory | null { + const tools = getAllTools(turn) + if (tools.length === 0) return null + + if (turn.assistantCalls.some(c => c.hasPlanMode)) return 'planning' + if (turn.assistantCalls.some(c => c.hasAgentSpawn)) return 'delegation' + + const hasEdits = hasEditTools(tools) + const hasReads = hasReadTools(tools) + const hasBash = hasBashTool(tools) + const hasTasks = hasTaskTools(tools) + const hasSearch = hasSearchTools(tools) + const hasMcp = hasMcpTools(tools) + const hasSkill = hasSkillTool(tools) + + if (hasBash && !hasEdits) { + const userMsg = turn.userMessage + if (TEST_PATTERNS.test(userMsg)) return 'testing' + if (GIT_PATTERNS.test(userMsg)) return 'git' + if (BUILD_PATTERNS.test(userMsg)) return 'build/deploy' + if (INSTALL_PATTERNS.test(userMsg)) return 'build/deploy' + } + + if (hasEdits) return 'coding' + + if (hasBash && hasReads) return 'exploration' + if (hasBash) return 'coding' + + if (hasSearch || hasMcp) return 'exploration' + if (hasReads && !hasEdits) return 'exploration' + if (hasTasks && !hasEdits) return 'planning' + if (hasSkill) return 'general' + + return null +} + +/// Picks the category whose keyword pattern matches earliest in the message. +/// On a tie (same start index) the candidate listed first in `candidates` wins, +/// so callers control tie-break priority by ordering. Returns null when no +/// pattern matches. The first-match heuristic fixes the long-standing problem +/// where "add error handling" was tagged Debugging because the DEBUG regex was +/// checked before FEATURE; now FEATURE wins because "add" appears before +/// "error". Issue #196. +function firstMatchingCategory( + text: string, + candidates: ReadonlyArray<{ regex: RegExp; category: TaskCategory }>, +): TaskCategory | null { + let best: { index: number; order: number; category: TaskCategory } | null = null + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]! + const m = c.regex.exec(text) + if (!m) continue + if (!best || m.index < best.index || (m.index === best.index && i < best.order)) { + best = { index: m.index, order: i, category: c.category } + } + } + return best?.category ?? null +} + +function refineByKeywords(category: TaskCategory, userMessage: string): TaskCategory { + if (category === 'coding') { + // Tie-break order (when two keywords match at the same index): refactoring + // first because its words are the most specific, then feature, then debug. + return firstMatchingCategory(userMessage, [ + { regex: REFACTOR_KEYWORDS, category: 'refactoring' }, + { regex: FEATURE_KEYWORDS, category: 'feature' }, + { regex: DEBUG_KEYWORDS, category: 'debugging' }, + ]) ?? 'coding' + } + + if (category === 'exploration') { + if (RESEARCH_KEYWORDS.test(userMessage)) return 'exploration' + if (DEBUG_KEYWORDS.test(userMessage)) return 'debugging' + return 'exploration' + } + + return category +} + +function classifyConversation(userMessage: string): TaskCategory { + if (BRAINSTORM_KEYWORDS.test(userMessage)) return 'brainstorming' + if (RESEARCH_KEYWORDS.test(userMessage)) return 'exploration' + // Same first-match-wins logic as refineByKeywords so a chat-only message + // starting with a feature verb does not flip to debugging because of an + // incidental "error" or "fix" word later in the same sentence. + const debugOrFeature = firstMatchingCategory(userMessage, [ + { regex: FEATURE_KEYWORDS, category: 'feature' }, + { regex: DEBUG_KEYWORDS, category: 'debugging' }, + ]) + if (debugOrFeature) return debugOrFeature + if (FILE_PATTERNS.test(userMessage)) return 'coding' + if (SCRIPT_PATTERNS.test(userMessage)) return 'coding' + if (URL_PATTERN.test(userMessage)) return 'exploration' + return 'conversation' +} + +function countRetries(turn: ParsedTurn): number { + const steps: ToolCall[][] = [] + for (const call of turn.assistantCalls) { + if (call.toolSequence && call.toolSequence.length > 0) { + steps.push(...call.toolSequence) + } else if (call.tools.length > 0) { + steps.push(call.tools.map(t => ({ tool: t }))) + } + } + + const lastEditStep = new Map() + let lastVerifyStep = -1 + let retries = 0 + + steps.forEach((step, i) => { + for (const call of step) { + if (BASH_TOOLS.has(call.tool)) { + lastVerifyStep = i + } + if (EDIT_TOOLS.has(call.tool)) { + const fileKey = call.file ?? '__no_file__' + const prevStep = lastEditStep.get(fileKey) + if (prevStep !== undefined && lastVerifyStep > prevStep && lastVerifyStep < i) { + retries++ + } + lastEditStep.set(fileKey, i) + } + } + }) + + return retries +} + +function turnHasEdits(turn: ParsedTurn): boolean { + return turn.assistantCalls.some(c => c.tools.some(t => EDIT_TOOLS.has(t))) +} + +export function classifyTurn(turn: ParsedTurn): ClassifiedTurn { + const tools = getAllTools(turn) + + let category: TaskCategory + + if (tools.length === 0) { + category = classifyConversation(turn.userMessage) + } else { + const toolCategory = classifyByToolPattern(turn) + if (toolCategory) { + category = refineByKeywords(toolCategory, turn.userMessage) + } else { + category = classifyConversation(turn.userMessage) + } + } + + const result: ClassifiedTurn = { ...turn, category, retries: countRetries(turn), hasEdits: turnHasEdits(turn) } + + if (category === 'general') { + const skills = getAllSkills(turn) + if (skills.length > 0) result.subCategory = skills[0] + } + + return result +} diff --git a/src/cli-date.ts b/src/cli-date.ts new file mode 100644 index 0000000..d1daceb --- /dev/null +++ b/src/cli-date.ts @@ -0,0 +1,227 @@ +import type { DateRange } from './types.js' +import { toDateString } from './daily-cache.js' + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/ + +const END_OF_DAY_HOURS = 23 +const END_OF_DAY_MINUTES = 59 +const END_OF_DAY_SECONDS = 59 +const END_OF_DAY_MS = 999 + +// "All Time" is intentionally bounded to the last 6 months. Older data is +// rarely actionable for a cost tracker, and capping the range keeps the parse +// path bounded so providers like Codex/Cursor with sparse multi-year history +// still load in seconds. Users who need an unbounded window can use +// `--from` / `--to`. +const ALL_TIME_MONTHS = 6 + +export type Period = 'today' | 'week' | '30days' | 'month' | 'all' + +export const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] + +// Short labels suitable for the dashboard tab strip. Long-form labels for +// header text come from `getDateRange().label`. +export const PERIOD_LABELS: Record = { + today: 'Today', + week: '7 Days', + '30days': '30 Days', + month: 'This Month', + all: '6 Months', +} + +const VALID_PERIODS: ReadonlyArray = ['today', 'week', '30days', 'month', 'all'] + +export class UsageQueryError extends Error { + constructor(message: string) { + super(message) + this.name = 'UsageQueryError' + } +} + +export function parsePeriodOrThrow(s: string): Period { + if ((VALID_PERIODS as readonly string[]).includes(s)) return s as Period + throw new UsageQueryError(`Unknown period "${s}". Valid values: ${VALID_PERIODS.join(', ')}.`) +} + +export function toPeriod(s: string): Period { + try { + return parsePeriodOrThrow(s) + } catch { + // Fail loudly instead of silently coercing to 'week'. Previously a typo + // like `-p mounth` produced a quiet 7-day report and the user thought + // they were viewing the month. + process.stderr.write( + `codeburn: unknown period "${s}". Valid values: ${VALID_PERIODS.join(', ')}.\n` + ) + process.exit(1) + } +} + +function parseLocalDate(s: string): Date { + if (!ISO_DATE_RE.test(s)) { + throw new UsageQueryError(`Invalid date format "${s}": expected YYYY-MM-DD`) + } + const [y, m, d] = s.split('-').map(Number) as [number, number, number] + const date = new Date(y, m - 1, d) + // JS Date silently rolls overflow forward (Feb 31 → Mar 3). That makes a + // typo like `--from 2026-02-31 --to 2026-03-15` quietly drop sessions + // dated Feb 28 - Mar 2. Reject overflow so the user gets a loud error + // instead of an off-by-N-days date range. + if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) { + throw new UsageQueryError(`Invalid date "${s}": ${m}/${d}/${y} is not a real calendar date`) + } + return date +} + +function endOfLocalDay(date: Date): Date { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + END_OF_DAY_HOURS, + END_OF_DAY_MINUTES, + END_OF_DAY_SECONDS, + END_OF_DAY_MS, + ) +} + +export function dayRangeForDate(date: Date): DateRange { + const start = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + return { start, end: endOfLocalDay(start) } +} + +export function formatDayRangeLabel(day: string): string { + return `Day (${day})` +} + +export function shiftDay(day: string, delta: number): string { + const date = parseLocalDate(day) + return toDateString(new Date(date.getFullYear(), date.getMonth(), date.getDate() + delta)) +} + +export function parseDayFlag(day: string | undefined): { day: string; range: DateRange; label: string } | null { + if (day === undefined) return null + + const now = new Date() + let date: Date + if (day === 'today') { + date = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + } else if (day === 'yesterday') { + date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + } else { + date = parseLocalDate(day) + } + + const resolvedDay = toDateString(date) + return { day: resolvedDay, range: dayRangeForDate(date), label: formatDayRangeLabel(resolvedDay) } +} + +export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null { + if (from === undefined && to === undefined) return null + + const now = new Date() + // When --from is omitted, default to 6 months back (the same window the + // dashboard's "all" period uses) instead of epoch. Previously a bare + // `--to 2026-01-01` opened a 55-year scan from 1970 which is rarely what + // the user meant and is expensive on machines with many session files. + const ALL_TIME_FALLBACK_MS = 6 * 31 * 24 * 60 * 60 * 1000 + const start = from !== undefined + ? parseLocalDate(from) + : new Date(now.getTime() - ALL_TIME_FALLBACK_MS) + + const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const end = endOfLocalDay(endDate) + + if (start > end) { + throw new UsageQueryError(`--from must not be after --to (got ${from} > ${to})`) + } + return { start, end } +} + +/** + * Returns the date range and a human-readable label for a named period. + * + * Accepts a string (rather than the strict `Period` type) because the CLI + * surfaces a few extra inputs not exposed in the dashboard tab strip + * (e.g. `'yesterday'`). Unknown values fall back to `'week'`. + * + * Note: `'all'` is bounded to the last 6 months. Use `--from`/`--to` for + * an unbounded historical window. + */ +export function getDateRange(period: string): { range: DateRange; label: string } { + const now = new Date() + const end = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + END_OF_DAY_HOURS, + END_OF_DAY_MINUTES, + END_OF_DAY_SECONDS, + END_OF_DAY_MS, + ) + + switch (period) { + case 'today': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + return { range: dayRangeForDate(start), label: `Today (${toDateString(start)})` } + } + case 'yesterday': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + return { range: dayRangeForDate(start), label: `Yesterday (${toDateString(start)})` } + } + case 'week': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7) + return { range: { start, end }, label: 'Last 7 Days' } + } + case 'month': { + const start = new Date(now.getFullYear(), now.getMonth(), 1) + return { range: { start, end }, label: `${now.toLocaleString('default', { month: 'long' })} ${now.getFullYear()}` } + } + case '30days': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30) + return { range: { start, end }, label: 'Last 30 Days' } + } + case 'all': { + const start = new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1) + return { range: { start, end }, label: 'Last 6 months' } + } + default: { + process.stderr.write( + `codeburn: unknown period "${period}". Valid values: today, week, 30days, month, all.\n` + ) + process.exit(1) + } + } +} + +export function parseDaysFlag(days: string | undefined): { days: Set; range: DateRange; label: string } | null { + if (days === undefined) return null + const list = days.split(',').map(s => s.trim()).filter(Boolean) + if (list.length === 0) return null + const dates = list.map(parseLocalDate) + const strings = dates.map(toDateString) + const sorted = [...strings].sort() + const startDate = parseLocalDate(sorted[0]!) + const endDate = parseLocalDate(sorted[sorted.length - 1]!) + return { + days: new Set(sorted), + range: { start: startDate, end: endOfLocalDay(endDate) }, + label: sorted.length === 1 ? sorted[0]! : `${sorted.length} days (${sorted[0]} .. ${sorted[sorted.length - 1]})`, + } +} + +export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string { + return `${from ?? 'all'} to ${to ?? 'today'}` +} + +/** Resolve a usage query period for HTTP handlers without calling process.exit. */ +export function periodInfoFromQuery( + q: { period?: string; from?: string; to?: string }, + defaultPeriod: string, +): { range: DateRange; label: string } { + const customRange = parseDateRangeFlags(q.from, q.to) + if (customRange) { + return { range: customRange, label: formatDateRangeLabel(q.from, q.to) } + } + return getDateRange(parsePeriodOrThrow(q.period ?? defaultPeriod)) +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..dec3d49 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +// This launcher must stay parseable by Node 18. Do NOT add static imports. +const [major, minor] = process.versions.node.split('.').map(Number) +if (major < 22 || (major === 22 && minor < 13)) { + process.stderr.write( + `codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` + + 'Upgrade at https://nodejs.org/\n', + ) + process.exit(1) +} + +import('./main.js').catch((err) => { + process.stderr.write(String(err?.message ?? err) + '\n') + process.exit(1) +}) diff --git a/src/codex-cache.ts b/src/codex-cache.ts new file mode 100644 index 0000000..b2499c8 --- /dev/null +++ b/src/codex-cache.ts @@ -0,0 +1,148 @@ +import { readFile, mkdir, stat, open, rename, unlink } from 'fs/promises' +import { existsSync } from 'fs' +import { randomBytes } from 'crypto' +import { join } from 'path' +import { homedir } from 'os' + +import type { ParsedProviderCall } from './providers/types.js' + +// v4: attribute MCP calls emitted as event_msg/mcp_tool_call_end (issue #478). +// Recent Codex sessions cached under v3 dropped these, so force a re-parse. +// v5: also attribute CLI-wrapped MCP calls (`mcp-cli call server tool`) that +// Codex logs as a plain exec_command (issue #478 follow-up). Force a re-parse +// so sessions cached under v4 pick up the CLI-MCP attribution. +const CODEX_CACHE_VERSION = 5 +const CACHE_FILE = 'codex-results.json' + +type FileFingerprint = { mtimeMs: number; sizeBytes: number } + +type FileEntry = { + mtimeMs: number + sizeBytes: number + project: string + calls: ParsedProviderCall[] +} + +type ResultCache = { + version: number + files: Record +} + +function getCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function getCachePath(): string { + return join(getCacheDir(), CACHE_FILE) +} + +let memCache: ResultCache | null = null + +async function loadCache(): Promise { + if (memCache) return memCache + try { + const raw = await readFile(getCachePath(), 'utf-8') + const cache = JSON.parse(raw) as ResultCache + if (cache.version === CODEX_CACHE_VERSION && cache.files && typeof cache.files === 'object') { + memCache = cache + return cache + } + } catch {} + memCache = { version: CODEX_CACHE_VERSION, files: {} } + return memCache +} + +function getEntry(cache: ResultCache, filePath: string, fp: FileFingerprint): FileEntry | null { + if (!Object.hasOwn(cache.files, filePath)) return null + const entry = cache.files[filePath] + if (entry && entry.mtimeMs === fp.mtimeMs && entry.sizeBytes === fp.sizeBytes) { + return entry + } + return null +} + +export async function readCachedCodexResults( + filePath: string, +): Promise { + try { + const s = await stat(filePath) + const cache = await loadCache() + const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size }) + return entry?.calls ?? null + } catch {} + return null +} + +export async function getCachedCodexProject( + filePath: string, +): Promise { + try { + const s = await stat(filePath) + const cache = await loadCache() + const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size }) + return entry?.project ?? null + } catch {} + return null +} + +export async function fingerprintFile( + filePath: string, +): Promise { + try { + const s = await stat(filePath) + return { mtimeMs: s.mtimeMs, sizeBytes: s.size } + } catch { + return null + } +} + +export async function writeCachedCodexResults( + filePath: string, + project: string, + calls: ParsedProviderCall[], + fingerprint: FileFingerprint, +): Promise { + try { + const cache = await loadCache() + cache.files[filePath] = { + mtimeMs: fingerprint.mtimeMs, + sizeBytes: fingerprint.sizeBytes, + project, + calls, + } + } catch {} +} + +export async function flushCodexCache(): Promise { + if (!memCache) return + try { + // Evict entries for files that no longer exist on disk + const paths = Object.keys(memCache.files) + for (const p of paths) { + try { + await stat(p) + } catch { + delete memCache.files[p] + } + } + + const dir = getCacheDir() + if (!existsSync(dir)) await mkdir(dir, { recursive: true }) + const finalPath = getCachePath() + const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` + const payload = JSON.stringify(memCache) + const handle = await open(tempPath, 'w', 0o600) + try { + await handle.writeFile(payload, { encoding: 'utf-8' }) + await handle.sync() + } finally { + await handle.close() + } + try { + await rename(tempPath, finalPath) + } catch (err) { + try { await unlink(tempPath) } catch {} + throw err + } + } catch {} +} 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/compare-stats.ts b/src/compare-stats.ts new file mode 100644 index 0000000..5e67781 --- /dev/null +++ b/src/compare-stats.ts @@ -0,0 +1,398 @@ +import { readdir, readFile } from 'fs/promises' +import { join } from 'path' + +import type { ProjectSummary } from './types.js' + +const PLANNING_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TodoWrite', 'EnterPlanMode', 'ExitPlanMode']) + +export type ModelStats = { + model: string + calls: number + cost: number + outputTokens: number + inputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + totalTurns: number + editTurns: number + oneShotTurns: number + retries: number + selfCorrections: number + editCost: number + firstSeen: string + lastSeen: string +} + +export function aggregateModelStats(projects: ProjectSummary[]): ModelStats[] { + const byModel = new Map() + + const ensure = (model: string): ModelStats => { + let s = byModel.get(model) + if (!s) { + s = { model, calls: 0, cost: 0, outputTokens: 0, inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTurns: 0, editTurns: 0, oneShotTurns: 0, retries: 0, selfCorrections: 0, editCost: 0, firstSeen: '', lastSeen: '' } + byModel.set(model, s) + } + return s + } + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (turn.assistantCalls.length === 0) continue + const primaryModel = turn.assistantCalls[0]!.model + if (primaryModel === '') continue + + const ms = ensure(primaryModel) + ms.totalTurns++ + if (turn.hasEdits) { + ms.editTurns++ + if (turn.retries === 0) ms.oneShotTurns++ + for (const c of turn.assistantCalls) { + if (c.model !== '') ms.editCost += c.costUSD + } + } + ms.retries += turn.retries + + for (const call of turn.assistantCalls) { + if (call.model === '') continue + const cs = call.model === primaryModel ? ms : ensure(call.model) + cs.calls++ + cs.cost += call.costUSD + cs.outputTokens += call.usage.outputTokens + cs.inputTokens += call.usage.inputTokens + cs.cacheReadTokens += call.usage.cacheReadInputTokens + cs.cacheWriteTokens += call.usage.cacheCreationInputTokens + + if (!cs.firstSeen || call.timestamp < cs.firstSeen) cs.firstSeen = call.timestamp + if (!cs.lastSeen || call.timestamp > cs.lastSeen) cs.lastSeen = call.timestamp + } + } + } + } + + return [...byModel.values()].sort((a, b) => b.cost - a.cost) +} + +export type ComparisonRow = { + section: string + label: string + valueA: number | null + valueB: number | null + formatFn: 'cost' | 'number' | 'percent' | 'decimal' + winner: 'a' | 'b' | 'tie' | 'none' +} + +export type CategoryComparison = { + category: string + turnsA: number + editTurnsA: number + oneShotRateA: number | null + turnsB: number + editTurnsB: number + oneShotRateB: number | null + winner: 'a' | 'b' | 'tie' | 'none' +} + +export type WorkingStyleRow = { + label: string + valueA: number | null + valueB: number | null + formatFn: ComparisonRow['formatFn'] +} + +type MetricDef = { + section: string + label: string + formatFn: ComparisonRow['formatFn'] + higherIsBetter: boolean + compute: (s: ModelStats) => number | null +} + +const METRICS: MetricDef[] = [ + { + section: 'Performance', + label: 'One-shot rate', + formatFn: 'percent', + higherIsBetter: true, + compute: s => s.editTurns > 0 ? (s.oneShotTurns / s.editTurns) * 100 : null, + }, + { + section: 'Performance', + label: 'Retry rate', + formatFn: 'decimal', + higherIsBetter: false, + compute: s => s.editTurns > 0 ? s.retries / s.editTurns : null, + }, + { + section: 'Performance', + label: 'Self-correction', + formatFn: 'percent', + higherIsBetter: false, + compute: s => s.totalTurns > 0 ? (s.selfCorrections / s.totalTurns) * 100 : null, + }, + { + section: 'Efficiency', + label: 'Cost / call', + formatFn: 'cost', + higherIsBetter: false, + compute: s => s.calls > 0 ? s.cost / s.calls : null, + }, + { + section: 'Efficiency', + label: 'Cost / edit', + formatFn: 'cost', + higherIsBetter: false, + compute: s => s.editTurns > 0 ? s.editCost / s.editTurns : null, + }, + { + section: 'Efficiency', + label: 'Output tok / call', + formatFn: 'number', + higherIsBetter: false, + compute: s => s.calls > 0 ? Math.round(s.outputTokens / s.calls) : null, + }, + { + section: 'Efficiency', + label: 'Cache hit rate', + formatFn: 'percent', + higherIsBetter: true, + compute: s => { + const total = s.inputTokens + s.cacheReadTokens + s.cacheWriteTokens + return total > 0 ? (s.cacheReadTokens / total) * 100 : null + }, + }, +] + +function pickWinner(valueA: number | null, valueB: number | null, higherIsBetter: boolean): ComparisonRow['winner'] { + if (valueA === null || valueB === null) return 'none' + if (valueA === valueB) return 'tie' + if (higherIsBetter) return valueA > valueB ? 'a' : 'b' + return valueA < valueB ? 'a' : 'b' +} + +export function computeComparison(a: ModelStats, b: ModelStats): ComparisonRow[] { + return METRICS.map(m => { + const valueA = m.compute(a) + const valueB = m.compute(b) + return { + section: m.section, + label: m.label, + valueA, + valueB, + formatFn: m.formatFn, + winner: pickWinner(valueA, valueB, m.higherIsBetter), + } + }) +} + +export function computeCategoryComparison(projects: ProjectSummary[], modelA: string, modelB: string): CategoryComparison[] { + type Accum = { turns: number; editTurns: number; oneShotTurns: number } + const mapA = new Map() + const mapB = new Map() + + const ensure = (map: Map, cat: string): Accum => { + let a = map.get(cat) + if (!a) { a = { turns: 0, editTurns: 0, oneShotTurns: 0 }; map.set(cat, a) } + return a + } + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (turn.assistantCalls.length === 0) continue + const primary = turn.assistantCalls[0]!.model + if (primary !== modelA && primary !== modelB) continue + + const acc = ensure(primary === modelA ? mapA : mapB, turn.category) + acc.turns++ + if (turn.hasEdits) { + acc.editTurns++ + if (turn.retries === 0) acc.oneShotTurns++ + } + } + } + } + + const allCats = new Set([...mapA.keys(), ...mapB.keys()]) + const result: CategoryComparison[] = [] + + for (const category of allCats) { + const a = mapA.get(category) + const b = mapB.get(category) + if ((!a || a.editTurns === 0) && (!b || b.editTurns === 0)) continue + + const rateA = a && a.editTurns > 0 ? (a.oneShotTurns / a.editTurns) * 100 : null + const rateB = b && b.editTurns > 0 ? (b.oneShotTurns / b.editTurns) * 100 : null + + result.push({ + category, + turnsA: a?.turns ?? 0, + editTurnsA: a?.editTurns ?? 0, + oneShotRateA: rateA, + turnsB: b?.turns ?? 0, + editTurnsB: b?.editTurns ?? 0, + oneShotRateB: rateB, + winner: pickWinner(rateA, rateB, true), + }) + } + + return result.sort((a, b) => (b.turnsA + b.turnsB) - (a.turnsA + a.turnsB)) +} + +export function computeWorkingStyle(projects: ProjectSummary[], modelA: string, modelB: string): WorkingStyleRow[] { + type StyleAccum = { totalTurns: number; agentSpawns: number; planModeUses: number; totalToolCalls: number; fastModeCalls: number } + const sA: StyleAccum = { totalTurns: 0, agentSpawns: 0, planModeUses: 0, totalToolCalls: 0, fastModeCalls: 0 } + const sB: StyleAccum = { totalTurns: 0, agentSpawns: 0, planModeUses: 0, totalToolCalls: 0, fastModeCalls: 0 } + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (turn.assistantCalls.length === 0) continue + const primary = turn.assistantCalls[0]!.model + if (primary !== modelA && primary !== modelB) continue + + const s = primary === modelA ? sA : sB + s.totalTurns++ + const turnTools = turn.assistantCalls.flatMap(c => c.tools) + if (turnTools.some(t => PLANNING_TOOLS.has(t)) || turn.assistantCalls.some(c => c.hasPlanMode)) { + s.planModeUses++ + } + for (const call of turn.assistantCalls) { + s.totalToolCalls += call.tools.length + if (call.hasAgentSpawn) s.agentSpawns++ + if (call.speed === 'fast') s.fastModeCalls++ + } + } + } + } + + const pct = (num: number, den: number) => den > 0 ? (num / den) * 100 : null + const avg = (num: number, den: number) => den > 0 ? num / den : null + + return [ + { label: 'Delegation rate', valueA: pct(sA.agentSpawns, sA.totalTurns), valueB: pct(sB.agentSpawns, sB.totalTurns), formatFn: 'percent' as const }, + { label: 'Planning rate', valueA: pct(sA.planModeUses, sA.totalTurns), valueB: pct(sB.planModeUses, sB.totalTurns), formatFn: 'percent' as const }, + { label: 'Avg tools / turn', valueA: avg(sA.totalToolCalls, sA.totalTurns), valueB: avg(sB.totalToolCalls, sB.totalTurns), formatFn: 'decimal' as const }, + { label: 'Fast mode usage', valueA: pct(sA.fastModeCalls, sA.totalTurns), valueB: pct(sB.fastModeCalls, sB.totalTurns), formatFn: 'percent' as const }, + ] +} + +const SELF_CORRECTION_PATTERNS = [ + /\bmy mistake\b/i, + /\bmy bad\b/i, + /\bmy apolog/i, + /\bI apologize\b/i, + /\bI was wrong\b/i, + /\bI was incorrect\b/i, + /\bI made (a |an )?(error|mistake)\b/i, + /\bI incorrectly\b/i, + /\bI mistakenly\b/i, + /\bthat was (incorrect|wrong|an error)\b/i, + /\blet me correct that\b/i, + /\bI need to correct\b/i, + /\byou're right[.,]? I/i, + /\bsorry about that\b/i, +] + +function extractText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content + .filter((b): b is { type: string; text: string } => b !== null && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') + .map(b => b.text) + .join(' ') +} + +function isCompactFile(name: string): boolean { + return name.includes('compact') +} + +async function collectJsonlFiles(sessionDir: string): Promise { + const entries = await readdir(sessionDir, { withFileTypes: true }) + const files: string[] = [] + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.jsonl') && !isCompactFile(entry.name)) { + files.push(join(sessionDir, entry.name)) + } else if (entry.isDirectory() && entry.name === 'subagents') { + const subEntries = await readdir(join(sessionDir, entry.name), { withFileTypes: true }) + for (const sub of subEntries) { + if (sub.isFile() && sub.name.endsWith('.jsonl') && !isCompactFile(sub.name)) { + files.push(join(sessionDir, entry.name, sub.name)) + } + } + } + } + return files +} + +export async function scanSelfCorrections(projectDirs: string[]): Promise> { + const counts = new Map() + const seen = new Set() + + for (const dir of projectDirs) { + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + continue + } + + const allFiles: string[] = [] + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.jsonl') && !isCompactFile(entry.name)) { + allFiles.push(join(dir, entry.name)) + } else if (entry.isDirectory()) { + try { + const sessionFiles = await collectJsonlFiles(join(dir, entry.name)) + allFiles.push(...sessionFiles) + } catch { + continue + } + } + } + + for (const file of allFiles) { + let raw: string + try { + raw = await readFile(file, 'utf8') + } catch { + continue + } + + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + continue + } + + const rec = parsed as Record + if (!rec || typeof rec !== 'object' || rec['type'] !== 'assistant') continue + + const ts = rec['timestamp'] + const msg = rec['message'] + if (msg === null || typeof msg !== 'object') continue + + const msgRec = msg as Record + const model = msgRec['model'] + if (typeof model !== 'string' || model === '') continue + + const dedupeKey = `${model}:${ts}` + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + + const text = extractText(msgRec['content']) + if (SELF_CORRECTION_PATTERNS.some(p => p.test(text))) { + counts.set(model, (counts.get(model) ?? 0) + 1) + } + } + } + } + + return counts +} diff --git a/src/compare.tsx b/src/compare.tsx new file mode 100644 index 0000000..565c840 --- /dev/null +++ b/src/compare.tsx @@ -0,0 +1,486 @@ +import React, { useState, useEffect, useRef } from 'react' +import { render, Box, Text, useInput, useApp, useStdout } from 'ink' + +import type { ModelStats, ComparisonRow, CategoryComparison, WorkingStyleRow } from './compare-stats.js' +import { aggregateModelStats, computeComparison, computeCategoryComparison, computeWorkingStyle, scanSelfCorrections } from './compare-stats.js' +import { formatCost } from './format.js' +import { parseAllSessions, setInteractiveScanUI } from './parser.js' +import { getAllProviders } from './providers/index.js' +import type { ProjectSummary, DateRange } from './types.js' +import { patchStdoutForWindows } from './ink-win.js' + +const ORANGE = '#FF8C42' +const GREEN = '#5BF5A0' +const DIM = '#888888' +const GOLD = '#FFD700' +const BAR_A = '#6495ED' +const BAR_B = '#5BF5A0' +const LOW_DATA_THRESHOLD = 20 +const LABEL_WIDTH = 20 +const VALUE_WIDTH = 14 +const MODEL_NAME_COL = 24 +const BAR_MAX_WIDTH = 30 +const MIN_WIDE = 90 +const PANEL_CHROME = 4 +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const FULL_BLOCK = '\u2588' + +function formatValue(value: number | null, fmt: ComparisonRow['formatFn']): string { + if (value === null) return '-' + switch (fmt) { + case 'cost': return formatCost(value) + case 'number': return Math.round(value).toLocaleString() + case 'percent': return `${value.toFixed(1)}%` + case 'decimal': return value.toFixed(2) + } +} + +function shortName(model: string): string { + return model.replace(/^claude-/, '').replace(/-\d{8}$/, '') +} + +function daysOfData(first: string, last: string): number { + if (!first || !last) return 0 + const ms = new Date(last).getTime() - new Date(first).getTime() + return Math.max(1, Math.ceil(ms / MS_PER_DAY)) +} + +function barWidth(rate: number): number { + return Math.round((rate / 100) * BAR_MAX_WIDTH) +} + +type ModelSelectorProps = { + models: ModelStats[] + onSelect: (a: ModelStats, b: ModelStats) => void + onBack: () => void +} + +function ModelSelector({ models, onSelect, onBack }: ModelSelectorProps) { + const { exit } = useApp() + const [cursor, setCursor] = useState(0) + const [selected, setSelected] = useState>(new Set()) + + useInput((input, key) => { + if (input === 'q') { exit(); return } + if (key.escape) { onBack(); return } + + if (key.upArrow) { + setCursor(c => (c - 1 + models.length) % models.length) + return + } + if (key.downArrow) { + setCursor(c => (c + 1) % models.length) + return + } + + if (input === ' ') { + setSelected(prev => { + const next = new Set(prev) + if (next.has(cursor)) { + next.delete(cursor) + } else if (next.size < 2) { + next.add(cursor) + } + return next + }) + return + } + + if (key.return && selected.size === 2) { + const indices = [...selected].sort((a, b) => a - b) + onSelect(models[indices[0]!]!, models[indices[1]!]!) + } + }) + + return ( + + + Model Comparison + + Select two models to compare: + + {models.map((m, i) => { + const isCursor = i === cursor + const isSelected = selected.has(i) + const lowData = m.calls < LOW_DATA_THRESHOLD + const prefix = isCursor ? '> ' : ' ' + return ( + + {prefix} + + {shortName(m.model).padEnd(MODEL_NAME_COL)} + + {m.calls.toLocaleString().padStart(8)} calls + {formatCost(m.cost).padStart(10)} + {isSelected && [selected]} + {lowData && low data} + + ) + })} + + + + [space] select + [enter] compare + {'<>'} switch period + [esc] back + [q] quit + + + ) +} + +type ComparisonResultsProps = { + modelA: ModelStats + modelB: ModelStats + rows: ComparisonRow[] + categories: CategoryComparison[] + workingStyle: WorkingStyleRow[] + onBack: () => void +} + +function MetricPanel({ title, rows, nameA, nameB, pw }: { title: string; rows: ComparisonRow[]; nameA: string; nameB: string; pw: number }) { + return ( + + {title} + + {''.padEnd(LABEL_WIDTH)} + {nameA.padStart(VALUE_WIDTH)} + {nameB.padStart(VALUE_WIDTH)} + + {rows.map(row => { + const fmtA = formatValue(row.valueA, row.formatFn) + const fmtB = formatValue(row.valueB, row.formatFn) + return ( + + {row.label.padEnd(LABEL_WIDTH)} + {fmtA.padStart(VALUE_WIDTH)} + {fmtB.padStart(VALUE_WIDTH)} + + ) + })} + + ) +} + +function ContextPanel({ title, rows, nameA, nameB, pw, lowDataWarning }: { title: string; rows: { label: string; valueA: string; valueB: string }[]; nameA: string; nameB: string; pw: number; lowDataWarning?: string }) { + return ( + + {title} + + {''.padEnd(LABEL_WIDTH)} + {nameA.padStart(VALUE_WIDTH)} + {nameB.padStart(VALUE_WIDTH)} + + {rows.map(row => ( + + {row.label.padEnd(LABEL_WIDTH)} + {row.valueA.padStart(VALUE_WIDTH)} + {row.valueB.padStart(VALUE_WIDTH)} + + ))} + {lowDataWarning && {lowDataWarning}} + + ) +} + +function ComparisonResults({ modelA, modelB, rows, categories, workingStyle, onBack }: ComparisonResultsProps) { + const { exit } = useApp() + const { stdout } = useStdout() + const termWidth = stdout?.columns || 80 + const dashWidth = Math.min(160, termWidth) + const wide = dashWidth >= MIN_WIDE + const halfWidth = wide ? Math.floor(dashWidth / 2) : dashWidth + + const nameA = shortName(modelA.model) + const nameB = shortName(modelB.model) + const lowDataA = modelA.calls < LOW_DATA_THRESHOLD + const lowDataB = modelB.calls < LOW_DATA_THRESHOLD + + useInput((input, key) => { + if (input === 'q') { exit(); return } + if (key.escape) { onBack(); return } + }) + + const sectionOrder: string[] = [] + const sectionRows = new Map() + for (const row of rows) { + if (!sectionRows.has(row.section)) { + sectionOrder.push(row.section) + sectionRows.set(row.section, []) + } + sectionRows.get(row.section)!.push(row) + } + + const fmtTokens = (n: number) => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return String(n) + } + + const contextRows: { label: string; valueA: string; valueB: string }[] = [ + { label: 'Calls', valueA: modelA.calls.toLocaleString(), valueB: modelB.calls.toLocaleString() }, + { label: 'Total cost', valueA: formatCost(modelA.cost), valueB: formatCost(modelB.cost) }, + { label: 'Input tokens', valueA: fmtTokens(modelA.inputTokens), valueB: fmtTokens(modelB.inputTokens) }, + { label: 'Output tokens', valueA: fmtTokens(modelA.outputTokens), valueB: fmtTokens(modelB.outputTokens) }, + { label: 'Days of data', valueA: String(daysOfData(modelA.firstSeen, modelA.lastSeen)), valueB: String(daysOfData(modelB.firstSeen, modelB.lastSeen)) }, + { label: 'Edit turns', valueA: modelA.editTurns.toLocaleString(), valueB: modelB.editTurns.toLocaleString() }, + { label: 'Self-corrections', valueA: modelA.selfCorrections.toLocaleString(), valueB: modelB.selfCorrections.toLocaleString() }, + ] + + const lowDataWarning = (lowDataA || lowDataB) + ? `Note: ${[lowDataA && shortName(modelA.model), lowDataB && shortName(modelB.model)].filter(Boolean).join(' and ')} ha${lowDataA && lowDataB ? 've' : 's'} fewer than ${LOW_DATA_THRESHOLD} calls` + : undefined + + const pw = wide ? halfWidth : dashWidth + + return ( + + + + {nameA} + vs + {nameB} + + + + + + + + + {categories.length > 0 && ( + + Category Head-to-Head + one-shot rate per category + + {' '} + {FULL_BLOCK + FULL_BLOCK} + {nameA} + {FULL_BLOCK + FULL_BLOCK} + {nameB} + + {categories.map(cat => { + const bwA = cat.oneShotRateA !== null ? barWidth(cat.oneShotRateA) : 0 + const bwB = cat.oneShotRateB !== null ? barWidth(cat.oneShotRateB) : 0 + const rateA = cat.oneShotRateA !== null ? `${cat.oneShotRateA.toFixed(1)}%` : '-' + const rateB = cat.oneShotRateB !== null ? `${cat.oneShotRateB.toFixed(1)}%` : '-' + const turnsA = cat.editTurnsA > 0 ? `(${cat.editTurnsA})` : '' + const turnsB = cat.editTurnsB > 0 ? `(${cat.editTurnsB})` : '' + + return ( + + + {' '}{cat.category} + + {' '} + {FULL_BLOCK.repeat(Math.max(bwA, 1))} + {' '.repeat(Math.max(0, BAR_MAX_WIDTH - bwA))} + {rateA.padStart(6)} + {turnsA} + + + {' '} + {FULL_BLOCK.repeat(Math.max(bwB, 1))} + {' '.repeat(Math.max(0, BAR_MAX_WIDTH - bwB))} + {rateB.padStart(6)} + {turnsB} + + + ) + })} + + )} + + + {workingStyle.length > 0 && ( + ({ label: r.label, valueA: formatValue(r.valueA, r.formatFn), valueB: formatValue(r.valueB, r.formatFn) }))} nameA={nameA} nameB={nameB} pw={pw} /> + )} + + + + + {'<>'} switch period + [esc] back + [q] quit + + + ) +} + +type CompareViewProps = { + projects: ProjectSummary[] + onBack: () => void +} + +export function CompareView({ projects, onBack }: CompareViewProps) { + const { exit } = useApp() + const [phase, setPhase] = useState<'select' | 'loading' | 'results'>('select') + const [models, setModels] = useState(() => aggregateModelStats(projects)) + const [pickedNames, setPickedNames] = useState<[string, string] | null>(null) + const [selectedA, setSelectedA] = useState(null) + const [selectedB, setSelectedB] = useState(null) + const [rows, setRows] = useState([]) + const [categories, setCategories] = useState([]) + const [style, setStyle] = useState([]) + const [loadTrigger, setLoadTrigger] = useState(0) + const projectsRef = useRef(projects) + projectsRef.current = projects + + useEffect(() => { + const newModels = aggregateModelStats(projects) + setModels(newModels) + + if (!pickedNames) return + const hasA = newModels.some(m => m.model === pickedNames[0]) + const hasB = newModels.some(m => m.model === pickedNames[1]) + if (!hasA || !hasB) { + setPickedNames(null) + setPhase('select') + return + } + + // When the periodic CLI refresh updates `projects` while the user is + // reading the results page, recompute the comparison rows IN PLACE rather + // than flipping to a loading screen. Previously every 30s tick bounced the + // user to a loading flash and reset their scroll position; the slow part + // (scanSelfCorrections, which walks every provider's session dir) is + // skipped on these refreshes — corrections drift slowly enough that + // staying with the existing values until the user re-enters compare from + // scratch is fine. + if (phase === 'results') { + const a = newModels.find(m => m.model === pickedNames[0]) + const b = newModels.find(m => m.model === pickedNames[1]) + if (!a || !b) return + const aCopy = { ...a, selfCorrections: selectedA?.selfCorrections ?? 0 } + const bCopy = { ...b, selfCorrections: selectedB?.selfCorrections ?? 0 } + setSelectedA(aCopy) + setSelectedB(bCopy) + setRows(computeComparison(aCopy, bCopy)) + setCategories(computeCategoryComparison(projects, a.model, b.model)) + setStyle(computeWorkingStyle(projects, a.model, b.model)) + return + } + + // Initial load (or returning from select after picking) — full pipeline, + // including scanSelfCorrections. + setLoadTrigger(t => t + 1) + }, [projects]) + + useEffect(() => { + if (loadTrigger === 0 || !pickedNames) return + let cancelled = false + setPhase('loading') + + const currentModels = aggregateModelStats(projectsRef.current) + const a = currentModels.find(m => m.model === pickedNames[0]) + const b = currentModels.find(m => m.model === pickedNames[1]) + if (!a || !b) { setPhase('select'); return } + + async function run() { + const providers = await getAllProviders() + const dirs: string[] = [] + for (const p of providers) { + const sessions = await p.discoverSessions() + for (const s of sessions) dirs.push(s.path) + } + const corrections = await scanSelfCorrections(dirs) + if (cancelled) return + + const currentProjects = projectsRef.current + const aCopy = { ...a!, selfCorrections: corrections.get(a!.model) ?? 0 } + const bCopy = { ...b!, selfCorrections: corrections.get(b!.model) ?? 0 } + setSelectedA(aCopy) + setSelectedB(bCopy) + setRows(computeComparison(aCopy, bCopy)) + setCategories(computeCategoryComparison(currentProjects, a!.model, b!.model)) + setStyle(computeWorkingStyle(currentProjects, a!.model, b!.model)) + setPhase('results') + } + + run() + return () => { cancelled = true } + }, [loadTrigger]) + + useInput((input, key) => { + if (phase !== 'select') return + if (models.length < 2) { + if (input === 'q') { exit(); return } + if (key.escape) { onBack(); return } + } + }) + + if (models.length < 2) { + return ( + + + Model Comparison + + Need at least 2 models to compare. Found {models.length}. + + + + [esc] back + [q] quit + + + ) + } + + const handleSelect = (a: ModelStats, b: ModelStats) => { + setPickedNames([a.model, b.model]) + setLoadTrigger(t => t + 1) + } + + if (phase === 'loading') { + return ( + + + Model Comparison + + Scanning self-corrections... + + + ) + } + + if (phase === 'results' && selectedA && selectedB) { + return ( + setPhase('select')} + /> + ) + } + + return ( + + ) +} + +export async function renderCompare(range: DateRange, provider: string): Promise { + // Interactive Ink UI: suppress the CLI scan-progress line for the whole + // lifetime so it can't print over the rendered comparison. Plain CLI + // commands still show progress. + setInteractiveScanUI() + const isTTY = process.stdin.isTTY && process.stdout.isTTY + if (!isTTY) { + process.stdout.write('Model comparison requires an interactive terminal.\n') + return + } + + patchStdoutForWindows() + const projects = await parseAllSessions(range, provider) + const { waitUntilExit } = render( + process.exit(0)} /> + ) + await waitUntilExit() +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6562075 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,168 @@ +import { readFile, writeFile, mkdir, rename } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' +import { randomBytes } from 'crypto' +import { PLAN_PROVIDERS } from './plans.js' + +export type PlanId = 'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy' | 'custom' | 'none' +export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'grok' | 'all' + +export type Plan = { + id: PlanId + monthlyUsd: number + provider: PlanProvider + resetDay?: number + setAt: string +} + +export type PlanConfig = Omit & Partial> +export type PlanConfigMap = Partial> +export type PlanMap = Partial> + +export type CodeburnConfig = { + currency?: { + code: string + symbol?: string + } + devin?: { + acuUsdRate?: number + } + plan?: Plan + plans?: PlanConfigMap + modelAliases?: Record + // Rates are stored as USD per 1,000,000 tokens; models.ts converts them to per-token ModelCosts. + priceOverrides?: Record + // Extra Claude config directories to aggregate usage across (e.g. work / + // personal accounts). Honored by getClaudeConfigDirs() below the + // CLAUDE_CONFIG_DIRS/CLAUDE_CONFIG_DIR env vars. Lets the macOS menubar (a + // GUI app that doesn't inherit the user's shell env) configure multi-account + // aggregation without injecting env into every spawned subprocess. + claudeConfigDirs?: string[] + // Map raw local-model names (e.g. "llama3.1:8b") to the paid model we would + // price the call against (e.g. "gpt-4o"). The local call still costs $0; we + // track what the same tokens would have cost on the baseline so the dashboard + // can show "saved $X by running locally". Distinct from modelAliases which + // rewrites actual spend. + localModelSavings?: Record + // Absolute directory prefixes whose Claude Code sessions are routed through a + // subscription-backed LLM proxy (e.g. GitHub Copilot via ANTHROPIC_BASE_URL; + // tools like claude-code-over-github-copilot / claudegate). The JSONL records + // the underlying model name and no endpoint, so codeburn cannot auto-detect + // proxying — the user declares it here, scoped by the project's canonical cwd. + // Matching projects keep their full API-rate `totalCostUSD` (the billable / + // would-be figure is never destroyed) but expose `totalProxiedCostUSD` so the + // report can show what was subscription-covered and the net out-of-pocket. + // Matched against the canonical project path: prefix on a path-segment + // boundary, case-insensitive, trailing-slash and backslash tolerant. + proxyPaths?: string[] +} + +function getConfigDir(): string { + return join(homedir(), '.config', 'codeburn') +} + +function getConfigPath(): string { + return join(getConfigDir(), 'config.json') +} + +export async function readConfig(): Promise { + try { + const raw = await readFile(getConfigPath(), 'utf-8') + return JSON.parse(raw) as CodeburnConfig + } catch { + return {} + } +} + +export async function saveConfig(config: CodeburnConfig): Promise { + await mkdir(getConfigDir(), { recursive: true }) + const configPath = getConfigPath() + // Randomize the temp path so two simultaneous saveConfig calls (from + // overlapping menubar + CLI runs, for example) do not race on the same + // staging file. The previous fixed `.tmp` suffix could leave one + // process reading partial bytes the other was mid-writing. + const tmpPath = `${configPath}.${randomBytes(8).toString('hex')}.tmp` + await writeFile(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8') + await rename(tmpPath, configPath) +} + +export async function readPlan(): Promise { + const plans = await readPlans() + for (const provider of PLAN_PROVIDERS) { + const plan = plans[provider] + if (plan) return plan + } + return undefined +} + +function planFromConfig(provider: PlanProvider, plan: PlanConfig | undefined): Plan | undefined { + if (!plan) return undefined + return { + ...plan, + provider, + setAt: plan.setAt ?? '', + } +} + +function normalizePlans(config: CodeburnConfig): PlanMap { + const plans: PlanMap = {} + + if (config.plans && Object.keys(config.plans).length > 0) { + for (const provider of PLAN_PROVIDERS) { + const plan = planFromConfig(provider, config.plans[provider]) + if (plan) plans[provider] = plan + } + if (plans.all && PLAN_PROVIDERS.some(provider => provider !== 'all' && plans[provider])) { + delete plans.all + } + return plans + } + + if (config.plan) { + plans[config.plan.provider] = config.plan + } + + return plans +} + +export async function readPlans(): Promise { + return normalizePlans(await readConfig()) +} + +export async function savePlan(plan: Plan): Promise { + const config = await readConfig() + const plans = normalizePlans(config) + if (plan.provider === 'all') { + config.plans = { all: plan } + } else { + delete plans.all + plans[plan.provider] = plan + config.plans = plans + } + delete config.plan + await saveConfig(config) +} + +export async function clearPlan(provider?: PlanProvider): Promise { + const config = await readConfig() + if (provider) { + const plans = normalizePlans(config) + delete plans[provider] + if (Object.keys(plans).length > 0) { + config.plans = plans + } else { + delete config.plans + } + delete config.plan + await saveConfig(config) + return + } + + delete config.plan + delete config.plans + await saveConfig(config) +} + +export function getConfigFilePath(): string { + return getConfigPath() +} diff --git a/src/content-utils.ts b/src/content-utils.ts new file mode 100644 index 0000000..5ca31ae --- /dev/null +++ b/src/content-utils.ts @@ -0,0 +1,26 @@ +/// Normalize a message's `content` into an array of content blocks. +/// +/// Most agent session formats write `content` as an array of typed blocks +/// (`{ type: 'text' | 'tool_use' | ... }`), and the parsers filter over that +/// array. But some agents (Pi, and others for programmatically injected turns) +/// legitimately write `content` as a plain **string**. A raw string reaching +/// `.filter`/`.some` throws a TypeError mid-parse — and because the 365-day +/// daily-cache backfill swallows parse errors, that single bad record silently +/// wipes the entire trend/history (issue #441). +/// +/// This coerces defensively: arrays pass through, a string becomes one text +/// block, and anything else (null/undefined/number/object) becomes empty. +export function normalizeContentBlocks( + content: T[] | string | null | undefined, +): T[] { + if (Array.isArray(content)) { + // A clean array (the overwhelming common case) is returned by reference — no + // copy. Only when an element is a non-object (null/undefined/primitive) do we + // filter, since the call sites read `.type` on each element and a null would + // throw — the same crash class this helper exists to prevent, one level down. + const isBlock = (b: T): boolean => b != null && typeof b === 'object' + return content.every(isBlock) ? content : content.filter(isBlock) + } + if (typeof content === 'string') return [{ type: 'text', text: content } as T] + return [] +} diff --git a/src/context-budget.ts b/src/context-budget.ts new file mode 100644 index 0000000..b5c72d6 --- /dev/null +++ b/src/context-budget.ts @@ -0,0 +1,149 @@ +import { readdir } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' + +import { readSessionFile } from './fs-utils.js' + +const CHARS_PER_TOKEN = 4 +const SYSTEM_BASE_TOKENS = 10400 +const TOOL_TOKENS_OVERHEAD = 400 +const SKILL_FRONTMATTER_TOKENS = 80 + +export type ContextBudget = { + systemBase: number + mcpTools: { count: number; tokens: number } + skills: { count: number; tokens: number } + memory: { count: number; tokens: number; files: Array<{ name: string; tokens: number }> } + total: number + modelContext: number +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / CHARS_PER_TOKEN) +} + +async function readConfigFile(path: string): Promise | null> { + if (!existsSync(path)) return null + const raw = await readSessionFile(path) + if (raw === null) return null + try { return JSON.parse(raw) } catch { return null } +} + +async function countMcpTools(projectPath?: string): Promise { + const home = homedir() + const configPaths = [ + join(home, '.claude', 'settings.json'), + join(home, '.claude', 'settings.local.json'), + ] + if (projectPath) { + configPaths.push(join(projectPath, '.mcp.json')) + configPaths.push(join(projectPath, '.claude', 'settings.json')) + configPaths.push(join(projectPath, '.claude', 'settings.local.json')) + } + + const servers = new Set() + let toolCount = 0 + + for (const p of configPaths) { + const config = await readConfigFile(p) + if (!config) continue + const mcpServers = (config.mcpServers ?? {}) as Record + for (const name of Object.keys(mcpServers)) { + if (servers.has(name)) continue + servers.add(name) + toolCount += 5 + } + } + + return toolCount +} + +async function countSkills(projectPath?: string): Promise { + const dirs = [join(homedir(), '.claude', 'skills')] + if (projectPath) dirs.push(join(projectPath, '.claude', 'skills')) + + let count = 0 + for (const dir of dirs) { + if (!existsSync(dir)) continue + try { + const entries = await readdir(dir) + for (const entry of entries) { + const skillFile = join(dir, entry, 'SKILL.md') + if (existsSync(skillFile)) count++ + } + } catch { continue } + } + + return count +} + +async function scanMemoryFiles(projectPath?: string): Promise> { + const home = homedir() + const files: Array<{ name: string; tokens: number }> = [] + const paths: Array<{ path: string; name: string }> = [ + { path: join(home, '.claude', 'CLAUDE.md'), name: '~/.claude/CLAUDE.md' }, + ] + + if (projectPath) { + paths.push({ path: join(projectPath, 'CLAUDE.md'), name: 'CLAUDE.md' }) + paths.push({ path: join(projectPath, '.claude', 'CLAUDE.md'), name: '.claude/CLAUDE.md' }) + paths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' }) + } + + for (const { path, name } of paths) { + if (!existsSync(path)) continue + const content = await readSessionFile(path) + if (content === null) continue + files.push({ name, tokens: estimateTokens(content) }) + } + + return files +} + +export async function estimateContextBudget(projectPath?: string, modelContext = 1_000_000): Promise { + const mcpToolCount = await countMcpTools(projectPath) + const skillCount = await countSkills(projectPath) + const memoryFiles = await scanMemoryFiles(projectPath) + + const mcpTokens = mcpToolCount * TOOL_TOKENS_OVERHEAD + const skillTokens = skillCount * SKILL_FRONTMATTER_TOKENS + const memoryTokens = memoryFiles.reduce((s, f) => s + f.tokens, 0) + const total = SYSTEM_BASE_TOKENS + mcpTokens + skillTokens + memoryTokens + + return { + systemBase: SYSTEM_BASE_TOKENS, + mcpTools: { count: mcpToolCount, tokens: mcpTokens }, + skills: { count: skillCount, tokens: skillTokens }, + memory: { count: memoryFiles.length, tokens: memoryTokens, files: memoryFiles }, + total, + modelContext, + } +} + +export async function estimateBudgetsByProject(projectPaths: Map): Promise> { + const results = new Map() + for (const [project, cwd] of projectPaths) { + const budget = await estimateContextBudget(cwd) + results.set(project, budget) + } + return results +} + +export async function discoverProjectCwd(sessionDir: string): Promise { + let files: string[] + try { + files = (await readdir(sessionDir)).filter(f => f.endsWith('.jsonl')) + } catch { return null } + if (files.length === 0) return null + const content = await readSessionFile(join(sessionDir, files[0])) + if (content === null) return null + for (const line of content.split('\n')) { + if (!line.trim()) continue + try { + const entry = JSON.parse(line) + if (entry.cwd && typeof entry.cwd === 'string') return entry.cwd + } catch { continue } + } + return null +} diff --git a/src/context-tree-codex.ts b/src/context-tree-codex.ts new file mode 100644 index 0000000..e9f9939 --- /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, undefined, { largeLineAsBuffer: true })) { + 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..9da3af8 --- /dev/null +++ b/src/context-tree.ts @@ -0,0 +1,744 @@ +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, type SessionLine } 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 +} + +// A single line above this decodes to a string near V8's limit; skip it +// instead of letting toString abort the whole walk. +const MAX_LINE_BYTES = 256 * 1024 * 1024 + +export function lineToText(line: SessionLine): string | null { + if (typeof line === 'string') return line + if (line.length > MAX_LINE_BYTES) return null + try { + return line.toString('utf-8') + } catch { + return 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 skipFileSnapshots = (head: string): boolean => head.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, skipFileSnapshots, { largeLineAsBuffer: true })) { + if (typeof line !== 'string') 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, skipFileSnapshots, { largeLineAsBuffer: true })) { + 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 new file mode 100644 index 0000000..d951c81 --- /dev/null +++ b/src/currency.ts @@ -0,0 +1,186 @@ +import { readFile, writeFile, mkdir } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' + +import { readConfig } from './config.js' +import { fetchWithTimeout } from './fetch-utils.js' + +type CurrencyState = { + code: string + rate: number + symbol: string +} + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const FRANKFURTER_URL = 'https://api.frankfurter.app/latest?from=USD&to=' +// Defensive bounds on any fetched FX rate. Outside this band the rate is either a parser bug +// or a tampered Frankfurter response, and we refuse to multiply it into displayed costs. +const MIN_VALID_FX_RATE = 0.0001 +const MAX_VALID_FX_RATE = 1_000_000 + +function isValidRate(value: unknown): value is number { + return typeof value === 'number' + && Number.isFinite(value) + && value >= MIN_VALID_FX_RATE + && value <= MAX_VALID_FX_RATE +} + +let active: CurrencyState = { code: 'USD', rate: 1, symbol: '$' } + +const USD: CurrencyState = { code: 'USD', rate: 1, symbol: '$' } +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 { + try { + new Intl.NumberFormat('en', { style: 'currency', currency: code }) + return true + } catch { + return false + } +} + +function resolveSymbol(code: string): string { + if (SYMBOL_OVERRIDES[code]) return SYMBOL_OVERRIDES[code] + + const parts = new Intl.NumberFormat('en', { + style: 'currency', + currency: code, + currencyDisplay: 'symbol', + }).formatToParts(0) + return parts.find(p => p.type === 'currency')?.value ?? code +} + +export function getFractionDigits(code: string): number { + return new Intl.NumberFormat('en', { + style: 'currency', + currency: code, + }).resolvedOptions().maximumFractionDigits ?? 2 +} + +/// Round a converted cost to the currency's natural decimal places. JPY/KRW/CLP +/// resolve to 0 fraction digits — exporting those with `round2` produced rows +/// like `¥412.37` while the dashboard rendered `¥412`, breaking finance reports +/// that compare the two surfaces. +export function roundForActiveCurrency(value: number): number { + const code = getCurrency().code + const digits = getFractionDigits(code) + const factor = Math.pow(10, digits) + return Math.round(value * factor) / factor +} + +function getCacheDir(): string { + return join(homedir(), '.cache', 'codeburn') +} + +function getRateCachePath(): string { + return join(getCacheDir(), 'exchange-rate.json') +} + +async function fetchRate(code: string): Promise { + // Bounded so a stalled network can't hang the daily refresh for non-USD users + // (same wedge as the pricing fetch); callers fall back to USD / cached rate. + const response = await fetchWithTimeout(`${FRANKFURTER_URL}${code}`) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const data = await response.json() as { rates?: Record } + const rate = data.rates?.[code] + if (!isValidRate(rate)) throw new Error(`Invalid rate returned for ${code}`) + return rate +} + +async function loadCachedRate(code: string): Promise { + try { + const raw = await readFile(getRateCachePath(), 'utf-8') + const cached = JSON.parse(raw) as Partial<{ timestamp: number; code: string; rate: number }> + // Validate every field -- a tampered cache file could set rate to a string, null, or + // Infinity and break downstream math silently. + if (typeof cached.code !== 'string' || cached.code !== code) return null + if (typeof cached.timestamp !== 'number' || !Number.isFinite(cached.timestamp)) return null + if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null + if (!isValidRate(cached.rate)) return null + return cached.rate + } catch { + return null + } +} + +async function cacheRate(code: string, rate: number): Promise { + await mkdir(getCacheDir(), { recursive: true }) + await writeFile(getRateCachePath(), JSON.stringify({ timestamp: Date.now(), code, rate })) +} + +async function getExchangeRate(code: string): Promise { + if (code === 'USD') return 1 + + const cached = await loadCachedRate(code) + if (cached) return cached + + let rate: number + try { + rate = await fetchRate(code) + } catch { + return 1 + } + // Persist the rate, but never let a cache-write failure (disk full, no + // permissions, etc.) cause us to return the USD-equivalent fallback. + // The original code wrapped fetch + cacheRate in one try/catch, so a + // disk-full at write time would discard a perfectly good rate and silently + // make every cost render as if the user had selected USD. + cacheRate(code, rate).catch(() => {}) + return rate +} + +export async function loadCurrency(): Promise { + const config = await readConfig() + if (!config.currency) return + + const code = config.currency.code.toUpperCase() + const rate = await getExchangeRate(code) + const symbol = config.currency.symbol ?? resolveSymbol(code) + + active = { code, rate, symbol } +} + +export function getCurrency(): CurrencyState { + return active +} + +export async function switchCurrency(code: string): Promise { + if (code === 'USD') { + active = USD + return + } + const rate = await getExchangeRate(code) + const symbol = resolveSymbol(code) + active = { code, rate, symbol } +} + +export function getCostColumnHeader(): string { + return `Cost (${active.code})` +} + +export function convertCost(costUSD: number): number { + // Return the unrounded converted cost. Rounding here meant zero-fraction + // currencies (JPY, KRW, CLP) clamped every per-session cost to the nearest + // whole unit before aggregation; a project with 1000 sessions averaging + // ¥0.4 each would aggregate to ¥0 instead of ¥400 because each row was + // rounded independently. formatCost (and the export rowsToCsv path) round + // at the display boundary instead. + return costUSD * active.rate +} + +export function formatCost(costUSD: number): string { + const { rate, symbol, code } = active + const cost = costUSD * rate + const digits = getFractionDigits(code) + + if (digits === 0) return `${symbol}${Math.round(cost)}` + + if (cost >= 1) return `${symbol}${cost.toFixed(2)}` + if (cost >= 0.01) return `${symbol}${cost.toFixed(3)}` + if (cost >= 0.0001) return `${symbol}${cost.toFixed(4)}` + return `${symbol}${cost.toFixed(2)}` +} diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts new file mode 100644 index 0000000..fe5e18e --- /dev/null +++ b/src/cursor-cache.ts @@ -0,0 +1,109 @@ +import { readFile, writeFile, mkdir, rename, stat, unlink } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' +import { randomBytes } from 'crypto' + +import type { ParsedProviderCall } from './providers/types.js' + +// Bumped to 3 for the workspace-aware breakdown change: the cursor parser +// now derives `sessionId` from the bubble row key (the real composer id) +// rather than the empty `conversationId` JSON field, and the workspace +// router relies on those composer ids to bucket calls per project. +// Version 2 caches contain `sessionId: 'unknown'` for every call and would +// route everything to the orphan project, so we invalidate them. +// Version 5: parseAgentKv was removed (it double-counted against bubbles); +// real context tokens from composerData.promptTokenBreakdown now drive +// input, and agentKv is used only for the tools/bash breakdown. Cached v4 +// results contain stale agentKv calls and lack the real token figures. +// Version 6: conversation input moved to composer-anchored records +// (cursor:composer-input:) with per-conversation source selection, the +// agent stream regained tool/system context and stream-only sessions, and +// tool names are canonicalized. v5 results mix crediting regimes. +const CURSOR_CACHE_VERSION = 6 + +type ResultCache = { + version?: number + dbMtimeMs: number + dbSizeBytes: number + lookbackFloor: string + calls: ParsedProviderCall[] +} + +const CACHE_FILE = 'cursor-results.json' + +function getCacheDir(): string { + return join(homedir(), '.cache', 'codeburn') +} + +function getCachePath(): string { + return join(getCacheDir(), CACHE_FILE) +} + +async function getDbFingerprint(dbPath: string): Promise<{ mtimeMs: number; size: number } | null> { + try { + const s = await stat(dbPath) + return { mtimeMs: s.mtimeMs, size: s.size } + } catch { + return null + } +} + +export async function readCachedResults( + dbPath: string, + requestedFloor: string, +): Promise { + try { + const fp = await getDbFingerprint(dbPath) + if (!fp) return null + + const raw = await readFile(getCachePath(), 'utf-8') + const cache = JSON.parse(raw) as ResultCache + + if ( + cache.version === CURSOR_CACHE_VERSION && + cache.dbMtimeMs === fp.mtimeMs && + cache.dbSizeBytes === fp.size && + typeof cache.lookbackFloor === 'string' && + cache.lookbackFloor <= requestedFloor + ) { + return cache.calls + } + return null + } catch { + return null + } +} + +export async function writeCachedResults( + dbPath: string, + calls: ParsedProviderCall[], + lookbackFloor: string, +): Promise { + const fp = await getDbFingerprint(dbPath) + if (!fp) return + + const dir = getCacheDir() + await mkdir(dir, { recursive: true }).catch(() => {}) + const cache: ResultCache = { + version: CURSOR_CACHE_VERSION, + dbMtimeMs: fp.mtimeMs, + dbSizeBytes: fp.size, + lookbackFloor, + calls, + } + + // Atomic write: stage to a randomized temp file in the same directory, + // then rename onto the final path. rename() is atomic on POSIX, so a + // crash mid-write never leaves a half-written cache, and concurrent + // CLI invocations using their own random temp names cannot interleave + // bytes in the destination file (they only race on the final rename, + // last-writer-wins, both with valid content). + const target = getCachePath() + const tempPath = `${target}.${randomBytes(8).toString('hex')}.tmp` + try { + await writeFile(tempPath, JSON.stringify(cache), 'utf-8') + await rename(tempPath, target) + } catch { + await unlink(tempPath).catch(() => {}) + } +} diff --git a/src/daily-cache.ts b/src/daily-cache.ts new file mode 100644 index 0000000..c697cfb --- /dev/null +++ b/src/daily-cache.ts @@ -0,0 +1,262 @@ +import { randomBytes } from 'crypto' +import { existsSync } from 'fs' +import { mkdir, open, readFile, rename, unlink } from 'fs/promises' +import { homedir } from 'os' +import { join } from 'path' +import type { DateRange, ProjectSummary } from './types.js' + +// Bumped to 10: cursor accounting changed (real composer context tokens on +// conversation-anchored records, Cursor-published composer pricing), so days +// finalized at v9 carry the old double-counted agentKv estimates and +// sonnet-proxy composer costs. Raising MIN_SUPPORTED_VERSION forces the +// one-time full re-hydration that backfills history under the new accounting. +// +// v9: providers added since the v8 rollup (Grok, Hermes, ZCode) parse usage +// that older binaries skipped. v8 added local-model savings to the daily +// rollup; the `savingsConfigHash` field is invalidated separately when the +// user changes their `localModelSavings` mapping. +export const DAILY_CACHE_VERSION = 10 +const MIN_SUPPORTED_VERSION = 10 +const DAILY_CACHE_FILENAME = 'daily-cache.json' + +export type DailyEntry = { + date: string + cost: number + savingsUSD: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + editTurns: number + oneShotTurns: number + models: Record + categories: Record + providers: Record +} + +export type DailyCache = { + version: number + /// Hash of the active `localModelSavings` config at the time the cache + /// was last written. When the user changes their baseline mapping the + /// hash mismatches and `ensureCacheHydrated` discards the cached days + /// so historical savings are recomputed against the current mapping. + savingsConfigHash: string + lastComputedDate: string | null + days: DailyEntry[] +} + +function getCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function getCachePath(): string { + return join(getCacheDir(), DAILY_CACHE_FILENAME) +} + +export function emptyCache(savingsConfigHash = ''): DailyCache { + return { version: DAILY_CACHE_VERSION, savingsConfigHash, lastComputedDate: null, days: [] } +} + +function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record[] } { + if (!parsed || typeof parsed !== 'object') return false + const c = parsed as Partial + if (typeof c.version !== 'number') return false + if (!Array.isArray(c.days)) return false + return c.version >= MIN_SUPPORTED_VERSION && c.version <= DAILY_CACHE_VERSION +} + +function migrateDays(days: Record[]): DailyEntry[] { + return days.map(d => ({ + date: d.date as string, + cost: (d.cost as number) ?? 0, + savingsUSD: (d.savingsUSD as number) ?? 0, + calls: (d.calls as number) ?? 0, + sessions: (d.sessions as number) ?? 0, + inputTokens: (d.inputTokens as number) ?? 0, + outputTokens: (d.outputTokens as number) ?? 0, + cacheReadTokens: (d.cacheReadTokens as number) ?? 0, + cacheWriteTokens: (d.cacheWriteTokens as number) ?? 0, + editTurns: (d.editTurns as number) ?? 0, + oneShotTurns: (d.oneShotTurns as number) ?? 0, + models: (d.models as DailyEntry['models']) ?? {}, + categories: (d.categories as DailyEntry['categories']) ?? {}, + providers: (d.providers as DailyEntry['providers']) ?? {}, + })) +} + +async function backupOldCache(path: string, version: number): Promise { + const backupPath = `${path}.v${version}.bak` + try { await rename(path, backupPath) } catch { /* best-effort */ } +} + +export async function loadDailyCache(): Promise { + const path = getCachePath() + if (!existsSync(path)) return emptyCache() + try { + const raw = await readFile(path, 'utf-8') + const parsed: unknown = JSON.parse(raw) + if (isMigratableCache(parsed)) { + const migrated: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: parsed.savingsConfigHash ?? '', + lastComputedDate: parsed.lastComputedDate, + days: migrateDays(parsed.days), + } + if (parsed.version < DAILY_CACHE_VERSION) { + await saveDailyCache(migrated).catch(() => {}) + } + return migrated + } + const oldVersion = (parsed as { version?: number })?.version + if (typeof oldVersion === 'number') await backupOldCache(path, oldVersion) + return emptyCache() + } catch { + return emptyCache() + } +} + +export async function saveDailyCache(cache: DailyCache): Promise { + const dir = getCacheDir() + if (!existsSync(dir)) await mkdir(dir, { recursive: true }) + const finalPath = getCachePath() + const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` + const payload = JSON.stringify(cache) + const handle = await open(tempPath, 'w', 0o600) + try { + await handle.writeFile(payload, { encoding: 'utf-8' }) + await handle.sync() + } finally { + await handle.close() + } + try { + await rename(tempPath, finalPath) + } catch (err) { + try { await unlink(tempPath) } catch { /* ignore */ } + throw err + } +} + +export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate: string): DailyCache { + const byDate = new Map(cache.days.map(d => [d.date, d])) + for (const day of incoming) { + byDate.set(day.date, day) + } + const merged = Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date)) + // Prune entries older than the BACKFILL window so the cache file does not + // grow unbounded over years of daily use. The "all time" / 6-month period + // and the BACKFILL_DAYS bootstrap both fit comfortably inside this cap. + // Anchor the cap on the newestDate boundary so a stale or stuck clock + // can't accidentally evict everything. Skip the prune entirely if + // newestDate is malformed — an invalid Date would produce a NaN cutoff + // and `d.date >= "Invalid Date"` would silently drop every entry. + const cutoffDate = new Date(`${newestDate}T00:00:00Z`) + let pruned = merged + if (!isNaN(cutoffDate.getTime())) { + cutoffDate.setUTCDate(cutoffDate.getUTCDate() - DAILY_CACHE_RETENTION_DAYS) + const cutoff = toDateString(cutoffDate) + pruned = merged.filter(d => d.date >= cutoff) + } + const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate + ? cache.lastComputedDate + : newestDate + return { + version: DAILY_CACHE_VERSION, + savingsConfigHash: cache.savingsConfigHash, + lastComputedDate: nextLast, + days: pruned, + } +} + +export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] { + return cache.days.filter(d => d.date >= start && d.date <= end) +} + +let lockChain: Promise = Promise.resolve() + +export function withDailyCacheLock(fn: () => Promise): Promise { + const next = lockChain.then(() => fn()) + lockChain = next.catch(() => undefined) + return next +} + +export const MS_PER_DAY = 24 * 60 * 60 * 1000 +export const BACKFILL_DAYS = 365 +// Keep 2 years of history so the longest UI-exposed period (6 months +// today, with headroom for future longer windows) always reads from +// cache while old entries get pruned. +export const DAILY_CACHE_RETENTION_DAYS = 730 + +export function toDateString(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +export async function ensureCacheHydrated( + parseSessions: (range: DateRange) => Promise, + aggregateDays: (projects: ProjectSummary[]) => DailyEntry[], + /// Hash of the active `localModelSavings` config. When this changes + /// (user re-mapped a baseline) the cached `savingsUSD` totals are no + /// longer accurate, so we treat the cache as stale and force a full + /// re-hydration. Pass `''` for "no savings config" to disable. + savingsConfigHash: string = '', +): Promise { + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const yesterdayEnd = new Date(todayStart.getTime() - 1) + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + + return withDailyCacheLock(async () => { + let c = await loadDailyCache() + + // Savings config changed: roll the cache forward into the active + // mapping. We can't cheaply recompute savings for already-cached + // historical days without re-parsing every session, so we drop the + // cached days and re-hydrate from the daily cache retention window. + if (c.savingsConfigHash !== savingsConfigHash) { + c = { + version: DAILY_CACHE_VERSION, + savingsConfigHash, + lastComputedDate: null, + days: [], + } + } + + // 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. + 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[freshDays.length - 1].date : null + c = { ...c, days: freshDays, lastComputedDate: latestFresh } + } + + const gapStart = c.lastComputedDate + ? new Date( + parseInt(c.lastComputedDate.slice(0, 4)), + parseInt(c.lastComputedDate.slice(5, 7)) - 1, + parseInt(c.lastComputedDate.slice(8, 10)) + 1 + ) + : new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS) + + if (gapStart.getTime() <= yesterdayEnd.getTime()) { + const gapRange: DateRange = { start: gapStart, end: yesterdayEnd } + const gapProjects = await parseSessions(gapRange) + const gapDays = aggregateDays(gapProjects) + c = addNewDays(c, gapDays, yesterdayStr) + await saveDailyCache(c) + } + return c + }) +} diff --git a/src/dashboard.tsx b/src/dashboard.tsx new file mode 100644 index 0000000..7ba2e7b --- /dev/null +++ b/src/dashboard.tsx @@ -0,0 +1,1115 @@ +import { homedir } from 'os' + +import React, { useState, useCallback, useEffect, useRef } from 'react' +import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' +import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' +import { formatCost, formatTokens } from './format.js' +import { aggregateModelEfficiency } from './model-efficiency.js' +import { parseAllSessions, filterProjectsByName, setInteractiveScanUI } from './parser.js' +import { findUnpricedModels, loadPricing } from './models.js' +import { getAllProviders } from './providers/index.js' +import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' +import { estimateContextBudget, type ContextBudget } from './context-budget.js' +import { dateKey } from './day-aggregator.js' +import { CompareView } from './compare.js' +import { getPlanUsages, type PlanUsage } from './plan-usage.js' +import { planDisplayName } from './plans.js' +import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js' +import { patchStdoutForWindows } from './ink-win.js' + +type View = 'dashboard' | 'optimize' | 'compare' + +const MIN_WIDE = 90 +const ORANGE = '#FF8C42' +const DIM = '#555555' +const GOLD = '#FFD700' +const PLAN_BAR_WIDTH = 10 +const HEAVY_PERIODS = new Set(['30days', 'month', 'all']) + +const LANG_DISPLAY_NAMES: Record = { + javascript: 'JavaScript', typescript: 'TypeScript', python: 'Python', + rust: 'Rust', go: 'Go', java: 'Java', cpp: 'C++', c: 'C', csharp: 'C#', + ruby: 'Ruby', php: 'PHP', swift: 'Swift', kotlin: 'Kotlin', + html: 'HTML', css: 'CSS', scss: 'SCSS', json: 'JSON', yaml: 'YAML', + sql: 'SQL', shell: 'Shell', shellscript: 'Shell Script', bash: 'Bash', + typescriptreact: 'TSX', javascriptreact: 'JSX', + markdown: 'Markdown', dockerfile: 'Dockerfile', toml: 'TOML', +} + +const PANEL_COLORS = { + overview: '#FF8C42', + daily: '#5B9EF5', + project: '#5BF5A0', + model: '#E05BF5', + activity: '#F5C85B', + tools: '#5BF5E0', + mcp: '#F55BE0', + bash: '#F5A05B', + skills: '#7B68EE', +} + +const PROVIDER_COLORS: Record = { + claude: '#FF8C42', + codex: '#5BF5A0', + cursor: '#00B4D8', + 'ibm-bob': '#0F62FE', + opencode: '#A78BFA', + pi: '#F472B6', + kimi: '#B6E34A', + all: '#FF8C42', +} + +const CATEGORY_COLORS: Record = { + coding: '#5B9EF5', + debugging: '#F55B5B', + feature: '#5BF58C', + refactoring: '#F5E05B', + testing: '#E05BF5', + exploration: '#5BF5E0', + planning: '#7B9EF5', + delegation: '#F5C85B', + git: '#CCCCCC', + 'build/deploy': '#5BF5A0', + conversation: '#888888', + brainstorming: '#F55BE0', + general: '#666666', +} + +const IMPACT_PANEL_COLORS: Record = { high: '#F55B5B', medium: ORANGE, low: DIM } + +function toHex(r: number, g: number, b: number): string { + return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('') +} + +function lerp(a: number, b: number, t: number): number { + return a + t * (b - a) +} + +function gradientColor(pct: number): string { + if (pct <= 0.33) { + const t = pct / 0.33 + return toHex(lerp(91, 245, t), lerp(158, 200, t), lerp(245, 91, t)) + } + if (pct <= 0.66) { + const t = (pct - 0.33) / 0.33 + return toHex(lerp(245, 255, t), lerp(200, 140, t), lerp(91, 66, t)) + } + const t = (pct - 0.66) / 0.34 + return toHex(lerp(255, 245, t), lerp(140, 91, t), lerp(66, 91, t)) +} + +function getPeriodRange(period: Period): { start: Date; end: Date } { + return getDateRange(period).range +} + +function getDayRange(day: string): DateRange { + return parseDayFlag(day)!.range +} + +function isHeavyPeriod(period: Period): boolean { + return HEAVY_PERIODS.has(period) +} + +function nextTick(): Promise { + return new Promise(resolve => setImmediate(resolve)) +} + +type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number } + +function getLayout(columns?: number): Layout { + const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80 + const dashWidth = Math.min(160, termWidth) + const wide = dashWidth >= MIN_WIDE + const halfWidth = wide ? Math.floor(dashWidth / 2) : dashWidth + const inner = halfWidth - 4 + const barWidth = Math.max(6, Math.min(10, inner - 30)) + return { dashWidth, wide, halfWidth, barWidth } +} + +function HBar({ value, max, width }: { value: number; max: number; width: number }) { + if (max === 0) return {'░'.repeat(width)} + const filled = Math.round((value / max) * width) + const fillChars: React.ReactNode[] = [] + for (let i = 0; i < Math.min(filled, width); i++) { + fillChars.push({'█'}) + } + return ( + + {fillChars} + {'░'.repeat(Math.max(width - filled, 0))} + + ) +} + +const PANEL_CHROME = 4 + +function Panel({ title, color, children, width }: { title: string; color: string; children: React.ReactNode; width: number }) { + return ( + + {title} + {children} + + ) +} + +function fit(s: string, n: number): string { + return s.length > n ? s.slice(0, n) : s.padEnd(n) +} + +function renderPlanBar(percentUsed: number, width: number): string { + if (percentUsed <= 100) { + const capped = Math.max(0, percentUsed) + const filled = Math.round((capped / 100) * width) + return `${'▓'.repeat(filled)}${'░'.repeat(Math.max(0, width - filled))}` + } + const factor = percentUsed / 100 + const chevrons = Math.min(4, Math.max(1, Math.floor(Math.log10(factor)) + 1)) + return `${'▓'.repeat(width)}${'▶'.repeat(chevrons)}` +} + +function planLabel(planUsage: PlanUsage): string { + const name = planDisplayName(planUsage.plan.id) + return planUsage.plan.id === 'custom' ? `${name} (${planUsage.plan.provider})` : name +} + +function planColor(planUsage: PlanUsage): string { + return planUsage.status === 'over' + ? '#F55B5B' + : planUsage.status === 'near' + ? ORANGE + : '#5BF58C' +} + +function planStatusText(planUsage: PlanUsage): string { + if (planUsage.status === 'under') { + return `Well within plan. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).` + } + if (planUsage.status === 'near') { + return `Approaching plan limit. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).` + } + return `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x your subscription value. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).` +} + +function Overview({ projects, label, width, planUsages }: { projects: ProjectSummary[]; label: string; width: number; planUsages?: PlanUsage[] }) { + const totalCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const totalSavings = projects.reduce((s, p) => s + p.totalSavingsUSD, 0) + const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) + const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) + const allSessions = projects.flatMap(p => p.sessions) + const totalInput = allSessions.reduce((s, sess) => s + sess.totalInputTokens, 0) + const totalOutput = allSessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) + const totalCacheRead = allSessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) + const totalCacheWrite = allSessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) + const allInputTokens = totalInput + totalCacheRead + totalCacheWrite + const cacheHit = allInputTokens > 0 + ? (totalCacheRead / allInputTokens) * 100 : 0 + const activePlanUsages = planUsages ?? [] + + return ( + + + CodeBurn + {label} + + + {formatCost(totalCost)} + cost + {totalCalls.toLocaleString()} + calls + {String(totalSessions)} + sessions + {cacheHit.toFixed(1)}% + cache hit + + + {formatTokens(totalInput)} in {formatTokens(totalOutput)} out {formatTokens(totalCacheRead)} cached {formatTokens(totalCacheWrite)} written + + {totalSavings > 0 && ( + + {formatCost(totalSavings)} + saved by local models + + )} + {activePlanUsages.length > 0 && ( + <> + {activePlanUsages.map(planUsage => { + const color = planColor(planUsage) + return ( + + + {planLabel(planUsage)}: {formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent vs {formatCost(planUsage.budgetUsd)} plan + + {renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)} + + {planUsage.percentUsed.toFixed(1)}% + + {planStatusText(planUsage)} + + ) + })} + + )} + + ) +} + +function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSummary[]; days?: number; pw: number; bw: number }) { + const dailyCosts: Record = {} + const dailyCalls: Record = {} + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (!turn.timestamp) continue + const day = dateKey(turn.timestamp) + dailyCosts[day] = (dailyCosts[day] ?? 0) + turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) + dailyCalls[day] = (dailyCalls[day] ?? 0) + turn.assistantCalls.length + } + } + } + const sortedDays = days !== undefined ? Object.keys(dailyCosts).sort().slice(-days) : Object.keys(dailyCosts).sort() + const maxCost = Math.max(...sortedDays.map(d => dailyCosts[d] ?? 0)) + + return ( + + {''.padEnd(6 + bw)}{'cost'.padStart(8)}{'calls'.padStart(6)} + {sortedDays.map(day => ( + + {day.slice(5)} + + {formatCost(dailyCosts[day] ?? 0).padStart(8)} + {String(dailyCalls[day] ?? 0).padStart(6)} + + ))} + + ) +} + +const _home = homedir() +const _homePrefix = _home.endsWith('/') ? _home : _home + '/' + +export function shortProject(absPath: string): string { + const normalized = absPath.replace(/\\/g, '/') + let path: string + if (normalized === _home) path = '' + else if (normalized.startsWith(_homePrefix)) path = normalized.slice(_homePrefix.length) + else path = normalized + path = path.replace(/^\/+/, '') + path = path.replace(/^private\/tmp\/[^/]+\/[^/]+\//, '').replace(/^private\/tmp\//, '').replace(/^tmp\//, '') + if (!path) return 'home' + const parts = path.split('/').filter(Boolean) + if (parts.length <= 3) return parts.join('/') + return parts.slice(-3).join('/') +} + +const PROJECT_COL_AVG = 7 +const PROJECT_COL_BASE_WIDTH = 30 +const PROJECT_COL_WITH_OVERHEAD_WIDTH = 40 + +function ProjectBreakdown({ projects, pw, bw, budgets, rows = 14 }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map; rows?: number }) { + const maxCost = Math.max(...projects.map(p => p.totalCostUSD)) + const hasBudgets = budgets && budgets.size > 0 + const nw = Math.max(8, pw - bw - (hasBudgets ? PROJECT_COL_WITH_OVERHEAD_WIDTH : PROJECT_COL_BASE_WIDTH)) + return ( + + + {''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''} + + {projects.slice(0, rows).map((project, i) => { + const budget = budgets?.get(project.project) + const avgCost = project.sessions.length > 0 + ? formatCost(project.totalCostUSD / project.sessions.length) + : '-' + return ( + + + {fit(shortProject(project.projectPath), nw)} + {formatCost(project.totalCostUSD).padStart(8)} + {avgCost.padStart(PROJECT_COL_AVG)} + {String(project.sessions.length).padStart(6)} + {hasBudgets && {(budget ? formatTokens(budget.total) : '-').padStart(10)}} + + ) + })} + + ) +} + +const MODEL_COL_COST = 8 +const MODEL_COL_CACHE = 7 +const MODEL_COL_CALLS = 7 +const MODEL_COL_ONESHOT = 7 +const MODEL_NAME_WIDTH = 14 +const MIN_EDIT_TURNS_FOR_RATE = 5 + +function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const modelTotals: Record = {} + const modelEfficiency = aggregateModelEfficiency(projects) + for (const project of projects) { + for (const session of project.sessions) { + for (const [model, data] of Object.entries(session.modelBreakdown)) { + if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0 } + modelTotals[model].calls += data.calls + modelTotals[model].costUSD += data.costUSD + modelTotals[model].freshInput += data.tokens.inputTokens + modelTotals[model].cacheRead += data.tokens.cacheReadInputTokens + modelTotals[model].cacheWrite += data.tokens.cacheCreationInputTokens + } + } + } + const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) + const maxCost = sorted[0]?.[1]?.costUSD ?? 0 + const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({ + model, + calls: d.calls, + cost: d.costUSD, + tokens: d.freshInput + d.cacheRead + d.cacheWrite, + }))) + + return ( + + {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)} + {sorted.map(([model, data], i) => { + const totalInput = data.freshInput + data.cacheRead + data.cacheWrite + const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0 + const cacheLabel = totalInput > 0 ? `${cacheHit.toFixed(1)}%` : '-' + const efficiency = modelEfficiency.get(model) + const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null + ? `${efficiency.oneShotRate.toFixed(1)}%` + : '-' + return ( + + + {fit(model, MODEL_NAME_WIDTH)} + {formatCost(data.costUSD).padStart(MODEL_COL_COST)} + {cacheLabel.padStart(MODEL_COL_CACHE)} + {String(data.calls).padStart(MODEL_COL_CALLS)} + {oneShotLabel.padStart(MODEL_COL_ONESHOT)} + + ) + })} + {unpriced.length > 0 && ( + + {`! ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced at $0, fix: codeburn model-alias (${unpriced.slice(0, 2).map(u => u.model).join(', ')}${unpriced.length > 2 ? ', ...' : ''})`} + + )} + + ) +} + +const SKILL_SUB_ROWS_LIMIT = 5 + +function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const categoryTotals: Record = {} + const skillTotals: Record = {} + for (const project of projects) { + for (const session of project.sessions) { + for (const [cat, data] of Object.entries(session.categoryBreakdown)) { + if (!categoryTotals[cat]) categoryTotals[cat] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } + categoryTotals[cat].turns += data.turns + categoryTotals[cat].costUSD += data.costUSD + categoryTotals[cat].editTurns += data.editTurns + categoryTotals[cat].oneShotTurns += data.oneShotTurns + } + for (const [skill, data] of Object.entries(session.skillBreakdown ?? {})) { + if (!skillTotals[skill]) skillTotals[skill] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 } + skillTotals[skill].turns += data.turns + skillTotals[skill].costUSD += data.costUSD + skillTotals[skill].editTurns += data.editTurns + skillTotals[skill].oneShotTurns += data.oneShotTurns + } + } + } + const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) + const sortedSkills = Object.entries(skillTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD).slice(0, SKILL_SUB_ROWS_LIMIT) + const maxCost = sorted[0]?.[1]?.costUSD ?? 0 + return ( + + {''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)} + {sorted.flatMap(([cat, data]) => { + const oneShotPct = data.editTurns > 0 ? Math.round((data.oneShotTurns / data.editTurns) * 100) + '%' : '-' + const rows = [ + + + {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)} + {formatCost(data.costUSD).padStart(8)} + {String(data.turns).padStart(6)} + {String(oneShotPct).padStart(7)} + , + ] + if (cat === 'general' && sortedSkills.length > 0) { + for (const [skill, sd] of sortedSkills) { + const subPct = sd.editTurns > 0 ? Math.round((sd.oneShotTurns / sd.editTurns) * 100) + '%' : '-' + rows.push( + + + {fit(` /${skill}`, 13)} + {formatCost(sd.costUSD).padStart(8)} + {String(sd.turns).padStart(6)} + {String(subPct).padStart(7)} + , + ) + } + } + return rows + })} + + ) +} + +function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: ProjectSummary[]; pw: number; bw: number; title?: string; filterPrefix?: string }) { + const toolTotals: Record = {} + for (const project of projects) { + for (const session of project.sessions) { + for (const [tool, data] of Object.entries(session.toolBreakdown)) { + if (filterPrefix) { if (!tool.startsWith(filterPrefix)) continue } else { if (tool.startsWith('lang:')) continue } + toolTotals[tool] = (toolTotals[tool] ?? 0) + data.calls + } + } + } + const sorted = Object.entries(toolTotals).sort(([, a], [, b]) => b - a) + const maxCalls = sorted[0]?.[1] ?? 0 + const nw = Math.max(6, pw - bw - 15) + return ( + + {''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)} + {sorted.slice(0, 10).map(([tool, calls]) => { + const raw = filterPrefix ? tool.slice(filterPrefix.length) : tool + const display = filterPrefix ? (LANG_DISPLAY_NAMES[raw] ?? raw) : raw + return ( + + + {fit(display, nw)} + {String(calls).padStart(7)} + + ) + })} + + ) +} + + +function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const mcpTotals: Record = {} + for (const project of projects) { for (const session of project.sessions) { for (const [server, data] of Object.entries(session.mcpBreakdown)) { mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls } } } + const sorted = Object.entries(mcpTotals).sort(([, a], [, b]) => b - a) + if (sorted.length === 0) return No MCP usage + const maxCalls = sorted[0]?.[1] ?? 0 + const nw = Math.max(6, pw - bw - 15) + return ( + + {''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)} + {sorted.slice(0, 8).map(([server, calls]) => ( + {fit(server, nw)}{String(calls).padStart(6)} + ))} + + ) +} + +function BashBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const bashTotals: Record = {} + for (const project of projects) { for (const session of project.sessions) { for (const [cmd, data] of Object.entries(session.bashBreakdown)) { bashTotals[cmd] = (bashTotals[cmd] ?? 0) + data.calls } } } + const sorted = Object.entries(bashTotals).sort(([, a], [, b]) => b - a) + if (sorted.length === 0) return No shell commands + const maxCalls = sorted[0]?.[1] ?? 0 + const nw = Math.max(6, pw - bw - 15) + return ( + + {''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)} + {sorted.slice(0, 10).map(([cmd, calls]) => ( + {fit(cmd, nw)}{String(calls).padStart(7)} + ))} + + ) +} + +function SkillsAndAgents({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const merged: Record = {} + for (const project of projects) { for (const session of project.sessions) { + for (const [skill, d] of Object.entries(session.skillBreakdown)) { const e = merged[skill] ?? { uses: 0, cost: 0 }; e.uses += d.turns; e.cost += d.costUSD; merged[skill] = e } + for (const [agent, d] of Object.entries(session.subagentBreakdown)) { const e = merged[agent] ?? { uses: 0, cost: 0 }; e.uses += d.calls; e.cost += d.costUSD; merged[agent] = e } + } } + const sorted = Object.entries(merged).sort(([, a], [, b]) => b.cost - a.cost) + if (sorted.length === 0) return No skill/agent usage + const maxCost = sorted[0]?.[1]?.cost ?? 0 + const nw = Math.max(6, pw - bw - 22) + return ( + + {''.padEnd(bw + 1 + nw)}{'uses'.padStart(6)}{'cost'.padStart(8)} + {sorted.slice(0, 10).map(([name, d]) => ( + {fit(name, nw)}{String(d.uses).padStart(6)}{formatCost(d.cost).padStart(8)} + ))} + + ) +} + +// Claude Code only: real subagent-transcript spend by agentType +// (workflow-subagent / Explore / general-purpose / …). Returns null when there +// are no agent transcripts, so it never shows for other providers. +function ClaudeAgentTypes({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const merged: Record = {} + for (const project of projects) { for (const session of project.sessions) { + if (!session.agentType) continue + const e = merged[session.agentType] ?? { uses: 0, cost: 0 } + e.uses += session.apiCalls; e.cost += session.totalCostUSD; merged[session.agentType] = e + } } + const sorted = Object.entries(merged).sort(([, a], [, b]) => b.cost - a.cost) + if (sorted.length === 0) return null + const maxCost = sorted[0]?.[1]?.cost ?? 0 + const nw = Math.max(6, pw - bw - 22) + return ( + + {''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}{'cost'.padStart(8)} + {sorted.slice(0, 10).map(([name, d]) => ( + {fit(name, nw)}{String(d.uses).padStart(6)}{formatCost(d.cost).padStart(8)} + ))} + + ) +} + +const PROVIDER_DISPLAY_NAMES: Record = { + all: 'All', + claude: 'Claude', + codex: 'Codex', + cursor: 'Cursor', + 'ibm-bob': 'IBM Bob', + opencode: 'OpenCode', + pi: 'Pi', + kimi: 'Kimi', +} +function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name } + +function PeriodTabs({ active, providerName, showProvider }: { active: Period; providerName?: string; showProvider?: boolean }) { + return ( + + + {PERIODS.map(p => ( + + {active === p ? `[ ${PERIOD_LABELS[p]} ]` : ` ${PERIOD_LABELS[p]} `} + + ))} + + {showProvider && providerName && ( + | [p] {getProviderDisplayName(providerName)} + )} + + ) +} + +/// Header for an action's intended destination. Helps users distinguish a +/// permanent CLAUDE.md rule from a one-time session opener so they don't +/// accidentally bake a single-run constraint into their project's permanent +/// instructions. Issue #277. +function actionDestinationHeader(action: WasteAction): string { + switch (action.type) { + case 'file-content': + return `── Suggested ${action.path} addition `.padEnd(64, '─') + case 'command': + return '── Run this command '.padEnd(64, '─') + case 'paste': { + switch (action.destination) { + case 'claude-md': + return '── Suggested CLAUDE.md addition (permanent rule) '.padEnd(64, '─') + case 'session-opener': + return '── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─') + case 'prompt': + return '── Ask Claude in the current session '.padEnd(64, '─') + case 'shell-config': + return '── Add to your shell config '.padEnd(64, '─') + default: + return '── Suggested action '.padEnd(64, '─') + } + } + } +} + +function FindingAction({ action }: { action: WasteAction }) { + const lines = action.type === 'file-content' ? action.content.split('\n') : action.type === 'command' ? action.text.split('\n') : [action.text] + const header = actionDestinationHeader(action) + return ( + <> + {header} + {action.label} + {lines.map((line, i) => {line})} + + ) +} + +function FindingPanel({ index, finding, costRate, width }: { index: number; finding: WasteFinding; costRate: number; width: number }) { + const costSaved = finding.tokensSaved * costRate + const color = IMPACT_PANEL_COLORS[finding.impact] ?? DIM + const label = finding.impact.charAt(0).toUpperCase() + finding.impact.slice(1) + const trendBadge = finding.trend === 'improving' ? ' improving \u2193' : '' + return ( + + + {index}. {finding.title} + + {label} + {trendBadge && {trendBadge}} + + {finding.explanation} + Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)}) + + + + ) +} + +const GRADE_COLORS: Record = { A: '#5BF5A0', B: '#5BF5A0', C: GOLD, D: ORANGE, F: '#F55B5B' } + +// Each finding panel takes ~6-8 lines. Show 3 at a time so the window fits a +// 30-line terminal alongside the optimize header + status bar; users page +// with j/k. Without this cap, 4 new detectors + 7 originals scrolled findings +// off the alt-buffer top and the user couldn't see the StatusBar at all. +const FINDINGS_WINDOW_SIZE = 3 + +function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade, cursor }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string; cursor: number }) { + const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0) + const totalCost = totalTokens * costRate + const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0 + const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1) + const gradeColor = GRADE_COLORS[healthGrade] ?? DIM + const total = findings.length + const start = total === 0 ? 0 : Math.min(cursor, Math.max(0, total - FINDINGS_WINDOW_SIZE)) + const end = Math.min(start + FINDINGS_WINDOW_SIZE, total) + const visible = findings.slice(start, end) + return ( + + + + CodeBurn Optimize + {label} Setup: + {healthGrade} + ({healthScore}/100) + + Savings: ~{formatTokens(totalTokens)} tokens (~{formatCost(totalCost)}, ~{pct}% of spend) + {total > FINDINGS_WINDOW_SIZE && ( + Showing {start + 1}–{end} of {total} · j/k to scroll + )} + + {visible.map((f, i) => )} + Token estimates are approximate. + + ) +} + +function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, compareAvailable, customRange, dayMode }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean; compareAvailable?: boolean; customRange?: boolean; dayMode?: boolean }) { + const isOptimize = view === 'optimize' + return ( + + + {isOptimize + ? <>b back j/k scroll + : dayMode + ? <>{'<'}{'>'} day + : !customRange + ? <>{'<'}{'>'} switch + : null} + q quit + {!customRange && !isOptimize && ( + <> + 1 today + 2 week + 3 30 days + 4 month + 5 6 months + + )} + {!customRange && !isOptimize && ( + <> + d{dayMode ? ' exit day' : ' yesterday'} + + )} + {!isOptimize && optimizeAvailable && ( + <> o optimize{findingCount != null && findingCount > 0 ? ({findingCount}) : null} + )} + {!isOptimize && compareAvailable && ( + <> c compare + )} + {showProvider && (<> p provider)} + + + ) +} + +function Row({ wide, width, children }: { wide: boolean; width: number; children: React.ReactNode }) { + if (wide) return {children} + return <>{children} +} + +function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) { + const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns) + const isCursor = activeProvider === 'cursor' + const activeLabel = label ?? PERIOD_LABELS[period] + if (projects.length === 0) return No usage data found for {activeLabel}. + const pw = wide ? halfWidth : dashWidth + const days = dayMode ? 1 : period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14) + // A provider-scoped plan (e.g. SuperGrok) only makes sense on its own + // provider tab, where the shown cost matches the plan's spend. Hide it on + // every other tab, including All, so its budget isn't compared to spend it + // doesn't cover. + const visiblePlanUsages = (planUsages ?? []).filter(p => p.plan.provider === (activeProvider ?? 'all')) + return ( + + + + + {isCursor ? ( + + ) : ( + <> + )} + + ) +} + +function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: { + initialProjects: ProjectSummary[] + initialPeriod: Period + initialProvider: string + initialPlanUsages?: PlanUsage[] + refreshSeconds?: number + projectFilter?: string[] + excludeFilter?: string[] + customRange?: DateRange | null + customRangeLabel?: string + initialDay?: string +}) { + const { exit } = useApp() + const [period, setPeriod] = useState(initialPeriod) + const [projects, setProjects] = useState(initialProjects) + const [loading, setLoading] = useState(false) + const [activeProvider, setActiveProvider] = useState(initialProvider) + const [detectedProviders, setDetectedProviders] = useState([]) + const [view, setView] = useState('dashboard') + const [optimizeResult, setOptimizeResult] = useState(null) + const [optimizeLoading, setOptimizeLoading] = useState(false) + const [projectBudgets, setProjectBudgets] = useState>(new Map()) + const [planUsages, setPlanUsages] = useState(initialPlanUsages ?? []) + const [dayDate, setDayDate] = useState(initialDay ?? null) + // Cursor for the OptimizeView's findings window. Reset whenever the user + // leaves the optimize view OR the underlying findings change so a long + // findings list never strands the user past the new array length. + const [findingsCursor, setFindingsCursor] = useState(0) + const isDayMode = dayDate != null + const isCustomRange = customRange != null && !isDayMode + const { columns } = useWindowSize() + const { dashWidth } = getLayout(columns) + const multipleProviders = detectedProviders.length > 1 + const optimizeAvailable = !isCustomRange && (activeProvider === 'all' || activeProvider === 'claude') + const modelCount = new Set( + projects.flatMap(p => p.sessions.flatMap(s => Object.keys(s.modelBreakdown))) + ).size + const compareAvailable = modelCount >= 2 + const debounceRef = useRef | null>(null) + const reloadGenerationRef = useRef(0) + const reloadInFlightRef = useRef(false) + const currentReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null) + const pendingReloadRef = useRef<{ period: Period; provider: string; day: string | null } | null>(null) + const findingCount = optimizeResult?.findings.length ?? 0 + + useEffect(() => { + let cancelled = false + async function detect() { + const found: string[] = [] + for (const p of await getAllProviders()) { const s = await p.discoverSessions(); if (s.length > 0) found.push(p.name) } + if (!cancelled) setDetectedProviders(found) + } + detect() + return () => { cancelled = true } + }, []) + + useEffect(() => { + let cancelled = false + async function loadBudgets() { + const budgets = new Map() + for (const project of projects.slice(0, 8)) { + if (cancelled) return + if (!project.projectPath.startsWith('/')) continue + budgets.set(project.project, await estimateContextBudget(project.projectPath)) + } + if (!cancelled) setProjectBudgets(budgets) + } + loadBudgets() + return () => { cancelled = true } + }, [projects]) + + const reloadData = useCallback(async (p: Period, prov: string, day: string | null = null) => { + if (reloadInFlightRef.current) { + const current = currentReloadRef.current + if (current?.period === p && current.provider === prov && current.day === day) { + pendingReloadRef.current = null + return + } + reloadGenerationRef.current++ + pendingReloadRef.current = { period: p, provider: prov, day } + return + } + reloadInFlightRef.current = true + currentReloadRef.current = { period: p, provider: prov, day } + const generation = ++reloadGenerationRef.current + setLoading(true) + setOptimizeLoading(false) + setOptimizeResult(null) + try { + if (!day && isHeavyPeriod(p)) { + setProjects([]) + setProjectBudgets(new Map()) + await nextTick() + if (reloadGenerationRef.current !== generation) return + } + const range = day ? getDayRange(day) : getPeriodRange(p) + const data = await parseAllSessions(range, prov) + if (reloadGenerationRef.current !== generation) return + + const filteredProjects = filterProjectsByName(data, projectFilter, excludeFilter) + if (reloadGenerationRef.current !== generation) return + + setProjects(filteredProjects) + const usage = await getPlanUsages() + if (reloadGenerationRef.current !== generation) return + setPlanUsages(usage) + } catch (error) { + console.error(error) + } finally { + if (reloadGenerationRef.current === generation) { + setLoading(false) + } + reloadInFlightRef.current = false + currentReloadRef.current = null + const pending = pendingReloadRef.current + pendingReloadRef.current = null + if (pending) { + void reloadData(pending.period, pending.provider, pending.day) + } + } + }, [projectFilter, excludeFilter]) + + const currentRange = useCallback(() => { + return dayDate ? getDayRange(dayDate) : getPeriodRange(period) + }, [dayDate, period]) + + const loadOptimizeResult = useCallback(async () => { + if (!optimizeAvailable || projects.length === 0 || optimizeLoading) return + setView('optimize') + setFindingsCursor(0) + if (optimizeResult) return + + const generation = reloadGenerationRef.current + setOptimizeLoading(true) + try { + const result = await scanAndDetect(projects, currentRange()) + if (reloadGenerationRef.current === generation) setOptimizeResult(result) + } catch (error) { + console.error(error) + } finally { + if (reloadGenerationRef.current === generation) setOptimizeLoading(false) + } + }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult]) + + useEffect(() => { + if (!refreshSeconds || refreshSeconds <= 0) return + if (!dayDate && isHeavyPeriod(period)) return + const id = setInterval(() => { reloadData(period, activeProvider, dayDate) }, refreshSeconds * 1000) + return () => clearInterval(id) + }, [refreshSeconds, period, activeProvider, dayDate, reloadData]) + + const switchPeriod = useCallback((np: Period) => { + if (np === period && !dayDate) return + // Clear projects + flip loading synchronously so the dashboard never + // renders the new period label over the old period's numbers between + // setPeriod() and the reloadData() promise resolving. Without this, + // there's a frame-to-hundreds-of-ms window where users saw wrong + // figures captioned with the new period. + setPeriod(np) + setDayDate(null) + setProjects([]) + setLoading(true) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { reloadData(np, activeProvider, null) }, 600) + }, [period, activeProvider, dayDate, reloadData]) + + const switchPeriodImmediate = useCallback(async (np: Period) => { + if (np === period && !dayDate) return + setPeriod(np) + setDayDate(null) + setProjects([]) + setLoading(true) + if (debounceRef.current) clearTimeout(debounceRef.current) + await reloadData(np, activeProvider, null) + }, [period, activeProvider, dayDate, reloadData]) + + const switchDay = useCallback(async (nextDay: string) => { + const today = parseDayFlag('today')!.day + const clampedDay = nextDay > today ? today : nextDay + if (clampedDay === dayDate) return + setDayDate(clampedDay) + setProjects([]) + setLoading(true) + setView('dashboard') + if (debounceRef.current) clearTimeout(debounceRef.current) + await reloadData(period, activeProvider, clampedDay) + }, [period, activeProvider, dayDate, reloadData]) + + const enterYesterday = useCallback(async () => { + const yesterday = parseDayFlag('yesterday')!.day + await switchDay(yesterday) + }, [switchDay]) + + useInput((input, key) => { + if (input === 'q') { exit(); return } + if (input === 'o' && view === 'dashboard' && optimizeAvailable) { void loadOptimizeResult(); return } + if ((input === 'b' || key.escape) && view === 'optimize') { setView('dashboard'); setFindingsCursor(0); return } + if (view === 'optimize') { + const total = optimizeResult?.findings.length ?? 0 + const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE) + if (input === 'j' || key.downArrow) { setFindingsCursor(c => Math.min(c + 1, maxStart)); return } + if (input === 'k' || key.upArrow) { setFindingsCursor(c => Math.max(c - 1, 0)); return } + return + } + if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return } + if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return } + if (input === 'p' && multipleProviders && view !== 'compare') { + const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length] + setActiveProvider(next); setView('dashboard') + if (debounceRef.current) clearTimeout(debounceRef.current) + reloadData(period, next, dayDate); return + } + // Period switches reload the underlying data. Disable them while the + // compare view is mounted; the compare view re-aggregates from + // `projects` and would visibly change underneath the user without any + // affordance back to the dashboard. Press `b` or Esc to return first. + if (view === 'compare') return + if (!customRange && input === 'd') { + if (dayDate) { + setDayDate(null) + setProjects([]) + setLoading(true) + void reloadData(period, activeProvider, null) + } else { + void enterYesterday() + } + return + } + // Also disable while a custom --from/--to range is in effect. Switching + // period would silently abandon the user's explicit range and reload + // standard period data; the period tab strip is hidden in this mode so + // users have no expectation that 1-5 should do anything. + if (isCustomRange) return + if (dayDate) { + if (key.leftArrow) { void switchDay(shiftDay(dayDate, -1)); return } + if (key.rightArrow || key.tab) { void switchDay(shiftDay(dayDate, 1)); return } + if (key.escape || input === 'b') { + setDayDate(null) + setProjects([]) + setLoading(true) + void reloadData(period, activeProvider, null) + return + } + } + const idx = PERIODS.indexOf(period) + if (key.leftArrow) switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length]!) + else if (key.rightArrow || key.tab) switchPeriod(PERIODS[(idx + 1) % PERIODS.length]!) + else if (input === '1') switchPeriodImmediate('today') + else if (input === '2') switchPeriodImmediate('week') + else if (input === '3') switchPeriodImmediate('30days') + else if (input === '4') switchPeriodImmediate('month') + else if (input === '5') switchPeriodImmediate('all') + }) + + const headerLabel = dayDate ? formatDayRangeLabel(dayDate) : customRangeLabel ?? PERIOD_LABELS[period] + + if (loading || optimizeLoading) { + return ( + + {!isCustomRange && !isDayMode && } + {isDayMode && } + {isCustomRange && } + {view === 'compare' + ? + + Model Comparison + + Loading {headerLabel} model data... + + + : view === 'optimize' + ? Scanning {headerLabel}... + : Loading {headerLabel}...} + {view !== 'compare' && } + + ) + } + + return ( + + {!isCustomRange && !isDayMode && } + {isDayMode && } + {isCustomRange && } + {view === 'compare' + ? setView('dashboard')} /> + : view === 'optimize' && optimizeResult + ? + : } + {view !== 'compare' && } + + ) +} + +function DayBanner({ label, width }: { label: string; width: number }) { + return ( + + {label} + + ) +} + +function CustomRangeBanner({ label, width }: { label: string; width: number }) { + return ( + + Custom range: + {label} + + ) +} + +function StaticDashboard({ projects, period, activeProvider, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) { + const { columns } = useWindowSize() + const { dashWidth } = getLayout(columns) + return ( + + {dayMode ? : } + + + ) +} + +export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise { + // Interactive Ink UI: it renders to the same terminal and has its own in-frame + // loading state, so the CLI scan-progress line must stay silent for its whole + // lifetime (initial scan and every 30s auto-refresh, including the + // getPlanUsages → parseAllSessions path). Plain CLI commands are unaffected. + setInteractiveScanUI() + await loadPricing() + const dayRange = initialDay ? getDayRange(initialDay) : null + const range = dayRange ?? customRange ?? getPeriodRange(period) + const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter) + const planUsages = await getPlanUsages() + const isTTY = process.stdin.isTTY && process.stdout.isTTY + const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel + patchStdoutForWindows() + if (isTTY) { + const { waitUntilExit } = render( + + ) + 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/data/litellm-snapshot.json b/src/data/litellm-snapshot.json new file mode 100644 index 0000000..e5a0e2e --- /dev/null +++ b/src/data/litellm-snapshot.json @@ -0,0 +1 @@ +{"ai21.j2-mid-v1":[0.0000125,0.0000125,null,null,null],"ai21.j2-ultra-v1":[0.0000188,0.0000188,null,null,null],"ai21.jamba-1-5-large-v1:0":[0.000002,0.000008,null,null,null],"ai21.jamba-1-5-mini-v1:0":[2e-7,4e-7,null,null,null],"ai21.jamba-instruct-v1:0":[5e-7,7e-7,null,null,null],"us.writer.palmyra-x4-v1:0":[0.0000025,0.00001,null,null,null],"us.writer.palmyra-x5-v1:0":[6e-7,0.000006,null,null,null],"writer.palmyra-x4-v1:0":[0.0000025,0.00001,null,null,null],"writer.palmyra-x5-v1:0":[6e-7,0.000006,null,null,null],"amazon.nova-lite-v1:0":[6e-8,2.4e-7,null,null,null],"amazon.nova-2-lite-v1:0":[3e-7,0.0000025,null,7.5e-8,null],"amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7,null],"apac.amazon.nova-2-lite-v1:0":[3.3e-7,0.00000275,null,8.25e-8,null],"apac.amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7,null],"eu.amazon.nova-2-lite-v1:0":[3.3e-7,0.00000275,null,8.25e-8,null],"eu.amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7,null],"us.amazon.nova-2-lite-v1:0":[3.3e-7,0.00000275,null,8.25e-8,null],"us.amazon.nova-2-pro-preview-20251202-v1:0":[0.0000021875,0.0000175,null,5.46875e-7,null],"amazon.nova-2-multimodal-embeddings-v1:0":[1.35e-7,0,null,null,null],"amazon.nova-micro-v1:0":[3.5e-8,1.4e-7,null,null,null],"amazon.nova-pro-v1:0":[8e-7,0.0000032,null,null,null],"amazon.rerank-v1:0":[0,0,null,null,null],"amazon.titan-embed-image-v1":[8e-7,0,null,null,null],"amazon.titan-embed-text-v1":[1e-7,0,null,null,null],"amazon.titan-embed-g1-text-02":[1e-7,0,null,null,null],"amazon.titan-embed-text-v2:0":[2e-8,0,null,null,null],"twelvelabs.marengo-embed-2-7-v1:0":[0.00007,0,null,null,null],"us.twelvelabs.marengo-embed-2-7-v1:0":[0.00007,0,null,null,null],"eu.twelvelabs.marengo-embed-2-7-v1:0":[0.00007,0,null,null,null],"amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null,null],"amazon.titan-text-lite-v1":[3e-7,4e-7,null,null,null],"amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null,null],"anthropic.claude-3-5-haiku-20241022-v1:0":[8e-7,0.000004,0.000001,8e-8,null],"anthropic.claude-haiku-4-5-20251001-v1:0":[0.000001,0.000005,0.00000125,1e-7,null],"anthropic.claude-haiku-4-5@20251001":[0.000001,0.000005,0.00000125,1e-7,null],"anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic.claude-3-7-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"anthropic.claude-3-7-sonnet-20250219-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8,null],"anthropic.claude-3-opus-20240229-v1:0":[0.000015,0.000075,0.00001875,0.0000015,null],"anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic.claude-instant-v1":[8e-7,0.0000024,null,null,null],"anthropic.claude-opus-4-1-20250805-v1:0":[0.000015,0.000075,0.00001875,0.0000015,null],"anthropic.claude-opus-4-20250514-v1:0":[0.000015,0.000075,0.00001875,0.0000015,null],"anthropic.claude-opus-4-5-20251101-v1:0":[0.000005,0.000025,0.00000625,5e-7,null],"anthropic.claude-opus-4-6-v1":[0.000005,0.000025,0.00000625,5e-7,null],"global.anthropic.claude-opus-4-6-v1":[0.000005,0.000025,0.00000625,5e-7,null],"us.anthropic.claude-opus-4-6-v1":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"eu.anthropic.claude-opus-4-6-v1":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"au.anthropic.claude-opus-4-6-v1":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"anthropic.claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7,null],"anthropic.claude-mythos-preview":[0,0,null,null,null],"global.anthropic.claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7,null],"us.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"eu.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"au.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"anthropic.claude-fable-5":[0.00001,0.00005,0.0000125,0.000001,null],"global.anthropic.claude-fable-5":[0.00001,0.00005,0.0000125,0.000001,null],"us.anthropic.claude-fable-5":[0.000011,0.000055,0.00001375,0.0000011,null],"eu.anthropic.claude-fable-5":[0.000011,0.000055,0.00001375,0.0000011,null],"anthropic.claude-opus-4-8":[0.000005,0.000025,0.00000625,5e-7,null],"global.anthropic.claude-opus-4-8":[0.000005,0.000025,0.00000625,5e-7,null],"us.anthropic.claude-opus-4-8":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"eu.anthropic.claude-opus-4-8":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"au.anthropic.claude-opus-4-8":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"jp.anthropic.claude-opus-4-7":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"anthropic.claude-sonnet-5":[0.000003,0.000015,0.00000375,3e-7,null],"global.anthropic.claude-sonnet-5":[0.000003,0.000015,0.00000375,3e-7,null],"us.anthropic.claude-sonnet-5":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"eu.anthropic.claude-sonnet-5":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"au.anthropic.claude-sonnet-5":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"jp.anthropic.claude-sonnet-5":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"anthropic.claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7,null],"global.anthropic.claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7,null],"us.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"eu.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"au.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"jp.anthropic.claude-sonnet-4-6":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic.claude-sonnet-4-5-20250929-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic.claude-v1":[0.000008,0.000024,null,null,null],"anthropic.claude-v2:1":[0.000008,0.000024,null,null,null],"apac.amazon.nova-lite-v1:0":[6.3e-8,2.52e-7,null,null,null],"apac.amazon.nova-micro-v1:0":[3.7e-8,1.48e-7,null,null,null],"apac.amazon.nova-pro-v1:0":[8.4e-7,0.00000336,null,null,null],"apac.anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"apac.anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7,null],"apac.anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8,null],"apac.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7,null],"apac.anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"apac.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"au.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"babbage-002":[4e-7,4e-7,null,null,null],"chatdolphin":[5e-7,5e-7,null,null,null],"chatgpt-4o-latest":[0.000005,0.000015,null,null,null],"gpt-4o-transcribe-diarize":[0.0000025,0.00001,null,null,null],"claude-haiku-4-5-20251001":[0.000001,0.000005,0.00000125,1e-7,null],"claude-haiku-4-5":[0.000001,0.000005,0.00000125,1e-7,null],"claude-3-7-sonnet-20250219":[0.000003,0.000015,0.00000375,3e-7,null],"claude-3-haiku-20240307":[2.5e-7,0.00000125,3e-7,3e-8,null],"claude-3-opus-20240229":[0.000015,0.000075,0.00001875,0.0000015,null],"claude-4-opus-20250514":[0.000015,0.000075,0.00001875,0.0000015,null],"claude-4-sonnet-20250514":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-4-5":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-4-5-20250929":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-5":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-4-5-20250929-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"claude-opus-4-1":[0.000015,0.000075,0.00001875,0.0000015,null],"claude-opus-4-1-20250805":[0.000015,0.000075,0.00001875,0.0000015,null],"claude-opus-4-20250514":[0.000015,0.000075,0.00001875,0.0000015,null],"claude-opus-4-5-20251101":[0.000005,0.000025,0.00000625,5e-7,null],"claude-opus-4-5":[0.000005,0.000025,0.00000625,5e-7,null],"claude-opus-4-6":[0.000005,0.000025,0.00000625,5e-7,6],"claude-opus-4-6-20260205":[0.000005,0.000025,0.00000625,5e-7,6],"claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7,6],"claude-opus-4-7-20260416":[0.000005,0.000025,0.00000625,5e-7,6],"claude-fable-5":[0.00001,0.00005,0.0000125,0.000001,null],"claude-opus-4-8":[0.000005,0.000025,0.00000625,5e-7,2],"claude-sonnet-4-20250514":[0.000003,0.000015,0.00000375,3e-7,null],"codex-mini-latest":[0.0000015,0.000006,null,3.75e-7,null],"cohere.command-light-text-v14":[3e-7,6e-7,null,null,null],"cohere.command-r-plus-v1:0":[0.000003,0.000015,null,null,null],"cohere.command-r-v1:0":[5e-7,0.0000015,null,null,null],"cohere.command-text-v14":[0.0000015,0.000002,null,null,null],"cohere.embed-english-v3":[1e-7,0,null,null,null],"cohere.embed-multilingual-v3":[1e-7,0,null,null,null],"cohere.embed-v4:0":[1.2e-7,0,null,null,null],"cohere.rerank-v3-5:0":[0,0,null,null,null],"command":[0.000001,0.000002,null,null,null],"command-a-03-2025":[0.0000025,0.00001,null,null,null],"command-light":[3e-7,6e-7,null,null,null],"command-nightly":[0.000001,0.000002,null,null,null],"command-r":[1.5e-7,6e-7,null,null,null],"command-r-08-2024":[1.5e-7,6e-7,null,null,null],"command-r-plus":[0.0000025,0.00001,null,null,null],"command-r-plus-08-2024":[0.0000025,0.00001,null,null,null],"command-r7b-12-2024":[3.75e-8,1.5e-7,null,null,null],"computer-use-preview":[0.000003,0.000012,null,null,null],"deepseek-chat":[2.8e-7,4.2e-7,null,2.8e-8,null],"deepseek-reasoner":[2.8e-7,4.2e-7,null,2.8e-8,null],"davinci-002":[0.000002,0.000002,null,null,null],"deepseek.v3-v1:0":[5.8e-7,0.00000168,null,null,null],"deepseek.v3.2":[6.2e-7,0.00000185,null,null,null],"dolphin":[5e-7,5e-7,null,null,null],"deepseek-v3-2-251201":[0,0,null,null,null],"glm-4-7-251222":[0,0,null,null,null],"kimi-k2-thinking-251104":[0,0,null,null,null],"doubao-embedding":[0,0,null,null,null],"doubao-embedding-large":[0,0,null,null,null],"doubao-embedding-large-text-240915":[0,0,null,null,null],"doubao-embedding-large-text-250515":[0,0,null,null,null],"doubao-embedding-text-240715":[0,0,null,null,null],"embed-english-light-v2.0":[1e-7,0,null,null,null],"embed-english-light-v3.0":[1e-7,0,null,null,null],"embed-english-v2.0":[1e-7,0,null,null,null],"embed-english-v3.0":[1e-7,0,null,null,null],"embed-multilingual-v2.0":[1e-7,0,null,null,null],"embed-multilingual-v3.0":[1e-7,0,null,null,null],"embed-multilingual-light-v3.0":[0.0001,0,null,null,null],"eu.amazon.nova-lite-v1:0":[7.8e-8,3.12e-7,null,null,null],"eu.amazon.nova-micro-v1:0":[4.6e-8,1.84e-7,null,null,null],"eu.amazon.nova-pro-v1:0":[0.00000105,0.0000042,null,null,null],"eu.anthropic.claude-3-5-haiku-20241022-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8,null],"eu.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7,null],"eu.anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"eu.anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7,null],"eu.anthropic.claude-3-7-sonnet-20250219-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"eu.anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8,null],"eu.anthropic.claude-3-opus-20240229-v1:0":[0.000015,0.000075,0.00001875,0.0000015,null],"eu.anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"eu.anthropic.claude-opus-4-1-20250805-v1:0":[0.000015,0.000075,0.00001875,0.0000015,null],"eu.anthropic.claude-opus-4-20250514-v1:0":[0.000015,0.000075,0.00001875,0.0000015,null],"eu.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"eu.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"eu.meta.llama3-2-1b-instruct-v1:0":[1.3e-7,1.3e-7,null,null,null],"eu.meta.llama3-2-3b-instruct-v1:0":[1.9e-7,1.9e-7,null,null,null],"eu.mistral.pixtral-large-2502-v1:0":[0.000002,0.000006,null,null,null],"fireworks-ai-4.1b-to-16b":[2e-7,2e-7,null,null,null],"fireworks-ai-56b-to-176b":[0.0000012,0.0000012,null,null,null],"fireworks-ai-above-16b":[9e-7,9e-7,null,null,null],"fireworks-ai-default":[0,0,null,null,null],"fireworks-ai-embedding-150m-to-350m":[1.6e-8,0,null,null,null],"fireworks-ai-embedding-up-to-150m":[8e-9,0,null,null,null],"fireworks-ai-moe-up-to-56b":[5e-7,5e-7,null,null,null],"fireworks-ai-up-to-4b":[2e-7,2e-7,null,null,null],"ft:babbage-002":[0.0000016,0.0000016,null,null,null],"ft:davinci-002":[0.000012,0.000012,null,null,null],"ft:gpt-3.5-turbo":[0.000003,0.000006,null,null,null],"ft:gpt-3.5-turbo-0125":[0.000003,0.000006,null,null,null],"ft:gpt-3.5-turbo-0613":[0.000003,0.000006,null,null,null],"ft:gpt-3.5-turbo-1106":[0.000003,0.000006,null,null,null],"ft:gpt-4-0613":[0.00003,0.00006,null,null,null],"ft:gpt-4o-2024-08-06":[0.00000375,0.000015,null,0.000001875,null],"ft:gpt-4o-2024-11-20":[0.00000375,0.000015,0.000001875,null,null],"ft:gpt-4o-mini-2024-07-18":[3e-7,0.0000012,null,1.5e-7,null],"ft:gpt-4.1-2025-04-14":[0.000003,0.000012,null,7.5e-7,null],"ft:gpt-4.1-mini-2025-04-14":[8e-7,0.0000032,null,2e-7,null],"ft:gpt-4.1-nano-2025-04-14":[2e-7,8e-7,null,5e-8,null],"ft:o4-mini-2025-04-16":[0.000004,0.000016,null,0.000001,null],"gemini-2.0-flash":[1e-7,4e-7,null,2.5e-8,null],"gemini-2.0-flash-001":[1.5e-7,6e-7,null,3.75e-8,null],"gemini-2.0-flash-lite":[7.5e-8,3e-7,null,1.875e-8,null],"gemini-2.0-flash-lite-001":[7.5e-8,3e-7,null,1.875e-8,null],"gemini-2.5-flash":[3e-7,0.0000025,null,3e-8,null],"gemini-2.5-flash-image":[3e-7,0.0000025,null,3e-8,null],"gemini-3-pro-image":[0.000002,0.000012,null,null,null],"gemini-3-pro-image-preview":[0.000002,0.000012,null,null,null],"gemini-3.1-flash-image":[5e-7,0.000003,null,null,null],"gemini-3.1-flash-image-preview":[5e-7,0.000003,null,null,null],"gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8,null],"gemini-3.1-flash-lite":[2.5e-7,0.0000015,null,2.5e-8,null],"deep-research-pro-preview-12-2025":[0.000002,0.000012,null,null,null],"gemini-2.5-flash-lite":[1e-7,4e-7,null,1e-8,null],"gemini-2.5-flash-lite-preview-09-2025":[1e-7,4e-7,null,1e-8,null],"gemini-2.5-flash-preview-09-2025":[3e-7,0.0000025,null,7.5e-8,null],"gemini-live-2.5-flash-preview-native-audio-09-2025":[3e-7,0.000002,null,7.5e-8,null],"gemini-2.5-flash-lite-preview-06-17":[1e-7,4e-7,null,2.5e-8,null],"gemini-2.5-pro":[0.00000125,0.00001,null,1.25e-7,null],"gemini-3-pro-preview":[0.000002,0.000012,null,2e-7,null],"gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7,null],"gemini-3.1-pro-preview-customtools":[0.000002,0.000012,null,2e-7,null],"gemini-2.5-pro-preview-tts":[0.00000125,0.00001,null,1.25e-7,null],"gemini-robotics-er-1.5-preview":[3e-7,0.0000025,null,0,null],"gemini-2.5-computer-use-preview-10-2025":[0.00000125,0.00001,null,null,null],"gemini-embedding-001":[1.5e-7,0,null,null,null],"gemini-embedding-2-preview":[2e-7,0,null,null,null],"gemini-embedding-2":[2e-7,0,null,null,null],"gemini-flash-experimental":[0,0,null,null,null],"gemini-3-flash-preview":[5e-7,0.000003,null,5e-8,null],"gemini-3.5-flash":[0.0000015,0.000009,null,1.5e-7,null],"google.gemma-3-12b-it":[9e-8,2.9e-7,null,null,null],"google.gemma-3-27b-it":[2.3e-7,3.8e-7,null,null,null],"google.gemma-3-4b-it":[4e-8,8e-8,null,null,null],"global.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"global.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"global.anthropic.claude-haiku-4-5-20251001-v1:0":[0.000001,0.000005,0.00000125,1e-7,null],"global.amazon.nova-2-lite-v1:0":[3e-7,0.0000025,null,7.5e-8,null],"gpt-3.5-turbo":[5e-7,0.0000015,null,null,null],"gpt-3.5-turbo-0125":[5e-7,0.0000015,null,null,null],"gpt-3.5-turbo-1106":[0.000001,0.000002,null,null,null],"gpt-3.5-turbo-16k":[0.000003,0.000004,null,null,null],"gpt-3.5-turbo-instruct":[0.0000015,0.000002,null,null,null],"gpt-3.5-turbo-instruct-0914":[0.0000015,0.000002,null,null,null],"gpt-4":[0.00003,0.00006,null,null,null],"gpt-4-0125-preview":[0.00001,0.00003,null,null,null],"gpt-4-0314":[0.00003,0.00006,null,null,null],"gpt-4-0613":[0.00003,0.00006,null,null,null],"gpt-4-1106-preview":[0.00001,0.00003,null,null,null],"gpt-4-turbo":[0.00001,0.00003,null,null,null],"gpt-4-turbo-2024-04-09":[0.00001,0.00003,null,null,null],"gpt-4-turbo-preview":[0.00001,0.00003,null,null,null],"gpt-4.1":[0.000002,0.000008,null,5e-7,null],"gpt-4.1-2025-04-14":[0.000002,0.000008,null,5e-7,null],"gpt-4.1-mini":[4e-7,0.0000016,null,1e-7,null],"gpt-4.1-mini-2025-04-14":[4e-7,0.0000016,null,1e-7,null],"gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8,null],"gpt-4.1-nano-2025-04-14":[1e-7,4e-7,null,2.5e-8,null],"gpt-4o":[0.0000025,0.00001,null,0.00000125,null],"gpt-4o-2024-05-13":[0.000005,0.000015,null,null,null],"gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125,null],"gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125,null],"gpt-4o-audio-preview":[0.0000025,0.00001,null,null,null],"gpt-4o-audio-preview-2024-12-17":[0.0000025,0.00001,null,null,null],"gpt-4o-audio-preview-2025-06-03":[0.0000025,0.00001,null,null,null],"gpt-audio":[0.0000025,0.00001,null,null,null],"gpt-audio-1.5":[0.0000025,0.00001,null,null,null],"gpt-audio-2025-08-28":[0.0000025,0.00001,null,null,null],"gpt-audio-mini":[6e-7,0.0000024,null,null,null],"gpt-audio-mini-2025-10-06":[6e-7,0.0000024,null,null,null],"gpt-audio-mini-2025-12-15":[6e-7,0.0000024,null,null,null],"gpt-4o-mini":[1.5e-7,6e-7,null,7.5e-8,null],"gpt-4o-mini-2024-07-18":[1.5e-7,6e-7,null,7.5e-8,null],"gpt-4o-mini-audio-preview":[1.5e-7,6e-7,null,null,null],"gpt-4o-mini-audio-preview-2024-12-17":[1.5e-7,6e-7,null,null,null],"gpt-4o-mini-realtime-preview":[6e-7,0.0000024,null,3e-7,null],"gpt-4o-mini-realtime-preview-2024-12-17":[6e-7,0.0000024,null,3e-7,null],"gpt-4o-mini-search-preview":[1.5e-7,6e-7,null,7.5e-8,null],"gpt-4o-mini-search-preview-2025-03-11":[1.5e-7,6e-7,null,7.5e-8,null],"gpt-4o-mini-transcribe":[0.00000125,0.000005,null,null,null],"gpt-4o-mini-tts":[0.0000025,0.00001,null,null,null],"gpt-4o-realtime-preview":[0.000005,0.00002,null,0.0000025,null],"gpt-4o-realtime-preview-2024-12-17":[0.000005,0.00002,null,0.0000025,null],"gpt-4o-realtime-preview-2025-06-03":[0.000005,0.00002,null,0.0000025,null],"gpt-4o-search-preview":[0.0000025,0.00001,null,0.00000125,null],"gpt-4o-search-preview-2025-03-11":[0.0000025,0.00001,null,0.00000125,null],"gpt-4o-transcribe":[0.0000025,0.00001,null,null,null],"gpt-image-1.5":[0.000005,0.00001,null,0.00000125,null],"gpt-image-1.5-2025-12-16":[0.000005,0.00001,null,0.00000125,null],"gpt-image-2":[0.000005,0.00001,null,0.00000125,null],"gpt-image-2-2026-04-21":[0.000005,0.00001,null,0.00000125,null],"gpt-5":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.1":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.1-2025-11-13":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.1-chat-latest":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.2":[0.00000175,0.000014,null,1.75e-7,null],"gpt-5.2-2025-12-11":[0.00000175,0.000014,null,1.75e-7,null],"gpt-5.2-chat-latest":[0.00000175,0.000014,null,1.75e-7,null],"gpt-5.3-chat-latest":[0.00000175,0.000014,null,1.75e-7,null],"gpt-5.2-pro":[0.000021,0.000168,null,null,null],"gpt-5.2-pro-2025-12-11":[0.000021,0.000168,null,null,null],"gpt-5.5":[0.000005,0.00003,null,5e-7,null],"gpt-5.5-2026-04-23":[0.000005,0.00003,null,5e-7,null],"gpt-5.5-pro":[0.00003,0.00018,null,0.000003,null],"gpt-5.5-pro-2026-04-23":[0.00003,0.00018,null,0.000003,null],"gpt-5.4":[0.0000025,0.000015,null,2.5e-7,null],"gpt-5.4-2026-03-05":[0.0000025,0.000015,null,2.5e-7,null],"gpt-5.4-pro":[0.00003,0.00018,null,0.000003,null],"gpt-5.4-pro-2026-03-05":[0.00003,0.00018,null,0.000003,null],"gpt-5.4-mini":[7.5e-7,0.0000045,null,7.5e-8,null],"gpt-5.4-mini-2026-03-17":[7.5e-7,0.0000045,null,7.5e-8,null],"gpt-5.4-nano":[2e-7,0.00000125,null,2e-8,null],"gpt-5.4-nano-2026-03-17":[2e-7,0.00000125,null,2e-8,null],"gpt-5-pro":[0.000015,0.00012,null,null,null],"gpt-5-pro-2025-10-06":[0.000015,0.00012,null,null,null],"gpt-5-2025-08-07":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5-chat":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5-chat-latest":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5-codex":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8,null],"gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7,null],"gpt-5.3-codex":[0.00000175,0.000014,null,1.75e-7,null],"gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8,null],"gpt-5-mini-2025-08-07":[2.5e-7,0.000002,null,2.5e-8,null],"gpt-5-nano":[5e-8,4e-7,null,5e-9,null],"gpt-5-nano-2025-08-07":[5e-8,4e-7,null,5e-9,null],"gpt-realtime":[0.000004,0.000016,null,4e-7,null],"gpt-realtime-1.5":[0.000004,0.000016,null,4e-7,null],"gpt-realtime-2":[0.000004,0.000016,null,4e-7,null],"gpt-realtime-mini":[6e-7,0.0000024,null,null,null],"gpt-realtime-2025-08-28":[0.000004,0.000016,null,4e-7,null],"j2-light":[0.000003,0.000003,null,null,null],"j2-mid":[0.00001,0.00001,null,null,null],"j2-ultra":[0.000015,0.000015,null,null,null],"jamba-1.5":[2e-7,4e-7,null,null,null],"jamba-1.5-large":[0.000002,0.000008,null,null,null],"jamba-1.5-large@001":[0.000002,0.000008,null,null,null],"jamba-1.5-mini":[2e-7,4e-7,null,null,null],"jamba-1.5-mini@001":[2e-7,4e-7,null,null,null],"jamba-large-1.6":[0.000002,0.000008,null,null,null],"jamba-large-1.7":[0.000002,0.000008,null,null,null],"jamba-mini-1.6":[2e-7,4e-7,null,null,null],"jamba-mini-1.7":[2e-7,4e-7,null,null,null],"jina-reranker-v2-base-multilingual":[1.8e-8,1.8e-8,null,null,null],"jp.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"jp.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7,null],"meta.llama2-13b-chat-v1":[7.5e-7,0.000001,null,null,null],"meta.llama2-70b-chat-v1":[0.00000195,0.00000256,null,null,null],"meta.llama3-1-405b-instruct-v1:0":[0.00000532,0.000016,null,null,null],"meta.llama3-1-70b-instruct-v1:0":[9.9e-7,9.9e-7,null,null,null],"meta.llama3-1-8b-instruct-v1:0":[2.2e-7,2.2e-7,null,null,null],"meta.llama3-2-11b-instruct-v1:0":[3.5e-7,3.5e-7,null,null,null],"meta.llama3-2-1b-instruct-v1:0":[1e-7,1e-7,null,null,null],"meta.llama3-2-3b-instruct-v1:0":[1.5e-7,1.5e-7,null,null,null],"meta.llama3-2-90b-instruct-v1:0":[0.000002,0.000002,null,null,null],"meta.llama3-3-70b-instruct-v1:0":[7.2e-7,7.2e-7,null,null,null],"meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null,null],"meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null,null],"meta.llama4-maverick-17b-instruct-v1:0":[2.4e-7,9.7e-7,null,null,null],"meta.llama4-scout-17b-instruct-v1:0":[1.7e-7,6.6e-7,null,null,null],"minimax.minimax-m2":[3e-7,0.0000012,null,null,null],"minimax.minimax-m2.1":[3e-7,0.0000012,null,null,null],"minimax.minimax-m2.5":[3e-7,0.0000012,null,null,null],"mistral.devstral-2-123b":[4e-7,0.000002,null,null,null],"mistral.magistral-small-2509":[5e-7,0.0000015,null,null,null],"mistral.ministral-3-14b-instruct":[2e-7,2e-7,null,null,null],"mistral.ministral-3-3b-instruct":[1e-7,1e-7,null,null,null],"mistral.ministral-3-8b-instruct":[1.5e-7,1.5e-7,null,null,null],"mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null,null],"mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null,null],"mistral.mistral-large-2407-v1:0":[0.000003,0.000009,null,null,null],"mistral.mistral-large-3-675b-instruct":[5e-7,0.0000015,null,null,null],"mistral.mistral-small-2402-v1:0":[0.000001,0.000003,null,null,null],"mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null,null],"mistral.voxtral-mini-3b-2507":[4e-8,4e-8,null,null,null],"mistral.voxtral-small-24b-2507":[1e-7,3e-7,null,null,null],"moonshot.kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"moonshotai.kimi-k2.5":[6e-7,0.000003,null,null,null],"multimodalembedding":[8e-7,0,null,null,null],"multimodalembedding@001":[8e-7,0,null,null,null],"nvidia.nemotron-nano-12b-v2":[2e-7,6e-7,null,null,null],"nvidia.nemotron-nano-9b-v2":[6e-8,2.3e-7,null,null,null],"nvidia.nemotron-nano-3-30b":[6e-8,2.4e-7,null,null,null],"nvidia.nemotron-super-3-120b":[1.5e-7,6.5e-7,null,null,null],"o1":[0.000015,0.00006,null,0.0000075,null],"o1-2024-12-17":[0.000015,0.00006,null,0.0000075,null],"o1-pro":[0.00015,0.0006,null,null,null],"o1-pro-2025-03-19":[0.00015,0.0006,null,null,null],"o3":[0.000002,0.000008,null,5e-7,null],"o3-2025-04-16":[0.000002,0.000008,null,5e-7,null],"o3-deep-research":[0.00001,0.00004,null,0.0000025,null],"o3-deep-research-2025-06-26":[0.00001,0.00004,null,0.0000025,null],"o3-mini":[0.0000011,0.0000044,null,5.5e-7,null],"o3-mini-2025-01-31":[0.0000011,0.0000044,null,5.5e-7,null],"o3-pro":[0.00002,0.00008,null,null,null],"o3-pro-2025-06-10":[0.00002,0.00008,null,null,null],"o4-mini":[0.0000011,0.0000044,null,2.75e-7,null],"o4-mini-2025-04-16":[0.0000011,0.0000044,null,2.75e-7,null],"o4-mini-deep-research":[0.000002,0.000008,null,5e-7,null],"o4-mini-deep-research-2025-06-26":[0.000002,0.000008,null,5e-7,null],"omni-moderation-2024-09-26":[0,0,null,null,null],"omni-moderation-latest":[0,0,null,null,null],"openai.gpt-oss-120b-1:0":[1.5e-7,6e-7,null,null,null],"openai.gpt-oss-20b-1:0":[7e-8,3e-7,null,null,null],"openai.gpt-oss-safeguard-120b":[1.5e-7,6e-7,null,null,null],"openai.gpt-oss-safeguard-20b":[7e-8,2e-7,null,null,null],"qwen.qwen3-coder-480b-a35b-v1:0":[2.2e-7,0.0000018,null,null,null],"qwen.qwen3-235b-a22b-2507-v1:0":[2.2e-7,8.8e-7,null,null,null],"qwen.qwen3-coder-30b-a3b-v1:0":[1.5e-7,6e-7,null,null,null],"qwen.qwen3-32b-v1:0":[1.5e-7,6e-7,null,null,null],"qwen.qwen3-next-80b-a3b":[1.5e-7,0.0000012,null,null,null],"qwen.qwen3-vl-235b-a22b":[5.3e-7,0.00000266,null,null,null],"qwen.qwen3-coder-next":[5e-7,0.0000012,null,null,null],"rerank-english-v2.0":[0,0,null,null,null],"rerank-english-v3.0":[0,0,null,null,null],"rerank-multilingual-v2.0":[0,0,null,null,null],"rerank-multilingual-v3.0":[0,0,null,null,null],"rerank-v3.5":[0,0,null,null,null],"text-embedding-004":[1e-7,0,null,null,null],"text-embedding-005":[1e-7,0,null,null,null],"text-embedding-3-large":[1.3e-7,0,null,null,null],"text-embedding-3-small":[2e-8,0,null,null,null],"text-embedding-ada-002":[1e-7,0,null,null,null],"text-embedding-ada-002-v2":[1e-7,0,null,null,null],"text-embedding-large-exp-03-07":[1e-7,0,null,null,null],"text-embedding-preview-0409":[6.25e-9,0,null,null,null],"text-moderation-007":[0,0,null,null,null],"text-moderation-latest":[0,0,null,null,null],"text-moderation-stable":[0,0,null,null,null],"text-multilingual-embedding-002":[1e-7,0,null,null,null],"text-unicorn":[0.00001,0.000028,null,null,null],"text-unicorn@001":[0.00001,0.000028,null,null,null],"together-ai-21.1b-41b":[8e-7,8e-7,null,null,null],"together-ai-4.1b-8b":[2e-7,2e-7,null,null,null],"together-ai-41.1b-80b":[9e-7,9e-7,null,null,null],"together-ai-8.1b-21b":[3e-7,3e-7,null,null,null],"together-ai-81.1b-110b":[0.0000018,0.0000018,null,null,null],"together-ai-embedding-151m-to-350m":[1.6e-8,0,null,null,null],"together-ai-embedding-up-to-150m":[8e-9,0,null,null,null],"together-ai-up-to-4b":[1e-7,1e-7,null,null,null],"us.amazon.nova-lite-v1:0":[6e-8,2.4e-7,null,null,null],"us.amazon.nova-micro-v1:0":[3.5e-8,1.4e-7,null,null,null],"us.amazon.nova-premier-v1:0":[0.0000025,0.0000125,null,null,null],"us.amazon.nova-pro-v1:0":[8e-7,0.0000032,null,null,null],"us.anthropic.claude-3-5-haiku-20241022-v1:0":[8e-7,0.000004,0.000001,8e-8,null],"us.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7,null],"us.anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"us.anthropic.claude-3-5-sonnet-20241022-v2:0":[0.000003,0.000015,0.00000375,3e-7,null],"us.anthropic.claude-3-7-sonnet-20250219-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"us.anthropic.claude-3-haiku-20240307-v1:0":[2.5e-7,0.00000125,3.125e-7,2.5e-8,null],"us.anthropic.claude-3-opus-20240229-v1:0":[0.000015,0.000075,0.00001875,0.0000015,null],"us.anthropic.claude-3-sonnet-20240229-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"us.anthropic.claude-opus-4-1-20250805-v1:0":[0.000015,0.000075,0.00001875,0.0000015,null],"us.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000033,0.0000165,0.000004125,3.3e-7,null],"us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"au.anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000011,0.0000055,0.000001375,1.1e-7,null],"us.anthropic.claude-opus-4-20250514-v1:0":[0.000015,0.000075,0.00001875,0.0000015,null],"us.anthropic.claude-opus-4-5-20251101-v1:0":[0.0000055,0.0000275,0.000006875,5.5e-7,null],"global.anthropic.claude-opus-4-5-20251101-v1:0":[0.000005,0.000025,0.00000625,5e-7,null],"eu.anthropic.claude-opus-4-5-20251101-v1:0":[0.000005,0.000025,0.00000625,5e-7,null],"us.anthropic.claude-sonnet-4-20250514-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"us.deepseek.r1-v1:0":[0.00000135,0.0000054,null,null,null],"us.deepseek.v3.2":[6.2e-7,0.00000185,null,null,null],"eu.deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"us.meta.llama3-1-405b-instruct-v1:0":[0.00000532,0.000016,null,null,null],"us.meta.llama3-1-70b-instruct-v1:0":[9.9e-7,9.9e-7,null,null,null],"us.meta.llama3-1-8b-instruct-v1:0":[2.2e-7,2.2e-7,null,null,null],"us.meta.llama3-2-11b-instruct-v1:0":[3.5e-7,3.5e-7,null,null,null],"us.meta.llama3-2-1b-instruct-v1:0":[1e-7,1e-7,null,null,null],"us.meta.llama3-2-3b-instruct-v1:0":[1.5e-7,1.5e-7,null,null,null],"us.meta.llama3-2-90b-instruct-v1:0":[0.000002,0.000002,null,null,null],"us.meta.llama3-3-70b-instruct-v1:0":[7.2e-7,7.2e-7,null,null,null],"us.meta.llama4-maverick-17b-instruct-v1:0":[2.4e-7,9.7e-7,null,null,null],"us.meta.llama4-scout-17b-instruct-v1:0":[1.7e-7,6.6e-7,null,null,null],"us.mistral.pixtral-large-2502-v1:0":[0.000002,0.000006,null,null,null],"zai.glm-4.7":[6e-7,0.0000022,null,null,null],"zai.glm-5":[0.000001,0.0000032,null,null,null],"zai.glm-4.7-flash":[7e-8,4e-7,null,null,null],"gpt-4o-mini-tts-2025-03-20":[0.0000025,0.00001,null,null,null],"gpt-4o-mini-tts-2025-12-15":[0.0000025,0.00001,null,null,null],"gpt-4o-mini-transcribe-2025-03-20":[0.00000125,0.000005,null,null,null],"gpt-4o-mini-transcribe-2025-12-15":[0.00000125,0.000005,null,null,null],"gpt-5-search-api":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5-search-api-2025-10-14":[0.00000125,0.00001,null,1.25e-7,null],"gpt-realtime-mini-2025-10-06":[6e-7,0.0000024,null,6e-8,null],"gpt-realtime-mini-2025-12-15":[6e-7,0.0000024,null,6e-8,null],"gemini-2.0-flash-exp-image-generation":[0,0,null,null,null],"gemini-2.5-flash-native-audio-latest":[3e-7,0.0000025,null,null,null],"gemini-2.5-flash-native-audio-preview-09-2025":[3e-7,0.0000025,null,null,null],"gemini-2.5-flash-native-audio-preview-12-2025":[3e-7,0.0000025,null,null,null],"gemini-3.1-flash-live-preview":[7.5e-7,0.0000045,null,null,null],"gemini-2.5-flash-preview-tts":[3e-7,0.0000025,null,null,null],"gemini-flash-latest":[3e-7,0.0000025,null,3e-8,null],"gemini-flash-lite-latest":[1e-7,4e-7,null,1e-8,null],"gemini-pro-latest":[0.00000125,0.00001,null,1.25e-7,null],"gemini-exp-1206":[3e-7,0.0000025,null,3e-8,null],"deepseek-v4-flash":[1.4e-7,2.8e-7,0,2.8e-9],"deepseek-v4-pro":[4.35e-7,8.7e-7,0,3.625e-9],"anyscale/HuggingFaceH4/zephyr-7b-beta":[1.5e-7,1.5e-7,null,null,null],"HuggingFaceH4/zephyr-7b-beta":[1.5e-7,1.5e-7,null,null,null],"anyscale/codellama/CodeLlama-34b-Instruct-hf":[0.000001,0.000001,null,null,null],"codellama/CodeLlama-34b-Instruct-hf":[0.000001,0.000001,null,null,null],"anyscale/codellama/CodeLlama-70b-Instruct-hf":[0.000001,0.000001,null,null,null],"codellama/CodeLlama-70b-Instruct-hf":[0.000001,0.000001,null,null,null],"anyscale/google/gemma-7b-it":[1.5e-7,1.5e-7,null,null,null],"google/gemma-7b-it":[1.5e-7,1.5e-7,null,null,null],"anyscale/meta-llama/Llama-2-13b-chat-hf":[2.5e-7,2.5e-7,null,null,null],"meta-llama/Llama-2-13b-chat-hf":[2.5e-7,2.5e-7,null,null,null],"anyscale/meta-llama/Llama-2-70b-chat-hf":[0.000001,0.000001,null,null,null],"meta-llama/Llama-2-70b-chat-hf":[0.000001,0.000001,null,null,null],"anyscale/meta-llama/Llama-2-7b-chat-hf":[1.5e-7,1.5e-7,null,null,null],"meta-llama/Llama-2-7b-chat-hf":[1.5e-7,1.5e-7,null,null,null],"anyscale/meta-llama/Meta-Llama-3-70B-Instruct":[0.000001,0.000001,null,null,null],"meta-llama/Meta-Llama-3-70B-Instruct":[0.000001,0.000001,null,null,null],"anyscale/meta-llama/Meta-Llama-3-8B-Instruct":[1.5e-7,1.5e-7,null,null,null],"meta-llama/Meta-Llama-3-8B-Instruct":[1.5e-7,1.5e-7,null,null,null],"anyscale/mistralai/Mistral-7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null,null],"mistralai/Mistral-7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null,null],"anyscale/mistralai/Mixtral-8x22B-Instruct-v0.1":[9e-7,9e-7,null,null,null],"mistralai/Mixtral-8x22B-Instruct-v0.1":[9e-7,9e-7,null,null,null],"anyscale/mistralai/Mixtral-8x7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null,null],"mistralai/Mixtral-8x7B-Instruct-v0.1":[1.5e-7,1.5e-7,null,null,null],"azure/ada":[1e-7,0,null,null,null],"ada":[1e-7,0,null,null,null],"azure/codex-mini":[0.0000015,0.000006,null,3.75e-7,null],"codex-mini":[0.0000015,0.000006,null,3.75e-7,null],"azure/command-r-plus":[0.000003,0.000015,null,null,null],"azure_ai/claude-haiku-4-5":[0.000001,0.000005,0.00000125,1e-7,null],"azure_ai/claude-opus-4-5":[0.000005,0.000025,0.00000625,5e-7,null],"azure_ai/claude-opus-4-6":[0.000005,0.000025,0.00000625,5e-7,null],"azure_ai/claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7,null],"azure_ai/claude-fable-5":[0.00001,0.00005,0.0000125,0.000001,null],"azure_ai/claude-opus-4-8":[0.000005,0.000025,0.00000625,5e-7,null],"azure_ai/claude-opus-4-1":[0.000015,0.000075,0.00001875,0.0000015,null],"azure_ai/claude-sonnet-4-5":[0.000003,0.000015,0.00000375,3e-7,null],"azure_ai/claude-sonnet-5":[0.000003,0.000015,0.00000375,3e-7,null],"azure_ai/claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7,null],"azure/computer-use-preview":[0.000003,0.000012,null,null,null],"azure_ai/gpt-oss-120b":[1.5e-7,6e-7,null,null,null],"gpt-oss-120b":[1.5e-7,6e-7,null,null,null],"azure_ai/gpt-5.5":[0.000005,0.00003,null,5e-7,null],"azure_ai/gpt-5.5-2026-04-23":[0.000005,0.00003,null,5e-7,null],"azure_ai/gpt-5.4":[0.0000025,0.000015,null,2.5e-7,null],"azure_ai/gpt-5.4-2026-03-05":[0.0000025,0.000015,null,2.5e-7,null],"azure_ai/gpt-5.4-pro":[0.00003,0.00018,null,0.000003,null],"azure_ai/gpt-5.4-pro-2026-03-05":[0.00003,0.00018,null,0.000003,null],"azure_ai/gpt-5.4-mini":[7.5e-7,0.0000045,null,7.5e-8,null],"azure_ai/gpt-5.4-mini-2026-03-17":[7.5e-7,0.0000045,null,7.5e-8,null],"azure_ai/gpt-5.4-nano":[2e-7,0.00000125,null,2e-8,null],"azure_ai/gpt-5.4-nano-2026-03-17":[2e-7,0.00000125,null,2e-8,null],"azure_ai/model_router":[1.4e-7,0,null,null,null],"model_router":[1.4e-7,0,null,null,null],"azure/eu/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375,null],"eu/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375,null],"azure/eu/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null,null],"eu/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null,null],"azure/eu/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8,null],"eu/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8,null],"azure/eu/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7,null],"eu/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7,null],"azure/eu/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275,null],"eu/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275,null],"azure/eu/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275,null],"eu/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275,null],"azure/eu/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7,null],"eu/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7,null],"azure/eu/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8,null],"eu/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8,null],"azure/eu/gpt-5.1":[0.00000138,0.000011,null,1.4e-7,null],"eu/gpt-5.1":[0.00000138,0.000011,null,1.4e-7,null],"azure/eu/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7,null],"eu/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7,null],"azure/eu/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7,null],"eu/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7,null],"azure/eu/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8,null],"eu/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8,null],"azure/eu/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9,null],"eu/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9,null],"azure/eu/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825,null],"eu/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825,null],"azure/eu/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7,null],"eu/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7,null],"azure/eu/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825,null],"eu/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825,null],"azure/eu/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7,null],"eu/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7,null],"azure/global-standard/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125,null],"global-standard/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125,null],"azure/global-standard/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125,null],"global-standard/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125,null],"azure/global-standard/gpt-4o-mini":[1.5e-7,6e-7,null,null,null],"global-standard/gpt-4o-mini":[1.5e-7,6e-7,null,null,null],"azure/global/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125,null],"global/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125,null],"azure/global/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125,null],"global/gpt-4o-2024-11-20":[0.0000025,0.00001,null,0.00000125,null],"azure/global/gpt-5.1":[0.00000125,0.00001,null,1.25e-7,null],"global/gpt-5.1":[0.00000125,0.00001,null,1.25e-7,null],"azure/global/gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7,null],"global/gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7,null],"azure/global/gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7,null],"global/gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7,null],"azure/global/gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8,null],"global/gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8,null],"azure/gpt-3.5-turbo":[5e-7,0.0000015,null,null,null],"azure/gpt-3.5-turbo-0125":[5e-7,0.0000015,null,null,null],"azure/gpt-3.5-turbo-instruct-0914":[0.0000015,0.000002,null,null,null],"azure/gpt-35-turbo":[5e-7,0.0000015,null,null,null],"gpt-35-turbo":[5e-7,0.0000015,null,null,null],"azure/gpt-35-turbo-0125":[5e-7,0.0000015,null,null,null],"gpt-35-turbo-0125":[5e-7,0.0000015,null,null,null],"azure/gpt-35-turbo-1106":[0.000001,0.000002,null,null,null],"gpt-35-turbo-1106":[0.000001,0.000002,null,null,null],"azure/gpt-35-turbo-16k":[0.000003,0.000004,null,null,null],"gpt-35-turbo-16k":[0.000003,0.000004,null,null,null],"azure/gpt-35-turbo-16k-0613":[0.000003,0.000004,null,null,null],"gpt-35-turbo-16k-0613":[0.000003,0.000004,null,null,null],"azure/gpt-35-turbo-instruct":[0.0000015,0.000002,null,null,null],"gpt-35-turbo-instruct":[0.0000015,0.000002,null,null,null],"azure/gpt-35-turbo-instruct-0914":[0.0000015,0.000002,null,null,null],"gpt-35-turbo-instruct-0914":[0.0000015,0.000002,null,null,null],"azure/gpt-4":[0.00003,0.00006,null,null,null],"azure/gpt-4-0125-preview":[0.00001,0.00003,null,null,null],"azure/gpt-4-0613":[0.00003,0.00006,null,null,null],"azure/gpt-4-1106-preview":[0.00001,0.00003,null,null,null],"azure/gpt-4-32k":[0.00006,0.00012,null,null,null],"gpt-4-32k":[0.00006,0.00012,null,null,null],"azure/gpt-4-32k-0613":[0.00006,0.00012,null,null,null],"gpt-4-32k-0613":[0.00006,0.00012,null,null,null],"azure/gpt-4-turbo":[0.00001,0.00003,null,null,null],"azure/gpt-4-turbo-2024-04-09":[0.00001,0.00003,null,null,null],"azure/gpt-4-turbo-vision-preview":[0.00001,0.00003,null,null,null],"gpt-4-turbo-vision-preview":[0.00001,0.00003,null,null,null],"azure/gpt-4.1":[0.000002,0.000008,null,5e-7,null],"azure/gpt-4.1-2025-04-14":[0.000002,0.000008,null,5e-7,null],"azure/gpt-4.1-mini":[4e-7,0.0000016,null,1e-7,null],"azure/gpt-4.1-mini-2025-04-14":[4e-7,0.0000016,null,1e-7,null],"azure/gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8,null],"azure/gpt-4.1-nano-2025-04-14":[1e-7,4e-7,null,2.5e-8,null],"azure/gpt-4.5-preview":[0.000075,0.00015,null,0.0000375,null],"gpt-4.5-preview":[0.000075,0.00015,null,0.0000375,null],"azure/gpt-4o":[0.0000025,0.00001,null,0.00000125,null],"azure/gpt-4o-2024-05-13":[0.000005,0.000015,null,null,null],"azure/gpt-4o-2024-08-06":[0.0000025,0.00001,null,0.00000125,null],"azure/gpt-4o-2024-11-20":[0.00000275,0.000011,null,0.00000125,null],"azure/gpt-audio-2025-08-28":[0.0000025,0.00001,null,null,null],"azure/gpt-audio-1.5-2026-02-23":[0.0000025,0.00001,null,null,null],"gpt-audio-1.5-2026-02-23":[0.0000025,0.00001,null,null,null],"azure/gpt-audio-mini-2025-10-06":[6e-7,0.0000024,null,null,null],"azure/gpt-4o-audio-preview-2024-12-17":[0.0000025,0.00001,null,null,null],"azure/gpt-4o-mini":[1.65e-7,6.6e-7,null,7.5e-8,null],"azure/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,7.5e-8,null],"azure/gpt-4o-mini-audio-preview-2024-12-17":[0.0000025,0.00001,null,null,null],"azure/gpt-4o-mini-realtime-preview-2024-12-17":[6e-7,0.0000024,null,3e-7,null],"azure/gpt-realtime-2025-08-28":[0.000004,0.000016,null,0.000004,null],"azure/gpt-realtime-1.5-2026-02-23":[0.000004,0.000016,null,0.000004,null],"gpt-realtime-1.5-2026-02-23":[0.000004,0.000016,null,0.000004,null],"azure/gpt-realtime-mini-2025-10-06":[6e-7,0.0000024,null,6e-8,null],"azure/gpt-4o-mini-transcribe":[0.00000125,0.000005,null,null,null],"azure/gpt-4o-mini-tts":[0.0000025,0.00001,null,null,null],"azure/gpt-4o-realtime-preview-2024-10-01":[0.000005,0.00002,null,0.0000025,null],"gpt-4o-realtime-preview-2024-10-01":[0.000005,0.00002,null,0.0000025,null],"azure/gpt-4o-realtime-preview-2024-12-17":[0.000005,0.00002,null,0.0000025,null],"azure/gpt-4o-transcribe":[0.0000025,0.00001,null,null,null],"azure/gpt-4o-transcribe-diarize":[0.0000025,0.00001,null,null,null],"azure/gpt-5.1-2025-11-13":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5.1-chat-2025-11-13":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.1-chat-2025-11-13":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5.1-codex-2025-11-13":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.1-codex-2025-11-13":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5.1-codex-mini-2025-11-13":[2.5e-7,0.000002,null,2.5e-8,null],"gpt-5.1-codex-mini-2025-11-13":[2.5e-7,0.000002,null,2.5e-8,null],"azure/gpt-5":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5-2025-08-07":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5-chat":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5-chat-latest":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5-codex":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8,null],"azure/gpt-5-mini-2025-08-07":[2.5e-7,0.000002,null,2.5e-8,null],"azure/gpt-5-nano":[5e-8,4e-7,null,5e-9,null],"azure/gpt-5-nano-2025-08-07":[5e-8,4e-7,null,5e-9,null],"azure/gpt-5-pro":[0.000015,0.00012,null,null,null],"azure/gpt-5.1":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7,null],"gpt-5.1-chat":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5.1-codex":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7,null],"azure/gpt-5.1-codex-mini":[2.5e-7,0.000002,null,2.5e-8,null],"azure/gpt-5.2":[0.00000175,0.000014,null,1.75e-7,null],"azure/gpt-5.2-2025-12-11":[0.00000175,0.000014,null,1.75e-7,null],"azure/gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7,null],"gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7,null],"azure/gpt-5.2-chat-2025-12-11":[0.00000175,0.000014,null,1.75e-7,null],"gpt-5.2-chat-2025-12-11":[0.00000175,0.000014,null,1.75e-7,null],"azure/gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7,null],"azure/gpt-5.3-chat":[0.00000175,0.000014,null,1.75e-7,null],"gpt-5.3-chat":[0.00000175,0.000014,null,1.75e-7,null],"azure/gpt-5.3-codex":[0.00000175,0.000014,null,1.75e-7,null],"azure/gpt-5.2-pro":[0.000021,0.000168,null,null,null],"azure/gpt-5.2-pro-2025-12-11":[0.000021,0.000168,null,null,null],"azure/gpt-5.4":[0.0000025,0.000015,null,2.5e-7,null],"azure/gpt-5.4-2026-03-05":[0.0000025,0.000015,null,2.5e-7,null],"azure/gpt-5.4-pro":[0.00003,0.00018,null,0.000003,null],"azure/gpt-5.4-pro-2026-03-05":[0.00003,0.00018,null,0.000003,null],"azure/gpt-5.5":[0.000005,0.00003,null,5e-7,null],"azure/gpt-5.5-2026-04-23":[0.000005,0.00003,null,5e-7,null],"azure/gpt-5.5-pro":[0.00003,0.00018,null,0.000003,null],"azure/gpt-5.5-pro-2026-04-23":[0.00003,0.00018,null,0.000003,null],"azure/gpt-5.4-mini":[7.5e-7,0.0000045,null,7.5e-8,null],"azure/gpt-5.4-mini-2026-03-17":[7.5e-7,0.0000045,null,7.5e-8,null],"azure/gpt-5.4-nano":[2e-7,0.00000125,null,2e-8,null],"azure/gpt-5.4-nano-2026-03-17":[2e-7,0.00000125,null,2e-8,null],"azure/gpt-image-2":[0.000005,0.00001,null,0.00000125,null],"azure/gpt-image-2-2026-04-21":[0.000005,0.00001,null,0.00000125,null],"azure/mistral-large-2402":[0.000008,0.000024,null,null,null],"mistral-large-2402":[0.000008,0.000024,null,null,null],"azure/mistral-large-latest":[0.000008,0.000024,null,null,null],"mistral-large-latest":[0.000008,0.000024,null,null,null],"azure/o1":[0.000015,0.00006,null,0.0000075,null],"azure/o1-2024-12-17":[0.000015,0.00006,null,0.0000075,null],"azure/o1-mini":[0.00000121,0.00000484,null,6.05e-7,null],"o1-mini":[0.00000121,0.00000484,null,6.05e-7,null],"azure/o1-mini-2024-09-12":[0.0000011,0.0000044,null,5.5e-7,null],"o1-mini-2024-09-12":[0.0000011,0.0000044,null,5.5e-7,null],"azure/o1-preview":[0.000015,0.00006,null,0.0000075,null],"o1-preview":[0.000015,0.00006,null,0.0000075,null],"azure/o1-preview-2024-09-12":[0.000015,0.00006,null,0.0000075,null],"o1-preview-2024-09-12":[0.000015,0.00006,null,0.0000075,null],"azure/o3":[0.000002,0.000008,null,5e-7,null],"azure/o3-2025-04-16":[0.000002,0.000008,null,5e-7,null],"azure/o3-deep-research":[0.00001,0.00004,null,0.0000025,null],"azure/o3-mini":[0.0000011,0.0000044,null,5.5e-7,null],"azure/o3-mini-2025-01-31":[0.0000011,0.0000044,null,5.5e-7,null],"azure/o3-pro":[0.00002,0.00008,null,null,null],"azure/o3-pro-2025-06-10":[0.00002,0.00008,null,null,null],"azure/o4-mini":[0.0000011,0.0000044,null,2.75e-7,null],"azure/o4-mini-2025-04-16":[0.0000011,0.0000044,null,2.75e-7,null],"azure/text-embedding-3-large":[1.3e-7,0,null,null,null],"azure/text-embedding-3-small":[2e-8,0,null,null,null],"azure/text-embedding-ada-002":[1e-7,0,null,null,null],"azure/us/gpt-4.1-2025-04-14":[0.0000022,0.0000088,null,5.5e-7,null],"us/gpt-4.1-2025-04-14":[0.0000022,0.0000088,null,5.5e-7,null],"azure/us/gpt-4.1-mini-2025-04-14":[4.4e-7,0.00000176,null,1.1e-7,null],"us/gpt-4.1-mini-2025-04-14":[4.4e-7,0.00000176,null,1.1e-7,null],"azure/us/gpt-4.1-nano-2025-04-14":[1.1e-7,4.4e-7,null,2.5e-8,null],"us/gpt-4.1-nano-2025-04-14":[1.1e-7,4.4e-7,null,2.5e-8,null],"azure/us/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375,null],"us/gpt-4o-2024-08-06":[0.00000275,0.000011,null,0.000001375,null],"azure/us/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null,null],"us/gpt-4o-2024-11-20":[0.00000275,0.000011,0.00000138,null,null],"azure/us/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8,null],"us/gpt-4o-mini-2024-07-18":[1.65e-7,6.6e-7,null,8.3e-8,null],"azure/us/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7,null],"us/gpt-4o-mini-realtime-preview-2024-12-17":[6.6e-7,0.00000264,null,3.3e-7,null],"azure/us/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275,null],"us/gpt-4o-realtime-preview-2024-10-01":[0.0000055,0.000022,null,0.00000275,null],"azure/us/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275,null],"us/gpt-4o-realtime-preview-2024-12-17":[0.0000055,0.000022,null,0.00000275,null],"azure/us/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7,null],"us/gpt-5-2025-08-07":[0.000001375,0.000011,null,1.375e-7,null],"azure/us/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8,null],"us/gpt-5-mini-2025-08-07":[2.75e-7,0.0000022,null,2.75e-8,null],"azure/us/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9,null],"us/gpt-5-nano-2025-08-07":[5.5e-8,4.4e-7,null,5.5e-9,null],"azure/us/gpt-5.1":[0.00000138,0.000011,null,1.4e-7,null],"us/gpt-5.1":[0.00000138,0.000011,null,1.4e-7,null],"azure/us/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7,null],"us/gpt-5.1-chat":[0.00000138,0.000011,null,1.4e-7,null],"azure/us/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7,null],"us/gpt-5.1-codex":[0.00000138,0.000011,null,1.4e-7,null],"azure/us/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8,null],"us/gpt-5.1-codex-mini":[2.75e-7,0.0000022,null,2.8e-8,null],"azure/us/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825,null],"us/o1-2024-12-17":[0.0000165,0.000066,null,0.00000825,null],"azure/us/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7,null],"us/o1-mini-2024-09-12":[0.00000121,0.00000484,null,6.05e-7,null],"azure/us/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825,null],"us/o1-preview-2024-09-12":[0.0000165,0.000066,null,0.00000825,null],"azure/us/o3-2025-04-16":[0.0000022,0.0000088,null,5.5e-7,null],"us/o3-2025-04-16":[0.0000022,0.0000088,null,5.5e-7,null],"azure/us/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7,null],"us/o3-mini-2025-01-31":[0.00000121,0.00000484,null,6.05e-7,null],"azure/us/o4-mini-2025-04-16":[0.00000121,0.00000484,null,3.1e-7,null],"us/o4-mini-2025-04-16":[0.00000121,0.00000484,null,3.1e-7,null],"azure_ai/Cohere-embed-v3-english":[1e-7,0,null,null,null],"Cohere-embed-v3-english":[1e-7,0,null,null,null],"azure_ai/Cohere-embed-v3-multilingual":[1e-7,0,null,null,null],"Cohere-embed-v3-multilingual":[1e-7,0,null,null,null],"azure_ai/Llama-3.2-11B-Vision-Instruct":[3.7e-7,3.7e-7,null,null,null],"Llama-3.2-11B-Vision-Instruct":[3.7e-7,3.7e-7,null,null,null],"azure_ai/Llama-3.2-90B-Vision-Instruct":[0.00000204,0.00000204,null,null,null],"Llama-3.2-90B-Vision-Instruct":[0.00000204,0.00000204,null,null,null],"azure_ai/Llama-3.3-70B-Instruct":[7.1e-7,7.1e-7,null,null,null],"Llama-3.3-70B-Instruct":[7.1e-7,7.1e-7,null,null,null],"azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8":[0.00000141,3.5e-7,null,null,null],"Llama-4-Maverick-17B-128E-Instruct-FP8":[0.00000141,3.5e-7,null,null,null],"azure_ai/Llama-4-Scout-17B-16E-Instruct":[2e-7,7.8e-7,null,null,null],"Llama-4-Scout-17B-16E-Instruct":[2e-7,7.8e-7,null,null,null],"azure_ai/Meta-Llama-3-70B-Instruct":[0.0000011,3.7e-7,null,null,null],"Meta-Llama-3-70B-Instruct":[0.0000011,3.7e-7,null,null,null],"azure_ai/Meta-Llama-3.1-405B-Instruct":[0.00000533,0.000016,null,null,null],"Meta-Llama-3.1-405B-Instruct":[0.00000533,0.000016,null,null,null],"azure_ai/Meta-Llama-3.1-70B-Instruct":[0.00000268,0.00000354,null,null,null],"Meta-Llama-3.1-70B-Instruct":[0.00000268,0.00000354,null,null,null],"azure_ai/Meta-Llama-3.1-8B-Instruct":[3e-7,6.1e-7,null,null,null],"Meta-Llama-3.1-8B-Instruct":[3e-7,6.1e-7,null,null,null],"azure_ai/Phi-3-medium-128k-instruct":[1.7e-7,6.8e-7,null,null,null],"Phi-3-medium-128k-instruct":[1.7e-7,6.8e-7,null,null,null],"azure_ai/Phi-3-medium-4k-instruct":[1.7e-7,6.8e-7,null,null,null],"Phi-3-medium-4k-instruct":[1.7e-7,6.8e-7,null,null,null],"azure_ai/Phi-3-mini-128k-instruct":[1.3e-7,5.2e-7,null,null,null],"Phi-3-mini-128k-instruct":[1.3e-7,5.2e-7,null,null,null],"azure_ai/Phi-3-mini-4k-instruct":[1.3e-7,5.2e-7,null,null,null],"Phi-3-mini-4k-instruct":[1.3e-7,5.2e-7,null,null,null],"azure_ai/Phi-3-small-128k-instruct":[1.5e-7,6e-7,null,null,null],"Phi-3-small-128k-instruct":[1.5e-7,6e-7,null,null,null],"azure_ai/Phi-3-small-8k-instruct":[1.5e-7,6e-7,null,null,null],"Phi-3-small-8k-instruct":[1.5e-7,6e-7,null,null,null],"azure_ai/Phi-3.5-MoE-instruct":[1.6e-7,6.4e-7,null,null,null],"Phi-3.5-MoE-instruct":[1.6e-7,6.4e-7,null,null,null],"azure_ai/Phi-3.5-mini-instruct":[1.3e-7,5.2e-7,null,null,null],"Phi-3.5-mini-instruct":[1.3e-7,5.2e-7,null,null,null],"azure_ai/Phi-3.5-vision-instruct":[1.3e-7,5.2e-7,null,null,null],"Phi-3.5-vision-instruct":[1.3e-7,5.2e-7,null,null,null],"azure_ai/Phi-4":[1.25e-7,5e-7,null,null,null],"Phi-4":[1.25e-7,5e-7,null,null,null],"azure_ai/Phi-4-mini-instruct":[7.5e-8,3e-7,null,null,null],"Phi-4-mini-instruct":[7.5e-8,3e-7,null,null,null],"azure_ai/Phi-4-multimodal-instruct":[8e-8,3.2e-7,null,null,null],"Phi-4-multimodal-instruct":[8e-8,3.2e-7,null,null,null],"azure_ai/Phi-4-mini-reasoning":[8e-8,3.2e-7,null,null,null],"Phi-4-mini-reasoning":[8e-8,3.2e-7,null,null,null],"azure_ai/Phi-4-reasoning":[1.25e-7,5e-7,null,null,null],"Phi-4-reasoning":[1.25e-7,5e-7,null,null,null],"azure_ai/MAI-DS-R1":[0.00000135,0.0000054,null,null,null],"MAI-DS-R1":[0.00000135,0.0000054,null,null,null],"azure_ai/cohere-rerank-v3-english":[0,0,null,null,null],"cohere-rerank-v3-english":[0,0,null,null,null],"azure_ai/cohere-rerank-v3-multilingual":[0,0,null,null,null],"cohere-rerank-v3-multilingual":[0,0,null,null,null],"azure_ai/cohere-rerank-v3.5":[0,0,null,null,null],"cohere-rerank-v3.5":[0,0,null,null,null],"azure_ai/cohere-rerank-v4.0-pro":[0,0,null,null,null],"cohere-rerank-v4.0-pro":[0,0,null,null,null],"azure_ai/cohere-rerank-v4.0-fast":[0,0,null,null,null],"cohere-rerank-v4.0-fast":[0,0,null,null,null],"azure_ai/deepseek-v3.2":[5.8e-7,0.00000168,null,null,null],"deepseek-v3.2":[5.8e-7,0.00000168,null,null,null],"azure_ai/deepseek-v3.2-speciale":[5.8e-7,0.00000168,null,null,null],"deepseek-v3.2-speciale":[5.8e-7,0.00000168,null,null,null],"azure_ai/deepseek-r1":[0.00000135,0.0000054,null,null,null],"deepseek-r1":[0.00000135,0.0000054,null,null,null],"azure_ai/deepseek-v3":[0.00000114,0.00000456,null,null,null],"deepseek-v3":[0.00000114,0.00000456,null,null,null],"azure_ai/deepseek-v3-0324":[0.00000114,0.00000456,null,null,null],"deepseek-v3-0324":[0.00000114,0.00000456,null,null,null],"azure_ai/deepseek-v3.1":[0.00000123,0.00000494,null,null,null],"deepseek-v3.1":[0.00000123,0.00000494,null,null,null],"azure_ai/deepseek-v4-pro":[0.00000174,0.00000348,null,null,null],"azure_ai/deepseek-v4-flash":[1.9e-7,5.1e-7,null,null,null],"azure_ai/embed-v-4-0":[1.2e-7,0,null,null,null],"embed-v-4-0":[1.2e-7,0,null,null,null],"azure_ai/global/grok-3":[0.000003,0.000015,null,null,null],"global/grok-3":[0.000003,0.000015,null,null,null],"azure_ai/global/grok-3-mini":[2.5e-7,0.00000127,null,null,null],"global/grok-3-mini":[2.5e-7,0.00000127,null,null,null],"azure_ai/grok-3":[0.000003,0.000015,null,null,null],"grok-3":[0.000003,0.000015,null,null,null],"azure_ai/grok-3-mini":[2.5e-7,0.00000127,null,null,null],"grok-3-mini":[2.5e-7,0.00000127,null,null,null],"azure_ai/grok-4":[0.000003,0.000015,null,null,null],"grok-4":[0.000003,0.000015,null,null,null],"azure_ai/grok-4-fast-non-reasoning":[2e-7,5e-7,null,null,null],"grok-4-fast-non-reasoning":[2e-7,5e-7,null,null,null],"azure_ai/grok-4-fast-reasoning":[2e-7,5e-7,null,null,null],"grok-4-fast-reasoning":[2e-7,5e-7,null,null,null],"azure_ai/grok-4-1-fast-non-reasoning":[2e-7,5e-7,null,null,null],"grok-4-1-fast-non-reasoning":[2e-7,5e-7,null,null,null],"azure_ai/grok-4-1-fast-reasoning":[2e-7,5e-7,null,null,null],"grok-4-1-fast-reasoning":[2e-7,5e-7,null,null,null],"azure_ai/grok-code-fast-1":[2e-7,0.0000015,null,null,null],"grok-code-fast-1":[2e-7,0.0000015,null,null,null],"azure_ai/jais-30b-chat":[0.0032,0.00971,null,null,null],"jais-30b-chat":[0.0032,0.00971,null,null,null],"azure_ai/jamba-instruct":[5e-7,7e-7,null,null,null],"jamba-instruct":[5e-7,7e-7,null,null,null],"azure_ai/kimi-k2.5":[6e-7,0.000003,null,null,null],"kimi-k2.5":[6e-7,0.000003,null,null,null],"azure_ai/kimi-k2.6":[9.5e-7,0.000004,null,null,null],"kimi-k2.6":[9.5e-7,0.000004,null,null,null],"azure_ai/ministral-3b":[4e-8,4e-8,null,null,null],"ministral-3b":[4e-8,4e-8,null,null,null],"azure_ai/mistral-large":[0.000004,0.000012,null,null,null],"mistral-large":[0.000004,0.000012,null,null,null],"azure_ai/mistral-large-2407":[0.000002,0.000006,null,null,null],"mistral-large-2407":[0.000002,0.000006,null,null,null],"azure_ai/mistral-large-latest":[0.000002,0.000006,null,null,null],"azure_ai/mistral-large-3":[5e-7,0.0000015,null,null,null],"mistral-large-3":[5e-7,0.0000015,null,null,null],"azure_ai/mistral-medium-2505":[4e-7,0.000002,null,null,null],"mistral-medium-2505":[4e-7,0.000002,null,null,null],"azure_ai/mistral-nemo":[1.5e-7,1.5e-7,null,null,null],"mistral-nemo":[1.5e-7,1.5e-7,null,null,null],"azure_ai/mistral-small":[0.000001,0.000003,null,null,null],"mistral-small":[0.000001,0.000003,null,null,null],"azure_ai/mistral-small-2503":[1e-7,3e-7,null,null,null],"mistral-small-2503":[1e-7,3e-7,null,null,null],"bedrock/ap-northeast-1/anthropic.claude-instant-v1":[0.00000223,0.00000755,null,null,null],"ap-northeast-1/anthropic.claude-instant-v1":[0.00000223,0.00000755,null,null,null],"bedrock/ap-northeast-1/anthropic.claude-v1":[0.000008,0.000024,null,null,null],"ap-northeast-1/anthropic.claude-v1":[0.000008,0.000024,null,null,null],"bedrock/ap-northeast-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null,null],"ap-northeast-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null,null],"bedrock/ap-northeast-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"ap-northeast-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"bedrock/ap-northeast-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"ap-northeast-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"bedrock/ap-northeast-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"ap-northeast-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"bedrock/ap-northeast-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null,null],"ap-northeast-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null,null],"bedrock/ap-northeast-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"ap-northeast-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"bedrock/ap-northeast-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"ap-northeast-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"bedrock/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null,null],"moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null,null],"bedrock/moonshotai.kimi-k2.5":[6e-7,0.00000303,null,null,null],"bedrock/ap-south-1/meta.llama3-70b-instruct-v1:0":[0.00000318,0.0000042,null,null,null],"ap-south-1/meta.llama3-70b-instruct-v1:0":[0.00000318,0.0000042,null,null,null],"bedrock/ap-south-1/meta.llama3-8b-instruct-v1:0":[3.6e-7,7.2e-7,null,null,null],"ap-south-1/meta.llama3-8b-instruct-v1:0":[3.6e-7,7.2e-7,null,null,null],"bedrock/ap-south-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"ap-south-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"bedrock/ap-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"ap-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"bedrock/ap-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"ap-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"bedrock/ap-south-1/moonshotai.kimi-k2-thinking":[7.1e-7,0.00000294,null,null,null],"ap-south-1/moonshotai.kimi-k2-thinking":[7.1e-7,0.00000294,null,null,null],"bedrock/ap-south-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"ap-south-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"bedrock/ap-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"ap-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"bedrock/ap-southeast-2/minimax.minimax-m2.5":[3.09e-7,0.000001236,null,null,null],"ap-southeast-2/minimax.minimax-m2.5":[3.09e-7,0.000001236,null,null,null],"bedrock/ap-southeast-3/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"ap-southeast-3/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"bedrock/ap-southeast-3/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"ap-southeast-3/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"bedrock/ap-southeast-3/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"ap-southeast-3/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"bedrock/ap-southeast-3/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"ap-southeast-3/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"bedrock/ap-southeast-3/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"ap-southeast-3/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"bedrock/ca-central-1/meta.llama3-70b-instruct-v1:0":[0.00000305,0.00000403,null,null,null],"ca-central-1/meta.llama3-70b-instruct-v1:0":[0.00000305,0.00000403,null,null,null],"bedrock/ca-central-1/meta.llama3-8b-instruct-v1:0":[3.5e-7,6.9e-7,null,null,null],"ca-central-1/meta.llama3-8b-instruct-v1:0":[3.5e-7,6.9e-7,null,null,null],"bedrock/eu-north-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"eu-north-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"bedrock/eu-north-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"eu-north-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"bedrock/eu-north-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"eu-north-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"bedrock/eu-north-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"eu-north-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"bedrock/eu-central-1/anthropic.claude-instant-v1":[0.00000248,0.00000838,null,null,null],"eu-central-1/anthropic.claude-instant-v1":[0.00000248,0.00000838,null,null,null],"bedrock/eu-central-1/anthropic.claude-v1":[0.000008,0.000024,null,null,null],"eu-central-1/anthropic.claude-v1":[0.000008,0.000024,null,null,null],"bedrock/eu-central-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null,null],"eu-central-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null,null],"bedrock/eu-central-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"eu-central-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"bedrock/eu-central-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"eu-central-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"bedrock/eu-central-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"eu-central-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"bedrock/eu-west-1/meta.llama3-70b-instruct-v1:0":[0.00000286,0.00000378,null,null,null],"eu-west-1/meta.llama3-70b-instruct-v1:0":[0.00000286,0.00000378,null,null,null],"bedrock/eu-west-1/meta.llama3-8b-instruct-v1:0":[3.2e-7,6.5e-7,null,null,null],"eu-west-1/meta.llama3-8b-instruct-v1:0":[3.2e-7,6.5e-7,null,null,null],"bedrock/eu-west-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"eu-west-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"bedrock/eu-west-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"eu-west-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"bedrock/eu-west-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"eu-west-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"bedrock/eu-west-2/meta.llama3-70b-instruct-v1:0":[0.00000345,0.00000455,null,null,null],"eu-west-2/meta.llama3-70b-instruct-v1:0":[0.00000345,0.00000455,null,null,null],"bedrock/eu-west-2/meta.llama3-8b-instruct-v1:0":[3.9e-7,7.8e-7,null,null,null],"eu-west-2/meta.llama3-8b-instruct-v1:0":[3.9e-7,7.8e-7,null,null,null],"bedrock/eu-west-2/minimax.minimax-m2.1":[4.7e-7,0.00000186,null,null,null],"eu-west-2/minimax.minimax-m2.1":[4.7e-7,0.00000186,null,null,null],"bedrock/eu-west-2/minimax.minimax-m2.5":[4.7e-7,0.00000186,null,null,null],"eu-west-2/minimax.minimax-m2.5":[4.7e-7,0.00000186,null,null,null],"bedrock/eu-west-2/qwen.qwen3-coder-next":[7.8e-7,0.00000186,null,null,null],"eu-west-2/qwen.qwen3-coder-next":[7.8e-7,0.00000186,null,null,null],"bedrock/eu-west-3/mistral.mistral-7b-instruct-v0:2":[2e-7,2.6e-7,null,null,null],"eu-west-3/mistral.mistral-7b-instruct-v0:2":[2e-7,2.6e-7,null,null,null],"bedrock/eu-west-3/mistral.mistral-large-2402-v1:0":[0.0000104,0.0000312,null,null,null],"eu-west-3/mistral.mistral-large-2402-v1:0":[0.0000104,0.0000312,null,null,null],"bedrock/eu-west-3/mistral.mixtral-8x7b-instruct-v0:1":[5.9e-7,9.1e-7,null,null,null],"eu-west-3/mistral.mixtral-8x7b-instruct-v0:1":[5.9e-7,9.1e-7,null,null,null],"bedrock/eu-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"eu-south-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"bedrock/eu-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"eu-south-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"bedrock/eu-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"eu-south-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"invoke/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.000003,0.000015,0.00000375,3e-7,null],"bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0":[0.00000445,0.00000588,null,null,null],"sa-east-1/meta.llama3-70b-instruct-v1:0":[0.00000445,0.00000588,null,null,null],"bedrock/sa-east-1/meta.llama3-8b-instruct-v1:0":[5e-7,0.00000101,null,null,null],"sa-east-1/meta.llama3-8b-instruct-v1:0":[5e-7,0.00000101,null,null,null],"bedrock/sa-east-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"sa-east-1/deepseek.v3.2":[7.4e-7,0.00000222,null,null,null],"bedrock/sa-east-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"sa-east-1/minimax.minimax-m2.1":[3.6e-7,0.00000144,null,null,null],"bedrock/sa-east-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"sa-east-1/minimax.minimax-m2.5":[3.6e-7,0.00000144,null,null,null],"bedrock/sa-east-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null,null],"sa-east-1/moonshotai.kimi-k2-thinking":[7.3e-7,0.00000303,null,null,null],"bedrock/sa-east-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"sa-east-1/moonshotai.kimi-k2.5":[7.2e-7,0.0000036,null,null,null],"bedrock/sa-east-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"sa-east-1/qwen.qwen3-coder-next":[6e-7,0.00000144,null,null,null],"bedrock/us-east-1/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null,null],"us-east-1/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null,null],"bedrock/us-east-1/anthropic.claude-v1":[0.000008,0.000024,null,null,null],"us-east-1/anthropic.claude-v1":[0.000008,0.000024,null,null,null],"bedrock/us-east-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null,null],"us-east-1/anthropic.claude-v2:1":[0.000008,0.000024,null,null,null],"bedrock/us-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null,null],"us-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null,null],"bedrock/us-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null,null],"us-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null,null],"bedrock/us-east-1/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null,null],"us-east-1/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null,null],"bedrock/us-east-1/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null,null],"us-east-1/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null,null],"bedrock/us-east-1/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null,null],"us-east-1/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null,null],"bedrock/us-east-1/deepseek.v3.2":[6.2e-7,0.00000185,null,null,null],"us-east-1/deepseek.v3.2":[6.2e-7,0.00000185,null,null,null],"bedrock/us-east-1/minimax.minimax-m2.1":[3e-7,0.0000012,null,null,null],"us-east-1/minimax.minimax-m2.1":[3e-7,0.0000012,null,null,null],"bedrock/us-east-1/minimax.minimax-m2.5":[3e-7,0.0000012,null,null,null],"us-east-1/minimax.minimax-m2.5":[3e-7,0.0000012,null,null,null],"bedrock/us-east-1/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"us-east-1/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"bedrock/us-east-1/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null,null],"us-east-1/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null,null],"bedrock/us-east-1/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null,null],"us-east-1/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null,null],"bedrock/us-east-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null,null],"us-east-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null,null],"bedrock/us-east-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null,null],"us-east-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null,null],"bedrock/us-east-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null,null],"us-east-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null,null],"bedrock/us-east-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"us-east-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"bedrock/us-east-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null,null],"us-east-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null,null],"bedrock/us-east-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null,null],"us-east-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null,null],"bedrock/us-gov-east-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null,null],"us-gov-east-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null,null],"bedrock/us-gov-east-1/amazon.titan-embed-text-v1":[1e-7,0,null,null,null],"us-gov-east-1/amazon.titan-embed-text-v1":[1e-7,0,null,null,null],"bedrock/us-gov-east-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null,null],"us-gov-east-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null,null],"bedrock/us-gov-east-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null,null],"us-gov-east-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null,null],"bedrock/us-gov-east-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null,null],"us-gov-east-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null,null],"bedrock/us-gov-east-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null,null],"us-gov-east-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null,null],"bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8,null],"us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8,null],"bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"us-gov-east-1/claude-sonnet-4-5-20250929-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null,null],"us-gov-east-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null,null],"bedrock/us-gov-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null,null],"us-gov-east-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null,null],"bedrock/us-gov-west-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null,null],"us-gov-west-1/amazon.nova-pro-v1:0":[9.6e-7,0.00000384,null,null,null],"bedrock/us-gov-west-1/amazon.titan-embed-text-v1":[1e-7,0,null,null,null],"us-gov-west-1/amazon.titan-embed-text-v1":[1e-7,0,null,null,null],"bedrock/us-gov-west-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null,null],"us-gov-west-1/amazon.titan-embed-text-v2:0":[2e-7,0,null,null,null],"bedrock/us-gov-west-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null,null],"us-gov-west-1/amazon.titan-text-express-v1":[0.0000013,0.0000017,null,null,null],"bedrock/us-gov-west-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null,null],"us-gov-west-1/amazon.titan-text-lite-v1":[3e-7,4e-7,null,null,null],"bedrock/us-gov-west-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null,null],"us-gov-west-1/amazon.titan-text-premier-v1:0":[5e-7,0.0000015,null,null,null],"bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8,null],"us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0":[3e-7,0.0000015,3.75e-7,3e-8,null],"bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"us-gov-west-1/claude-sonnet-4-5-20250929-v1:0":[0.0000036,0.000018,0.0000045,3.6e-7,null],"bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null,null],"us-gov-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null,null],"bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null,null],"us-gov-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,0.00000265,null,null,null],"bedrock/us-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null,null],"us-west-1/meta.llama3-70b-instruct-v1:0":[0.00000265,0.0000035,null,null,null],"bedrock/us-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null,null],"us-west-1/meta.llama3-8b-instruct-v1:0":[3e-7,6e-7,null,null,null],"bedrock/us-west-2/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null,null],"us-west-2/anthropic.claude-instant-v1":[8e-7,0.0000024,null,null,null],"bedrock/us-west-2/anthropic.claude-v1":[0.000008,0.000024,null,null,null],"us-west-2/anthropic.claude-v1":[0.000008,0.000024,null,null,null],"bedrock/us-west-2/anthropic.claude-v2:1":[0.000008,0.000024,null,null,null],"us-west-2/anthropic.claude-v2:1":[0.000008,0.000024,null,null,null],"bedrock/us-west-2/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null,null],"us-west-2/mistral.mistral-7b-instruct-v0:2":[1.5e-7,2e-7,null,null,null],"bedrock/us-west-2/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null,null],"us-west-2/mistral.mistral-large-2402-v1:0":[0.000008,0.000024,null,null,null],"bedrock/us-west-2/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null,null],"us-west-2/mistral.mixtral-8x7b-instruct-v0:1":[4.5e-7,7e-7,null,null,null],"bedrock/us-west-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null,null],"us-west-2/deepseek.v3.2":[6.2e-7,0.00000185,null,null,null],"bedrock/us-west-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null,null],"us-west-2/minimax.minimax-m2.1":[3e-7,0.0000012,null,null,null],"bedrock/us-west-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null,null],"us-west-2/minimax.minimax-m2.5":[3e-7,0.0000012,null,null,null],"bedrock/us-west-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"us-west-2/moonshotai.kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"bedrock/us-west-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null,null],"us-west-2/moonshotai.kimi-k2.5":[6e-7,0.000003,null,null,null],"bedrock/us-west-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null,null],"us-west-2/qwen.qwen3-coder-next":[5e-7,0.0000012,null,null,null],"bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0":[8e-7,0.000004,0.000001,8e-8,null],"cerebras/llama-3.3-70b":[8.5e-7,0.0000012,null,null,null],"llama-3.3-70b":[8.5e-7,0.0000012,null,null,null],"cerebras/llama3.1-70b":[6e-7,6e-7,null,null,null],"llama3.1-70b":[6e-7,6e-7,null,null,null],"cerebras/llama3.1-8b":[1e-7,1e-7,null,null,null],"llama3.1-8b":[1e-7,1e-7,null,null,null],"cerebras/gpt-oss-120b":[3.5e-7,7.5e-7,null,null,null],"cerebras/qwen-3-32b":[4e-7,8e-7,null,null,null],"qwen-3-32b":[4e-7,8e-7,null,null,null],"cerebras/zai-glm-4.6":[0.00000225,0.00000275,null,null,null],"zai-glm-4.6":[0.00000225,0.00000275,null,null,null],"cerebras/zai-glm-4.7":[0.00000225,0.00000275,null,null,null],"zai-glm-4.7":[0.00000225,0.00000275,null,null,null],"cloudflare/@cf/meta/llama-2-7b-chat-fp16":[0.000001923,0.000001923,null,null,null],"@cf/meta/llama-2-7b-chat-fp16":[0.000001923,0.000001923,null,null,null],"cloudflare/@cf/meta/llama-2-7b-chat-int8":[0.000001923,0.000001923,null,null,null],"@cf/meta/llama-2-7b-chat-int8":[0.000001923,0.000001923,null,null,null],"cloudflare/@cf/mistral/mistral-7b-instruct-v0.1":[0.000001923,0.000001923,null,null,null],"@cf/mistral/mistral-7b-instruct-v0.1":[0.000001923,0.000001923,null,null,null],"cloudflare/@hf/thebloke/codellama-7b-instruct-awq":[0.000001923,0.000001923,null,null,null],"@hf/thebloke/codellama-7b-instruct-awq":[0.000001923,0.000001923,null,null,null],"cloudflare/@cf/openai/gpt-oss-120b":[3.5e-7,7.5e-7,null,null,null],"@cf/openai/gpt-oss-120b":[3.5e-7,7.5e-7,null,null,null],"cloudflare/@cf/google/gemma-2b-it-lora":[0,0,null,null,null],"@cf/google/gemma-2b-it-lora":[0,0,null,null,null],"cloudflare/@cf/meta/llama-3.2-3b-instruct":[5.09e-8,3.35e-7,null,null,null],"@cf/meta/llama-3.2-3b-instruct":[5.09e-8,3.35e-7,null,null,null],"cloudflare/@cf/meta/llama-guard-3-8b":[4.84e-7,3e-8,null,null,null],"@cf/meta/llama-guard-3-8b":[4.84e-7,3e-8,null,null,null],"cloudflare/@cf/mistral/mistral-7b-instruct-v0.2-lora":[0,0,null,null,null],"@cf/mistral/mistral-7b-instruct-v0.2-lora":[0,0,null,null,null],"cloudflare/@cf/moonshotai/kimi-k2.7-code":[9.5e-7,0.000004,null,1.9e-7,null],"@cf/moonshotai/kimi-k2.7-code":[9.5e-7,0.000004,null,1.9e-7,null],"cloudflare/@cf/deepseek-ai/deepseek-r1-distill-qwen-32b":[4.97e-7,0.000004881,null,null,null],"@cf/deepseek-ai/deepseek-r1-distill-qwen-32b":[4.97e-7,0.000004881,null,null,null],"cloudflare/@cf/meta/llama-3.1-8b-instruct-fp8":[1.52e-7,2.87e-7,null,null,null],"@cf/meta/llama-3.1-8b-instruct-fp8":[1.52e-7,2.87e-7,null,null,null],"cloudflare/@cf/meta/llama-3.2-1b-instruct":[2.7e-8,2.01e-7,null,null,null],"@cf/meta/llama-3.2-1b-instruct":[2.7e-8,2.01e-7,null,null,null],"cloudflare/@cf/moonshotai/kimi-k2.6":[9.5e-7,0.000004,null,1.6e-7,null],"@cf/moonshotai/kimi-k2.6":[9.5e-7,0.000004,null,1.6e-7,null],"cloudflare/@cf/zai-org/glm-4.7-flash":[6.05e-8,4e-7,null,null,null],"@cf/zai-org/glm-4.7-flash":[6.05e-8,4e-7,null,null,null],"cloudflare/@cf/meta-llama/llama-2-7b-chat-hf-lora":[0,0,null,null,null],"@cf/meta-llama/llama-2-7b-chat-hf-lora":[0,0,null,null,null],"cloudflare/@cf/meta/llama-3.3-70b-instruct-fp8-fast":[2.93e-7,0.000002253,null,null,null],"@cf/meta/llama-3.3-70b-instruct-fp8-fast":[2.93e-7,0.000002253,null,null,null],"cloudflare/@cf/ibm-granite/granite-4.0-h-micro":[1.7e-8,1.12e-7,null,null,null],"@cf/ibm-granite/granite-4.0-h-micro":[1.7e-8,1.12e-7,null,null,null],"cloudflare/@cf/qwen/qwen2.5-coder-32b-instruct":[6.6e-7,0.000001,null,null,null],"@cf/qwen/qwen2.5-coder-32b-instruct":[6.6e-7,0.000001,null,null,null],"cloudflare/@cf/zai-org/glm-5.2":[0.0000014,0.0000044,null,2.6e-7,null],"@cf/zai-org/glm-5.2":[0.0000014,0.0000044,null,2.6e-7,null],"cloudflare/@cf/nvidia/nemotron-3-120b-a12b":[5e-7,0.0000015,null,null,null],"@cf/nvidia/nemotron-3-120b-a12b":[5e-7,0.0000015,null,null,null],"cloudflare/@cf/aisingapore/gemma-sea-lion-v4-27b-it":[3.51e-7,5.55e-7,null,null,null],"@cf/aisingapore/gemma-sea-lion-v4-27b-it":[3.51e-7,5.55e-7,null,null,null],"cloudflare/@cf/qwen/qwen3-30b-a3b-fp8":[5.09e-8,3.35e-7,null,null,null],"@cf/qwen/qwen3-30b-a3b-fp8":[5.09e-8,3.35e-7,null,null,null],"cloudflare/@cf/google/gemma-7b-it-lora":[0,0,null,null,null],"@cf/google/gemma-7b-it-lora":[0,0,null,null,null],"cloudflare/@cf/google/gemma-4-26b-a4b-it":[1e-7,3e-7,null,null,null],"@cf/google/gemma-4-26b-a4b-it":[1e-7,3e-7,null,null,null],"cloudflare/@cf/mistralai/mistral-small-3.1-24b-instruct":[3.51e-7,5.55e-7,null,null,null],"@cf/mistralai/mistral-small-3.1-24b-instruct":[3.51e-7,5.55e-7,null,null,null],"cloudflare/@cf/meta/llama-3.2-11b-vision-instruct":[4.85e-8,6.76e-7,null,null,null],"@cf/meta/llama-3.2-11b-vision-instruct":[4.85e-8,6.76e-7,null,null,null],"cloudflare/@cf/openai/gpt-oss-20b":[2e-7,3e-7,null,null,null],"@cf/openai/gpt-oss-20b":[2e-7,3e-7,null,null,null],"cloudflare/@cf/meta/llama-4-scout-17b-16e-instruct":[2.7e-7,8.5e-7,null,null,null],"@cf/meta/llama-4-scout-17b-16e-instruct":[2.7e-7,8.5e-7,null,null,null],"cloudflare/@cf/qwen/qwq-32b":[6.6e-7,0.000001,null,null,null],"@cf/qwen/qwq-32b":[6.6e-7,0.000001,null,null,null],"codestral/codestral-2405":[0,0,null,null,null],"codestral-2405":[0,0,null,null,null],"codestral/codestral-latest":[0,0,null,null,null],"codestral-latest":[0,0,null,null,null],"cohere/embed-v4.0":[1.2e-7,0,null,null,null],"embed-v4.0":[1.2e-7,0,null,null,null],"dashscope/qwen-coder":[3e-7,0.0000015,null,null,null],"qwen-coder":[3e-7,0.0000015,null,null,null],"dashscope/qwen-max":[0.0000016,0.0000064,null,null,null],"qwen-max":[0.0000016,0.0000064,null,null,null],"dashscope/qwen-plus":[4e-7,0.0000012,null,null,null],"qwen-plus":[4e-7,0.0000012,null,null,null],"dashscope/qwen-plus-2025-01-25":[4e-7,0.0000012,null,null,null],"qwen-plus-2025-01-25":[4e-7,0.0000012,null,null,null],"dashscope/qwen-plus-2025-04-28":[4e-7,0.0000012,null,null,null],"qwen-plus-2025-04-28":[4e-7,0.0000012,null,null,null],"dashscope/qwen-plus-2025-07-14":[4e-7,0.0000012,null,null,null],"qwen-plus-2025-07-14":[4e-7,0.0000012,null,null,null],"dashscope/qwen-turbo":[5e-8,2e-7,null,null,null],"qwen-turbo":[5e-8,2e-7,null,null,null],"dashscope/qwen-turbo-2024-11-01":[5e-8,2e-7,null,null,null],"qwen-turbo-2024-11-01":[5e-8,2e-7,null,null,null],"dashscope/qwen-turbo-2025-04-28":[5e-8,2e-7,null,null,null],"qwen-turbo-2025-04-28":[5e-8,2e-7,null,null,null],"dashscope/qwen-turbo-latest":[5e-8,2e-7,null,null,null],"qwen-turbo-latest":[5e-8,2e-7,null,null,null],"dashscope/qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000012,null,null,null],"qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000012,null,null,null],"dashscope/qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000012,null,null,null],"qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000012,null,null,null],"dashscope/qwen3-vl-235b-a22b-instruct":[4e-7,0.0000016,null,null,null],"qwen3-vl-235b-a22b-instruct":[4e-7,0.0000016,null,null,null],"dashscope/qwen3-vl-235b-a22b-thinking":[4e-7,0.000004,null,null,null],"qwen3-vl-235b-a22b-thinking":[4e-7,0.000004,null,null,null],"dashscope/qwen3-vl-32b-instruct":[1.6e-7,6.4e-7,null,null,null],"qwen3-vl-32b-instruct":[1.6e-7,6.4e-7,null,null,null],"dashscope/qwen3-vl-32b-thinking":[1.6e-7,0.00000287,null,null,null],"qwen3-vl-32b-thinking":[1.6e-7,0.00000287,null,null,null],"dashscope/qwq-plus":[8e-7,0.0000024,null,null,null],"qwq-plus":[8e-7,0.0000024,null,null,null],"databricks/databricks-bge-large-en":[1.0003e-7,0,null,null,null],"databricks-bge-large-en":[1.0003e-7,0,null,null,null],"databricks/databricks-claude-3-7-sonnet":[0.0000029999900000000002,0.000015000020000000002,null,null,null],"databricks-claude-3-7-sonnet":[0.0000029999900000000002,0.000015000020000000002,null,null,null],"databricks/databricks-claude-haiku-4-5":[0.00000100002,0.00000500003,null,null,null],"databricks-claude-haiku-4-5":[0.00000100002,0.00000500003,null,null,null],"databricks/databricks-claude-opus-4":[0.000015000020000000002,0.00007500003000000001,null,null,null],"databricks-claude-opus-4":[0.000015000020000000002,0.00007500003000000001,null,null,null],"databricks/databricks-claude-opus-4-1":[0.000015000020000000002,0.00007500003000000001,null,null,null],"databricks-claude-opus-4-1":[0.000015000020000000002,0.00007500003000000001,null,null,null],"databricks/databricks-claude-opus-4-5":[0.00000500003,0.000025000010000000002,null,null,null],"databricks-claude-opus-4-5":[0.00000500003,0.000025000010000000002,null,null,null],"databricks/databricks-claude-sonnet-4":[0.0000029999900000000002,0.000015000020000000002,null,null,null],"databricks-claude-sonnet-4":[0.0000029999900000000002,0.000015000020000000002,null,null,null],"databricks/databricks-claude-sonnet-4-1":[0.0000029999900000000002,0.000015000020000000002,null,null,null],"databricks-claude-sonnet-4-1":[0.0000029999900000000002,0.000015000020000000002,null,null,null],"databricks/databricks-claude-sonnet-4-5":[0.0000029999900000000002,0.000015000020000000002,null,null,null],"databricks-claude-sonnet-4-5":[0.0000029999900000000002,0.000015000020000000002,null,null,null],"databricks/databricks-gemini-2-5-flash":[3.0001999999999996e-7,0.00000249998,null,null,null],"databricks-gemini-2-5-flash":[3.0001999999999996e-7,0.00000249998,null,null,null],"databricks/databricks-gemini-2-5-pro":[0.00000124999,0.000009999990000000002,null,null,null],"databricks-gemini-2-5-pro":[0.00000124999,0.000009999990000000002,null,null,null],"databricks/databricks-gemma-3-12b":[1.5000999999999998e-7,5.0001e-7,null,null,null],"databricks-gemma-3-12b":[1.5000999999999998e-7,5.0001e-7,null,null,null],"databricks/databricks-gpt-5":[0.00000124999,0.000009999990000000002,null,null,null],"databricks-gpt-5":[0.00000124999,0.000009999990000000002,null,null,null],"databricks/databricks-gpt-5-1":[0.00000124999,0.000009999990000000002,null,null,null],"databricks-gpt-5-1":[0.00000124999,0.000009999990000000002,null,null,null],"databricks/databricks-gpt-5-mini":[2.4997000000000006e-7,0.0000019999700000000004,null,null,null],"databricks-gpt-5-mini":[2.4997000000000006e-7,0.0000019999700000000004,null,null,null],"databricks/databricks-gpt-5-nano":[4.998e-8,3.9998000000000007e-7,null,null,null],"databricks-gpt-5-nano":[4.998e-8,3.9998000000000007e-7,null,null,null],"databricks/databricks-gpt-oss-120b":[1.5000999999999998e-7,5.9997e-7,null,null,null],"databricks-gpt-oss-120b":[1.5000999999999998e-7,5.9997e-7,null,null,null],"databricks/databricks-gpt-oss-20b":[7e-8,3.0001999999999996e-7,null,null,null],"databricks-gpt-oss-20b":[7e-8,3.0001999999999996e-7,null,null,null],"databricks/databricks-gte-large-en":[1.2999000000000001e-7,0,null,null,null],"databricks-gte-large-en":[1.2999000000000001e-7,0,null,null,null],"databricks/databricks-llama-2-70b-chat":[5.0001e-7,0.0000015000300000000002,null,null,null],"databricks-llama-2-70b-chat":[5.0001e-7,0.0000015000300000000002,null,null,null],"databricks/databricks-llama-4-maverick":[5.0001e-7,0.0000015000300000000002,null,null,null],"databricks-llama-4-maverick":[5.0001e-7,0.0000015000300000000002,null,null,null],"databricks/databricks-meta-llama-3-1-405b-instruct":[0.00000500003,0.000015000020000000002,null,null,null],"databricks-meta-llama-3-1-405b-instruct":[0.00000500003,0.000015000020000000002,null,null,null],"databricks/databricks-meta-llama-3-1-8b-instruct":[1.5000999999999998e-7,4.5003000000000007e-7,null,null,null],"databricks-meta-llama-3-1-8b-instruct":[1.5000999999999998e-7,4.5003000000000007e-7,null,null,null],"databricks/databricks-meta-llama-3-3-70b-instruct":[5.0001e-7,0.0000015000300000000002,null,null,null],"databricks-meta-llama-3-3-70b-instruct":[5.0001e-7,0.0000015000300000000002,null,null,null],"databricks/databricks-meta-llama-3-70b-instruct":[0.00000100002,0.0000029999900000000002,null,null,null],"databricks-meta-llama-3-70b-instruct":[0.00000100002,0.0000029999900000000002,null,null,null],"databricks/databricks-mixtral-8x7b-instruct":[5.0001e-7,0.00000100002,null,null,null],"databricks-mixtral-8x7b-instruct":[5.0001e-7,0.00000100002,null,null,null],"databricks/databricks-mpt-30b-instruct":[0.00000100002,0.00000100002,null,null,null],"databricks-mpt-30b-instruct":[0.00000100002,0.00000100002,null,null,null],"databricks/databricks-mpt-7b-instruct":[5.0001e-7,0,null,null,null],"databricks-mpt-7b-instruct":[5.0001e-7,0,null,null,null],"deepinfra/Gryphe/MythoMax-L2-13b":[8e-8,9e-8,null,null,null],"Gryphe/MythoMax-L2-13b":[8e-8,9e-8,null,null,null],"deepinfra/NousResearch/Hermes-3-Llama-3.1-405B":[0.000001,0.000001,null,null,null],"NousResearch/Hermes-3-Llama-3.1-405B":[0.000001,0.000001,null,null,null],"deepinfra/NousResearch/Hermes-3-Llama-3.1-70B":[3e-7,3e-7,null,null,null],"NousResearch/Hermes-3-Llama-3.1-70B":[3e-7,3e-7,null,null,null],"deepinfra/Qwen/QwQ-32B":[1.5e-7,4e-7,null,null,null],"Qwen/QwQ-32B":[1.5e-7,4e-7,null,null,null],"deepinfra/Qwen/Qwen2.5-72B-Instruct":[1.2e-7,3.9e-7,null,null,null],"Qwen/Qwen2.5-72B-Instruct":[1.2e-7,3.9e-7,null,null,null],"deepinfra/Qwen/Qwen2.5-7B-Instruct":[4e-8,1e-7,null,null,null],"Qwen/Qwen2.5-7B-Instruct":[4e-8,1e-7,null,null,null],"deepinfra/Qwen/Qwen2.5-VL-32B-Instruct":[2e-7,6e-7,null,null,null],"Qwen/Qwen2.5-VL-32B-Instruct":[2e-7,6e-7,null,null,null],"deepinfra/Qwen/Qwen3-14B":[6e-8,2.4e-7,null,null,null],"Qwen/Qwen3-14B":[6e-8,2.4e-7,null,null,null],"deepinfra/Qwen/Qwen3-235B-A22B":[1.8e-7,5.4e-7,null,null,null],"Qwen/Qwen3-235B-A22B":[1.8e-7,5.4e-7,null,null,null],"deepinfra/Qwen/Qwen3-235B-A22B-Instruct-2507":[9e-8,6e-7,null,null,null],"Qwen/Qwen3-235B-A22B-Instruct-2507":[9e-8,6e-7,null,null,null],"deepinfra/Qwen/Qwen3-235B-A22B-Thinking-2507":[3e-7,0.0000029,null,null,null],"Qwen/Qwen3-235B-A22B-Thinking-2507":[3e-7,0.0000029,null,null,null],"deepinfra/Qwen/Qwen3-30B-A3B":[8e-8,2.9e-7,null,null,null],"Qwen/Qwen3-30B-A3B":[8e-8,2.9e-7,null,null,null],"deepinfra/Qwen/Qwen3-32B":[1e-7,2.8e-7,null,null,null],"Qwen/Qwen3-32B":[1e-7,2.8e-7,null,null,null],"deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct":[4e-7,0.0000016,null,null,null],"Qwen/Qwen3-Coder-480B-A35B-Instruct":[4e-7,0.0000016,null,null,null],"deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo":[2.9e-7,0.0000012,null,null,null],"Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo":[2.9e-7,0.0000012,null,null,null],"deepinfra/Qwen/Qwen3-Next-80B-A3B-Instruct":[1.4e-7,0.0000014,null,null,null],"Qwen/Qwen3-Next-80B-A3B-Instruct":[1.4e-7,0.0000014,null,null,null],"deepinfra/Qwen/Qwen3-Next-80B-A3B-Thinking":[1.4e-7,0.0000014,null,null,null],"Qwen/Qwen3-Next-80B-A3B-Thinking":[1.4e-7,0.0000014,null,null,null],"deepinfra/Sao10K/L3-8B-Lunaris-v1-Turbo":[4e-8,5e-8,null,null,null],"Sao10K/L3-8B-Lunaris-v1-Turbo":[4e-8,5e-8,null,null,null],"deepinfra/Sao10K/L3.1-70B-Euryale-v2.2":[6.5e-7,7.5e-7,null,null,null],"Sao10K/L3.1-70B-Euryale-v2.2":[6.5e-7,7.5e-7,null,null,null],"deepinfra/Sao10K/L3.3-70B-Euryale-v2.3":[6.5e-7,7.5e-7,null,null,null],"Sao10K/L3.3-70B-Euryale-v2.3":[6.5e-7,7.5e-7,null,null,null],"deepinfra/allenai/olmOCR-7B-0725-FP8":[2.7e-7,0.0000015,null,null,null],"allenai/olmOCR-7B-0725-FP8":[2.7e-7,0.0000015,null,null,null],"deepinfra/anthropic/claude-3-7-sonnet-latest":[0.0000033,0.0000165,null,3.3e-7,null],"anthropic/claude-3-7-sonnet-latest":[0.0000033,0.0000165,null,3.3e-7,null],"deepinfra/anthropic/claude-4-opus":[0.0000165,0.0000825,null,null,null],"anthropic/claude-4-opus":[0.0000165,0.0000825,null,null,null],"deepinfra/anthropic/claude-4-sonnet":[0.0000033,0.0000165,null,null,null],"anthropic/claude-4-sonnet":[0.0000033,0.0000165,null,null,null],"deepinfra/deepseek-ai/DeepSeek-R1":[7e-7,0.0000024,null,null,null],"deepseek-ai/DeepSeek-R1":[7e-7,0.0000024,null,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-0528":[5e-7,0.00000215,null,4e-7,null],"deepseek-ai/DeepSeek-R1-0528":[5e-7,0.00000215,null,4e-7,null],"deepinfra/deepseek-ai/DeepSeek-R1-0528-Turbo":[0.000001,0.000003,null,null,null],"deepseek-ai/DeepSeek-R1-0528-Turbo":[0.000001,0.000003,null,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[2e-7,6e-7,null,null,null],"deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[2e-7,6e-7,null,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":[2.7e-7,2.7e-7,null,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":[2.7e-7,2.7e-7,null,null,null],"deepinfra/deepseek-ai/DeepSeek-R1-Turbo":[0.000001,0.000003,null,null,null],"deepseek-ai/DeepSeek-R1-Turbo":[0.000001,0.000003,null,null,null],"deepinfra/deepseek-ai/DeepSeek-V3":[3.8e-7,8.9e-7,null,null,null],"deepseek-ai/DeepSeek-V3":[3.8e-7,8.9e-7,null,null,null],"deepinfra/deepseek-ai/DeepSeek-V3-0324":[2.5e-7,8.8e-7,null,null,null],"deepseek-ai/DeepSeek-V3-0324":[2.5e-7,8.8e-7,null,null,null],"deepinfra/deepseek-ai/DeepSeek-V3.1":[2.7e-7,0.000001,null,2.16e-7,null],"deepseek-ai/DeepSeek-V3.1":[2.7e-7,0.000001,null,2.16e-7,null],"deepinfra/deepseek-ai/DeepSeek-V3.1-Terminus":[2.7e-7,0.000001,null,2.16e-7,null],"deepseek-ai/DeepSeek-V3.1-Terminus":[2.7e-7,0.000001,null,2.16e-7,null],"deepinfra/google/gemini-2.0-flash-001":[1e-7,4e-7,null,null,null],"google/gemini-2.0-flash-001":[1e-7,4e-7,null,null,null],"deepinfra/google/gemini-2.5-flash":[3e-7,0.0000025,null,null,null],"google/gemini-2.5-flash":[3e-7,0.0000025,null,null,null],"deepinfra/google/gemini-2.5-pro":[0.00000125,0.00001,null,null,null],"google/gemini-2.5-pro":[0.00000125,0.00001,null,null,null],"deepinfra/google/gemma-3-12b-it":[5e-8,1e-7,null,null,null],"google/gemma-3-12b-it":[5e-8,1e-7,null,null,null],"deepinfra/google/gemma-3-27b-it":[9e-8,1.6e-7,null,null,null],"google/gemma-3-27b-it":[9e-8,1.6e-7,null,null,null],"deepinfra/google/gemma-3-4b-it":[4e-8,8e-8,null,null,null],"google/gemma-3-4b-it":[4e-8,8e-8,null,null,null],"deepinfra/meta-llama/Llama-3.2-11B-Vision-Instruct":[4.9e-8,4.9e-8,null,null,null],"meta-llama/Llama-3.2-11B-Vision-Instruct":[4.9e-8,4.9e-8,null,null,null],"deepinfra/meta-llama/Llama-3.2-3B-Instruct":[2e-8,2e-8,null,null,null],"meta-llama/Llama-3.2-3B-Instruct":[2e-8,2e-8,null,null,null],"deepinfra/meta-llama/Llama-3.3-70B-Instruct":[2.3e-7,4e-7,null,null,null],"meta-llama/Llama-3.3-70B-Instruct":[2.3e-7,4e-7,null,null,null],"deepinfra/meta-llama/Llama-3.3-70B-Instruct-Turbo":[1.3e-7,3.9e-7,null,null,null],"meta-llama/Llama-3.3-70B-Instruct-Turbo":[1.3e-7,3.9e-7,null,null,null],"deepinfra/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":[1.5e-7,6e-7,null,null,null],"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":[1.5e-7,6e-7,null,null,null],"deepinfra/meta-llama/Llama-4-Scout-17B-16E-Instruct":[8e-8,3e-7,null,null,null],"meta-llama/Llama-4-Scout-17B-16E-Instruct":[8e-8,3e-7,null,null,null],"deepinfra/meta-llama/Llama-Guard-3-8B":[5.5e-8,5.5e-8,null,null,null],"meta-llama/Llama-Guard-3-8B":[5.5e-8,5.5e-8,null,null,null],"deepinfra/meta-llama/Llama-Guard-4-12B":[1.8e-7,1.8e-7,null,null,null],"meta-llama/Llama-Guard-4-12B":[1.8e-7,1.8e-7,null,null,null],"deepinfra/meta-llama/Meta-Llama-3-8B-Instruct":[3e-8,6e-8,null,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct":[4e-7,4e-7,null,null,null],"meta-llama/Meta-Llama-3.1-70B-Instruct":[4e-7,4e-7,null,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":[1e-7,2.8e-7,null,null,null],"meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":[1e-7,2.8e-7,null,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct":[3e-8,5e-8,null,null,null],"meta-llama/Meta-Llama-3.1-8B-Instruct":[3e-8,5e-8,null,null,null],"deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":[2e-8,3e-8,null,null,null],"meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":[2e-8,3e-8,null,null,null],"deepinfra/microsoft/WizardLM-2-8x22B":[4.8e-7,4.8e-7,null,null,null],"microsoft/WizardLM-2-8x22B":[4.8e-7,4.8e-7,null,null,null],"deepinfra/microsoft/phi-4":[7e-8,1.4e-7,null,null,null],"microsoft/phi-4":[7e-8,1.4e-7,null,null,null],"deepinfra/mistralai/Mistral-Nemo-Instruct-2407":[2e-8,4e-8,null,null,null],"mistralai/Mistral-Nemo-Instruct-2407":[2e-8,4e-8,null,null,null],"deepinfra/mistralai/Mistral-Small-24B-Instruct-2501":[5e-8,8e-8,null,null,null],"mistralai/Mistral-Small-24B-Instruct-2501":[5e-8,8e-8,null,null,null],"deepinfra/mistralai/Mistral-Small-3.2-24B-Instruct-2506":[7.5e-8,2e-7,null,null,null],"mistralai/Mistral-Small-3.2-24B-Instruct-2506":[7.5e-8,2e-7,null,null,null],"deepinfra/mistralai/Mixtral-8x7B-Instruct-v0.1":[4e-7,4e-7,null,null,null],"deepinfra/moonshotai/Kimi-K2-Instruct":[5e-7,0.000002,null,null,null],"moonshotai/Kimi-K2-Instruct":[5e-7,0.000002,null,null,null],"deepinfra/moonshotai/Kimi-K2-Instruct-0905":[5e-7,0.000002,null,4e-7,null],"moonshotai/Kimi-K2-Instruct-0905":[5e-7,0.000002,null,4e-7,null],"deepinfra/nvidia/Llama-3.1-Nemotron-70B-Instruct":[6e-7,6e-7,null,null,null],"nvidia/Llama-3.1-Nemotron-70B-Instruct":[6e-7,6e-7,null,null,null],"deepinfra/nvidia/Llama-3.3-Nemotron-Super-49B-v1.5":[1e-7,4e-7,null,null,null],"nvidia/Llama-3.3-Nemotron-Super-49B-v1.5":[1e-7,4e-7,null,null,null],"deepinfra/nvidia/NVIDIA-Nemotron-Nano-9B-v2":[4e-8,1.6e-7,null,null,null],"nvidia/NVIDIA-Nemotron-Nano-9B-v2":[4e-8,1.6e-7,null,null,null],"deepinfra/openai/gpt-oss-120b":[5e-8,4.5e-7,null,null,null],"openai/gpt-oss-120b":[5e-8,4.5e-7,null,null,null],"deepinfra/openai/gpt-oss-20b":[4e-8,1.5e-7,null,null,null],"openai/gpt-oss-20b":[4e-8,1.5e-7,null,null,null],"deepinfra/zai-org/GLM-4.5":[4e-7,0.0000016,null,null,null],"zai-org/GLM-4.5":[4e-7,0.0000016,null,null,null],"deepseek/deepseek-chat":[2.8e-7,4.2e-7,0,2.8e-8,null],"deepseek/deepseek-coder":[1.4e-7,2.8e-7,null,null,null],"deepseek-coder":[1.4e-7,2.8e-7,null,null,null],"deepseek/deepseek-r1":[5.5e-7,0.00000219,null,null,null],"deepseek/deepseek-reasoner":[2.8e-7,4.2e-7,null,2.8e-8,null],"deepseek/deepseek-v3":[2.7e-7,0.0000011,0,7e-8,null],"deepseek/deepseek-v3.2":[2.8e-7,4e-7,null,null,null],"fireworks_ai/WhereIsAI/UAE-Large-V1":[1.6e-8,0,null,null,null],"WhereIsAI/UAE-Large-V1":[1.6e-8,0,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-instruct":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/deepseek-coder-v2-instruct":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1":[0.000003,0.000008,null,null,null],"accounts/fireworks/models/deepseek-r1":[0.000003,0.000008,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-0528":[0.000003,0.000008,null,null,null],"accounts/fireworks/models/deepseek-r1-0528":[0.000003,0.000008,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-basic":[5.5e-7,0.00000219,null,null,null],"accounts/fireworks/models/deepseek-r1-basic":[5.5e-7,0.00000219,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/deepseek-v3":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3-0324":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/deepseek-v3-0324":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3p1":[5.6e-7,0.00000168,null,null,null],"accounts/fireworks/models/deepseek-v3p1":[5.6e-7,0.00000168,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3p1-terminus":[5.6e-7,0.00000168,null,null,null],"accounts/fireworks/models/deepseek-v3p1-terminus":[5.6e-7,0.00000168,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v3p2":[5.6e-7,0.00000168,null,null,null],"accounts/fireworks/models/deepseek-v3p2":[5.6e-7,0.00000168,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v4-flash":[1.4e-7,2.8e-7,null,2.8e-8,null],"accounts/fireworks/models/deepseek-v4-flash":[1.4e-7,2.8e-7,null,2.8e-8,null],"fireworks_ai/accounts/fireworks/models/deepseek-v4-pro":[0.00000174,0.00000348,null,1.45e-7,null],"accounts/fireworks/models/deepseek-v4-pro":[0.00000174,0.00000348,null,1.45e-7,null],"fireworks_ai/accounts/fireworks/models/firefunction-v2":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/firefunction-v2":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p5":[5.5e-7,0.00000219,null,null,null],"accounts/fireworks/models/glm-4p5":[5.5e-7,0.00000219,null,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p5-air":[2.2e-7,8.8e-7,null,null,null],"accounts/fireworks/models/glm-4p5-air":[2.2e-7,8.8e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p6":[5.5e-7,0.00000219,null,null,null],"accounts/fireworks/models/glm-4p6":[5.5e-7,0.00000219,null,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p7":[6e-7,0.0000022,null,3e-7,null],"accounts/fireworks/models/glm-4p7":[6e-7,0.0000022,null,3e-7,null],"fireworks_ai/accounts/fireworks/models/glm-5p1":[0.0000014,0.0000044,null,2.6e-7,null],"accounts/fireworks/models/glm-5p1":[0.0000014,0.0000044,null,2.6e-7,null],"fireworks_ai/accounts/fireworks/models/glm-5p2":[0.0000014,0.0000044,null,2.6e-7,null],"accounts/fireworks/models/glm-5p2":[0.0000014,0.0000044,null,2.6e-7,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-120b":[1.5e-7,6e-7,null,1.5e-8,null],"accounts/fireworks/models/gpt-oss-120b":[1.5e-7,6e-7,null,1.5e-8,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-20b":[7e-8,3e-7,null,3.5e-8,null],"accounts/fireworks/models/gpt-oss-20b":[7e-8,3e-7,null,3.5e-8,null],"fireworks_ai/accounts/fireworks/models/kimi-k2-instruct":[6e-7,0.0000025,null,null,null],"accounts/fireworks/models/kimi-k2-instruct":[6e-7,0.0000025,null,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2-instruct-0905":[6e-7,0.0000025,null,null,null],"accounts/fireworks/models/kimi-k2-instruct-0905":[6e-7,0.0000025,null,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"accounts/fireworks/models/kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"fireworks_ai/accounts/fireworks/models/kimi-k2p5":[6e-7,0.000003,null,1e-7,null],"accounts/fireworks/models/kimi-k2p5":[6e-7,0.000003,null,1e-7,null],"fireworks_ai/accounts/fireworks/models/kimi-k2p6":[9.5e-7,0.000004,null,1.6e-7,null],"accounts/fireworks/models/kimi-k2p6":[9.5e-7,0.000004,null,1.6e-7,null],"fireworks_ai/accounts/fireworks/models/kimi-k2p7-code":[9.5e-7,0.000004,null,1.9e-7,null],"accounts/fireworks/models/kimi-k2p7-code":[9.5e-7,0.000004,null,1.9e-7,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct":[0.000003,0.000003,null,null,null],"accounts/fireworks/models/llama-v3p1-405b-instruct":[0.000003,0.000003,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-8b-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/llama-v3p1-8b-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-11b-vision-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llama-v3p2-11b-vision-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-1b-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/llama-v3p2-1b-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-3b-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/llama-v3p2-3b-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-90b-vision-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/llama-v3p2-90b-vision-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama4-maverick-instruct-basic":[2.2e-7,8.8e-7,null,null,null],"accounts/fireworks/models/llama4-maverick-instruct-basic":[2.2e-7,8.8e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama4-scout-instruct-basic":[1.5e-7,6e-7,null,null,null],"accounts/fireworks/models/llama4-scout-instruct-basic":[1.5e-7,6e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/minimax-m2p1":[3e-7,0.0000012,null,3e-8,null],"accounts/fireworks/models/minimax-m2p1":[3e-7,0.0000012,null,3e-8,null],"fireworks_ai/accounts/fireworks/models/minimax-m2p7":[3e-7,0.0000012,null,6e-8,null],"accounts/fireworks/models/minimax-m2p7":[3e-7,0.0000012,null,6e-8,null],"fireworks_ai/accounts/fireworks/models/minimax-m3":[3e-7,0.0000012,null,6e-8,null],"accounts/fireworks/models/minimax-m3":[3e-7,0.0000012,null,6e-8,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/mixtral-8x22b-instruct-hf":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-72b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2-72b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/yi-large":[0.000003,0.000003,null,null,null],"accounts/fireworks/models/yi-large":[0.000003,0.000003,null,null,null],"fireworks_ai/deepseek-v4-flash":[1.4e-7,2.8e-7,null,2.8e-8,null],"fireworks_ai/deepseek-v4-pro":[0.00000174,0.00000348,null,1.45e-7,null],"fireworks_ai/glm-4p7":[6e-7,0.0000022,null,3e-7,null],"glm-4p7":[6e-7,0.0000022,null,3e-7,null],"fireworks_ai/glm-5p1":[0.0000014,0.0000044,null,2.6e-7,null],"glm-5p1":[0.0000014,0.0000044,null,2.6e-7,null],"fireworks_ai/glm-5p1-fast":[0.0000028,0.0000088,null,5.2e-7,null],"glm-5p1-fast":[0.0000028,0.0000088,null,5.2e-7,null],"fireworks_ai/glm-5p2":[0.0000014,0.0000044,null,2.6e-7,null],"glm-5p2":[0.0000014,0.0000044,null,2.6e-7,null],"fireworks_ai/gpt-oss-120b":[1.5e-7,6e-7,null,1.5e-8,null],"fireworks_ai/gpt-oss-20b":[7e-8,3e-7,null,3.5e-8,null],"gpt-oss-20b":[7e-8,3e-7,null,3.5e-8,null],"fireworks_ai/kimi-k2p5":[6e-7,0.000003,null,1e-7,null],"kimi-k2p5":[6e-7,0.000003,null,1e-7,null],"fireworks_ai/kimi-k2p6":[9.5e-7,0.000004,null,1.6e-7,null],"kimi-k2p6":[9.5e-7,0.000004,null,1.6e-7,null],"fireworks_ai/kimi-k2p6-fast":[0.000002,0.000008,null,3e-7,null],"kimi-k2p6-fast":[0.000002,0.000008,null,3e-7,null],"fireworks_ai/kimi-k2p7-code":[9.5e-7,0.000004,null,1.9e-7,null],"kimi-k2p7-code":[9.5e-7,0.000004,null,1.9e-7,null],"fireworks_ai/kimi-k2p7-code-fast":[0.0000019,0.000008,null,3.8e-7,null],"kimi-k2p7-code-fast":[0.0000019,0.000008,null,3.8e-7,null],"fireworks_ai/minimax-m2p1":[3e-7,0.0000012,null,3e-8,null],"minimax-m2p1":[3e-7,0.0000012,null,3e-8,null],"fireworks_ai/minimax-m2p7":[3e-7,0.0000012,null,6e-8,null],"minimax-m2p7":[3e-7,0.0000012,null,6e-8,null],"fireworks_ai/minimax-m3":[3e-7,0.0000012,null,6e-8,null],"minimax-m3":[3e-7,0.0000012,null,6e-8,null],"fireworks_ai/qwen3p7-plus":[4e-7,0.0000016,null,8e-8,null],"qwen3p7-plus":[4e-7,0.0000016,null,8e-8,null],"fireworks_ai/nomic-ai/nomic-embed-text-v1":[8e-9,0,null,null,null],"nomic-ai/nomic-embed-text-v1":[8e-9,0,null,null,null],"fireworks_ai/nomic-ai/nomic-embed-text-v1.5":[8e-9,0,null,null,null],"nomic-ai/nomic-embed-text-v1.5":[8e-9,0,null,null,null],"fireworks_ai/thenlper/gte-base":[8e-9,0,null,null,null],"thenlper/gte-base":[8e-9,0,null,null,null],"fireworks_ai/thenlper/gte-large":[1.6e-8,0,null,null,null],"thenlper/gte-large":[1.6e-8,0,null,null,null],"friendliai/meta-llama-3.1-70b-instruct":[6e-7,6e-7,null,null,null],"meta-llama-3.1-70b-instruct":[6e-7,6e-7,null,null,null],"friendliai/meta-llama-3.1-8b-instruct":[1e-7,1e-7,null,null,null],"meta-llama-3.1-8b-instruct":[1e-7,1e-7,null,null,null],"gemini/gemini-live-2.5-flash-preview-native-audio-09-2025":[3e-7,0.000002,null,7.5e-8,null],"vertex_ai/gemini-3-pro-preview":[0.000002,0.000012,null,2e-7,null],"vertex_ai/gemini-3-flash-preview":[5e-7,0.000003,null,5e-8,null],"vertex_ai/gemini-3.5-flash":[0.0000015,0.000009,null,1.5e-7,null],"vertex_ai/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7,null],"vertex_ai/gemini-3.1-pro-preview-customtools":[0.000002,0.000012,null,2e-7,null],"gemini/gemini-robotics-er-1.5-preview":[3e-7,0.0000025,null,0,null],"vertex_ai/gemini-embedding-2-preview":[2e-7,0,null,null,null],"vertex_ai/gemini-embedding-2":[2e-7,0,null,null,null],"gemini/gemini-embedding-001":[1.5e-7,0,null,null,null],"gemini/gemini-embedding-2-preview":[2e-7,0,null,null,null],"gemini/gemini-embedding-2":[2e-7,0,null,null,null],"gemini/gemini-1.5-flash":[7.5e-8,0,null,null,null],"gemini-1.5-flash":[7.5e-8,0,null,null,null],"gemini/gemini-2.0-flash":[1e-7,4e-7,null,2.5e-8,null],"gemini/gemini-2.0-flash-001":[1e-7,4e-7,null,2.5e-8,null],"gemini/gemini-2.0-flash-lite":[7.5e-8,3e-7,null,1.875e-8,null],"gemini/gemini-2.5-flash":[3e-7,0.0000025,null,3e-8,null],"gemini/gemini-2.5-flash-image":[3e-7,0.0000025,null,3e-8,null],"gemini/gemini-3-pro-image":[0.000002,0.000012,null,null,null],"gemini/gemini-3-pro-image-preview":[0.000002,0.000012,null,null,null],"gemini/gemini-3.1-flash-image":[2.5e-7,0.0000015,null,null,null],"gemini/gemini-3.1-flash-image-preview":[2.5e-7,0.0000015,null,null,null],"gemini/deep-research-pro-preview-12-2025":[0.000002,0.000012,null,null,null],"gemini/gemini-2.5-flash-lite":[1e-7,4e-7,null,1e-8,null],"gemini/gemini-2.5-flash-lite-preview-09-2025":[1e-7,4e-7,null,1e-8,null],"gemini/gemini-2.5-flash-preview-09-2025":[3e-7,0.0000025,null,7.5e-8,null],"gemini/gemini-flash-latest":[3e-7,0.0000025,null,7.5e-8,null],"gemini/gemini-flash-lite-latest":[1e-7,4e-7,null,2.5e-8,null],"gemini/gemini-2.5-flash-lite-preview-06-17":[1e-7,4e-7,null,2.5e-8,null],"gemini/gemini-2.5-flash-preview-tts":[3e-7,0.0000025,null,null,null],"gemini/gemini-2.5-pro":[0.00000125,0.00001,null,1.25e-7,null],"gemini/gemini-2.5-computer-use-preview-10-2025":[0.00000125,0.00001,null,null,null],"gemini/gemini-3-pro-preview":[0.000002,0.000012,null,2e-7,null],"gemini/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8,null],"gemini/gemini-3.1-flash-lite":[2.5e-7,0.0000015,null,2.5e-8,null],"gemini/gemini-3-flash-preview":[5e-7,0.000003,null,5e-8,null],"gemini/gemini-3.5-flash":[0.0000015,0.000009,null,1.5e-7,null],"gemini/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7,null],"gemini/gemini-3.1-pro-preview-customtools":[0.000002,0.000012,null,2e-7,null],"gemini/gemini-2.5-pro-preview-tts":[0.00000125,0.00001,null,1.25e-7,null],"gemini/gemini-exp-1114":[0,0,null,null,null],"gemini-exp-1114":[0,0,null,null,null],"gemini/gemini-exp-1206":[0,0,null,null,null],"gemini/gemini-gemma-2-27b-it":[3.5e-7,0.00000105,null,null,null],"gemini-gemma-2-27b-it":[3.5e-7,0.00000105,null,null,null],"gemini/gemini-gemma-2-9b-it":[3.5e-7,0.00000105,null,null,null],"gemini-gemma-2-9b-it":[3.5e-7,0.00000105,null,null,null],"gemini/gemma-3-27b-it":[0,0,null,null,null],"gemma-3-27b-it":[0,0,null,null,null],"gemini/learnlm-1.5-pro-experimental":[0,0,null,null,null],"learnlm-1.5-pro-experimental":[0,0,null,null,null],"gemini/lyria-3-clip-preview":[0,0,null,null,null],"lyria-3-clip-preview":[0,0,null,null,null],"gemini/lyria-3-pro-preview":[0,0,null,null,null],"lyria-3-pro-preview":[0,0,null,null,null],"github_copilot/mai-code-1-flash":[7.5e-7,0.0000045,null,7.5e-8,null],"mai-code-1-flash":[7.5e-7,0.0000045,null,7.5e-8,null],"github_copilot/mai-code-1-flash-internal":[7.5e-7,0.0000045,null,7.5e-8,null],"mai-code-1-flash-internal":[7.5e-7,0.0000045,null,7.5e-8,null],"gigachat/GigaChat-2-Lite":[0,0,null,null,null],"GigaChat-2-Lite":[0,0,null,null,null],"gigachat/GigaChat-2-Max":[0,0,null,null,null],"GigaChat-2-Max":[0,0,null,null,null],"gigachat/GigaChat-2-Pro":[0,0,null,null,null],"GigaChat-2-Pro":[0,0,null,null,null],"gigachat/Embeddings":[0,0,null,null,null],"Embeddings":[0,0,null,null,null],"gigachat/Embeddings-2":[0,0,null,null,null],"Embeddings-2":[0,0,null,null,null],"gigachat/EmbeddingsGigaR":[0,0,null,null,null],"EmbeddingsGigaR":[0,0,null,null,null],"gmi/anthropic/claude-opus-4.5":[0.000005,0.000025,null,null,null],"anthropic/claude-opus-4.5":[0.000005,0.000025,null,null,null],"gmi/anthropic/claude-sonnet-4.5":[0.000003,0.000015,null,null,null],"anthropic/claude-sonnet-4.5":[0.000003,0.000015,null,null,null],"gmi/anthropic/claude-sonnet-4":[0.000003,0.000015,null,null,null],"anthropic/claude-sonnet-4":[0.000003,0.000015,null,null,null],"gmi/anthropic/claude-opus-4":[0.000015,0.000075,null,null,null],"anthropic/claude-opus-4":[0.000015,0.000075,null,null,null],"gmi/openai/gpt-5.2":[0.00000175,0.000014,null,null,null],"openai/gpt-5.2":[0.00000175,0.000014,null,null,null],"gmi/openai/gpt-5.1":[0.00000125,0.00001,null,null,null],"openai/gpt-5.1":[0.00000125,0.00001,null,null,null],"gmi/openai/gpt-5":[0.00000125,0.00001,null,null,null],"openai/gpt-5":[0.00000125,0.00001,null,null,null],"gmi/openai/gpt-4o":[0.0000025,0.00001,null,null,null],"openai/gpt-4o":[0.0000025,0.00001,null,null,null],"gmi/openai/gpt-4o-mini":[1.5e-7,6e-7,null,null,null],"openai/gpt-4o-mini":[1.5e-7,6e-7,null,null,null],"gmi/deepseek-ai/DeepSeek-V3.2":[2.8e-7,4e-7,null,null,null],"deepseek-ai/DeepSeek-V3.2":[2.8e-7,4e-7,null,null,null],"gmi/deepseek-ai/DeepSeek-V3-0324":[2.8e-7,8.8e-7,null,null,null],"gmi/google/gemini-3-pro-preview":[0.000002,0.000012,null,null,null],"google/gemini-3-pro-preview":[0.000002,0.000012,null,null,null],"gmi/google/gemini-3-flash-preview":[5e-7,0.000003,null,null,null],"google/gemini-3-flash-preview":[5e-7,0.000003,null,null,null],"gmi/moonshotai/Kimi-K2-Thinking":[8e-7,0.0000012,null,null,null],"moonshotai/Kimi-K2-Thinking":[8e-7,0.0000012,null,null,null],"gmi/MiniMaxAI/MiniMax-M2.1":[3e-7,0.0000012,null,null,null],"MiniMaxAI/MiniMax-M2.1":[3e-7,0.0000012,null,null,null],"baseten/MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,null,null],"MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,null,null],"baseten/nvidia/Nemotron-120B-A12B":[3e-7,7.5e-7,null,null,null],"nvidia/Nemotron-120B-A12B":[3e-7,7.5e-7,null,null,null],"baseten/zai-org/GLM-5":[9.5e-7,0.00000315,null,null,null],"zai-org/GLM-5":[9.5e-7,0.00000315,null,null,null],"baseten/zai-org/GLM-4.7":[6e-7,0.0000022,null,null,null],"zai-org/GLM-4.7":[6e-7,0.0000022,null,null,null],"baseten/zai-org/GLM-4.6":[6e-7,0.0000022,null,null,null],"zai-org/GLM-4.6":[6e-7,0.0000022,null,null,null],"baseten/moonshotai/Kimi-K2.5":[6e-7,0.000003,null,null,null],"moonshotai/Kimi-K2.5":[6e-7,0.000003,null,null,null],"baseten/moonshotai/Kimi-K2-Thinking":[6e-7,0.0000025,null,null,null],"baseten/moonshotai/Kimi-K2-Instruct-0905":[6e-7,0.0000025,null,null,null],"baseten/openai/gpt-oss-120b":[1e-7,5e-7,null,null,null],"baseten/deepseek-ai/DeepSeek-V3.1":[5e-7,0.0000015,null,null,null],"baseten/deepseek-ai/DeepSeek-V3-0324":[7.7e-7,7.7e-7,null,null,null],"gmi/Qwen/Qwen3-VL-235B-A22B-Instruct-FP8":[3e-7,0.0000014,null,null,null],"Qwen/Qwen3-VL-235B-A22B-Instruct-FP8":[3e-7,0.0000014,null,null,null],"gmi/zai-org/GLM-4.7-FP8":[4e-7,0.000002,null,null,null],"zai-org/GLM-4.7-FP8":[4e-7,0.000002,null,null,null],"gradient_ai/anthropic-claude-3-opus":[0.000015,0.000075,null,null,null],"anthropic-claude-3-opus":[0.000015,0.000075,null,null,null],"gradient_ai/anthropic-claude-3.5-haiku":[8e-7,0.000004,null,null,null],"anthropic-claude-3.5-haiku":[8e-7,0.000004,null,null,null],"gradient_ai/anthropic-claude-3.5-sonnet":[0.000003,0.000015,null,null,null],"anthropic-claude-3.5-sonnet":[0.000003,0.000015,null,null,null],"gradient_ai/anthropic-claude-3.7-sonnet":[0.000003,0.000015,null,null,null],"anthropic-claude-3.7-sonnet":[0.000003,0.000015,null,null,null],"gradient_ai/deepseek-r1-distill-llama-70b":[9.9e-7,9.9e-7,null,null,null],"deepseek-r1-distill-llama-70b":[9.9e-7,9.9e-7,null,null,null],"gradient_ai/llama3-8b-instruct":[2e-7,2e-7,null,null,null],"llama3-8b-instruct":[2e-7,2e-7,null,null,null],"gradient_ai/llama3.3-70b-instruct":[6.5e-7,6.5e-7,null,null,null],"llama3.3-70b-instruct":[6.5e-7,6.5e-7,null,null,null],"gradient_ai/mistral-nemo-instruct-2407":[3e-7,3e-7,null,null,null],"mistral-nemo-instruct-2407":[3e-7,3e-7,null,null,null],"gradient_ai/openai-o3":[0.000002,0.000008,null,null,null],"openai-o3":[0.000002,0.000008,null,null,null],"gradient_ai/openai-o3-mini":[0.0000011,0.0000044,null,null,null],"openai-o3-mini":[0.0000011,0.0000044,null,null,null],"lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF":[0,0,null,null,null],"Qwen3-Coder-30B-A3B-Instruct-GGUF":[0,0,null,null,null],"lemonade/gpt-oss-20b-mxfp4-GGUF":[0,0,null,null,null],"gpt-oss-20b-mxfp4-GGUF":[0,0,null,null,null],"lemonade/gpt-oss-120b-mxfp-GGUF":[0,0,null,null,null],"gpt-oss-120b-mxfp-GGUF":[0,0,null,null,null],"lemonade/Gemma-3-4b-it-GGUF":[0,0,null,null,null],"Gemma-3-4b-it-GGUF":[0,0,null,null,null],"lemonade/Qwen3-4B-Instruct-2507-GGUF":[0,0,null,null,null],"Qwen3-4B-Instruct-2507-GGUF":[0,0,null,null,null],"amazon-nova/nova-micro-v1":[3.5e-8,1.4e-7,null,null,null],"nova-micro-v1":[3.5e-8,1.4e-7,null,null,null],"amazon-nova/nova-lite-v1":[6e-8,2.4e-7,null,null,null],"nova-lite-v1":[6e-8,2.4e-7,null,null,null],"amazon-nova/nova-premier-v1":[0.0000025,0.0000125,null,null,null],"nova-premier-v1":[0.0000025,0.0000125,null,null,null],"amazon-nova/nova-pro-v1":[8e-7,0.0000032,null,null,null],"nova-pro-v1":[8e-7,0.0000032,null,null,null],"groq/llama-3.1-8b-instant":[5e-8,8e-8,null,null,null],"llama-3.1-8b-instant":[5e-8,8e-8,null,null,null],"groq/llama-3.3-70b-versatile":[5.9e-7,7.9e-7,null,null,null],"llama-3.3-70b-versatile":[5.9e-7,7.9e-7,null,null,null],"groq/gemma-7b-it":[5e-8,8e-8,null,null,null],"gemma-7b-it":[5e-8,8e-8,null,null,null],"groq/meta-llama/llama-guard-4-12b":[2e-7,2e-7,null,null,null],"meta-llama/llama-guard-4-12b":[2e-7,2e-7,null,null,null],"groq/meta-llama/llama-4-maverick-17b-128e-instruct":[2e-7,6e-7,null,null,null],"meta-llama/llama-4-maverick-17b-128e-instruct":[2e-7,6e-7,null,null,null],"groq/meta-llama/llama-4-scout-17b-16e-instruct":[1.1e-7,3.4e-7,null,null,null],"meta-llama/llama-4-scout-17b-16e-instruct":[1.1e-7,3.4e-7,null,null,null],"groq/moonshotai/kimi-k2-instruct-0905":[0.000001,0.000003,null,5e-7,null],"moonshotai/kimi-k2-instruct-0905":[0.000001,0.000003,null,5e-7,null],"groq/openai/gpt-oss-120b":[1.5e-7,6e-7,null,7.5e-8,null],"groq/openai/gpt-oss-20b":[7.5e-8,3e-7,null,3.75e-8,null],"groq/openai/gpt-oss-safeguard-20b":[7.5e-8,3e-7,null,3.7e-8,null],"openai/gpt-oss-safeguard-20b":[7.5e-8,3e-7,null,3.7e-8,null],"groq/qwen/qwen3-32b":[2.9e-7,5.9e-7,null,null,null],"qwen/qwen3-32b":[2.9e-7,5.9e-7,null,null,null],"hyperbolic/NousResearch/Hermes-3-Llama-3.1-70B":[1.2e-7,3e-7,null,null,null],"hyperbolic/Qwen/QwQ-32B":[2e-7,2e-7,null,null,null],"hyperbolic/Qwen/Qwen2.5-72B-Instruct":[1.2e-7,3e-7,null,null,null],"hyperbolic/Qwen/Qwen2.5-Coder-32B-Instruct":[1.2e-7,3e-7,null,null,null],"Qwen/Qwen2.5-Coder-32B-Instruct":[1.2e-7,3e-7,null,null,null],"hyperbolic/Qwen/Qwen3-235B-A22B":[0.000002,0.000002,null,null,null],"hyperbolic/deepseek-ai/DeepSeek-R1":[4e-7,4e-7,null,null,null],"hyperbolic/deepseek-ai/DeepSeek-R1-0528":[2.5e-7,2.5e-7,null,null,null],"hyperbolic/deepseek-ai/DeepSeek-V3":[2e-7,2e-7,null,null,null],"hyperbolic/deepseek-ai/DeepSeek-V3-0324":[4e-7,4e-7,null,null,null],"hyperbolic/meta-llama/Llama-3.2-3B-Instruct":[1.2e-7,3e-7,null,null,null],"hyperbolic/meta-llama/Llama-3.3-70B-Instruct":[1.2e-7,3e-7,null,null,null],"hyperbolic/meta-llama/Meta-Llama-3-70B-Instruct":[1.2e-7,3e-7,null,null,null],"hyperbolic/meta-llama/Meta-Llama-3.1-405B-Instruct":[1.2e-7,3e-7,null,null,null],"meta-llama/Meta-Llama-3.1-405B-Instruct":[1.2e-7,3e-7,null,null,null],"hyperbolic/meta-llama/Meta-Llama-3.1-70B-Instruct":[1.2e-7,3e-7,null,null,null],"hyperbolic/meta-llama/Meta-Llama-3.1-8B-Instruct":[1.2e-7,3e-7,null,null,null],"hyperbolic/moonshotai/Kimi-K2-Instruct":[0.000002,0.000002,null,null,null],"crusoe/deepseek-ai/DeepSeek-R1-0528":[0.000003,0.000007,null,null,null],"crusoe/deepseek-ai/DeepSeek-V3-0324":[0.0000015,0.0000015,null,null,null],"crusoe/google/gemma-3-12b-it":[1e-7,1e-7,null,null,null],"crusoe/meta-llama/Llama-3.3-70B-Instruct":[2e-7,2e-7,null,null,null],"crusoe/moonshotai/Kimi-K2-Thinking":[0.0000025,0.0000025,null,null,null],"crusoe/openai/gpt-oss-120b":[8e-7,8e-7,null,null,null],"crusoe/Qwen/Qwen3-235B-A22B-Instruct-2507":[0.000003,0.000003,null,null,null],"inception/mercury-2":[2.5e-7,7.5e-7,null,2.5e-8,null],"mercury-2":[2.5e-7,7.5e-7,null,2.5e-8,null],"text-completion-inception/mercury-edit-2":[2.5e-7,7.5e-7,null,2.5e-8,null],"mercury-edit-2":[2.5e-7,7.5e-7,null,2.5e-8,null],"lambda_ai/deepseek-llama3.3-70b":[2e-7,6e-7,null,null,null],"deepseek-llama3.3-70b":[2e-7,6e-7,null,null,null],"lambda_ai/deepseek-r1-0528":[2e-7,6e-7,null,null,null],"deepseek-r1-0528":[2e-7,6e-7,null,null,null],"lambda_ai/deepseek-r1-671b":[8e-7,8e-7,null,null,null],"deepseek-r1-671b":[8e-7,8e-7,null,null,null],"lambda_ai/deepseek-v3-0324":[2e-7,6e-7,null,null,null],"lambda_ai/hermes3-405b":[8e-7,8e-7,null,null,null],"hermes3-405b":[8e-7,8e-7,null,null,null],"lambda_ai/hermes3-70b":[1.2e-7,3e-7,null,null,null],"hermes3-70b":[1.2e-7,3e-7,null,null,null],"lambda_ai/hermes3-8b":[2.5e-8,4e-8,null,null,null],"hermes3-8b":[2.5e-8,4e-8,null,null,null],"lambda_ai/lfm-40b":[1e-7,2e-7,null,null,null],"lfm-40b":[1e-7,2e-7,null,null,null],"lambda_ai/lfm-7b":[2.5e-8,4e-8,null,null,null],"lfm-7b":[2.5e-8,4e-8,null,null,null],"lambda_ai/llama-4-maverick-17b-128e-instruct-fp8":[5e-8,1e-7,null,null,null],"llama-4-maverick-17b-128e-instruct-fp8":[5e-8,1e-7,null,null,null],"lambda_ai/llama-4-scout-17b-16e-instruct":[5e-8,1e-7,null,null,null],"llama-4-scout-17b-16e-instruct":[5e-8,1e-7,null,null,null],"lambda_ai/llama3.1-405b-instruct-fp8":[8e-7,8e-7,null,null,null],"llama3.1-405b-instruct-fp8":[8e-7,8e-7,null,null,null],"lambda_ai/llama3.1-70b-instruct-fp8":[1.2e-7,3e-7,null,null,null],"llama3.1-70b-instruct-fp8":[1.2e-7,3e-7,null,null,null],"lambda_ai/llama3.1-8b-instruct":[2.5e-8,4e-8,null,null,null],"llama3.1-8b-instruct":[2.5e-8,4e-8,null,null,null],"lambda_ai/llama3.1-nemotron-70b-instruct-fp8":[1.2e-7,3e-7,null,null,null],"llama3.1-nemotron-70b-instruct-fp8":[1.2e-7,3e-7,null,null,null],"lambda_ai/llama3.2-11b-vision-instruct":[1.5e-8,2.5e-8,null,null,null],"llama3.2-11b-vision-instruct":[1.5e-8,2.5e-8,null,null,null],"lambda_ai/llama3.2-3b-instruct":[1.5e-8,2.5e-8,null,null,null],"llama3.2-3b-instruct":[1.5e-8,2.5e-8,null,null,null],"lambda_ai/llama3.3-70b-instruct-fp8":[1.2e-7,3e-7,null,null,null],"llama3.3-70b-instruct-fp8":[1.2e-7,3e-7,null,null,null],"lambda_ai/qwen25-coder-32b-instruct":[5e-8,1e-7,null,null,null],"qwen25-coder-32b-instruct":[5e-8,1e-7,null,null,null],"lambda_ai/qwen3-32b-fp8":[5e-8,1e-7,null,null,null],"qwen3-32b-fp8":[5e-8,1e-7,null,null,null],"minimax/MiniMax-M2.1":[3e-7,0.0000012,3.75e-7,3e-8,null],"MiniMax-M2.1":[3e-7,0.0000012,3.75e-7,3e-8,null],"minimax/MiniMax-M2.1-lightning":[3e-7,0.0000024,3.75e-7,3e-8,null],"MiniMax-M2.1-lightning":[3e-7,0.0000024,3.75e-7,3e-8,null],"minimax/MiniMax-M2.5":[3e-7,0.0000012,3.75e-7,3e-8,null],"MiniMax-M2.5":[3e-7,0.0000012,3.75e-7,3e-8,null],"minimax/MiniMax-M2.5-lightning":[3e-7,0.0000024,3.75e-7,3e-8,null],"MiniMax-M2.5-lightning":[3e-7,0.0000024,3.75e-7,3e-8,null],"minimax/MiniMax-M2":[3e-7,0.0000012,3.75e-7,3e-8,null],"MiniMax-M2":[3e-7,0.0000012,3.75e-7,3e-8,null],"minimax/MiniMax-M3":[3e-7,0.0000012,null,6e-8,null],"MiniMax-M3":[3e-7,0.0000012,null,6e-8,null],"mistral/codestral-2405":[0.000001,0.000003,null,null,null],"mistral/codestral-2508":[3e-7,9e-7,null,null,null],"codestral-2508":[3e-7,9e-7,null,null,null],"mistral/codestral-latest":[0.000001,0.000003,null,null,null],"mistral/codestral-mamba-latest":[2.5e-7,2.5e-7,null,null,null],"codestral-mamba-latest":[2.5e-7,2.5e-7,null,null,null],"mistral/devstral-medium-2507":[4e-7,0.000002,null,null,null],"devstral-medium-2507":[4e-7,0.000002,null,null,null],"mistral/devstral-small-2505":[1e-7,3e-7,null,null,null],"devstral-small-2505":[1e-7,3e-7,null,null,null],"mistral/devstral-small-2507":[1e-7,3e-7,null,null,null],"devstral-small-2507":[1e-7,3e-7,null,null,null],"mistral/devstral-small-latest":[1e-7,3e-7,null,null,null],"devstral-small-latest":[1e-7,3e-7,null,null,null],"mistral/labs-devstral-small-2512":[1e-7,3e-7,null,null,null],"labs-devstral-small-2512":[1e-7,3e-7,null,null,null],"mistral/devstral-latest":[4e-7,0.000002,null,null,null],"devstral-latest":[4e-7,0.000002,null,null,null],"mistral/devstral-medium-latest":[4e-7,0.000002,null,null,null],"devstral-medium-latest":[4e-7,0.000002,null,null,null],"mistral/devstral-2512":[4e-7,0.000002,null,null,null],"devstral-2512":[4e-7,0.000002,null,null,null],"mistral/magistral-medium-2506":[0.000002,0.000005,null,null,null],"magistral-medium-2506":[0.000002,0.000005,null,null,null],"mistral/magistral-medium-2509":[0.000002,0.000005,null,null,null],"magistral-medium-2509":[0.000002,0.000005,null,null,null],"mistral/magistral-medium-1-2-2509":[0.000002,0.000005,null,null,null],"magistral-medium-1-2-2509":[0.000002,0.000005,null,null,null],"mistral/magistral-medium-latest":[0.000002,0.000005,null,null,null],"magistral-medium-latest":[0.000002,0.000005,null,null,null],"mistral/magistral-small-2506":[5e-7,0.0000015,null,null,null],"magistral-small-2506":[5e-7,0.0000015,null,null,null],"mistral/magistral-small-latest":[5e-7,0.0000015,null,null,null],"magistral-small-latest":[5e-7,0.0000015,null,null,null],"mistral/magistral-small-1-2-2509":[5e-7,0.0000015,null,null,null],"magistral-small-1-2-2509":[5e-7,0.0000015,null,null,null],"mistral/mistral-large-2402":[0.000004,0.000012,null,null,null],"mistral/mistral-large-2407":[0.000003,0.000009,null,null,null],"mistral/mistral-large-2411":[0.000002,0.000006,null,null,null],"mistral-large-2411":[0.000002,0.000006,null,null,null],"mistral/mistral-large-latest":[5e-7,0.0000015,null,null,null],"mistral/mistral-large-3":[5e-7,0.0000015,null,null,null],"mistral/mistral-large-2512":[5e-7,0.0000015,null,null,null],"mistral-large-2512":[5e-7,0.0000015,null,null,null],"mistral/mistral-medium":[0.0000027,0.0000081,null,null,null],"mistral-medium":[0.0000027,0.0000081,null,null,null],"mistral/mistral-medium-2312":[0.0000027,0.0000081,null,null,null],"mistral-medium-2312":[0.0000027,0.0000081,null,null,null],"mistral/mistral-medium-2505":[4e-7,0.000002,null,null,null],"mistral/mistral-medium-2508":[4e-7,0.000002,null,null,null],"mistral-medium-2508":[4e-7,0.000002,null,null,null],"mistral/mistral-medium-2604":[0.0000015,0.0000075,null,null,null],"mistral-medium-2604":[0.0000015,0.0000075,null,null,null],"mistral/mistral-medium-latest":[0.0000015,0.0000075,null,null,null],"mistral-medium-latest":[0.0000015,0.0000075,null,null,null],"mistral/mistral-medium-3-1-2508":[4e-7,0.000002,null,null,null],"mistral-medium-3-1-2508":[4e-7,0.000002,null,null,null],"mistral/mistral-medium-3-5":[0.0000015,0.0000075,null,null,null],"mistral-medium-3-5":[0.0000015,0.0000075,null,null,null],"mistral/mistral-small":[1e-7,3e-7,null,null,null],"mistral/mistral-small-latest":[6e-8,1.8e-7,null,null,null],"mistral-small-latest":[6e-8,1.8e-7,null,null,null],"mistral/mistral-small-3-2-2506":[6e-8,1.8e-7,null,null,null],"mistral-small-3-2-2506":[6e-8,1.8e-7,null,null,null],"mistral/ministral-3-3b-2512":[1e-7,1e-7,null,null,null],"ministral-3-3b-2512":[1e-7,1e-7,null,null,null],"mistral/ministral-3-8b-2512":[1.5e-7,1.5e-7,null,null,null],"ministral-3-8b-2512":[1.5e-7,1.5e-7,null,null,null],"mistral/ministral-3-14b-2512":[2e-7,2e-7,null,null,null],"ministral-3-14b-2512":[2e-7,2e-7,null,null,null],"mistral/ministral-8b-2512":[1.5e-7,1.5e-7,null,null,null],"ministral-8b-2512":[1.5e-7,1.5e-7,null,null,null],"mistral/ministral-8b-latest":[1.5e-7,1.5e-7,null,null,null],"ministral-8b-latest":[1.5e-7,1.5e-7,null,null,null],"mistral/mistral-tiny":[2.5e-7,2.5e-7,null,null,null],"mistral-tiny":[2.5e-7,2.5e-7,null,null,null],"mistral/open-codestral-mamba":[2.5e-7,2.5e-7,null,null,null],"open-codestral-mamba":[2.5e-7,2.5e-7,null,null,null],"mistral/open-mistral-7b":[2.5e-7,2.5e-7,null,null,null],"open-mistral-7b":[2.5e-7,2.5e-7,null,null,null],"mistral/open-mistral-nemo":[3e-7,3e-7,null,null,null],"open-mistral-nemo":[3e-7,3e-7,null,null,null],"mistral/open-mistral-nemo-2407":[3e-7,3e-7,null,null,null],"open-mistral-nemo-2407":[3e-7,3e-7,null,null,null],"mistral/open-mixtral-8x22b":[0.000002,0.000006,null,null,null],"open-mixtral-8x22b":[0.000002,0.000006,null,null,null],"mistral/open-mixtral-8x7b":[7e-7,7e-7,null,null,null],"open-mixtral-8x7b":[7e-7,7e-7,null,null,null],"mistral/pixtral-12b-2409":[1.5e-7,1.5e-7,null,null,null],"pixtral-12b-2409":[1.5e-7,1.5e-7,null,null,null],"mistral/pixtral-large-2411":[0.000002,0.000006,null,null,null],"pixtral-large-2411":[0.000002,0.000006,null,null,null],"mistral/pixtral-large-latest":[0.000002,0.000006,null,null,null],"pixtral-large-latest":[0.000002,0.000006,null,null,null],"moonshot/kimi-k2-0711-preview":[6e-7,0.0000025,null,1.5e-7,null],"kimi-k2-0711-preview":[6e-7,0.0000025,null,1.5e-7,null],"moonshot/kimi-k2-0905-preview":[6e-7,0.0000025,null,1.5e-7,null],"kimi-k2-0905-preview":[6e-7,0.0000025,null,1.5e-7,null],"moonshot/kimi-k2-turbo-preview":[0.00000115,0.000008,null,1.5e-7,null],"kimi-k2-turbo-preview":[0.00000115,0.000008,null,1.5e-7,null],"moonshot/kimi-k2.5":[6e-7,0.000003,null,1e-7,null],"moonshot/kimi-k2.6":[9.5e-7,0.000004,null,1.6e-7,null],"moonshot/kimi-latest":[0.000002,0.000005,null,1.5e-7,null],"kimi-latest":[0.000002,0.000005,null,1.5e-7,null],"moonshot/kimi-latest-128k":[0.000002,0.000005,null,1.5e-7,null],"kimi-latest-128k":[0.000002,0.000005,null,1.5e-7,null],"moonshot/kimi-latest-32k":[0.000001,0.000003,null,1.5e-7,null],"kimi-latest-32k":[0.000001,0.000003,null,1.5e-7,null],"moonshot/kimi-latest-8k":[2e-7,0.000002,null,1.5e-7,null],"kimi-latest-8k":[2e-7,0.000002,null,1.5e-7,null],"moonshot/kimi-thinking-preview":[6e-7,0.0000025,null,1.5e-7,null],"kimi-thinking-preview":[6e-7,0.0000025,null,1.5e-7,null],"moonshot/kimi-k2-thinking":[6e-7,0.0000025,null,1.5e-7,null],"kimi-k2-thinking":[6e-7,0.0000025,null,1.5e-7,null],"moonshot/kimi-k2-thinking-turbo":[0.00000115,0.000008,null,1.5e-7,null],"kimi-k2-thinking-turbo":[0.00000115,0.000008,null,1.5e-7,null],"moonshot/moonshot-v1-128k":[0.000002,0.000005,null,null,null],"moonshot-v1-128k":[0.000002,0.000005,null,null,null],"moonshot/moonshot-v1-128k-0430":[0.000002,0.000005,null,null,null],"moonshot-v1-128k-0430":[0.000002,0.000005,null,null,null],"moonshot/moonshot-v1-128k-vision-preview":[0.000002,0.000005,null,null,null],"moonshot-v1-128k-vision-preview":[0.000002,0.000005,null,null,null],"moonshot/moonshot-v1-32k":[0.000001,0.000003,null,null,null],"moonshot-v1-32k":[0.000001,0.000003,null,null,null],"moonshot/moonshot-v1-32k-0430":[0.000001,0.000003,null,null,null],"moonshot-v1-32k-0430":[0.000001,0.000003,null,null,null],"moonshot/moonshot-v1-32k-vision-preview":[0.000001,0.000003,null,null,null],"moonshot-v1-32k-vision-preview":[0.000001,0.000003,null,null,null],"moonshot/moonshot-v1-8k":[2e-7,0.000002,null,null,null],"moonshot-v1-8k":[2e-7,0.000002,null,null,null],"moonshot/moonshot-v1-8k-0430":[2e-7,0.000002,null,null,null],"moonshot-v1-8k-0430":[2e-7,0.000002,null,null,null],"moonshot/moonshot-v1-8k-vision-preview":[2e-7,0.000002,null,null,null],"moonshot-v1-8k-vision-preview":[2e-7,0.000002,null,null,null],"moonshot/moonshot-v1-auto":[0.000002,0.000005,null,null,null],"moonshot-v1-auto":[0.000002,0.000005,null,null,null],"morph/morph-v3-fast":[8e-7,0.0000012,null,null,null],"morph-v3-fast":[8e-7,0.0000012,null,null,null],"morph/morph-v3-large":[9e-7,0.0000019,null,null,null],"morph-v3-large":[9e-7,0.0000019,null,null,null],"nscale/Qwen/QwQ-32B":[1.8e-7,2e-7,null,null,null],"nscale/Qwen/Qwen2.5-Coder-32B-Instruct":[6e-8,2e-7,null,null,null],"nscale/Qwen/Qwen2.5-Coder-3B-Instruct":[1e-8,3e-8,null,null,null],"Qwen/Qwen2.5-Coder-3B-Instruct":[1e-8,3e-8,null,null,null],"nscale/Qwen/Qwen2.5-Coder-7B-Instruct":[1e-8,3e-8,null,null,null],"Qwen/Qwen2.5-Coder-7B-Instruct":[1e-8,3e-8,null,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[3.75e-7,3.75e-7,null,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-8B":[2.5e-8,2.5e-8,null,null,null],"deepseek-ai/DeepSeek-R1-Distill-Llama-8B":[2.5e-8,2.5e-8,null,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B":[9e-8,9e-8,null,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B":[9e-8,9e-8,null,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B":[7e-8,7e-8,null,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B":[7e-8,7e-8,null,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":[1.5e-7,1.5e-7,null,null,null],"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B":[2e-7,2e-7,null,null,null],"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B":[2e-7,2e-7,null,null,null],"nscale/meta-llama/Llama-3.1-8B-Instruct":[3e-8,3e-8,null,null,null],"meta-llama/Llama-3.1-8B-Instruct":[3e-8,3e-8,null,null,null],"nscale/meta-llama/Llama-3.3-70B-Instruct":[2e-7,2e-7,null,null,null],"nscale/meta-llama/Llama-4-Scout-17B-16E-Instruct":[9e-8,2.9e-7,null,null,null],"nscale/mistralai/mixtral-8x22b-instruct-v0.1":[6e-7,6e-7,null,null,null],"mistralai/mixtral-8x22b-instruct-v0.1":[6e-7,6e-7,null,null,null],"nebius/deepseek-ai/DeepSeek-R1":[8e-7,0.0000024,null,null,null],"nebius/deepseek-ai/DeepSeek-R1-0528":[8e-7,0.0000024,null,null,null],"nebius/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":[2.5e-7,7.5e-7,null,null,null],"nebius/deepseek-ai/DeepSeek-V3":[5e-7,0.0000015,null,null,null],"nebius/deepseek-ai/DeepSeek-V3-0324":[5e-7,0.0000015,null,null,null],"nebius/google/gemma-3-27b-it":[6e-8,2e-7,null,null,null],"nebius/meta-llama/Llama-3.3-70B-Instruct":[1.3e-7,4e-7,null,null,null],"nebius/meta-llama/Llama-Guard-3-8B":[2e-8,6e-8,null,null,null],"nebius/meta-llama/Meta-Llama-3.1-8B-Instruct":[2e-8,6e-8,null,null,null],"nebius/meta-llama/Meta-Llama-3.1-70B-Instruct":[1.3e-7,4e-7,null,null,null],"nebius/meta-llama/Meta-Llama-3.1-405B-Instruct":[0.000001,0.000003,null,null,null],"nebius/mistralai/Mistral-Nemo-Instruct-2407":[4e-8,1.2e-7,null,null,null],"nebius/NousResearch/Hermes-3-Llama-3.1-405B":[0.000001,0.000003,null,null,null],"nebius/nvidia/Llama-3.1-Nemotron-Ultra-253B-v1":[6e-7,0.0000018,null,null,null],"nvidia/Llama-3.1-Nemotron-Ultra-253B-v1":[6e-7,0.0000018,null,null,null],"nebius/nvidia/Llama-3.3-Nemotron-Super-49B-v1":[1e-7,4e-7,null,null,null],"nvidia/Llama-3.3-Nemotron-Super-49B-v1":[1e-7,4e-7,null,null,null],"nebius/Qwen/Qwen3-235B-A22B":[2e-7,6e-7,null,null,null],"nebius/Qwen/Qwen3-32B":[1e-7,3e-7,null,null,null],"nebius/Qwen/Qwen3-30B-A3B":[1e-7,3e-7,null,null,null],"nebius/Qwen/Qwen3-14B":[8e-8,2.4e-7,null,null,null],"nebius/Qwen/Qwen3-4B":[8e-8,2.4e-7,null,null,null],"Qwen/Qwen3-4B":[8e-8,2.4e-7,null,null,null],"nebius/Qwen/QwQ-32B":[1.5e-7,4.5e-7,null,null,null],"nebius/Qwen/Qwen2.5-72B-Instruct":[1.3e-7,4e-7,null,null,null],"nebius/Qwen/Qwen2.5-32B-Instruct":[6e-8,2e-7,null,null,null],"Qwen/Qwen2.5-32B-Instruct":[6e-8,2e-7,null,null,null],"nebius/Qwen/Qwen2.5-Coder-7B":[1e-8,3e-8,null,null,null],"Qwen/Qwen2.5-Coder-7B":[1e-8,3e-8,null,null,null],"nebius/Qwen/Qwen2.5-VL-72B-Instruct":[1.3e-7,4e-7,null,null,null],"Qwen/Qwen2.5-VL-72B-Instruct":[1.3e-7,4e-7,null,null,null],"nebius/Qwen/Qwen2-VL-72B-Instruct":[1.3e-7,4e-7,null,null,null],"Qwen/Qwen2-VL-72B-Instruct":[1.3e-7,4e-7,null,null,null],"nebius/Qwen/Qwen2-VL-7B-Instruct":[2e-8,6e-8,null,null,null],"Qwen/Qwen2-VL-7B-Instruct":[2e-8,6e-8,null,null,null],"nebius/BAAI/bge-en-icl":[1e-8,0,null,null,null],"BAAI/bge-en-icl":[1e-8,0,null,null,null],"nebius/BAAI/bge-multilingual-gemma2":[1e-8,0,null,null,null],"BAAI/bge-multilingual-gemma2":[1e-8,0,null,null,null],"nebius/intfloat/e5-mistral-7b-instruct":[1e-8,0,null,null,null],"intfloat/e5-mistral-7b-instruct":[1e-8,0,null,null,null],"oci/meta.llama-3.1-8b-instruct":[7.2e-7,7.2e-7,null,null,null],"meta.llama-3.1-8b-instruct":[7.2e-7,7.2e-7,null,null,null],"oci/meta.llama-3.1-70b-instruct":[7.2e-7,7.2e-7,null,null,null],"meta.llama-3.1-70b-instruct":[7.2e-7,7.2e-7,null,null,null],"oci/meta.llama-3.1-405b-instruct":[0.00001068,0.00001068,null,null,null],"meta.llama-3.1-405b-instruct":[0.00001068,0.00001068,null,null,null],"oci/meta.llama-3.2-90b-vision-instruct":[0.000002,0.000002,null,null,null],"meta.llama-3.2-90b-vision-instruct":[0.000002,0.000002,null,null,null],"oci/meta.llama-3.3-70b-instruct":[7.2e-7,7.2e-7,null,null,null],"meta.llama-3.3-70b-instruct":[7.2e-7,7.2e-7,null,null,null],"oci/meta.llama-4-maverick-17b-128e-instruct-fp8":[7.2e-7,7.2e-7,null,null,null],"meta.llama-4-maverick-17b-128e-instruct-fp8":[7.2e-7,7.2e-7,null,null,null],"oci/meta.llama-4-scout-17b-16e-instruct":[7.2e-7,7.2e-7,null,null,null],"meta.llama-4-scout-17b-16e-instruct":[7.2e-7,7.2e-7,null,null,null],"oci/xai.grok-3":[0.000003,0.000015,null,null,null],"xai.grok-3":[0.000003,0.000015,null,null,null],"oci/xai.grok-3-fast":[0.000005,0.000025,null,null,null],"xai.grok-3-fast":[0.000005,0.000025,null,null,null],"oci/xai.grok-3-mini":[3e-7,5e-7,null,null,null],"xai.grok-3-mini":[3e-7,5e-7,null,null,null],"oci/xai.grok-3-mini-fast":[6e-7,0.000004,null,null,null],"xai.grok-3-mini-fast":[6e-7,0.000004,null,null,null],"oci/xai.grok-4":[0.000003,0.000015,null,null,null],"xai.grok-4":[0.000003,0.000015,null,null,null],"oci/cohere.command-latest":[0.00000156,0.00000156,null,null,null],"cohere.command-latest":[0.00000156,0.00000156,null,null,null],"oci/cohere.command-a-03-2025":[0.00000156,0.00000156,null,null,null],"cohere.command-a-03-2025":[0.00000156,0.00000156,null,null,null],"oci/cohere.command-plus-latest":[0.00000156,0.00000156,null,null,null],"cohere.command-plus-latest":[0.00000156,0.00000156,null,null,null],"oci/google.gemini-2.5-flash":[1.5e-7,6e-7,null,null,null],"google.gemini-2.5-flash":[1.5e-7,6e-7,null,null,null],"oci/google.gemini-2.5-pro":[0.00000125,0.00001,null,null,null],"google.gemini-2.5-pro":[0.00000125,0.00001,null,null,null],"oci/google.gemini-2.5-flash-lite":[7.5e-8,3e-7,null,null,null],"google.gemini-2.5-flash-lite":[7.5e-8,3e-7,null,null,null],"oci/cohere.command-a-vision":[0.00000156,0.00000156,null,null,null],"cohere.command-a-vision":[0.00000156,0.00000156,null,null,null],"oci/cohere.command-a-reasoning":[0.00000156,0.00000156,null,null,null],"cohere.command-a-reasoning":[0.00000156,0.00000156,null,null,null],"oci/cohere.command-a-reasoning-08-2025":[0.00000156,0.00000156,null,null,null],"cohere.command-a-reasoning-08-2025":[0.00000156,0.00000156,null,null,null],"oci/cohere.command-a-vision-07-2025":[0.00000156,0.00000156,null,null,null],"cohere.command-a-vision-07-2025":[0.00000156,0.00000156,null,null,null],"oci/cohere.command-a-translate-08-2025":[9e-8,9e-8,null,null,null],"cohere.command-a-translate-08-2025":[9e-8,9e-8,null,null,null],"oci/cohere.command-r-08-2024":[1.5e-7,1.5e-7,null,null,null],"cohere.command-r-08-2024":[1.5e-7,1.5e-7,null,null,null],"oci/cohere.command-r-plus-08-2024":[0.00000156,0.00000156,null,null,null],"cohere.command-r-plus-08-2024":[0.00000156,0.00000156,null,null,null],"oci/meta.llama-3.2-11b-vision-instruct":[0.000002,0.000002,null,null,null],"meta.llama-3.2-11b-vision-instruct":[0.000002,0.000002,null,null,null],"oci/meta.llama-3.3-70b-instruct-fp8-dynamic":[7.2e-7,7.2e-7,null,null,null],"meta.llama-3.3-70b-instruct-fp8-dynamic":[7.2e-7,7.2e-7,null,null,null],"oci/xai.grok-4-fast":[0.000005,0.000025,null,null,null],"xai.grok-4-fast":[0.000005,0.000025,null,null,null],"oci/xai.grok-4.1-fast":[0.000005,0.000025,null,null,null],"xai.grok-4.1-fast":[0.000005,0.000025,null,null,null],"oci/xai.grok-4.20":[0.000003,0.000015,null,null,null],"xai.grok-4.20":[0.000003,0.000015,null,null,null],"oci/xai.grok-4.20-multi-agent":[0.000003,0.000015,null,null,null],"xai.grok-4.20-multi-agent":[0.000003,0.000015,null,null,null],"oci/xai.grok-code-fast-1":[0.000005,0.000025,null,null,null],"xai.grok-code-fast-1":[0.000005,0.000025,null,null,null],"oci/openai.gpt-5":[0.00000125,0.00001,null,null,null],"openai.gpt-5":[0.00000125,0.00001,null,null,null],"oci/openai.gpt-5-mini":[2.5e-7,0.000002,null,null,null],"openai.gpt-5-mini":[2.5e-7,0.000002,null,null,null],"oci/openai.gpt-5-nano":[5e-8,4e-7,null,null,null],"openai.gpt-5-nano":[5e-8,4e-7,null,null,null],"oci/cohere.embed-english-v3.0":[1e-7,0,null,null,null],"cohere.embed-english-v3.0":[1e-7,0,null,null,null],"oci/cohere.embed-english-light-v3.0":[1e-7,0,null,null,null],"cohere.embed-english-light-v3.0":[1e-7,0,null,null,null],"oci/cohere.embed-multilingual-v3.0":[1e-7,0,null,null,null],"cohere.embed-multilingual-v3.0":[1e-7,0,null,null,null],"oci/cohere.embed-multilingual-light-v3.0":[1e-7,0,null,null,null],"cohere.embed-multilingual-light-v3.0":[1e-7,0,null,null,null],"oci/cohere.embed-english-image-v3.0":[1e-7,0,null,null,null],"cohere.embed-english-image-v3.0":[1e-7,0,null,null,null],"oci/cohere.embed-english-light-image-v3.0":[1e-7,0,null,null,null],"cohere.embed-english-light-image-v3.0":[1e-7,0,null,null,null],"oci/cohere.embed-multilingual-light-image-v3.0":[1e-7,0,null,null,null],"cohere.embed-multilingual-light-image-v3.0":[1e-7,0,null,null,null],"oci/cohere.embed-v4.0":[1.2e-7,0,null,null,null],"cohere.embed-v4.0":[1.2e-7,0,null,null,null],"ollama/codegeex4":[0,0,null,null,null],"codegeex4":[0,0,null,null,null],"ollama/codegemma":[0,0,null,null,null],"codegemma":[0,0,null,null,null],"ollama/codellama":[0,0,null,null,null],"codellama":[0,0,null,null,null],"ollama/deepseek-coder-v2-base":[0,0,null,null,null],"deepseek-coder-v2-base":[0,0,null,null,null],"ollama/deepseek-coder-v2-instruct":[0,0,null,null,null],"deepseek-coder-v2-instruct":[0,0,null,null,null],"ollama/deepseek-coder-v2-lite-base":[0,0,null,null,null],"deepseek-coder-v2-lite-base":[0,0,null,null,null],"ollama/deepseek-coder-v2-lite-instruct":[0,0,null,null,null],"deepseek-coder-v2-lite-instruct":[0,0,null,null,null],"ollama/deepseek-v3.1:671b-cloud":[0,0,null,null,null],"deepseek-v3.1:671b-cloud":[0,0,null,null,null],"ollama/gpt-oss:120b-cloud":[0,0,null,null,null],"gpt-oss:120b-cloud":[0,0,null,null,null],"ollama/gpt-oss:20b-cloud":[0,0,null,null,null],"gpt-oss:20b-cloud":[0,0,null,null,null],"ollama/internlm2_5-20b-chat":[0,0,null,null,null],"internlm2_5-20b-chat":[0,0,null,null,null],"ollama/llama2":[0,0,null,null,null],"llama2":[0,0,null,null,null],"ollama/llama2-uncensored":[0,0,null,null,null],"llama2-uncensored":[0,0,null,null,null],"ollama/llama2:13b":[0,0,null,null,null],"llama2:13b":[0,0,null,null,null],"ollama/llama2:70b":[0,0,null,null,null],"llama2:70b":[0,0,null,null,null],"ollama/llama2:7b":[0,0,null,null,null],"llama2:7b":[0,0,null,null,null],"ollama/llama3":[0,0,null,null,null],"llama3":[0,0,null,null,null],"ollama/llama3.1":[0,0,null,null,null],"llama3.1":[0,0,null,null,null],"ollama/llama3:70b":[0,0,null,null,null],"llama3:70b":[0,0,null,null,null],"ollama/llama3:8b":[0,0,null,null,null],"llama3:8b":[0,0,null,null,null],"ollama/mistral":[0,0,null,null,null],"mistral":[0,0,null,null,null],"ollama/mistral-7B-Instruct-v0.1":[0,0,null,null,null],"mistral-7B-Instruct-v0.1":[0,0,null,null,null],"ollama/mistral-7B-Instruct-v0.2":[0,0,null,null,null],"mistral-7B-Instruct-v0.2":[0,0,null,null,null],"ollama/mistral-large-instruct-2407":[0,0,null,null,null],"mistral-large-instruct-2407":[0,0,null,null,null],"ollama/mixtral-8x22B-Instruct-v0.1":[0,0,null,null,null],"mixtral-8x22B-Instruct-v0.1":[0,0,null,null,null],"ollama/mixtral-8x7B-Instruct-v0.1":[0,0,null,null,null],"mixtral-8x7B-Instruct-v0.1":[0,0,null,null,null],"ollama/orca-mini":[0,0,null,null,null],"orca-mini":[0,0,null,null,null],"ollama/qwen3-coder:480b-cloud":[0,0,null,null,null],"qwen3-coder:480b-cloud":[0,0,null,null,null],"ollama/vicuna":[0,0,null,null,null],"vicuna":[0,0,null,null,null],"openrouter/anthropic/claude-3-haiku":[2.5e-7,0.00000125,null,null,null],"anthropic/claude-3-haiku":[2.5e-7,0.00000125,null,null,null],"openrouter/anthropic/claude-3.5-sonnet":[0.000003,0.000015,null,null,null],"anthropic/claude-3.5-sonnet":[0.000003,0.000015,null,null,null],"openrouter/anthropic/claude-3.7-sonnet":[0.000003,0.000015,null,null,null],"anthropic/claude-3.7-sonnet":[0.000003,0.000015,null,null,null],"openrouter/anthropic/claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015,null],"openrouter/anthropic/claude-opus-4.1":[0.000015,0.000075,0.00001875,0.0000015,null],"anthropic/claude-opus-4.1":[0.000015,0.000075,0.00001875,0.0000015,null],"openrouter/anthropic/claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7,null],"openrouter/anthropic/claude-sonnet-4.6":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic/claude-sonnet-4.6":[0.000003,0.000015,0.00000375,3e-7,null],"openrouter/anthropic/claude-opus-4.5":[0.000005,0.000025,0.00000625,5e-7,null],"openrouter/anthropic/claude-opus-4.6":[0.000005,0.000025,0.00000625,5e-7,null],"anthropic/claude-opus-4.6":[0.000005,0.000025,0.00000625,5e-7,null],"openrouter/anthropic/claude-sonnet-4.5":[0.000003,0.000015,0.00000375,3e-7,null],"openrouter/anthropic/claude-haiku-4.5":[0.000001,0.000005,0.00000125,1e-7,null],"anthropic/claude-haiku-4.5":[0.000001,0.000005,0.00000125,1e-7,null],"openrouter/anthropic/claude-opus-4.7":[0.000005,0.000025,0.00000625,5e-7,null],"anthropic/claude-opus-4.7":[0.000005,0.000025,0.00000625,5e-7,null],"openrouter/bytedance/ui-tars-1.5-7b":[1e-7,2e-7,null,null,null],"bytedance/ui-tars-1.5-7b":[1e-7,2e-7,null,null,null],"openrouter/deepseek/deepseek-chat":[1.4e-7,2.8e-7,null,null,null],"openrouter/deepseek/deepseek-chat-v3-0324":[1.4e-7,2.8e-7,null,null,null],"deepseek/deepseek-chat-v3-0324":[1.4e-7,2.8e-7,null,null,null],"openrouter/deepseek/deepseek-chat-v3.1":[2e-7,8e-7,null,null,null],"deepseek/deepseek-chat-v3.1":[2e-7,8e-7,null,null,null],"openrouter/deepseek/deepseek-v3.2":[2.8e-7,4e-7,null,null,null],"openrouter/deepseek/deepseek-v3.2-exp":[2e-7,4e-7,null,null,null],"deepseek/deepseek-v3.2-exp":[2e-7,4e-7,null,null,null],"openrouter/deepseek/deepseek-r1":[5.5e-7,0.00000219,null,null,null],"openrouter/deepseek/deepseek-r1-0528":[5e-7,0.00000215,null,null,null],"deepseek/deepseek-r1-0528":[5e-7,0.00000215,null,null,null],"openrouter/google/gemini-2.0-flash-001":[1e-7,4e-7,null,null,null],"openrouter/google/gemini-2.5-flash":[3e-7,0.0000025,null,null,null],"openrouter/google/gemini-2.5-pro":[0.00000125,0.00001,null,null,null],"openrouter/google/gemini-3-pro-preview":[0.000002,0.000012,null,2e-7,null],"openrouter/google/gemini-3-flash-preview":[5e-7,0.000003,null,5e-8,null],"openrouter/google/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8,null],"google/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8,null],"openrouter/google/gemini-3.1-flash-lite":[2.5e-7,0.0000015,null,2.5e-8,null],"google/gemini-3.1-flash-lite":[2.5e-7,0.0000015,null,2.5e-8,null],"openrouter/google/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7,null],"google/gemini-3.1-pro-preview":[0.000002,0.000012,null,2e-7,null],"openrouter/gryphe/mythomax-l2-13b":[0.000001875,0.000001875,null,null,null],"gryphe/mythomax-l2-13b":[0.000001875,0.000001875,null,null,null],"openrouter/mancer/weaver":[0.000005625,0.000005625,null,null,null],"mancer/weaver":[0.000005625,0.000005625,null,null,null],"openrouter/meta-llama/llama-3-70b-instruct":[5.9e-7,7.9e-7,null,null,null],"meta-llama/llama-3-70b-instruct":[5.9e-7,7.9e-7,null,null,null],"openrouter/minimax/minimax-m2":[2.55e-7,0.00000102,null,null,null],"minimax/minimax-m2":[2.55e-7,0.00000102,null,null,null],"openrouter/mistralai/devstral-2512":[1.5e-7,6e-7,null,null,null],"mistralai/devstral-2512":[1.5e-7,6e-7,null,null,null],"openrouter/mistralai/ministral-3b-2512":[1e-7,1e-7,null,null,null],"mistralai/ministral-3b-2512":[1e-7,1e-7,null,null,null],"openrouter/mistralai/ministral-8b-2512":[1.5e-7,1.5e-7,null,null,null],"mistralai/ministral-8b-2512":[1.5e-7,1.5e-7,null,null,null],"openrouter/mistralai/ministral-14b-2512":[2e-7,2e-7,null,null,null],"mistralai/ministral-14b-2512":[2e-7,2e-7,null,null,null],"openrouter/mistralai/mistral-large-2512":[5e-7,0.0000015,null,null,null],"mistralai/mistral-large-2512":[5e-7,0.0000015,null,null,null],"openrouter/mistralai/mistral-7b-instruct":[1.3e-7,1.3e-7,null,null,null],"mistralai/mistral-7b-instruct":[1.3e-7,1.3e-7,null,null,null],"openrouter/mistralai/mistral-large":[0.000008,0.000024,null,null,null],"mistralai/mistral-large":[0.000008,0.000024,null,null,null],"openrouter/mistralai/mistral-small-3.1-24b-instruct":[1e-7,3e-7,null,null,null],"mistralai/mistral-small-3.1-24b-instruct":[1e-7,3e-7,null,null,null],"openrouter/mistralai/mistral-small-3.2-24b-instruct":[1e-7,3e-7,null,null,null],"mistralai/mistral-small-3.2-24b-instruct":[1e-7,3e-7,null,null,null],"openrouter/mistralai/mixtral-8x22b-instruct":[6.5e-7,6.5e-7,null,null,null],"mistralai/mixtral-8x22b-instruct":[6.5e-7,6.5e-7,null,null,null],"openrouter/moonshotai/kimi-k2.5":[6e-7,0.000003,null,1e-7,null],"moonshotai/kimi-k2.5":[6e-7,0.000003,null,1e-7,null],"openrouter/openai/gpt-3.5-turbo":[0.0000015,0.000002,null,null,null],"openai/gpt-3.5-turbo":[0.0000015,0.000002,null,null,null],"openrouter/openai/gpt-3.5-turbo-16k":[0.000003,0.000004,null,null,null],"openai/gpt-3.5-turbo-16k":[0.000003,0.000004,null,null,null],"openrouter/openai/gpt-4":[0.00003,0.00006,null,null,null],"openai/gpt-4":[0.00003,0.00006,null,null,null],"openrouter/openai/gpt-4.1":[0.000002,0.000008,null,5e-7,null],"openai/gpt-4.1":[0.000002,0.000008,null,5e-7,null],"openrouter/openai/gpt-4.1-mini":[4e-7,0.0000016,null,1e-7,null],"openai/gpt-4.1-mini":[4e-7,0.0000016,null,1e-7,null],"openrouter/openai/gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8,null],"openai/gpt-4.1-nano":[1e-7,4e-7,null,2.5e-8,null],"openrouter/openai/gpt-4o":[0.0000025,0.00001,null,null,null],"openrouter/openai/gpt-4o-2024-05-13":[0.000005,0.000015,null,null,null],"openai/gpt-4o-2024-05-13":[0.000005,0.000015,null,null,null],"openrouter/openai/gpt-5-chat":[0.00000125,0.00001,null,1.25e-7,null],"openai/gpt-5-chat":[0.00000125,0.00001,null,1.25e-7,null],"openrouter/openai/gpt-5-codex":[0.00000125,0.00001,null,1.25e-7,null],"openai/gpt-5-codex":[0.00000125,0.00001,null,1.25e-7,null],"openrouter/openai/gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7,null],"openai/gpt-5.2-codex":[0.00000175,0.000014,null,1.75e-7,null],"openrouter/openai/gpt-5":[0.00000125,0.00001,null,1.25e-7,null],"openrouter/openai/gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8,null],"openai/gpt-5-mini":[2.5e-7,0.000002,null,2.5e-8,null],"openrouter/openai/gpt-5-nano":[5e-8,4e-7,null,5e-9,null],"openai/gpt-5-nano":[5e-8,4e-7,null,5e-9,null],"openrouter/openai/gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7,null],"openai/gpt-5.1-codex-max":[0.00000125,0.00001,null,1.25e-7,null],"openrouter/openai/gpt-5.2":[0.00000175,0.000014,null,1.75e-7,null],"openrouter/openai/gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7,null],"openai/gpt-5.2-chat":[0.00000175,0.000014,null,1.75e-7,null],"openrouter/openai/gpt-5.2-pro":[0.000021,0.000168,null,null,null],"openai/gpt-5.2-pro":[0.000021,0.000168,null,null,null],"openrouter/openai/gpt-oss-120b":[1.8e-7,8e-7,null,null,null],"openrouter/openai/gpt-oss-20b":[2e-8,1e-7,null,null,null],"openrouter/openai/o1":[0.000015,0.00006,null,0.0000075,null],"openai/o1":[0.000015,0.00006,null,0.0000075,null],"openrouter/openai/o3-mini":[0.0000011,0.0000044,null,null,null],"openai/o3-mini":[0.0000011,0.0000044,null,null,null],"openrouter/openai/o3-mini-high":[0.0000011,0.0000044,null,null,null],"openai/o3-mini-high":[0.0000011,0.0000044,null,null,null],"openrouter/qwen/qwen-2.5-coder-32b-instruct":[1.8e-7,1.8e-7,null,null,null],"qwen/qwen-2.5-coder-32b-instruct":[1.8e-7,1.8e-7,null,null,null],"openrouter/qwen/qwen-vl-plus":[2.1e-7,6.3e-7,null,null,null],"qwen/qwen-vl-plus":[2.1e-7,6.3e-7,null,null,null],"openrouter/qwen/qwen3-coder":[2.2e-7,9.5e-7,null,null,null],"qwen/qwen3-coder":[2.2e-7,9.5e-7,null,null,null],"openrouter/qwen/qwen3-coder-plus":[0.000001,0.000005,null,null,null],"qwen/qwen3-coder-plus":[0.000001,0.000005,null,null,null],"openrouter/qwen/qwen3-235b-a22b-2507":[7.1e-8,1e-7,null,null,null],"qwen/qwen3-235b-a22b-2507":[7.1e-8,1e-7,null,null,null],"openrouter/qwen/qwen3-235b-a22b-thinking-2507":[1.1e-7,6e-7,null,null,null],"qwen/qwen3-235b-a22b-thinking-2507":[1.1e-7,6e-7,null,null,null],"openrouter/qwen/qwen3.6-plus":[3.25e-7,0.00000195,null,null,null],"qwen/qwen3.6-plus":[3.25e-7,0.00000195,null,null,null],"openrouter/qwen/qwen3.5-35b-a3b":[2.5e-7,0.000002,null,null,null],"qwen/qwen3.5-35b-a3b":[2.5e-7,0.000002,null,null,null],"openrouter/qwen/qwen3.5-27b":[3e-7,0.0000024,null,null,null],"qwen/qwen3.5-27b":[3e-7,0.0000024,null,null,null],"openrouter/qwen/qwen3.5-122b-a10b":[4e-7,0.000002,null,null,null],"qwen/qwen3.5-122b-a10b":[4e-7,0.000002,null,null,null],"openrouter/qwen/qwen3.5-flash-02-23":[1e-7,4e-7,null,null,null],"qwen/qwen3.5-flash-02-23":[1e-7,4e-7,null,null,null],"openrouter/qwen/qwen3.5-plus-02-15":[4e-7,0.0000024,null,null,null],"qwen/qwen3.5-plus-02-15":[4e-7,0.0000024,null,null,null],"openrouter/qwen/qwen3.5-397b-a17b":[6e-7,0.0000036,null,null,null],"qwen/qwen3.5-397b-a17b":[6e-7,0.0000036,null,null,null],"openrouter/switchpoint/router":[8.5e-7,0.0000034,null,null,null],"switchpoint/router":[8.5e-7,0.0000034,null,null,null],"openrouter/undi95/remm-slerp-l2-13b":[0.000001875,0.000001875,null,null,null],"undi95/remm-slerp-l2-13b":[0.000001875,0.000001875,null,null,null],"openrouter/x-ai/grok-4":[0.000003,0.000015,null,null,null],"x-ai/grok-4":[0.000003,0.000015,null,null,null],"openrouter/z-ai/glm-4.6":[4e-7,0.00000175,null,null,null],"z-ai/glm-4.6":[4e-7,0.00000175,null,null,null],"openrouter/z-ai/glm-4.6:exacto":[4.5e-7,0.0000019,null,null,null],"z-ai/glm-4.6:exacto":[4.5e-7,0.0000019,null,null,null],"openrouter/xiaomi/mimo-v2-flash":[1e-7,3e-7,0,1e-8,null],"xiaomi/mimo-v2-flash":[1e-7,3e-7,0,1e-8,null],"openrouter/xiaomi/mimo-v2.5-pro":[0.000001,0.000003,0,2e-7,null],"xiaomi/mimo-v2.5-pro":[0.000001,0.000003,0,2e-7,null],"openrouter/xiaomi/mimo-v2.5":[4e-7,0.000002,0,8e-8,null],"xiaomi/mimo-v2.5":[4e-7,0.000002,0,8e-8,null],"openrouter/z-ai/glm-4.7":[4e-7,0.0000015,0,0,null],"z-ai/glm-4.7":[4e-7,0.0000015,0,0,null],"openrouter/z-ai/glm-4.7-flash":[7e-8,4e-7,0,0,null],"z-ai/glm-4.7-flash":[7e-8,4e-7,0,0,null],"openrouter/z-ai/glm-5":[8e-7,0.00000256,null,null,null],"z-ai/glm-5":[8e-7,0.00000256,null,null,null],"openrouter/z-ai/glm-5.1":[0.00000105,0.0000035,0,5.25e-7,null],"z-ai/glm-5.1":[0.00000105,0.0000035,0,5.25e-7,null],"openrouter/minimax/minimax-m2.1":[2.7e-7,0.0000012,0,0,null],"minimax/minimax-m2.1":[2.7e-7,0.0000012,0,0,null],"openrouter/minimax/minimax-m2.5":[3e-7,0.0000011,null,1.5e-7,null],"minimax/minimax-m2.5":[3e-7,0.0000011,null,1.5e-7,null],"openrouter/openrouter/auto":[0,0,null,null,null],"openrouter/auto":[0,0,null,null,null],"openrouter/openrouter/free":[0,0,null,null,null],"openrouter/free":[0,0,null,null,null],"openrouter/openrouter/bodybuilder":[0,0,null,null,null],"openrouter/bodybuilder":[0,0,null,null,null],"ovhcloud/DeepSeek-R1-Distill-Llama-70B":[6.7e-7,6.7e-7,null,null,null],"DeepSeek-R1-Distill-Llama-70B":[6.7e-7,6.7e-7,null,null,null],"ovhcloud/Llama-3.1-8B-Instruct":[1e-7,1e-7,null,null,null],"Llama-3.1-8B-Instruct":[1e-7,1e-7,null,null,null],"ovhcloud/Meta-Llama-3_1-70B-Instruct":[6.7e-7,6.7e-7,null,null,null],"Meta-Llama-3_1-70B-Instruct":[6.7e-7,6.7e-7,null,null,null],"ovhcloud/Meta-Llama-3_3-70B-Instruct":[6.7e-7,6.7e-7,null,null,null],"Meta-Llama-3_3-70B-Instruct":[6.7e-7,6.7e-7,null,null,null],"ovhcloud/Mistral-7B-Instruct-v0.3":[1e-7,1e-7,null,null,null],"Mistral-7B-Instruct-v0.3":[1e-7,1e-7,null,null,null],"ovhcloud/Mistral-Nemo-Instruct-2407":[1.3e-7,1.3e-7,null,null,null],"Mistral-Nemo-Instruct-2407":[1.3e-7,1.3e-7,null,null,null],"ovhcloud/Mistral-Small-3.2-24B-Instruct-2506":[9e-8,2.8e-7,null,null,null],"Mistral-Small-3.2-24B-Instruct-2506":[9e-8,2.8e-7,null,null,null],"ovhcloud/Mixtral-8x7B-Instruct-v0.1":[6.3e-7,6.3e-7,null,null,null],"Mixtral-8x7B-Instruct-v0.1":[6.3e-7,6.3e-7,null,null,null],"ovhcloud/Qwen2.5-Coder-32B-Instruct":[8.7e-7,8.7e-7,null,null,null],"Qwen2.5-Coder-32B-Instruct":[8.7e-7,8.7e-7,null,null,null],"ovhcloud/Qwen2.5-VL-72B-Instruct":[9.1e-7,9.1e-7,null,null,null],"Qwen2.5-VL-72B-Instruct":[9.1e-7,9.1e-7,null,null,null],"ovhcloud/Qwen3-32B":[8e-8,2.3e-7,null,null,null],"Qwen3-32B":[8e-8,2.3e-7,null,null,null],"ovhcloud/gpt-oss-120b":[8e-8,4e-7,null,null,null],"ovhcloud/gpt-oss-20b":[4e-8,1.5e-7,null,null,null],"ovhcloud/llava-v1.6-mistral-7b-hf":[2.9e-7,2.9e-7,null,null,null],"llava-v1.6-mistral-7b-hf":[2.9e-7,2.9e-7,null,null,null],"ovhcloud/mamba-codestral-7B-v0.1":[1.9e-7,1.9e-7,null,null,null],"mamba-codestral-7B-v0.1":[1.9e-7,1.9e-7,null,null,null],"palm/chat-bison":[1.25e-7,1.25e-7,null,null,null],"chat-bison":[1.25e-7,1.25e-7,null,null,null],"palm/chat-bison-001":[1.25e-7,1.25e-7,null,null,null],"chat-bison-001":[1.25e-7,1.25e-7,null,null,null],"palm/text-bison":[1.25e-7,1.25e-7,null,null,null],"text-bison":[1.25e-7,1.25e-7,null,null,null],"palm/text-bison-001":[1.25e-7,1.25e-7,null,null,null],"text-bison-001":[1.25e-7,1.25e-7,null,null,null],"palm/text-bison-safety-off":[1.25e-7,1.25e-7,null,null,null],"text-bison-safety-off":[1.25e-7,1.25e-7,null,null,null],"palm/text-bison-safety-recitation-off":[1.25e-7,1.25e-7,null,null,null],"text-bison-safety-recitation-off":[1.25e-7,1.25e-7,null,null,null],"perplexity/codellama-34b-instruct":[3.5e-7,0.0000014,null,null,null],"codellama-34b-instruct":[3.5e-7,0.0000014,null,null,null],"perplexity/codellama-70b-instruct":[7e-7,0.0000028,null,null,null],"codellama-70b-instruct":[7e-7,0.0000028,null,null,null],"perplexity/llama-2-70b-chat":[7e-7,0.0000028,null,null,null],"llama-2-70b-chat":[7e-7,0.0000028,null,null,null],"perplexity/llama-3.1-70b-instruct":[0.000001,0.000001,null,null,null],"llama-3.1-70b-instruct":[0.000001,0.000001,null,null,null],"perplexity/llama-3.1-8b-instruct":[2e-7,2e-7,null,null,null],"llama-3.1-8b-instruct":[2e-7,2e-7,null,null,null],"perplexity/mistral-7b-instruct":[7e-8,2.8e-7,null,null,null],"mistral-7b-instruct":[7e-8,2.8e-7,null,null,null],"perplexity/mixtral-8x7b-instruct":[7e-8,2.8e-7,null,null,null],"mixtral-8x7b-instruct":[7e-8,2.8e-7,null,null,null],"perplexity/pplx-70b-chat":[7e-7,0.0000028,null,null,null],"pplx-70b-chat":[7e-7,0.0000028,null,null,null],"perplexity/pplx-70b-online":[0,0.0000028,null,null,null],"pplx-70b-online":[0,0.0000028,null,null,null],"perplexity/pplx-7b-chat":[7e-8,2.8e-7,null,null,null],"pplx-7b-chat":[7e-8,2.8e-7,null,null,null],"perplexity/pplx-7b-online":[0,2.8e-7,null,null,null],"pplx-7b-online":[0,2.8e-7,null,null,null],"perplexity/sonar":[0.000001,0.000001,null,null,null],"sonar":[0.000001,0.000001,null,null,null],"perplexity/sonar-deep-research":[0.000002,0.000008,null,null,null],"sonar-deep-research":[0.000002,0.000008,null,null,null],"perplexity/sonar-medium-chat":[6e-7,0.0000018,null,null,null],"sonar-medium-chat":[6e-7,0.0000018,null,null,null],"perplexity/sonar-medium-online":[0,0.0000018,null,null,null],"sonar-medium-online":[0,0.0000018,null,null,null],"perplexity/sonar-pro":[0.000003,0.000015,null,null,null],"sonar-pro":[0.000003,0.000015,null,null,null],"perplexity/sonar-reasoning":[0.000001,0.000005,null,null,null],"sonar-reasoning":[0.000001,0.000005,null,null,null],"perplexity/sonar-reasoning-pro":[0.000002,0.000008,null,null,null],"sonar-reasoning-pro":[0.000002,0.000008,null,null,null],"perplexity/sonar-small-chat":[7e-8,2.8e-7,null,null,null],"sonar-small-chat":[7e-8,2.8e-7,null,null,null],"perplexity/sonar-small-online":[0,2.8e-7,null,null,null],"sonar-small-online":[0,2.8e-7,null,null,null],"publicai/swiss-ai/apertus-8b-instruct":[0,0,null,null,null],"swiss-ai/apertus-8b-instruct":[0,0,null,null,null],"publicai/swiss-ai/apertus-70b-instruct":[0,0,null,null,null],"swiss-ai/apertus-70b-instruct":[0,0,null,null,null],"publicai/aisingapore/Gemma-SEA-LION-v4-27B-IT":[0,0,null,null,null],"aisingapore/Gemma-SEA-LION-v4-27B-IT":[0,0,null,null,null],"publicai/BSC-LT/salamandra-7b-instruct-tools-16k":[0,0,null,null,null],"BSC-LT/salamandra-7b-instruct-tools-16k":[0,0,null,null,null],"publicai/BSC-LT/ALIA-40b-instruct_Q8_0":[0,0,null,null,null],"BSC-LT/ALIA-40b-instruct_Q8_0":[0,0,null,null,null],"publicai/allenai/Olmo-3-7B-Instruct":[0,0,null,null,null],"allenai/Olmo-3-7B-Instruct":[0,0,null,null,null],"perplexity/pplx-embed-v1-0.6b":[4e-9,0,null,null,null],"pplx-embed-v1-0.6b":[4e-9,0,null,null,null],"perplexity/pplx-embed-v1-4b":[3e-8,0,null,null,null],"pplx-embed-v1-4b":[3e-8,0,null,null,null],"publicai/aisingapore/Qwen-SEA-LION-v4-32B-IT":[0,0,null,null,null],"aisingapore/Qwen-SEA-LION-v4-32B-IT":[0,0,null,null,null],"publicai/allenai/Olmo-3-7B-Think":[0,0,null,null,null],"allenai/Olmo-3-7B-Think":[0,0,null,null,null],"publicai/allenai/Olmo-3-32B-Think":[0,0,null,null,null],"allenai/Olmo-3-32B-Think":[0,0,null,null,null],"replicate/meta/llama-2-13b":[1e-7,5e-7,null,null,null],"meta/llama-2-13b":[1e-7,5e-7,null,null,null],"replicate/meta/llama-2-13b-chat":[1e-7,5e-7,null,null,null],"meta/llama-2-13b-chat":[1e-7,5e-7,null,null,null],"replicate/meta/llama-2-70b":[6.5e-7,0.00000275,null,null,null],"meta/llama-2-70b":[6.5e-7,0.00000275,null,null,null],"replicate/meta/llama-2-70b-chat":[6.5e-7,0.00000275,null,null,null],"meta/llama-2-70b-chat":[6.5e-7,0.00000275,null,null,null],"replicate/meta/llama-2-7b":[5e-8,2.5e-7,null,null,null],"meta/llama-2-7b":[5e-8,2.5e-7,null,null,null],"replicate/meta/llama-2-7b-chat":[5e-8,2.5e-7,null,null,null],"meta/llama-2-7b-chat":[5e-8,2.5e-7,null,null,null],"replicate/meta/llama-3-70b":[6.5e-7,0.00000275,null,null,null],"meta/llama-3-70b":[6.5e-7,0.00000275,null,null,null],"replicate/meta/llama-3-70b-instruct":[6.5e-7,0.00000275,null,null,null],"meta/llama-3-70b-instruct":[6.5e-7,0.00000275,null,null,null],"replicate/meta/llama-3-8b":[5e-8,2.5e-7,null,null,null],"meta/llama-3-8b":[5e-8,2.5e-7,null,null,null],"replicate/meta/llama-3-8b-instruct":[5e-8,2.5e-7,null,null,null],"meta/llama-3-8b-instruct":[5e-8,2.5e-7,null,null,null],"replicate/mistralai/mistral-7b-instruct-v0.2":[5e-8,2.5e-7,null,null,null],"mistralai/mistral-7b-instruct-v0.2":[5e-8,2.5e-7,null,null,null],"replicate/mistralai/mistral-7b-v0.1":[5e-8,2.5e-7,null,null,null],"mistralai/mistral-7b-v0.1":[5e-8,2.5e-7,null,null,null],"replicate/mistralai/mixtral-8x7b-instruct-v0.1":[3e-7,0.000001,null,null,null],"mistralai/mixtral-8x7b-instruct-v0.1":[3e-7,0.000001,null,null,null],"replicate/openai/gpt-5":[0.00000125,0.00001,null,null,null],"replicateopenai/gpt-oss-20b":[9e-8,3.6e-7,null,null,null],"replicate/anthropic/claude-4.5-haiku":[0.000001,0.000005,null,null,null],"anthropic/claude-4.5-haiku":[0.000001,0.000005,null,null,null],"replicate/ibm-granite/granite-3.3-8b-instruct":[3e-8,2.5e-7,null,null,null],"ibm-granite/granite-3.3-8b-instruct":[3e-8,2.5e-7,null,null,null],"replicate/openai/gpt-4o":[0.0000025,0.00001,null,null,null],"replicate/openai/o4-mini":[0.000001,0.000004,null,null,null],"openai/o4-mini":[0.000001,0.000004,null,null,null],"replicate/openai/o1-mini":[0.0000011,0.0000044,null,null,null],"openai/o1-mini":[0.0000011,0.0000044,null,null,null],"replicate/openai/o1":[0.000015,0.00006,null,null,null],"replicate/openai/gpt-4o-mini":[1.5e-7,6e-7,null,null,null],"replicate/qwen/qwen3-235b-a22b-instruct-2507":[2.64e-7,0.00000106,null,null,null],"qwen/qwen3-235b-a22b-instruct-2507":[2.64e-7,0.00000106,null,null,null],"replicate/anthropic/claude-4-sonnet":[0.000003,0.000015,null,null,null],"replicate/deepseek-ai/deepseek-v3":[0.00000145,0.00000145,null,null,null],"deepseek-ai/deepseek-v3":[0.00000145,0.00000145,null,null,null],"replicate/anthropic/claude-3.7-sonnet":[0.000003,0.000015,null,null,null],"replicate/anthropic/claude-3.5-haiku":[0.000001,0.000005,null,null,null],"anthropic/claude-3.5-haiku":[0.000001,0.000005,null,null,null],"replicate/anthropic/claude-3.5-sonnet":[0.00000375,0.00001875,null,null,null],"replicate/google/gemini-3-pro":[0.000002,0.000012,null,null,null],"google/gemini-3-pro":[0.000002,0.000012,null,null,null],"replicate/anthropic/claude-4.5-sonnet":[0.000003,0.000015,null,null,null],"anthropic/claude-4.5-sonnet":[0.000003,0.000015,null,null,null],"replicate/openai/gpt-4.1":[0.000002,0.000008,null,null,null],"replicate/openai/gpt-4.1-nano":[1e-7,4e-7,null,null,null],"replicate/openai/gpt-4.1-mini":[4e-7,0.0000016,null,null,null],"replicate/openai/gpt-5-nano":[5e-8,4e-7,null,null,null],"replicate/openai/gpt-5-mini":[2.5e-7,0.000002,null,null,null],"replicate/google/gemini-2.5-flash":[0.0000025,0.0000025,null,null,null],"replicate/openai/gpt-oss-120b":[1.8e-7,7.2e-7,null,null,null],"replicate/deepseek-ai/deepseek-v3.1":[6.72e-7,0.000002016,null,null,null],"deepseek-ai/deepseek-v3.1":[6.72e-7,0.000002016,null,null,null],"replicate/xai/grok-4":[0.0000072,0.000036,null,null,null],"xai/grok-4":[0.0000072,0.000036,null,null,null],"replicate/deepseek-ai/deepseek-r1":[0.00000375,0.00001,null,null,null],"deepseek-ai/deepseek-r1":[0.00000375,0.00001,null,null,null],"nvidia_nim/nvidia/nv-rerankqa-mistral-4b-v3":[0,0,null,null,null],"nvidia/nv-rerankqa-mistral-4b-v3":[0,0,null,null,null],"nvidia_nim/nvidia/llama-3_2-nv-rerankqa-1b-v2":[0,0,null,null,null],"nvidia/llama-3_2-nv-rerankqa-1b-v2":[0,0,null,null,null],"nvidia_nim/ranking/nvidia/llama-3.2-nv-rerankqa-1b-v2":[0,0,null,null,null],"ranking/nvidia/llama-3.2-nv-rerankqa-1b-v2":[0,0,null,null,null],"sagemaker/meta-textgeneration-llama-2-13b":[0,0,null,null,null],"meta-textgeneration-llama-2-13b":[0,0,null,null,null],"sagemaker/meta-textgeneration-llama-2-13b-f":[0,0,null,null,null],"meta-textgeneration-llama-2-13b-f":[0,0,null,null,null],"sagemaker/meta-textgeneration-llama-2-70b":[0,0,null,null,null],"meta-textgeneration-llama-2-70b":[0,0,null,null,null],"sagemaker/meta-textgeneration-llama-2-70b-b-f":[0,0,null,null,null],"meta-textgeneration-llama-2-70b-b-f":[0,0,null,null,null],"sagemaker/meta-textgeneration-llama-2-7b":[0,0,null,null,null],"meta-textgeneration-llama-2-7b":[0,0,null,null,null],"sagemaker/meta-textgeneration-llama-2-7b-f":[0,0,null,null,null],"meta-textgeneration-llama-2-7b-f":[0,0,null,null,null],"sambanova/MiniMax-M2.7":[6e-7,0.0000024,null,null,null],"MiniMax-M2.7":[3e-7,0.0000012,3.75e-7,6e-8],"sambanova/DeepSeek-R1":[0.000005,0.000007,null,null,null],"DeepSeek-R1":[0.000005,0.000007,null,null,null],"sambanova/DeepSeek-R1-Distill-Llama-70B":[7e-7,0.0000014,null,null,null],"sambanova/DeepSeek-V3-0324":[0.000003,0.0000045,null,null,null],"DeepSeek-V3-0324":[0.000003,0.0000045,null,null,null],"sambanova/Llama-4-Maverick-17B-128E-Instruct":[6.3e-7,0.0000018,null,null,null],"Llama-4-Maverick-17B-128E-Instruct":[6.3e-7,0.0000018,null,null,null],"sambanova/Llama-4-Scout-17B-16E-Instruct":[4e-7,7e-7,null,null,null],"sambanova/Meta-Llama-3.1-405B-Instruct":[0.000005,0.00001,null,null,null],"sambanova/Meta-Llama-3.1-8B-Instruct":[1e-7,2e-7,null,null,null],"sambanova/Meta-Llama-3.2-1B-Instruct":[4e-8,8e-8,null,null,null],"Meta-Llama-3.2-1B-Instruct":[4e-8,8e-8,null,null,null],"sambanova/Meta-Llama-3.2-3B-Instruct":[8e-8,1.6e-7,null,null,null],"Meta-Llama-3.2-3B-Instruct":[8e-8,1.6e-7,null,null,null],"sambanova/Meta-Llama-3.3-70B-Instruct":[6e-7,0.0000012,null,null,null],"Meta-Llama-3.3-70B-Instruct":[6e-7,0.0000012,null,null,null],"sambanova/Meta-Llama-Guard-3-8B":[3e-7,3e-7,null,null,null],"Meta-Llama-Guard-3-8B":[3e-7,3e-7,null,null,null],"sambanova/QwQ-32B":[5e-7,0.000001,null,null,null],"QwQ-32B":[5e-7,0.000001,null,null,null],"sambanova/Qwen2-Audio-7B-Instruct":[5e-7,0.0001,null,null,null],"Qwen2-Audio-7B-Instruct":[5e-7,0.0001,null,null,null],"sambanova/Qwen3-32B":[4e-7,8e-7,null,null,null],"sambanova/DeepSeek-V3.1":[0.000003,0.0000045,null,null,null],"DeepSeek-V3.1":[0.000003,0.0000045,null,null,null],"sambanova/gpt-oss-120b":[2.2e-7,5.9e-7,null,null,null],"sambanova/DeepSeek-V3.2":[0.000003,0.0000045,null,null,null],"DeepSeek-V3.2":[0.000003,0.0000045,null,null,null],"sambanova/gemma-4-31B-it":[3.8e-7,0.00000115,null,null,null],"gemma-4-31B-it":[3.8e-7,0.00000115,null,null,null],"snowflake/claude-3-5-sonnet":[0.000003,0.000015,null,3e-7,null],"claude-3-5-sonnet":[0.000003,0.000015,null,3e-7,null],"snowflake/deepseek-r1":[0.00000135,0.0000054,null,null,null],"snowflake/llama3.1-405b":[0.0000012,0.0000012,null,null,null],"llama3.1-405b":[0.0000012,0.0000012,null,null,null],"snowflake/llama3.1-70b":[7.2e-7,7.2e-7,null,null,null],"snowflake/llama3.1-8b":[2.4e-7,2.4e-7,null,null,null],"snowflake/llama3.3-70b":[7.2e-7,7.2e-7,null,null,null],"llama3.3-70b":[7.2e-7,7.2e-7,null,null,null],"snowflake/mistral-large2":[0.000002,0.000006,null,null,null],"mistral-large2":[0.000002,0.000006,null,null,null],"snowflake/snowflake-llama-3.3-70b":[7.2e-7,7.2e-7,null,null,null],"snowflake-llama-3.3-70b":[7.2e-7,7.2e-7,null,null,null],"text-completion-codestral/codestral-2405":[0,0,null,null,null],"text-completion-codestral/codestral-latest":[0,0,null,null,null],"together_ai/baai/bge-base-en-v1.5":[8e-9,0,null,null,null],"baai/bge-base-en-v1.5":[8e-9,0,null,null,null],"together_ai/BAAI/bge-base-en-v1.5":[8e-9,0,null,null,null],"BAAI/bge-base-en-v1.5":[8e-9,0,null,null,null],"together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput":[2e-7,0.000006,null,null,null],"Qwen/Qwen3-235B-A22B-Instruct-2507-tput":[2e-7,0.000006,null,null,null],"together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507":[6.5e-7,0.000003,null,null,null],"together_ai/Qwen/Qwen3-235B-A22B-fp8-tput":[2e-7,6e-7,null,null,null],"Qwen/Qwen3-235B-A22B-fp8-tput":[2e-7,6e-7,null,null,null],"together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":[0.000002,0.000002,null,null,null],"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":[0.000002,0.000002,null,null,null],"together_ai/deepseek-ai/DeepSeek-R1":[0.000003,0.000007,null,null,null],"together_ai/deepseek-ai/DeepSeek-R1-0528-tput":[5.5e-7,0.00000219,null,null,null],"deepseek-ai/DeepSeek-R1-0528-tput":[5.5e-7,0.00000219,null,null,null],"together_ai/deepseek-ai/DeepSeek-V3":[0.00000125,0.00000125,null,null,null],"together_ai/deepseek-ai/DeepSeek-V3.1":[6e-7,0.0000017,null,null,null],"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo":[8.8e-7,8.8e-7,null,null,null],"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free":[0,0,null,null,null],"meta-llama/Llama-3.3-70B-Instruct-Turbo-Free":[0,0,null,null,null],"together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":[2.7e-7,8.5e-7,null,null,null],"together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct":[1.8e-7,5.9e-7,null,null,null],"together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo":[0.0000035,0.0000035,null,null,null],"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo":[0.0000035,0.0000035,null,null,null],"together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":[8.8e-7,8.8e-7,null,null,null],"together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":[1.8e-7,1.8e-7,null,null,null],"together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1":[6e-7,6e-7,null,null,null],"together_ai/moonshotai/Kimi-K2-Instruct":[0.000001,0.000003,null,null,null],"together_ai/openai/gpt-oss-120b":[1.5e-7,6e-7,null,null,null],"together_ai/openai/gpt-oss-20b":[5e-8,2e-7,null,null,null],"together_ai/zai-org/GLM-4.5-Air-FP8":[2e-7,0.0000011,null,null,null],"zai-org/GLM-4.5-Air-FP8":[2e-7,0.0000011,null,null,null],"together_ai/zai-org/GLM-4.6":[6e-7,0.0000022,null,null,null],"together_ai/zai-org/GLM-4.7":[4.5e-7,0.000002,null,null,null],"together_ai/moonshotai/Kimi-K2.5":[5e-7,0.0000028,null,null,null],"together_ai/moonshotai/Kimi-K2-Instruct-0905":[0.000001,0.000003,null,null,null],"together_ai/Qwen/Qwen3-Next-80B-A3B-Instruct":[1.5e-7,0.0000015,null,null,null],"together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking":[1.5e-7,0.0000015,null,null,null],"together_ai/Qwen/Qwen3.5-397B-A17B":[6e-7,0.0000036,null,null,null],"Qwen/Qwen3.5-397B-A17B":[6e-7,0.0000036,null,null,null],"v0/v0-1.0-md":[0.000003,0.000015,null,null,null],"v0-1.0-md":[0.000003,0.000015,null,null,null],"v0/v0-1.5-lg":[0.000015,0.000075,null,null,null],"v0-1.5-lg":[0.000015,0.000075,null,null,null],"v0/v0-1.5-md":[0.000003,0.000015,null,null,null],"v0-1.5-md":[0.000003,0.000015,null,null,null],"vercel_ai_gateway/alibaba/qwen-3-14b":[8e-8,2.4e-7,null,null,null],"alibaba/qwen-3-14b":[8e-8,2.4e-7,null,null,null],"vercel_ai_gateway/alibaba/qwen-3-235b":[2e-7,6e-7,null,null,null],"alibaba/qwen-3-235b":[2e-7,6e-7,null,null,null],"vercel_ai_gateway/alibaba/qwen-3-30b":[1e-7,3e-7,null,null,null],"alibaba/qwen-3-30b":[1e-7,3e-7,null,null,null],"vercel_ai_gateway/alibaba/qwen-3-32b":[1e-7,3e-7,null,null,null],"alibaba/qwen-3-32b":[1e-7,3e-7,null,null,null],"vercel_ai_gateway/alibaba/qwen3-coder":[4e-7,0.0000016,null,null,null],"alibaba/qwen3-coder":[4e-7,0.0000016,null,null,null],"vercel_ai_gateway/amazon/nova-lite":[6e-8,2.4e-7,null,null,null],"amazon/nova-lite":[6e-8,2.4e-7,null,null,null],"vercel_ai_gateway/amazon/nova-micro":[3.5e-8,1.4e-7,null,null,null],"amazon/nova-micro":[3.5e-8,1.4e-7,null,null,null],"vercel_ai_gateway/amazon/nova-pro":[8e-7,0.0000032,null,null,null],"amazon/nova-pro":[8e-7,0.0000032,null,null,null],"vercel_ai_gateway/amazon/titan-embed-text-v2":[2e-8,0,null,null,null],"amazon/titan-embed-text-v2":[2e-8,0,null,null,null],"vercel_ai_gateway/anthropic/claude-3-haiku":[2.5e-7,0.00000125,3e-7,3e-8,null],"vercel_ai_gateway/anthropic/claude-3-opus":[0.000015,0.000075,0.00001875,0.0000015,null],"anthropic/claude-3-opus":[0.000015,0.000075,0.00001875,0.0000015,null],"vercel_ai_gateway/anthropic/claude-3.5-haiku":[8e-7,0.000004,0.000001,8e-8,null],"vercel_ai_gateway/anthropic/claude-3.5-sonnet":[0.000003,0.000015,0.00000375,3e-7,null],"vercel_ai_gateway/anthropic/claude-3.7-sonnet":[0.000003,0.000015,0.00000375,3e-7,null],"vercel_ai_gateway/anthropic/claude-4-opus":[0.000015,0.000075,0.00001875,0.0000015,null],"vercel_ai_gateway/anthropic/claude-4-sonnet":[0.000003,0.000015,0.00000375,3e-7,null],"vercel_ai_gateway/anthropic/claude-3-5-sonnet":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic/claude-3-5-sonnet":[0.000003,0.000015,0.00000375,3e-7,null],"vercel_ai_gateway/anthropic/claude-3-5-sonnet-20241022":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic/claude-3-5-sonnet-20241022":[0.000003,0.000015,0.00000375,3e-7,null],"vercel_ai_gateway/anthropic/claude-3-7-sonnet":[0.000003,0.000015,0.00000375,3e-7,null],"anthropic/claude-3-7-sonnet":[0.000003,0.000015,0.00000375,3e-7,null],"vercel_ai_gateway/anthropic/claude-haiku-4.5":[0.000001,0.000005,0.00000125,1e-7,null],"vercel_ai_gateway/anthropic/claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015,null],"vercel_ai_gateway/anthropic/claude-opus-4.1":[0.000015,0.000075,0.00001875,0.0000015,null],"vercel_ai_gateway/anthropic/claude-opus-4.5":[0.000005,0.000025,0.00000625,5e-7,null],"vercel_ai_gateway/anthropic/claude-opus-4.6":[0.000005,0.000025,0.00000625,5e-7,null],"vercel_ai_gateway/anthropic/claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7,null],"vercel_ai_gateway/anthropic/claude-sonnet-4.5":[0.000003,0.000015,0.00000375,3e-7,null],"vercel_ai_gateway/cohere/command-a":[0.0000025,0.00001,null,null,null],"cohere/command-a":[0.0000025,0.00001,null,null,null],"vercel_ai_gateway/cohere/command-r":[1.5e-7,6e-7,null,null,null],"cohere/command-r":[1.5e-7,6e-7,null,null,null],"vercel_ai_gateway/cohere/command-r-plus":[0.0000025,0.00001,null,null,null],"cohere/command-r-plus":[0.0000025,0.00001,null,null,null],"vercel_ai_gateway/cohere/embed-v4.0":[1.2e-7,0,null,null,null],"vercel_ai_gateway/deepseek/deepseek-r1":[5.5e-7,0.00000219,null,null,null],"vercel_ai_gateway/deepseek/deepseek-r1-distill-llama-70b":[7.5e-7,9.9e-7,null,null,null],"deepseek/deepseek-r1-distill-llama-70b":[7.5e-7,9.9e-7,null,null,null],"vercel_ai_gateway/deepseek/deepseek-v3":[9e-7,9e-7,null,null,null],"vercel_ai_gateway/google/gemini-2.0-flash":[1.5e-7,6e-7,null,null,null],"google/gemini-2.0-flash":[1.5e-7,6e-7,null,null,null],"vercel_ai_gateway/google/gemini-2.0-flash-lite":[7.5e-8,3e-7,null,null,null],"google/gemini-2.0-flash-lite":[7.5e-8,3e-7,null,null,null],"vercel_ai_gateway/google/gemini-2.5-flash":[3e-7,0.0000025,null,null,null],"vercel_ai_gateway/google/gemini-2.5-pro":[0.0000025,0.00001,null,null,null],"vercel_ai_gateway/google/gemini-embedding-001":[1.5e-7,0,null,null,null],"google/gemini-embedding-001":[1.5e-7,0,null,null,null],"vercel_ai_gateway/google/gemma-2-9b":[2e-7,2e-7,null,null,null],"google/gemma-2-9b":[2e-7,2e-7,null,null,null],"vercel_ai_gateway/google/text-embedding-005":[2.5e-8,0,null,null,null],"google/text-embedding-005":[2.5e-8,0,null,null,null],"vercel_ai_gateway/google/text-multilingual-embedding-002":[2.5e-8,0,null,null,null],"google/text-multilingual-embedding-002":[2.5e-8,0,null,null,null],"vercel_ai_gateway/inception/mercury-coder-small":[2.5e-7,0.000001,null,null,null],"inception/mercury-coder-small":[2.5e-7,0.000001,null,null,null],"vercel_ai_gateway/meta/llama-3-70b":[5.9e-7,7.9e-7,null,null,null],"vercel_ai_gateway/meta/llama-3-8b":[5e-8,8e-8,null,null,null],"vercel_ai_gateway/meta/llama-3.1-70b":[7.2e-7,7.2e-7,null,null,null],"meta/llama-3.1-70b":[7.2e-7,7.2e-7,null,null,null],"vercel_ai_gateway/meta/llama-3.1-8b":[5e-8,8e-8,null,null,null],"meta/llama-3.1-8b":[5e-8,8e-8,null,null,null],"vercel_ai_gateway/meta/llama-3.2-11b":[1.6e-7,1.6e-7,null,null,null],"meta/llama-3.2-11b":[1.6e-7,1.6e-7,null,null,null],"vercel_ai_gateway/meta/llama-3.2-1b":[1e-7,1e-7,null,null,null],"meta/llama-3.2-1b":[1e-7,1e-7,null,null,null],"vercel_ai_gateway/meta/llama-3.2-3b":[1.5e-7,1.5e-7,null,null,null],"meta/llama-3.2-3b":[1.5e-7,1.5e-7,null,null,null],"vercel_ai_gateway/meta/llama-3.2-90b":[7.2e-7,7.2e-7,null,null,null],"meta/llama-3.2-90b":[7.2e-7,7.2e-7,null,null,null],"vercel_ai_gateway/meta/llama-3.3-70b":[7.2e-7,7.2e-7,null,null,null],"meta/llama-3.3-70b":[7.2e-7,7.2e-7,null,null,null],"vercel_ai_gateway/meta/llama-4-maverick":[2e-7,6e-7,null,null,null],"meta/llama-4-maverick":[2e-7,6e-7,null,null,null],"vercel_ai_gateway/meta/llama-4-scout":[1e-7,3e-7,null,null,null],"meta/llama-4-scout":[1e-7,3e-7,null,null,null],"vercel_ai_gateway/mistral/codestral":[3e-7,9e-7,null,null,null],"mistral/codestral":[3e-7,9e-7,null,null,null],"vercel_ai_gateway/mistral/codestral-embed":[1.5e-7,0,null,null,null],"mistral/codestral-embed":[1.5e-7,0,null,null,null],"vercel_ai_gateway/mistral/devstral-small":[7e-8,2.8e-7,null,null,null],"mistral/devstral-small":[7e-8,2.8e-7,null,null,null],"vercel_ai_gateway/mistral/magistral-medium":[0.000002,0.000005,null,null,null],"mistral/magistral-medium":[0.000002,0.000005,null,null,null],"vercel_ai_gateway/mistral/magistral-small":[5e-7,0.0000015,null,null,null],"mistral/magistral-small":[5e-7,0.0000015,null,null,null],"vercel_ai_gateway/mistral/ministral-3b":[4e-8,4e-8,null,null,null],"mistral/ministral-3b":[4e-8,4e-8,null,null,null],"vercel_ai_gateway/mistral/ministral-8b":[1e-7,1e-7,null,null,null],"mistral/ministral-8b":[1e-7,1e-7,null,null,null],"vercel_ai_gateway/mistral/mistral-embed":[1e-7,0,null,null,null],"mistral/mistral-embed":[1e-7,0,null,null,null],"vercel_ai_gateway/mistral/mistral-large":[0.000002,0.000006,null,null,null],"mistral/mistral-large":[0.000002,0.000006,null,null,null],"vercel_ai_gateway/mistral/mistral-saba-24b":[7.9e-7,7.9e-7,null,null,null],"mistral/mistral-saba-24b":[7.9e-7,7.9e-7,null,null,null],"vercel_ai_gateway/mistral/mistral-small":[1e-7,3e-7,null,null,null],"vercel_ai_gateway/mistral/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null,null],"mistral/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null,null],"vercel_ai_gateway/mistral/pixtral-12b":[1.5e-7,1.5e-7,null,null,null],"mistral/pixtral-12b":[1.5e-7,1.5e-7,null,null,null],"vercel_ai_gateway/mistral/pixtral-large":[0.000002,0.000006,null,null,null],"mistral/pixtral-large":[0.000002,0.000006,null,null,null],"vercel_ai_gateway/moonshotai/kimi-k2":[5.5e-7,0.0000022,null,null,null],"moonshotai/kimi-k2":[5.5e-7,0.0000022,null,null,null],"vercel_ai_gateway/morph/morph-v3-fast":[8e-7,0.0000012,null,null,null],"vercel_ai_gateway/morph/morph-v3-large":[9e-7,0.0000019,null,null,null],"vercel_ai_gateway/openai/gpt-3.5-turbo":[5e-7,0.0000015,null,null,null],"vercel_ai_gateway/openai/gpt-3.5-turbo-instruct":[0.0000015,0.000002,null,null,null],"openai/gpt-3.5-turbo-instruct":[0.0000015,0.000002,null,null,null],"vercel_ai_gateway/openai/gpt-4-turbo":[0.00001,0.00003,null,null,null],"openai/gpt-4-turbo":[0.00001,0.00003,null,null,null],"vercel_ai_gateway/openai/gpt-4.1":[0.000002,0.000008,0,5e-7,null],"vercel_ai_gateway/openai/gpt-4.1-mini":[4e-7,0.0000016,0,1e-7,null],"vercel_ai_gateway/openai/gpt-4.1-nano":[1e-7,4e-7,0,2.5e-8,null],"vercel_ai_gateway/openai/gpt-4o":[0.0000025,0.00001,0,0.00000125,null],"vercel_ai_gateway/openai/gpt-4o-mini":[1.5e-7,6e-7,0,7.5e-8,null],"vercel_ai_gateway/openai/o1":[0.000015,0.00006,0,0.0000075,null],"vercel_ai_gateway/openai/o3":[0.000002,0.000008,0,5e-7,null],"openai/o3":[0.000002,0.000008,0,5e-7,null],"vercel_ai_gateway/openai/o3-mini":[0.0000011,0.0000044,0,5.5e-7,null],"vercel_ai_gateway/openai/o4-mini":[0.0000011,0.0000044,0,2.75e-7,null],"vercel_ai_gateway/openai/text-embedding-3-large":[1.3e-7,0,null,null,null],"openai/text-embedding-3-large":[1.3e-7,0,null,null,null],"vercel_ai_gateway/openai/text-embedding-3-small":[2e-8,0,null,null,null],"openai/text-embedding-3-small":[2e-8,0,null,null,null],"vercel_ai_gateway/openai/text-embedding-ada-002":[1e-7,0,null,null,null],"openai/text-embedding-ada-002":[1e-7,0,null,null,null],"vercel_ai_gateway/perplexity/sonar":[0.000001,0.000001,null,null,null],"vercel_ai_gateway/perplexity/sonar-pro":[0.000003,0.000015,null,null,null],"vercel_ai_gateway/perplexity/sonar-reasoning":[0.000001,0.000005,null,null,null],"vercel_ai_gateway/perplexity/sonar-reasoning-pro":[0.000002,0.000008,null,null,null],"vercel_ai_gateway/vercel/v0-1.0-md":[0.000003,0.000015,null,null,null],"vercel/v0-1.0-md":[0.000003,0.000015,null,null,null],"vercel_ai_gateway/vercel/v0-1.5-md":[0.000003,0.000015,null,null,null],"vercel/v0-1.5-md":[0.000003,0.000015,null,null,null],"vercel_ai_gateway/xai/grok-2":[0.000002,0.00001,null,null,null],"xai/grok-2":[0.000002,0.00001,null,null,null],"vercel_ai_gateway/xai/grok-2-vision":[0.000002,0.00001,null,null,null],"xai/grok-2-vision":[0.000002,0.00001,null,null,null],"vercel_ai_gateway/xai/grok-3":[0.000003,0.000015,null,null,null],"xai/grok-3":[0.000003,0.000015,null,null,null],"vercel_ai_gateway/xai/grok-3-fast":[0.000005,0.000025,null,null,null],"xai/grok-3-fast":[0.000005,0.000025,null,null,null],"vercel_ai_gateway/xai/grok-3-mini":[3e-7,5e-7,null,null,null],"xai/grok-3-mini":[3e-7,5e-7,null,null,null],"vercel_ai_gateway/xai/grok-3-mini-fast":[6e-7,0.000004,null,null,null],"xai/grok-3-mini-fast":[6e-7,0.000004,null,null,null],"vercel_ai_gateway/xai/grok-4":[0.000003,0.000015,null,null,null],"vercel_ai_gateway/zai/glm-4.5":[6e-7,0.0000022,null,null,null],"zai/glm-4.5":[6e-7,0.0000022,null,null,null],"vercel_ai_gateway/zai/glm-4.5-air":[2e-7,0.0000011,null,null,null],"zai/glm-4.5-air":[2e-7,0.0000011,null,null,null],"vercel_ai_gateway/zai/glm-4.6":[4.5e-7,0.0000018,null,1.1e-7,null],"zai/glm-4.6":[4.5e-7,0.0000018,null,1.1e-7,null],"vertex_ai/claude-3-5-haiku":[0.000001,0.000005,null,null,null],"claude-3-5-haiku":[0.000001,0.000005,null,null,null],"vertex_ai/claude-3-5-haiku@20241022":[0.000001,0.000005,null,null,null],"claude-3-5-haiku@20241022":[0.000001,0.000005,null,null,null],"vertex_ai/claude-haiku-4-5":[0.000001,0.000005,0.00000125,1e-7,null],"vertex_ai/claude-haiku-4-5@20251001":[0.000001,0.000005,0.00000125,1e-7,null],"claude-haiku-4-5@20251001":[0.000001,0.000005,0.00000125,1e-7,null],"vertex_ai/claude-3-5-sonnet":[0.000003,0.000015,null,null,null],"vertex_ai/claude-3-5-sonnet@20240620":[0.000003,0.000015,null,null,null],"claude-3-5-sonnet@20240620":[0.000003,0.000015,null,null,null],"vertex_ai/claude-3-7-sonnet@20250219":[0.000003,0.000015,0.00000375,3e-7,null],"claude-3-7-sonnet@20250219":[0.000003,0.000015,0.00000375,3e-7,null],"vertex_ai/claude-3-haiku":[2.5e-7,0.00000125,null,null,null],"claude-3-haiku":[2.5e-7,0.00000125,null,null,null],"vertex_ai/claude-3-haiku@20240307":[2.5e-7,0.00000125,null,null,null],"claude-3-haiku@20240307":[2.5e-7,0.00000125,null,null,null],"vertex_ai/claude-3-opus":[0.000015,0.000075,null,null,null],"claude-3-opus":[0.000015,0.000075,null,null,null],"vertex_ai/claude-3-opus@20240229":[0.000015,0.000075,null,null,null],"claude-3-opus@20240229":[0.000015,0.000075,null,null,null],"vertex_ai/claude-3-sonnet":[0.000003,0.000015,null,null,null],"claude-3-sonnet":[0.000003,0.000015,null,null,null],"vertex_ai/claude-3-sonnet@20240229":[0.000003,0.000015,null,null,null],"claude-3-sonnet@20240229":[0.000003,0.000015,null,null,null],"vertex_ai/claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015,null],"claude-opus-4":[0.000015,0.000075,0.00001875,0.0000015,null],"vertex_ai/claude-opus-4-1":[0.000015,0.000075,0.00001875,0.0000015,null],"vertex_ai/claude-opus-4-1@20250805":[0.000015,0.000075,0.00001875,0.0000015,null],"claude-opus-4-1@20250805":[0.000015,0.000075,0.00001875,0.0000015,null],"vertex_ai/claude-opus-4-5":[0.000005,0.000025,0.00000625,5e-7,null],"vertex_ai/claude-opus-4-5@20251101":[0.000005,0.000025,0.00000625,5e-7,null],"claude-opus-4-5@20251101":[0.000005,0.000025,0.00000625,5e-7,null],"vertex_ai/claude-opus-4-6":[0.000005,0.000025,0.00000625,5e-7,null],"vertex_ai/claude-opus-4-6@default":[0.000005,0.000025,0.00000625,5e-7,null],"claude-opus-4-6@default":[0.000005,0.000025,0.00000625,5e-7,null],"vertex_ai/claude-opus-4-7":[0.000005,0.000025,0.00000625,5e-7,null],"vertex_ai/claude-opus-4-7@default":[0.000005,0.000025,0.00000625,5e-7,null],"claude-opus-4-7@default":[0.000005,0.000025,0.00000625,5e-7,null],"vertex_ai/claude-fable-5":[0.00001,0.00005,0.0000125,0.000001,null],"vertex_ai/claude-fable-5@default":[0.00001,0.00005,0.0000125,0.000001,null],"claude-fable-5@default":[0.00001,0.00005,0.0000125,0.000001,null],"vertex_ai/claude-opus-4-8":[0.000005,0.000025,0.00000625,5e-7,null],"vertex_ai/claude-opus-4-8@default":[0.000005,0.000025,0.00000625,5e-7,null],"claude-opus-4-8@default":[0.000005,0.000025,0.00000625,5e-7,null],"vertex_ai/claude-sonnet-4-5":[0.000003,0.000015,0.00000375,3e-7,null],"vertex_ai/claude-sonnet-5":[0.000003,0.000015,0.00000375,3e-7,null],"vertex_ai/claude-sonnet-4-6":[0.000003,0.000015,0.00000375,3e-7,null],"vertex_ai/claude-sonnet-4-5@20250929":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-4-5@20250929":[0.000003,0.000015,0.00000375,3e-7,null],"vertex_ai/claude-opus-4@20250514":[0.000015,0.000075,0.00001875,0.0000015,null],"claude-opus-4@20250514":[0.000015,0.000075,0.00001875,0.0000015,null],"vertex_ai/claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-4":[0.000003,0.000015,0.00000375,3e-7,null],"vertex_ai/claude-sonnet-4@20250514":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-4@20250514":[0.000003,0.000015,0.00000375,3e-7,null],"vertex_ai/mistralai/codestral-2@001":[3e-7,9e-7,null,null,null],"mistralai/codestral-2@001":[3e-7,9e-7,null,null,null],"vertex_ai/codestral-2":[3e-7,9e-7,null,null,null],"codestral-2":[3e-7,9e-7,null,null,null],"vertex_ai/codestral-2@001":[3e-7,9e-7,null,null,null],"codestral-2@001":[3e-7,9e-7,null,null,null],"vertex_ai/mistralai/codestral-2":[3e-7,9e-7,null,null,null],"mistralai/codestral-2":[3e-7,9e-7,null,null,null],"vertex_ai/codestral-2501":[2e-7,6e-7,null,null,null],"codestral-2501":[2e-7,6e-7,null,null,null],"vertex_ai/codestral@2405":[2e-7,6e-7,null,null,null],"codestral@2405":[2e-7,6e-7,null,null,null],"vertex_ai/codestral@latest":[2e-7,6e-7,null,null,null],"codestral@latest":[2e-7,6e-7,null,null,null],"vertex_ai/deepseek-ai/deepseek-v3.1-maas":[0.00000135,0.0000054,null,null,null],"deepseek-ai/deepseek-v3.1-maas":[0.00000135,0.0000054,null,null,null],"vertex_ai/deepseek-ai/deepseek-v3.2-maas":[5.6e-7,0.00000168,null,null,null],"deepseek-ai/deepseek-v3.2-maas":[5.6e-7,0.00000168,null,null,null],"vertex_ai/deepseek-ai/deepseek-r1-0528-maas":[0.00000135,0.0000054,null,null,null],"deepseek-ai/deepseek-r1-0528-maas":[0.00000135,0.0000054,null,null,null],"vertex_ai/gemini-2.5-flash-image":[3e-7,0.0000025,null,3e-8,null],"vertex_ai/gemini-3-pro-image":[0.000002,0.000012,null,null,null],"vertex_ai/gemini-3-pro-image-preview":[0.000002,0.000012,null,null,null],"vertex_ai/gemini-3.1-flash-image":[5e-7,0.000003,null,null,null],"vertex_ai/gemini-3.1-flash-image-preview":[5e-7,0.000003,null,null,null],"vertex_ai/gemini-3.1-flash-lite-preview":[2.5e-7,0.0000015,null,2.5e-8,null],"vertex_ai/gemini-3.1-flash-lite":[2.5e-7,0.0000015,null,2.5e-8,null],"vertex_ai/deep-research-pro-preview-12-2025":[0.000002,0.000012,null,null,null],"vertex_ai/jamba-1.5":[2e-7,4e-7,null,null,null],"vertex_ai/jamba-1.5-large":[0.000002,0.000008,null,null,null],"vertex_ai/jamba-1.5-large@001":[0.000002,0.000008,null,null,null],"vertex_ai/jamba-1.5-mini":[2e-7,4e-7,null,null,null],"vertex_ai/jamba-1.5-mini@001":[2e-7,4e-7,null,null,null],"vertex_ai/meta/llama-3.1-405b-instruct-maas":[0.000005,0.000016,null,null,null],"meta/llama-3.1-405b-instruct-maas":[0.000005,0.000016,null,null,null],"vertex_ai/meta/llama-3.1-70b-instruct-maas":[0,0,null,null,null],"meta/llama-3.1-70b-instruct-maas":[0,0,null,null,null],"vertex_ai/meta/llama-3.1-8b-instruct-maas":[0,0,null,null,null],"meta/llama-3.1-8b-instruct-maas":[0,0,null,null,null],"vertex_ai/meta/llama-3.2-90b-vision-instruct-maas":[0,0,null,null,null],"meta/llama-3.2-90b-vision-instruct-maas":[0,0,null,null,null],"vertex_ai/meta/llama-4-maverick-17b-128e-instruct-maas":[3.5e-7,0.00000115,null,null,null],"meta/llama-4-maverick-17b-128e-instruct-maas":[3.5e-7,0.00000115,null,null,null],"vertex_ai/meta/llama-4-maverick-17b-16e-instruct-maas":[3.5e-7,0.00000115,null,null,null],"meta/llama-4-maverick-17b-16e-instruct-maas":[3.5e-7,0.00000115,null,null,null],"vertex_ai/meta/llama-4-scout-17b-128e-instruct-maas":[2.5e-7,7e-7,null,null,null],"meta/llama-4-scout-17b-128e-instruct-maas":[2.5e-7,7e-7,null,null,null],"vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas":[2.5e-7,7e-7,null,null,null],"meta/llama-4-scout-17b-16e-instruct-maas":[2.5e-7,7e-7,null,null,null],"vertex_ai/meta/llama3-405b-instruct-maas":[0,0,null,null,null],"meta/llama3-405b-instruct-maas":[0,0,null,null,null],"vertex_ai/meta/llama3-70b-instruct-maas":[0,0,null,null,null],"meta/llama3-70b-instruct-maas":[0,0,null,null,null],"vertex_ai/meta/llama3-8b-instruct-maas":[0,0,null,null,null],"meta/llama3-8b-instruct-maas":[0,0,null,null,null],"vertex_ai/minimaxai/minimax-m2-maas":[3e-7,0.0000012,null,null,null],"minimaxai/minimax-m2-maas":[3e-7,0.0000012,null,null,null],"vertex_ai/moonshotai/kimi-k2-thinking-maas":[6e-7,0.0000025,null,null,null],"moonshotai/kimi-k2-thinking-maas":[6e-7,0.0000025,null,null,null],"vertex_ai/zai-org/glm-4.7-maas":[6e-7,0.0000022,null,null,null],"zai-org/glm-4.7-maas":[6e-7,0.0000022,null,null,null],"vertex_ai/zai-org/glm-5-maas":[0.000001,0.0000032,null,1e-7,null],"zai-org/glm-5-maas":[0.000001,0.0000032,null,1e-7,null],"vertex_ai/mistral-medium-3":[4e-7,0.000002,null,null,null],"mistral-medium-3":[4e-7,0.000002,null,null,null],"vertex_ai/mistral-medium-3@001":[4e-7,0.000002,null,null,null],"mistral-medium-3@001":[4e-7,0.000002,null,null,null],"vertex_ai/mistralai/mistral-medium-3":[4e-7,0.000002,null,null,null],"mistralai/mistral-medium-3":[4e-7,0.000002,null,null,null],"vertex_ai/mistralai/mistral-medium-3@001":[4e-7,0.000002,null,null,null],"mistralai/mistral-medium-3@001":[4e-7,0.000002,null,null,null],"vertex_ai/mistral-large-2411":[0.000002,0.000006,null,null,null],"vertex_ai/mistral-large@2407":[0.000002,0.000006,null,null,null],"mistral-large@2407":[0.000002,0.000006,null,null,null],"vertex_ai/mistral-large@2411-001":[0.000002,0.000006,null,null,null],"mistral-large@2411-001":[0.000002,0.000006,null,null,null],"vertex_ai/mistral-large@latest":[0.000002,0.000006,null,null,null],"mistral-large@latest":[0.000002,0.000006,null,null,null],"vertex_ai/mistral-nemo@2407":[0.000003,0.000003,null,null,null],"mistral-nemo@2407":[0.000003,0.000003,null,null,null],"vertex_ai/mistral-nemo@latest":[1.5e-7,1.5e-7,null,null,null],"mistral-nemo@latest":[1.5e-7,1.5e-7,null,null,null],"vertex_ai/mistral-small-2503":[0.000001,0.000003,null,null,null],"vertex_ai/mistral-small-2503@001":[0.000001,0.000003,null,null,null],"mistral-small-2503@001":[0.000001,0.000003,null,null,null],"vertex_ai/deepseek-ai/deepseek-ocr-maas":[3e-7,0.0000012,null,null,null],"deepseek-ai/deepseek-ocr-maas":[3e-7,0.0000012,null,null,null],"vertex_ai/google/gemma-4-26b-a4b-it-maas":[1.5e-7,6e-7,null,null,null],"google/gemma-4-26b-a4b-it-maas":[1.5e-7,6e-7,null,null,null],"vertex_ai/openai/gpt-oss-120b-maas":[1.5e-7,6e-7,null,null,null],"openai/gpt-oss-120b-maas":[1.5e-7,6e-7,null,null,null],"vertex_ai/openai/gpt-oss-20b-maas":[7.5e-8,3e-7,null,null,null],"openai/gpt-oss-20b-maas":[7.5e-8,3e-7,null,null,null],"vertex_ai/xai/grok-4.1-fast-non-reasoning":[2e-7,5e-7,null,5e-8,null],"xai/grok-4.1-fast-non-reasoning":[2e-7,5e-7,null,5e-8,null],"vertex_ai/xai/grok-4.1-fast-reasoning":[2e-7,5e-7,null,5e-8,null],"xai/grok-4.1-fast-reasoning":[2e-7,5e-7,null,5e-8,null],"vertex_ai/xai/grok-4.20-non-reasoning":[0.000002,0.000006,null,2e-7,null],"xai/grok-4.20-non-reasoning":[0.000002,0.000006,null,2e-7,null],"vertex_ai/xai/grok-4.20-reasoning":[0.000002,0.000006,null,2e-7,null],"xai/grok-4.20-reasoning":[0.000002,0.000006,null,2e-7,null],"vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas":[2.5e-7,0.000001,null,null,null],"qwen/qwen3-235b-a22b-instruct-2507-maas":[2.5e-7,0.000001,null,null,null],"vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas":[0.000001,0.000004,null,null,null],"qwen/qwen3-coder-480b-a35b-instruct-maas":[0.000001,0.000004,null,null,null],"vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas":[1.5e-7,0.0000012,null,null,null],"qwen/qwen3-next-80b-a3b-instruct-maas":[1.5e-7,0.0000012,null,null,null],"vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas":[1.5e-7,0.0000012,null,null,null],"qwen/qwen3-next-80b-a3b-thinking-maas":[1.5e-7,0.0000012,null,null,null],"voyage/rerank-2":[5e-8,0,null,null,null],"rerank-2":[5e-8,0,null,null,null],"voyage/rerank-2-lite":[2e-8,0,null,null,null],"rerank-2-lite":[2e-8,0,null,null,null],"voyage/rerank-2.5":[5e-8,0,null,null,null],"rerank-2.5":[5e-8,0,null,null,null],"voyage/rerank-2.5-lite":[2e-8,0,null,null,null],"rerank-2.5-lite":[2e-8,0,null,null,null],"voyage/voyage-2":[1e-7,0,null,null,null],"voyage-2":[1e-7,0,null,null,null],"voyage/voyage-3":[6e-8,0,null,null,null],"voyage-3":[6e-8,0,null,null,null],"voyage/voyage-3-large":[1.8e-7,0,null,null,null],"voyage-3-large":[1.8e-7,0,null,null,null],"voyage/voyage-3-lite":[2e-8,0,null,null,null],"voyage-3-lite":[2e-8,0,null,null,null],"voyage/voyage-3.5":[6e-8,0,null,null,null],"voyage-3.5":[6e-8,0,null,null,null],"voyage/voyage-3.5-lite":[2e-8,0,null,null,null],"voyage-3.5-lite":[2e-8,0,null,null,null],"voyage/voyage-code-2":[1.2e-7,0,null,null,null],"voyage-code-2":[1.2e-7,0,null,null,null],"voyage/voyage-code-3":[1.8e-7,0,null,null,null],"voyage-code-3":[1.8e-7,0,null,null,null],"voyage/voyage-context-3":[1.8e-7,0,null,null,null],"voyage-context-3":[1.8e-7,0,null,null,null],"voyage/voyage-finance-2":[1.2e-7,0,null,null,null],"voyage-finance-2":[1.2e-7,0,null,null,null],"voyage/voyage-large-2":[1.2e-7,0,null,null,null],"voyage-large-2":[1.2e-7,0,null,null,null],"voyage/voyage-law-2":[1.2e-7,0,null,null,null],"voyage-law-2":[1.2e-7,0,null,null,null],"voyage/voyage-lite-01":[1e-7,0,null,null,null],"voyage-lite-01":[1e-7,0,null,null,null],"voyage/voyage-lite-02-instruct":[1e-7,0,null,null,null],"voyage-lite-02-instruct":[1e-7,0,null,null,null],"voyage/voyage-multimodal-3":[1.2e-7,0,null,null,null],"voyage-multimodal-3":[1.2e-7,0,null,null,null],"wandb/openai/gpt-oss-120b":[0.015,0.06,null,null,null],"wandb/openai/gpt-oss-20b":[0.005,0.02,null,null,null],"wandb/zai-org/GLM-4.5":[0.055,0.2,null,null,null],"wandb/Qwen/Qwen3-235B-A22B-Instruct-2507":[0.01,0.01,null,null,null],"wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct":[0.1,0.15,null,null,null],"wandb/Qwen/Qwen3-235B-A22B-Thinking-2507":[0.01,0.01,null,null,null],"wandb/moonshotai/Kimi-K2-Instruct":[6e-7,0.0000025,null,null,null],"wandb/moonshotai/Kimi-K2.5":[6e-7,0.000003,null,1e-7,null],"wandb/MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,null,null],"wandb/meta-llama/Llama-3.1-8B-Instruct":[0.022,0.022,null,null,null],"wandb/deepseek-ai/DeepSeek-V3.1":[0.055,0.165,null,null,null],"wandb/deepseek-ai/DeepSeek-R1-0528":[0.135,0.54,null,null,null],"wandb/deepseek-ai/DeepSeek-V3-0324":[0.114,0.275,null,null,null],"wandb/meta-llama/Llama-3.3-70B-Instruct":[0.071,0.071,null,null,null],"wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct":[0.017,0.066,null,null,null],"wandb/microsoft/Phi-4-mini-instruct":[0.008,0.035,null,null,null],"microsoft/Phi-4-mini-instruct":[0.008,0.035,null,null,null],"watsonx/ibm/granite-3-8b-instruct":[2e-7,2e-7,null,null,null],"ibm/granite-3-8b-instruct":[2e-7,2e-7,null,null,null],"watsonx/mistralai/mistral-large":[0.000003,0.00001,null,null,null],"watsonx/bigscience/mt0-xxl-13b":[0.0005,0.002,null,null,null],"bigscience/mt0-xxl-13b":[0.0005,0.002,null,null,null],"watsonx/core42/jais-13b-chat":[0.0005,0.002,null,null,null],"core42/jais-13b-chat":[0.0005,0.002,null,null,null],"watsonx/google/flan-t5-xl-3b":[6e-7,6e-7,null,null,null],"google/flan-t5-xl-3b":[6e-7,6e-7,null,null,null],"watsonx/ibm/granite-13b-chat-v2":[6e-7,6e-7,null,null,null],"ibm/granite-13b-chat-v2":[6e-7,6e-7,null,null,null],"watsonx/ibm/granite-13b-instruct-v2":[6e-7,6e-7,null,null,null],"ibm/granite-13b-instruct-v2":[6e-7,6e-7,null,null,null],"watsonx/ibm/granite-3-3-8b-instruct":[2e-7,2e-7,null,null,null],"ibm/granite-3-3-8b-instruct":[2e-7,2e-7,null,null,null],"watsonx/ibm/granite-4-h-small":[6e-8,2.5e-7,null,null,null],"ibm/granite-4-h-small":[6e-8,2.5e-7,null,null,null],"watsonx/ibm/granite-guardian-3-2-2b":[1e-7,1e-7,null,null,null],"ibm/granite-guardian-3-2-2b":[1e-7,1e-7,null,null,null],"watsonx/ibm/granite-guardian-3-3-8b":[2e-7,2e-7,null,null,null],"ibm/granite-guardian-3-3-8b":[2e-7,2e-7,null,null,null],"watsonx/ibm/granite-ttm-1024-96-r2":[3.8e-7,3.8e-7,null,null,null],"ibm/granite-ttm-1024-96-r2":[3.8e-7,3.8e-7,null,null,null],"watsonx/ibm/granite-ttm-1536-96-r2":[3.8e-7,3.8e-7,null,null,null],"ibm/granite-ttm-1536-96-r2":[3.8e-7,3.8e-7,null,null,null],"watsonx/ibm/granite-ttm-512-96-r2":[3.8e-7,3.8e-7,null,null,null],"ibm/granite-ttm-512-96-r2":[3.8e-7,3.8e-7,null,null,null],"watsonx/ibm/granite-vision-3-2-2b":[1e-7,1e-7,null,null,null],"ibm/granite-vision-3-2-2b":[1e-7,1e-7,null,null,null],"watsonx/meta-llama/llama-3-2-11b-vision-instruct":[3.5e-7,3.5e-7,null,null,null],"meta-llama/llama-3-2-11b-vision-instruct":[3.5e-7,3.5e-7,null,null,null],"watsonx/meta-llama/llama-3-2-1b-instruct":[1e-7,1e-7,null,null,null],"meta-llama/llama-3-2-1b-instruct":[1e-7,1e-7,null,null,null],"watsonx/meta-llama/llama-3-2-3b-instruct":[1.5e-7,1.5e-7,null,null,null],"meta-llama/llama-3-2-3b-instruct":[1.5e-7,1.5e-7,null,null,null],"watsonx/meta-llama/llama-3-2-90b-vision-instruct":[0.000002,0.000002,null,null,null],"meta-llama/llama-3-2-90b-vision-instruct":[0.000002,0.000002,null,null,null],"watsonx/meta-llama/llama-3-3-70b-instruct":[7.1e-7,7.1e-7,null,null,null],"meta-llama/llama-3-3-70b-instruct":[7.1e-7,7.1e-7,null,null,null],"watsonx/meta-llama/llama-4-maverick-17b":[3.5e-7,0.0000014,null,null,null],"meta-llama/llama-4-maverick-17b":[3.5e-7,0.0000014,null,null,null],"watsonx/meta-llama/llama-guard-3-11b-vision":[3.5e-7,3.5e-7,null,null,null],"meta-llama/llama-guard-3-11b-vision":[3.5e-7,3.5e-7,null,null,null],"watsonx/mistralai/mistral-medium-2505":[0.000003,0.00001,null,null,null],"mistralai/mistral-medium-2505":[0.000003,0.00001,null,null,null],"watsonx/mistralai/mistral-small-2503":[1e-7,3e-7,null,null,null],"mistralai/mistral-small-2503":[1e-7,3e-7,null,null,null],"watsonx/mistralai/mistral-small-3-1-24b-instruct-2503":[1e-7,3e-7,null,null,null],"mistralai/mistral-small-3-1-24b-instruct-2503":[1e-7,3e-7,null,null,null],"watsonx/mistralai/pixtral-12b-2409":[3.5e-7,3.5e-7,null,null,null],"mistralai/pixtral-12b-2409":[3.5e-7,3.5e-7,null,null,null],"watsonx/openai/gpt-oss-120b":[1.5e-7,6e-7,null,null,null],"watsonx/sdaia/allam-1-13b-instruct":[0.0000018,0.0000018,null,null,null],"sdaia/allam-1-13b-instruct":[0.0000018,0.0000018,null,null,null],"grok-2":[0.000002,0.00001,null,null,null],"xai/grok-2-1212":[0.000002,0.00001,null,null,null],"grok-2-1212":[0.000002,0.00001,null,null,null],"xai/grok-2-latest":[0.000002,0.00001,null,null,null],"grok-2-latest":[0.000002,0.00001,null,null,null],"grok-2-vision":[0.000002,0.00001,null,null,null],"xai/grok-2-vision-1212":[0.000002,0.00001,null,null,null],"grok-2-vision-1212":[0.000002,0.00001,null,null,null],"xai/grok-2-vision-latest":[0.000002,0.00001,null,null,null],"grok-2-vision-latest":[0.000002,0.00001,null,null,null],"xai/grok-3-beta":[0.000003,0.000015,null,7.5e-7,null],"grok-3-beta":[0.000003,0.000015,null,7.5e-7,null],"xai/grok-3-fast-beta":[0.000005,0.000025,null,0.00000125,null],"grok-3-fast-beta":[0.000005,0.000025,null,0.00000125,null],"xai/grok-3-fast-latest":[0.000005,0.000025,null,0.00000125,null],"grok-3-fast-latest":[0.000005,0.000025,null,0.00000125,null],"xai/grok-3-latest":[0.000003,0.000015,null,7.5e-7,null],"grok-3-latest":[0.000003,0.000015,null,7.5e-7,null],"xai/grok-3-mini-beta":[3e-7,5e-7,null,7.5e-8,null],"grok-3-mini-beta":[3e-7,5e-7,null,7.5e-8,null],"grok-3-mini-fast":[6e-7,0.000004,null,1.5e-7,null],"xai/grok-3-mini-fast-beta":[6e-7,0.000004,null,1.5e-7,null],"grok-3-mini-fast-beta":[6e-7,0.000004,null,1.5e-7,null],"xai/grok-3-mini-fast-latest":[6e-7,0.000004,null,1.5e-7,null],"grok-3-mini-fast-latest":[6e-7,0.000004,null,1.5e-7,null],"xai/grok-3-mini-latest":[3e-7,5e-7,null,7.5e-8,null],"grok-3-mini-latest":[3e-7,5e-7,null,7.5e-8,null],"xai/grok-4-fast-reasoning":[2e-7,5e-7,null,5e-8,null],"xai/grok-4-fast-non-reasoning":[2e-7,5e-7,null,5e-8,null],"xai/grok-4-0709":[0.000003,0.000015,null,null,null],"grok-4-0709":[0.000003,0.000015,null,null,null],"xai/grok-4-latest":[0.000003,0.000015,null,null,null],"grok-4-latest":[0.000003,0.000015,null,null,null],"xai/grok-4-1-fast":[2e-7,5e-7,null,5e-8,null],"grok-4-1-fast":[2e-7,5e-7,null,5e-8,null],"xai/grok-4-1-fast-reasoning":[2e-7,5e-7,null,5e-8,null],"xai/grok-4-1-fast-reasoning-latest":[2e-7,5e-7,null,5e-8,null],"grok-4-1-fast-reasoning-latest":[2e-7,5e-7,null,5e-8,null],"xai/grok-4-1-fast-non-reasoning":[2e-7,5e-7,null,5e-8,null],"xai/grok-4-1-fast-non-reasoning-latest":[2e-7,5e-7,null,5e-8,null],"grok-4-1-fast-non-reasoning-latest":[2e-7,5e-7,null,5e-8,null],"xai/grok-4.20-multi-agent-beta-0309":[0.000002,0.000006,null,2e-7,null],"grok-4.20-multi-agent-beta-0309":[0.000002,0.000006,null,2e-7,null],"xai/grok-4.20-beta-0309-reasoning":[0.000002,0.000006,null,2e-7,null],"grok-4.20-beta-0309-reasoning":[0.000002,0.000006,null,2e-7,null],"xai/grok-4.20-0309-reasoning":[0.000002,0.000006,null,2e-7,null],"grok-4.20-0309-reasoning":[0.000002,0.000006,null,2e-7,null],"xai/grok-4.20-beta-0309-non-reasoning":[0.000002,0.000006,null,2e-7,null],"grok-4.20-beta-0309-non-reasoning":[0.000002,0.000006,null,2e-7,null],"xai/grok-4.3":[0.00000125,0.0000025,null,2e-7,null],"grok-4.3":[0.00000125,0.0000025,null,2e-7,null],"xai/grok-4.3-latest":[0.00000125,0.0000025,null,2e-7,null],"grok-4.3-latest":[0.00000125,0.0000025,null,2e-7,null],"xai/grok-beta":[0.000005,0.000015,null,null,null],"grok-beta":[0.000005,0.000015,null,null,null],"xai/grok-code-fast":[2e-7,0.0000015,null,2e-8,null],"grok-code-fast":[2e-7,0.0000015,null,2e-8,null],"xai/grok-code-fast-1":[2e-7,0.0000015,null,2e-8,null],"xai/grok-code-fast-1-0825":[2e-7,0.0000015,null,2e-8,null],"grok-code-fast-1-0825":[2e-7,0.0000015,null,2e-8,null],"xai/grok-vision-beta":[0.000005,0.000015,null,null,null],"grok-vision-beta":[0.000005,0.000015,null,null,null],"zai/glm-5":[0.000001,0.0000032,0,2e-7,null],"glm-5":[0.000001,0.0000032,0,2e-7,null],"zai/glm-5.1":[0.0000014,0.0000044,0,2.6e-7,null],"glm-5.1":[0.0000014,0.0000044,0,2.6e-7,null],"zai/glm-5-code":[0.0000012,0.000005,0,3e-7,null],"glm-5-code":[0.0000012,0.000005,0,3e-7,null],"zai/glm-4.7":[6e-7,0.0000022,0,1.1e-7,null],"glm-4.7":[6e-7,0.0000022,0,1.1e-7,null],"zai/glm-4.7-flash":[0,0,0,0,null],"glm-4.7-flash":[0,0,0,0,null],"glm-4.6":[6e-7,0.0000022,0,1.1e-7,null],"glm-4.5":[6e-7,0.0000022,null,null,null],"zai/glm-4.5v":[6e-7,0.0000018,null,null,null],"glm-4.5v":[6e-7,0.0000018,null,null,null],"zai/glm-4.5-x":[0.0000022,0.0000089,null,null,null],"glm-4.5-x":[0.0000022,0.0000089,null,null,null],"glm-4.5-air":[2e-7,0.0000011,null,null,null],"zai/glm-4.5-airx":[0.0000011,0.0000045,null,null,null],"glm-4.5-airx":[0.0000011,0.0000045,null,null,null],"zai/glm-4-32b-0414-128k":[1e-7,1e-7,null,null,null],"glm-4-32b-0414-128k":[1e-7,1e-7,null,null,null],"zai/glm-4.5-flash":[0,0,null,null,null],"glm-4.5-flash":[0,0,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":[4.5e-7,0.0000018,null,null,null],"accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":[4.5e-7,0.0000018,null,null,null],"fireworks_ai/accounts/fireworks/models/flux-kontext-pro":[4e-8,4e-8,null,null,null],"accounts/fireworks/models/flux-kontext-pro":[4e-8,4e-8,null,null,null],"fireworks_ai/accounts/fireworks/models/SSD-1B":[1.3e-10,1.3e-10,null,null,null],"accounts/fireworks/models/SSD-1B":[1.3e-10,1.3e-10,null,null,null],"fireworks_ai/accounts/fireworks/models/chronos-hermes-13b-v2":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/chronos-hermes-13b-v2":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-13b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/code-llama-13b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-13b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/code-llama-13b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-13b-python":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/code-llama-13b-python":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-34b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/code-llama-34b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-34b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/code-llama-34b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-34b-python":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/code-llama-34b-python":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-70b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/code-llama-70b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-70b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/code-llama-70b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-70b-python":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/code-llama-70b-python":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/code-llama-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-7b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/code-llama-7b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-llama-7b-python":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/code-llama-7b-python":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/code-qwen-1p5-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/code-qwen-1p5-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/codegemma-2b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/codegemma-2b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/codegemma-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/codegemma-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/cogito-671b-v2-p1":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/cogito-671b-v2-p1":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-3b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/cogito-v1-preview-llama-3b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-70b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/cogito-v1-preview-llama-70b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-8b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/cogito-v1-preview-llama-8b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-qwen-14b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/cogito-v1-preview-qwen-14b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-qwen-32b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/cogito-v1-preview-qwen-32b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/flux-kontext-max":[8e-8,8e-8,null,null,null],"accounts/fireworks/models/flux-kontext-max":[8e-8,8e-8,null,null,null],"fireworks_ai/accounts/fireworks/models/dbrx-instruct":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/dbrx-instruct":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-1b-base":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/deepseek-coder-1b-base":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-33b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/deepseek-coder-33b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-base":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/deepseek-coder-7b-base":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-base-v1p5":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/deepseek-coder-7b-base-v1p5":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-instruct-v1p5":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/deepseek-coder-7b-instruct-v1p5":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-lite-base":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/deepseek-coder-v2-lite-base":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-lite-instruct":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/deepseek-coder-v2-lite-instruct":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-prover-v2":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/deepseek-prover-v2":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-0528-distill-qwen3-8b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/deepseek-r1-0528-distill-qwen3-8b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-llama-70b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/deepseek-r1-distill-llama-70b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-llama-8b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/deepseek-r1-distill-llama-8b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-14b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-14b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-1p5b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-1p5b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-32b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-32b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/deepseek-r1-distill-qwen-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v2-lite-chat":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/deepseek-v2-lite-chat":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/deepseek-v2p5":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/deepseek-v2p5":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/devstral-small-2505":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/devstral-small-2505":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/dobby-mini-unhinged-plus-llama-3-1-8b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/dobby-mini-unhinged-plus-llama-3-1-8b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/dobby-unhinged-llama-3-3-70b-new":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/dobby-unhinged-llama-3-3-70b-new":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/dolphin-2-9-2-qwen2-72b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/dolphin-2-9-2-qwen2-72b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/dolphin-2p6-mixtral-8x7b":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/dolphin-2p6-mixtral-8x7b":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/ernie-4p5-21b-a3b-pt":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/ernie-4p5-21b-a3b-pt":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/ernie-4p5-300b-a47b-pt":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/ernie-4p5-300b-a47b-pt":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/fare-20b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/fare-20b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/firefunction-v1":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/firefunction-v1":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/firellava-13b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/firellava-13b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/firesearch-ocr-v6":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/firesearch-ocr-v6":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/fireworks-asr-large":[0,0,null,null,null],"accounts/fireworks/models/fireworks-asr-large":[0,0,null,null,null],"fireworks_ai/accounts/fireworks/models/fireworks-asr-v2":[0,0,null,null,null],"accounts/fireworks/models/fireworks-asr-v2":[0,0,null,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-dev":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/flux-1-dev":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-dev-controlnet-union":[1e-9,1e-9,null,null,null],"accounts/fireworks/models/flux-1-dev-controlnet-union":[1e-9,1e-9,null,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-dev-fp8":[5e-10,5e-10,null,null,null],"accounts/fireworks/models/flux-1-dev-fp8":[5e-10,5e-10,null,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-schnell":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/flux-1-schnell":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/flux-1-schnell-fp8":[3.5e-10,3.5e-10,null,null,null],"accounts/fireworks/models/flux-1-schnell-fp8":[3.5e-10,3.5e-10,null,null,null],"fireworks_ai/accounts/fireworks/models/gemma-2b-it":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/gemma-2b-it":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/gemma-3-27b-it":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/gemma-3-27b-it":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/gemma-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/gemma-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/gemma-7b-it":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/gemma-7b-it":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/gemma2-9b-it":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/gemma2-9b-it":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/glm-4p5v":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/glm-4p5v":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-safeguard-120b":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/gpt-oss-safeguard-120b":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/gpt-oss-safeguard-20b":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/gpt-oss-safeguard-20b":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/hermes-2-pro-mistral-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/hermes-2-pro-mistral-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/internvl3-38b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/internvl3-38b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/internvl3-78b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/internvl3-78b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/internvl3-8b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/internvl3-8b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/japanese-stable-diffusion-xl":[1.3e-10,1.3e-10,null,null,null],"accounts/fireworks/models/japanese-stable-diffusion-xl":[1.3e-10,1.3e-10,null,null,null],"fireworks_ai/accounts/fireworks/models/kat-coder":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/kat-coder":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/kat-dev-32b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/kat-dev-32b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/kat-dev-72b-exp":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/kat-dev-72b-exp":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-guard-2-8b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llama-guard-2-8b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-guard-3-1b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/llama-guard-3-1b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-guard-3-8b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llama-guard-3-8b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-13b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llama-v2-13b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-13b-chat":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llama-v2-13b-chat":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-70b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/llama-v2-70b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-70b-chat":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/llama-v2-70b-chat":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llama-v2-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v2-7b-chat":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llama-v2-7b-chat":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/llama-v3-70b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct-hf":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/llama-v3-70b-instruct-hf":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-8b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llama-v3-8b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3-8b-instruct-hf":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llama-v3-8b-instruct-hf":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct-long":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/llama-v3p1-405b-instruct-long":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/llama-v3p1-70b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct-1b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/llama-v3p1-70b-instruct-1b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p1-nemotron-70b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/llama-v3p1-nemotron-70b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-1b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/llama-v3p2-1b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p2-3b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/llama-v3p2-3b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llama-v3p3-70b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/llama-v3p3-70b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llamaguard-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/llamaguard-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/llava-yi-34b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/llava-yi-34b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/minimax-m1-80k":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/minimax-m1-80k":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/minimax-m2":[3e-7,0.0000012,null,null,null],"accounts/fireworks/models/minimax-m2":[3e-7,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/ministral-3-14b-instruct-2512":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/ministral-3-14b-instruct-2512":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/ministral-3-3b-instruct-2512":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/ministral-3-3b-instruct-2512":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/ministral-3-8b-instruct-2512":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/ministral-3-8b-instruct-2512":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/mistral-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-4k":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/mistral-7b-instruct-4k":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-v0p2":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/mistral-7b-instruct-v0p2":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-v3":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/mistral-7b-instruct-v3":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mistral-7b-v0p2":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/mistral-7b-v0p2":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mistral-large-3-fp8":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/mistral-large-3-fp8":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/mistral-nemo-base-2407":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/mistral-nemo-base-2407":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mistral-nemo-instruct-2407":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/mistral-nemo-instruct-2407":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mistral-small-24b-instruct-2501":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/mistral-small-24b-instruct-2501":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x22b":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/mixtral-8x22b":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null,null],"accounts/fireworks/models/mixtral-8x22b-instruct":[0.0000012,0.0000012,null,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x7b":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/mixtral-8x7b":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/mixtral-8x7b-instruct":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct-hf":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/mixtral-8x7b-instruct-hf":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/mythomax-l2-13b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/mythomax-l2-13b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/nemotron-nano-v2-12b-vl":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/nemotron-nano-v2-12b-vl":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/nous-capybara-7b-v1p9":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/nous-capybara-7b-v1p9":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-2-mixtral-8x7b-dpo":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/nous-hermes-2-mixtral-8x7b-dpo":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-2-yi-34b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/nous-hermes-2-yi-34b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-13b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/nous-hermes-llama2-13b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-70b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/nous-hermes-llama2-70b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/nous-hermes-llama2-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/nvidia-nemotron-nano-12b-v2":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/nvidia-nemotron-nano-12b-v2":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/nvidia-nemotron-nano-9b-v2":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/nvidia-nemotron-nano-9b-v2":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/openchat-3p5-0106-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/openchat-3p5-0106-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/openhermes-2-mistral-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/openhermes-2-mistral-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/openhermes-2p5-mistral-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/openhermes-2p5-mistral-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/openorca-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/openorca-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/phi-2-3b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/phi-2-3b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/phi-3-mini-128k-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/phi-3-mini-128k-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/phi-3-vision-128k-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/phi-3-vision-128k-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-python-v1":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/phind-code-llama-34b-python-v1":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-v1":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/phind-code-llama-34b-v1":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-v2":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/phind-code-llama-34b-v2":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/playground-v2-1024px-aesthetic":[1.3e-10,1.3e-10,null,null,null],"accounts/fireworks/models/playground-v2-1024px-aesthetic":[1.3e-10,1.3e-10,null,null,null],"fireworks_ai/accounts/fireworks/models/playground-v2-5-1024px-aesthetic":[1.3e-10,1.3e-10,null,null,null],"accounts/fireworks/models/playground-v2-5-1024px-aesthetic":[1.3e-10,1.3e-10,null,null,null],"fireworks_ai/accounts/fireworks/models/pythia-12b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/pythia-12b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen-qwq-32b-preview":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen-qwq-32b-preview":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen-v2p5-14b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen-v2p5-14b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen-v2p5-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen-v2p5-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen1p5-72b-chat":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen1p5-72b-chat":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-7b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2-7b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-vl-2b-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen2-vl-2b-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-vl-72b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2-vl-72b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2-vl-7b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2-vl-7b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-0p5b-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen2p5-0p5b-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-14b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2p5-14b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-1p5b-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen2p5-1p5b-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-32b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-32b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-32b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-32b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-72b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-72b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-72b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-72b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-7b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2p5-7b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-0p5b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-0p5b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-0p5b-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-0p5b-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-14b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-14b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-14b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-14b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-1p5b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-1p5b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-1p5b-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-1p5b-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-32b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-128k":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct-128k":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-32k-rope":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct-32k-rope":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-64k":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-32b-instruct-64k":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-3b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-3b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-3b-instruct":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-3b-instruct":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-7b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2p5-coder-7b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-math-72b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-math-72b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-32b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-vl-32b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-3b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2p5-vl-3b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-72b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen2p5-vl-72b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-7b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen2p5-vl-7b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-0p6b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen3-0p6b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-14b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen3-14b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen3-1p7b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen3-1p7b-fp8-draft":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft-131072":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen3-1p7b-fp8-draft-131072":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft-40960":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/qwen3-1p7b-fp8-draft-40960":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b":[2.2e-7,8.8e-7,null,null,null],"accounts/fireworks/models/qwen3-235b-a22b":[2.2e-7,8.8e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b-instruct-2507":[2.2e-7,8.8e-7,null,null,null],"accounts/fireworks/models/qwen3-235b-a22b-instruct-2507":[2.2e-7,8.8e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b-thinking-2507":[2.2e-7,8.8e-7,null,null,null],"accounts/fireworks/models/qwen3-235b-a22b-thinking-2507":[2.2e-7,8.8e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b":[1.5e-7,6e-7,null,null,null],"accounts/fireworks/models/qwen3-30b-a3b":[1.5e-7,6e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b-instruct-2507":[5e-7,5e-7,null,null,null],"accounts/fireworks/models/qwen3-30b-a3b-instruct-2507":[5e-7,5e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b-thinking-2507":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen3-30b-a3b-thinking-2507":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-32b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen3-32b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-4b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen3-4b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-4b-instruct-2507":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen3-4b-instruct-2507":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-8b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen3-8b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-coder-30b-a3b-instruct":[1.5e-7,6e-7,null,null,null],"accounts/fireworks/models/qwen3-coder-30b-a3b-instruct":[1.5e-7,6e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-instruct-bf16":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen3-coder-480b-instruct-bf16":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-embedding-0p6b":[0,0,null,null,null],"accounts/fireworks/models/qwen3-embedding-0p6b":[0,0,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-embedding-4b":[0,0,null,null,null],"accounts/fireworks/models/qwen3-embedding-4b":[0,0,null,null,null],"fireworks_ai/accounts/fireworks/models/":[1e-7,0,null,null,null],"accounts/fireworks/models/":[1e-7,0,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-next-80b-a3b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen3-next-80b-a3b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-next-80b-a3b-thinking":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen3-next-80b-a3b-thinking":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-reranker-0p6b":[0,0,null,null,null],"accounts/fireworks/models/qwen3-reranker-0p6b":[0,0,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-reranker-4b":[0,0,null,null,null],"accounts/fireworks/models/qwen3-reranker-4b":[0,0,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-reranker-8b":[0,0,null,null,null],"accounts/fireworks/models/qwen3-reranker-8b":[0,0,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-instruct":[2.2e-7,8.8e-7,null,null,null],"accounts/fireworks/models/qwen3-vl-235b-a22b-instruct":[2.2e-7,8.8e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-thinking":[2.2e-7,8.8e-7,null,null,null],"accounts/fireworks/models/qwen3-vl-235b-a22b-thinking":[2.2e-7,8.8e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b-instruct":[1.5e-7,6e-7,null,null,null],"accounts/fireworks/models/qwen3-vl-30b-a3b-instruct":[1.5e-7,6e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b-thinking":[1.5e-7,6e-7,null,null,null],"accounts/fireworks/models/qwen3-vl-30b-a3b-thinking":[1.5e-7,6e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-32b-instruct":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwen3-vl-32b-instruct":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3-vl-8b-instruct":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/qwen3-vl-8b-instruct":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/qwen3p7-plus":[4e-7,0.0000016,null,8e-8,null],"accounts/fireworks/models/qwen3p7-plus":[4e-7,0.0000016,null,8e-8,null],"fireworks_ai/accounts/fireworks/models/qwq-32b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/qwq-32b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/rolm-ocr":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/rolm-ocr":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/snorkel-mistral-7b-pairrm-dpo":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/snorkel-mistral-7b-pairrm-dpo":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/stable-diffusion-xl-1024-v1-0":[1.3e-10,1.3e-10,null,null,null],"accounts/fireworks/models/stable-diffusion-xl-1024-v1-0":[1.3e-10,1.3e-10,null,null,null],"fireworks_ai/accounts/fireworks/models/stablecode-3b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/stablecode-3b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/starcoder-16b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/starcoder-16b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/starcoder-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/starcoder-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/starcoder2-15b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/starcoder2-15b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/starcoder2-3b":[1e-7,1e-7,null,null,null],"accounts/fireworks/models/starcoder2-3b":[1e-7,1e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/starcoder2-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/starcoder2-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/toppy-m-7b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/toppy-m-7b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/yi-34b":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/yi-34b":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/yi-34b-200k-capybara":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/yi-34b-200k-capybara":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/yi-34b-chat":[9e-7,9e-7,null,null,null],"accounts/fireworks/models/yi-34b-chat":[9e-7,9e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/yi-6b":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/yi-6b":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/models/zephyr-7b-beta":[2e-7,2e-7,null,null,null],"accounts/fireworks/models/zephyr-7b-beta":[2e-7,2e-7,null,null,null],"fireworks_ai/accounts/fireworks/routers/glm-5p1-fast":[0.0000028,0.0000088,null,5.2e-7,null],"accounts/fireworks/routers/glm-5p1-fast":[0.0000028,0.0000088,null,5.2e-7,null],"fireworks_ai/accounts/fireworks/routers/kimi-k2p6-fast":[0.000002,0.000008,null,3e-7,null],"accounts/fireworks/routers/kimi-k2p6-fast":[0.000002,0.000008,null,3e-7,null],"fireworks_ai/accounts/fireworks/routers/kimi-k2p7-code-fast":[0.0000019,0.000008,null,3.8e-7,null],"accounts/fireworks/routers/kimi-k2p7-code-fast":[0.0000019,0.000008,null,3.8e-7,null],"scaleway/qwen/qwen3.5-397b-a17b":[6e-7,0.0000036,null,null,null],"scaleway/qwen/qwen3.6-35b-a3b":[2.5e-7,0.0000015,null,null,null],"qwen/qwen3.6-35b-a3b":[2.5e-7,0.0000015,null,null,null],"scaleway/qwen/qwen3-235b-a22b-instruct-2507":[7.5e-7,0.00000225,null,null,null],"scaleway/qwen/qwen3-embedding-8b":[1e-7,0,null,null,null],"qwen/qwen3-embedding-8b":[1e-7,0,null,null,null],"scaleway/qwen/qwen3-coder-30b-a3b-instruct":[2e-7,8e-7,null,null,null],"qwen/qwen3-coder-30b-a3b-instruct":[2e-7,8e-7,null,null,null],"scaleway/openai/gpt-oss-120b":[1.5e-7,6e-7,null,null,null],"scaleway/google/gemma-4-26b-a4b-it":[2.5e-7,5e-7,null,null,null],"google/gemma-4-26b-a4b-it":[2.5e-7,5e-7,null,null,null],"scaleway/google/gemma-3-27b-it":[2.5e-7,5e-7,null,null,null],"scaleway/hcompany/holo2-30b-a3b":[3e-7,7e-7,null,null,null],"hcompany/holo2-30b-a3b":[3e-7,7e-7,null,null,null],"scaleway/mistralai/mistral-medium-3.5-128b":[0.0000015,0.0000075,null,null,null],"mistralai/mistral-medium-3.5-128b":[0.0000015,0.0000075,null,null,null],"scaleway/mistralai/devstral-2-123b-instruct-2512":[4e-7,0.000002,null,null,null],"mistralai/devstral-2-123b-instruct-2512":[4e-7,0.000002,null,null,null],"scaleway/mistralai/voxtral-small-24b-2507":[1.5e-7,3.5e-7,null,null,null],"mistralai/voxtral-small-24b-2507":[1.5e-7,3.5e-7,null,null,null],"scaleway/mistralai/mistral-small-3.2-24b-instruct-2506":[1.5e-7,3.5e-7,null,null,null],"mistralai/mistral-small-3.2-24b-instruct-2506":[1.5e-7,3.5e-7,null,null,null],"scaleway/mistralai/pixtral-12b-2409":[2e-7,2e-7,null,null,null],"scaleway/BAAI/bge-multilingual-gemma2":[1e-7,0,null,null,null],"scaleway/meta/llama-3.3-70b-instruct":[9e-7,9e-7,null,null,null],"meta/llama-3.3-70b-instruct":[9e-7,9e-7,null,null,null],"novita/deepseek/deepseek-v3.2":[2.69e-7,4e-7,null,1.345e-7,null],"novita/minimax/minimax-m2.1":[3e-7,0.0000012,null,3e-8,null],"novita/zai-org/glm-4.7":[6e-7,0.0000022,null,1.1e-7,null],"zai-org/glm-4.7":[6e-7,0.0000022,null,1.1e-7,null],"novita/xiaomimimo/mimo-v2-flash":[1e-7,3e-7,null,2e-8,null],"xiaomimimo/mimo-v2-flash":[1e-7,3e-7,null,2e-8,null],"novita/zai-org/autoglm-phone-9b-multilingual":[3.5e-8,1.38e-7,null,null,null],"zai-org/autoglm-phone-9b-multilingual":[3.5e-8,1.38e-7,null,null,null],"novita/moonshotai/kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"moonshotai/kimi-k2-thinking":[6e-7,0.0000025,null,null,null],"novita/minimax/minimax-m2":[3e-7,0.0000012,null,3e-8,null],"novita/paddlepaddle/paddleocr-vl":[2e-8,2e-8,null,null,null],"paddlepaddle/paddleocr-vl":[2e-8,2e-8,null,null,null],"novita/deepseek/deepseek-v3.2-exp":[2.7e-7,4.1e-7,null,null,null],"novita/qwen/qwen3-vl-235b-a22b-thinking":[9.8e-7,0.00000395,null,null,null],"qwen/qwen3-vl-235b-a22b-thinking":[9.8e-7,0.00000395,null,null,null],"novita/zai-org/glm-4.6v":[3e-7,9e-7,null,5.5e-8,null],"zai-org/glm-4.6v":[3e-7,9e-7,null,5.5e-8,null],"novita/zai-org/glm-4.6":[5.5e-7,0.0000022,null,1.1e-7,null],"zai-org/glm-4.6":[5.5e-7,0.0000022,null,1.1e-7,null],"novita/kwaipilot/kat-coder-pro":[3e-7,0.0000012,null,6e-8,null],"kwaipilot/kat-coder-pro":[3e-7,0.0000012,null,6e-8,null],"novita/qwen/qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000015,null,null,null],"qwen/qwen3-next-80b-a3b-instruct":[1.5e-7,0.0000015,null,null,null],"novita/qwen/qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000015,null,null,null],"qwen/qwen3-next-80b-a3b-thinking":[1.5e-7,0.0000015,null,null,null],"novita/deepseek/deepseek-ocr":[3e-8,3e-8,null,null,null],"deepseek/deepseek-ocr":[3e-8,3e-8,null,null,null],"novita/deepseek/deepseek-v3.1-terminus":[2.7e-7,0.000001,null,1.35e-7,null],"deepseek/deepseek-v3.1-terminus":[2.7e-7,0.000001,null,1.35e-7,null],"novita/qwen/qwen3-vl-235b-a22b-instruct":[3e-7,0.0000015,null,null,null],"qwen/qwen3-vl-235b-a22b-instruct":[3e-7,0.0000015,null,null,null],"novita/qwen/qwen3-max":[0.00000211,0.00000845,null,null,null],"qwen/qwen3-max":[0.00000211,0.00000845,null,null,null],"novita/skywork/r1v4-lite":[2e-7,6e-7,null,null,null],"skywork/r1v4-lite":[2e-7,6e-7,null,null,null],"novita/deepseek/deepseek-v3.1":[2.7e-7,0.000001,null,1.35e-7,null],"deepseek/deepseek-v3.1":[2.7e-7,0.000001,null,1.35e-7,null],"novita/moonshotai/kimi-k2-0905":[6e-7,0.0000025,null,null,null],"moonshotai/kimi-k2-0905":[6e-7,0.0000025,null,null,null],"novita/qwen/qwen3-coder-480b-a35b-instruct":[3e-7,0.0000013,null,null,null],"qwen/qwen3-coder-480b-a35b-instruct":[3e-7,0.0000013,null,null,null],"novita/qwen/qwen3-coder-30b-a3b-instruct":[7e-8,2.7e-7,null,null,null],"novita/openai/gpt-oss-120b":[5e-8,2.5e-7,null,null,null],"novita/moonshotai/kimi-k2-instruct":[5.7e-7,0.0000023,null,null,null],"moonshotai/kimi-k2-instruct":[5.7e-7,0.0000023,null,null,null],"novita/deepseek/deepseek-v3-0324":[2.7e-7,0.00000112,null,1.35e-7,null],"deepseek/deepseek-v3-0324":[2.7e-7,0.00000112,null,1.35e-7,null],"novita/zai-org/glm-4.5":[6e-7,0.0000022,null,1.1e-7,null],"zai-org/glm-4.5":[6e-7,0.0000022,null,1.1e-7,null],"novita/qwen/qwen3-235b-a22b-thinking-2507":[3e-7,0.000003,null,null,null],"novita/meta-llama/llama-3.1-8b-instruct":[2e-8,5e-8,null,null,null],"meta-llama/llama-3.1-8b-instruct":[2e-8,5e-8,null,null,null],"novita/google/gemma-3-12b-it":[5e-8,1e-7,null,null,null],"novita/zai-org/glm-4.5v":[6e-7,0.0000018,null,1.1e-7,null],"zai-org/glm-4.5v":[6e-7,0.0000018,null,1.1e-7,null],"novita/openai/gpt-oss-20b":[4e-8,1.5e-7,null,null,null],"novita/qwen/qwen3-235b-a22b-instruct-2507":[9e-8,5.8e-7,null,null,null],"novita/deepseek/deepseek-r1-distill-qwen-14b":[1.5e-7,1.5e-7,null,null,null],"deepseek/deepseek-r1-distill-qwen-14b":[1.5e-7,1.5e-7,null,null,null],"novita/meta-llama/llama-3.3-70b-instruct":[1.35e-7,4e-7,null,null,null],"meta-llama/llama-3.3-70b-instruct":[1.35e-7,4e-7,null,null,null],"novita/qwen/qwen-2.5-72b-instruct":[3.8e-7,4e-7,null,null,null],"qwen/qwen-2.5-72b-instruct":[3.8e-7,4e-7,null,null,null],"novita/mistralai/mistral-nemo":[4e-8,1.7e-7,null,null,null],"mistralai/mistral-nemo":[4e-8,1.7e-7,null,null,null],"novita/minimaxai/minimax-m1-80k":[5.5e-7,0.0000022,null,null,null],"minimaxai/minimax-m1-80k":[5.5e-7,0.0000022,null,null,null],"novita/deepseek/deepseek-r1-0528":[7e-7,0.0000025,null,3.5e-7,null],"novita/deepseek/deepseek-r1-distill-qwen-32b":[3e-7,3e-7,null,null,null],"deepseek/deepseek-r1-distill-qwen-32b":[3e-7,3e-7,null,null,null],"novita/meta-llama/llama-3-8b-instruct":[4e-8,4e-8,null,null,null],"meta-llama/llama-3-8b-instruct":[4e-8,4e-8,null,null,null],"novita/microsoft/wizardlm-2-8x22b":[6.2e-7,6.2e-7,null,null,null],"microsoft/wizardlm-2-8x22b":[6.2e-7,6.2e-7,null,null,null],"novita/deepseek/deepseek-r1-0528-qwen3-8b":[6e-8,9e-8,null,null,null],"deepseek/deepseek-r1-0528-qwen3-8b":[6e-8,9e-8,null,null,null],"novita/deepseek/deepseek-r1-distill-llama-70b":[8e-7,8e-7,null,null,null],"novita/meta-llama/llama-3-70b-instruct":[5.1e-7,7.4e-7,null,null,null],"novita/qwen/qwen3-235b-a22b-fp8":[2e-7,8e-7,null,null,null],"qwen/qwen3-235b-a22b-fp8":[2e-7,8e-7,null,null,null],"novita/meta-llama/llama-4-maverick-17b-128e-instruct-fp8":[2.7e-7,8.5e-7,null,null,null],"meta-llama/llama-4-maverick-17b-128e-instruct-fp8":[2.7e-7,8.5e-7,null,null,null],"novita/meta-llama/llama-4-scout-17b-16e-instruct":[1.8e-7,5.9e-7,null,null,null],"novita/nousresearch/hermes-2-pro-llama-3-8b":[1.4e-7,1.4e-7,null,null,null],"nousresearch/hermes-2-pro-llama-3-8b":[1.4e-7,1.4e-7,null,null,null],"novita/qwen/qwen2.5-vl-72b-instruct":[8e-7,8e-7,null,null,null],"qwen/qwen2.5-vl-72b-instruct":[8e-7,8e-7,null,null,null],"novita/sao10k/l3-70b-euryale-v2.1":[0.00000148,0.00000148,null,null,null],"sao10k/l3-70b-euryale-v2.1":[0.00000148,0.00000148,null,null,null],"novita/baidu/ernie-4.5-21B-a3b-thinking":[7e-8,2.8e-7,null,null,null],"baidu/ernie-4.5-21B-a3b-thinking":[7e-8,2.8e-7,null,null,null],"novita/sao10k/l3-8b-lunaris":[5e-8,5e-8,null,null,null],"sao10k/l3-8b-lunaris":[5e-8,5e-8,null,null,null],"novita/baichuan/baichuan-m2-32b":[7e-8,7e-8,null,null,null],"baichuan/baichuan-m2-32b":[7e-8,7e-8,null,null,null],"novita/baidu/ernie-4.5-vl-424b-a47b":[4.2e-7,0.00000125,null,null,null],"baidu/ernie-4.5-vl-424b-a47b":[4.2e-7,0.00000125,null,null,null],"novita/baidu/ernie-4.5-300b-a47b-paddle":[2.8e-7,0.0000011,null,null,null],"baidu/ernie-4.5-300b-a47b-paddle":[2.8e-7,0.0000011,null,null,null],"novita/deepseek/deepseek-prover-v2-671b":[7e-7,0.0000025,null,null,null],"deepseek/deepseek-prover-v2-671b":[7e-7,0.0000025,null,null,null],"novita/qwen/qwen3-32b-fp8":[1e-7,4.5e-7,null,null,null],"qwen/qwen3-32b-fp8":[1e-7,4.5e-7,null,null,null],"novita/qwen/qwen3-30b-a3b-fp8":[9e-8,4.5e-7,null,null,null],"qwen/qwen3-30b-a3b-fp8":[9e-8,4.5e-7,null,null,null],"novita/google/gemma-3-27b-it":[1.19e-7,2e-7,null,null,null],"novita/deepseek/deepseek-v3-turbo":[4e-7,0.0000013,null,null,null],"deepseek/deepseek-v3-turbo":[4e-7,0.0000013,null,null,null],"novita/deepseek/deepseek-r1-turbo":[7e-7,0.0000025,null,null,null],"deepseek/deepseek-r1-turbo":[7e-7,0.0000025,null,null,null],"novita/Sao10K/L3-8B-Stheno-v3.2":[5e-8,5e-8,null,null,null],"Sao10K/L3-8B-Stheno-v3.2":[5e-8,5e-8,null,null,null],"novita/gryphe/mythomax-l2-13b":[9e-8,9e-8,null,null,null],"novita/baidu/ernie-4.5-vl-28b-a3b-thinking":[3.9e-7,3.9e-7,null,null,null],"baidu/ernie-4.5-vl-28b-a3b-thinking":[3.9e-7,3.9e-7,null,null,null],"novita/qwen/qwen3-vl-8b-instruct":[8e-8,5e-7,null,null,null],"qwen/qwen3-vl-8b-instruct":[8e-8,5e-7,null,null,null],"novita/zai-org/glm-4.5-air":[1.3e-7,8.5e-7,null,null,null],"zai-org/glm-4.5-air":[1.3e-7,8.5e-7,null,null,null],"novita/qwen/qwen3-vl-30b-a3b-instruct":[2e-7,7e-7,null,null,null],"qwen/qwen3-vl-30b-a3b-instruct":[2e-7,7e-7,null,null,null],"novita/qwen/qwen3-vl-30b-a3b-thinking":[2e-7,0.000001,null,null,null],"qwen/qwen3-vl-30b-a3b-thinking":[2e-7,0.000001,null,null,null],"novita/qwen/qwen3-omni-30b-a3b-thinking":[2.5e-7,9.7e-7,null,null,null],"qwen/qwen3-omni-30b-a3b-thinking":[2.5e-7,9.7e-7,null,null,null],"novita/qwen/qwen3-omni-30b-a3b-instruct":[2.5e-7,9.7e-7,null,null,null],"qwen/qwen3-omni-30b-a3b-instruct":[2.5e-7,9.7e-7,null,null,null],"novita/qwen/qwen-mt-plus":[2.5e-7,7.5e-7,null,null,null],"qwen/qwen-mt-plus":[2.5e-7,7.5e-7,null,null,null],"novita/baidu/ernie-4.5-vl-28b-a3b":[1.4e-7,5.6e-7,null,null,null],"baidu/ernie-4.5-vl-28b-a3b":[1.4e-7,5.6e-7,null,null,null],"novita/baidu/ernie-4.5-21B-a3b":[7e-8,2.8e-7,null,null,null],"baidu/ernie-4.5-21B-a3b":[7e-8,2.8e-7,null,null,null],"novita/qwen/qwen3-8b-fp8":[3.5e-8,1.38e-7,null,null,null],"qwen/qwen3-8b-fp8":[3.5e-8,1.38e-7,null,null,null],"novita/qwen/qwen3-4b-fp8":[3e-8,3e-8,null,null,null],"qwen/qwen3-4b-fp8":[3e-8,3e-8,null,null,null],"novita/qwen/qwen2.5-7b-instruct":[7e-8,7e-8,null,null,null],"qwen/qwen2.5-7b-instruct":[7e-8,7e-8,null,null,null],"novita/meta-llama/llama-3.2-3b-instruct":[3e-8,5e-8,null,null,null],"meta-llama/llama-3.2-3b-instruct":[3e-8,5e-8,null,null,null],"novita/sao10k/l31-70b-euryale-v2.2":[0.00000148,0.00000148,null,null,null],"sao10k/l31-70b-euryale-v2.2":[0.00000148,0.00000148,null,null,null],"novita/qwen/qwen3-embedding-0.6b":[7e-8,0,null,null,null],"qwen/qwen3-embedding-0.6b":[7e-8,0,null,null,null],"novita/qwen/qwen3-embedding-8b":[7e-8,0,null,null,null],"novita/baai/bge-m3":[1e-8,1e-8,null,null,null],"baai/bge-m3":[1e-8,1e-8,null,null,null],"novita/qwen/qwen3-reranker-8b":[5e-8,5e-8,null,null,null],"qwen/qwen3-reranker-8b":[5e-8,5e-8,null,null,null],"novita/baai/bge-reranker-v2-m3":[1e-8,1e-8,null,null,null],"baai/bge-reranker-v2-m3":[1e-8,1e-8,null,null,null],"llamagate/llama-3.1-8b":[3e-8,5e-8,null,null,null],"llama-3.1-8b":[3e-8,5e-8,null,null,null],"llamagate/llama-3.2-3b":[4e-8,8e-8,null,null,null],"llama-3.2-3b":[4e-8,8e-8,null,null,null],"llamagate/mistral-7b-v0.3":[1e-7,1.5e-7,null,null,null],"mistral-7b-v0.3":[1e-7,1.5e-7,null,null,null],"llamagate/qwen3-8b":[4e-8,1.4e-7,null,null,null],"qwen3-8b":[4e-8,1.4e-7,null,null,null],"llamagate/dolphin3-8b":[8e-8,1.5e-7,null,null,null],"dolphin3-8b":[8e-8,1.5e-7,null,null,null],"llamagate/deepseek-r1-8b":[1e-7,2e-7,null,null,null],"deepseek-r1-8b":[1e-7,2e-7,null,null,null],"llamagate/deepseek-r1-7b-qwen":[8e-8,1.5e-7,null,null,null],"deepseek-r1-7b-qwen":[8e-8,1.5e-7,null,null,null],"llamagate/openthinker-7b":[8e-8,1.5e-7,null,null,null],"openthinker-7b":[8e-8,1.5e-7,null,null,null],"llamagate/qwen2.5-coder-7b":[6e-8,1.2e-7,null,null,null],"qwen2.5-coder-7b":[6e-8,1.2e-7,null,null,null],"llamagate/deepseek-coder-6.7b":[6e-8,1.2e-7,null,null,null],"deepseek-coder-6.7b":[6e-8,1.2e-7,null,null,null],"llamagate/codellama-7b":[6e-8,1.2e-7,null,null,null],"codellama-7b":[6e-8,1.2e-7,null,null,null],"llamagate/qwen3-vl-8b":[1.5e-7,5.5e-7,null,null,null],"qwen3-vl-8b":[1.5e-7,5.5e-7,null,null,null],"llamagate/llava-7b":[1e-7,2e-7,null,null,null],"llava-7b":[1e-7,2e-7,null,null,null],"llamagate/gemma3-4b":[3e-8,8e-8,null,null,null],"gemma3-4b":[3e-8,8e-8,null,null,null],"llamagate/nomic-embed-text":[2e-8,0,null,null,null],"nomic-embed-text":[2e-8,0,null,null,null],"llamagate/qwen3-embedding-8b":[2e-8,0,null,null,null],"qwen3-embedding-8b":[2e-8,0,null,null,null],"libertai/hermes-3-8b-tee":[1.5e-7,6e-7,null,null,null],"hermes-3-8b-tee":[1.5e-7,6e-7,null,null,null],"libertai/gemma-4-31b-it":[1.5e-7,4e-7,null,null,null],"gemma-4-31b-it":[1.5e-7,4e-7,null,null,null],"libertai/gemma-4-31b-it-thinking":[1.5e-7,4e-7,null,null,null],"gemma-4-31b-it-thinking":[1.5e-7,4e-7,null,null,null],"libertai/qwen3.6-27b":[1.5e-7,5e-7,null,null,null],"qwen3.6-27b":[1.5e-7,5e-7,null,null,null],"libertai/qwen3.6-27b-thinking":[1.5e-7,5e-7,null,null,null],"qwen3.6-27b-thinking":[1.5e-7,5e-7,null,null,null],"libertai/qwen3.6-35b-a3b":[1.5e-7,5e-7,null,null,null],"qwen3.6-35b-a3b":[1.5e-7,5e-7,null,null,null],"libertai/qwen3.6-35b-a3b-thinking":[1.5e-7,5e-7,null,null,null],"qwen3.6-35b-a3b-thinking":[1.5e-7,5e-7,null,null,null],"libertai/qwen3.5-122b-a10b":[2.5e-7,0.00000175,null,null,null],"qwen3.5-122b-a10b":[2.5e-7,0.00000175,null,null,null],"libertai/qwen3.5-122b-a10b-thinking":[2.5e-7,0.00000175,null,null,null],"qwen3.5-122b-a10b-thinking":[2.5e-7,0.00000175,null,null,null],"libertai/deepseek-v4-flash":[2.5e-7,0.00000175,null,null,null],"libertai/deepseek-v4-flash-thinking":[2.5e-7,0.00000175,null,null,null],"deepseek-v4-flash-thinking":[2.5e-7,0.00000175,null,null,null],"libertai/bge-m3":[1e-8,0,null,null,null],"bge-m3":[1e-8,0,null,null,null],"sarvam/sarvam-m":[0,0,0,0,null],"sarvam-m":[0,0,0,0,null],"gemini/gemini-2.0-flash-exp-image-generation":[0,0,null,null,null],"gemini/gemini-2.0-flash-lite-001":[7.5e-8,3e-7,null,1.875e-8,null],"gemini/gemini-2.5-flash-native-audio-latest":[3e-7,0.0000025,null,null,null],"gemini/gemini-2.5-flash-native-audio-preview-09-2025":[3e-7,0.0000025,null,null,null],"gemini/gemini-2.5-flash-native-audio-preview-12-2025":[3e-7,0.0000025,null,null,null],"gemini/gemini-3.1-flash-live-preview":[7.5e-7,0.0000045,null,null,null],"gemini/gemini-pro-latest":[0.00000125,0.00001,null,1.25e-7,null],"vertex_ai/claude-sonnet-5@default":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-5@default":[0.000003,0.000015,0.00000375,3e-7,null],"vertex_ai/claude-sonnet-4-6@default":[0.000003,0.000015,0.00000375,3e-7,null],"claude-sonnet-4-6@default":[0.000003,0.000015,0.00000375,3e-7,null],"bedrock_mantle/openai.gpt-oss-120b":[1.5e-7,6e-7,null,null,null],"openai.gpt-oss-120b":[1.5e-7,6e-7,null,null,null],"bedrock_mantle/openai.gpt-oss-20b":[7.5e-8,3e-7,null,null,null],"openai.gpt-oss-20b":[7.5e-8,3e-7,null,null,null],"bedrock_mantle/openai.gpt-oss-safeguard-120b":[1.5e-7,6e-7,null,null,null],"bedrock_mantle/openai.gpt-oss-safeguard-20b":[7.5e-8,3e-7,null,null,null],"bedrock_mantle/openai.gpt-5.5":[0.0000055,0.000033,null,5.5e-7,null],"openai.gpt-5.5":[0.0000055,0.000033,null,5.5e-7,null],"bedrock_mantle/openai.gpt-5.4":[0.00000275,0.0000165,null,2.75e-7,null],"openai.gpt-5.4":[0.00000275,0.0000165,null,2.75e-7,null],"bedrock_mantle/google.gemma-4-31b":[1.4e-7,4e-7,null,null,null],"google.gemma-4-31b":[1.4e-7,4e-7,null,null,null],"bedrock_mantle/google.gemma-4-26b-a4b":[1.3e-7,4e-7,null,null,null],"google.gemma-4-26b-a4b":[1.3e-7,4e-7,null,null,null],"bedrock_mantle/google.gemma-4-e2b":[4e-8,8e-8,null,null,null],"google.gemma-4-e2b":[4e-8,8e-8,null,null,null],"bedrock/us-east-1/zai.glm-5":[0.000001,0.0000032,null,null,null],"us-east-1/zai.glm-5":[0.000001,0.0000032,null,null,null],"bedrock/us-west-2/zai.glm-5":[0.000001,0.0000032,null,null,null],"us-west-2/zai.glm-5":[0.000001,0.0000032,null,null,null],"bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7,null],"us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7,null],"bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7,null],"us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0":[0.0000012,0.000006,0.0000015,1.2e-7,null],"snowflake/claude-sonnet-4-5":[0.000003,0.000015,null,3e-7,null],"snowflake/claude-sonnet-4-6":[0.000003,0.000015,null,3e-7,null],"snowflake/claude-4-sonnet":[0.000003,0.000015,null,3e-7,null],"claude-4-sonnet":[0.000003,0.000015,null,3e-7,null],"snowflake/claude-4-opus":[0.000005,0.000025,null,5e-7,null],"claude-4-opus":[0.000005,0.000025,null,5e-7,null],"snowflake/claude-haiku-4-5":[0.000001,0.000005,null,1e-7,null],"snowflake/claude-3-7-sonnet":[0.000003,0.000015,null,3e-7,null],"claude-3-7-sonnet":[0.000003,0.000015,null,3e-7,null],"snowflake/openai-gpt-4.1":[0.000002,0.000008,null,5e-7,null],"openai-gpt-4.1":[0.000002,0.000008,null,5e-7,null],"snowflake/openai-gpt-5":[0.00000125,0.00001,null,1.25e-7,null],"openai-gpt-5":[0.00000125,0.00001,null,1.25e-7,null],"snowflake/openai-gpt-5-mini":[3e-7,0.0000012,null,null,null],"openai-gpt-5-mini":[3e-7,0.0000012,null,null,null],"snowflake/openai-gpt-5-nano":[1.5e-7,6e-7,null,null,null],"openai-gpt-5-nano":[1.5e-7,6e-7,null,null,null],"snowflake/llama4-maverick":[2.4e-7,9.7e-7,null,null,null],"llama4-maverick":[2.4e-7,9.7e-7,null,null,null],"snowflake/snowflake-arctic-embed-l-v2.0":[7e-8,0,null,null,null],"snowflake-arctic-embed-l-v2.0":[7e-8,0,null,null,null],"snowflake/snowflake-arctic-embed-m-v2.0":[7e-8,0,null,null,null],"snowflake-arctic-embed-m-v2.0":[7e-8,0,null,null,null],"tensormesh/Qwen/Qwen3.5-397B-A17B-FP8":[6e-7,0.0000036,null,0,null],"Qwen/Qwen3.5-397B-A17B-FP8":[6e-7,0.0000036,null,0,null],"tensormesh/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":[4.5e-7,0.0000018,null,0,null],"tensormesh/Qwen/Qwen3.6-27B-FP8":[3.2e-7,0.0000032,null,0,null],"Qwen/Qwen3.6-27B-FP8":[3.2e-7,0.0000032,null,0,null],"tensormesh/lukealonso/GLM-5.1-NVFP4-MTP":[0.0000014,0.0000044,null,0,null],"lukealonso/GLM-5.1-NVFP4-MTP":[0.0000014,0.0000044,null,0,null],"tensormesh/deepseek-ai/DeepSeek-V4-Flash":[1.4e-7,2.8e-7,null,0,null],"deepseek-ai/DeepSeek-V4-Flash":[1.4e-7,2.8e-7,null,0,null],"tensormesh/moonshotai/Kimi-K2.6":[9.6e-7,0.000004,null,0,null],"moonshotai/Kimi-K2.6":[9.6e-7,0.000004,null,0,null],"tensormesh/MiniMaxAI/MiniMax-M2.5":[3e-7,0.0000012,null,0,null],"tensormesh/google/gemma-4-31B-it":[1.4e-7,5.6e-7,null,0,null],"google/gemma-4-31B-it":[1.4e-7,5.6e-7,null,0,null],"tensormesh/openai/gpt-oss-120b":[1.5e-7,6e-7,null,0,null],"tensormesh/openai/gpt-oss-20b":[7e-8,2.8e-7,null,0,null],"deepseek/deepseek-v4-flash":[1.4e-7,2.8e-7,0,2.8e-9,null],"deepseek/deepseek-v4-pro":[4.35e-7,8.7e-7,0,3.625e-9,null],"pinstripes/ps/glm-4.5-air":[1.25e-7,4.5e-7,null,null,null],"ps/glm-4.5-air":[1.25e-7,4.5e-7,null,null,null],"pinstripes/ps/qwen3.6-35b-a3b":[1.4e-7,4.5e-7,null,null,null],"ps/qwen3.6-35b-a3b":[1.4e-7,4.5e-7,null,null,null],"pinstripes/ps/qwen3-30b-a3b":[9e-8,2e-7,null,null,null],"ps/qwen3-30b-a3b":[9e-8,2e-7,null,null,null],"pinstripes/ps/qwen3-coder-30b-a3b":[3e-7,6e-7,null,null,null],"ps/qwen3-coder-30b-a3b":[3e-7,6e-7,null,null,null],"pinstripes/ps/deepseek-v4-flash":[1e-7,2e-7,null,null,null],"ps/deepseek-v4-flash":[1e-7,2e-7,null,null,null],"pinstripes/ps/minimax-m2.7":[2.55e-7,5.5e-7,null,null,null],"ps/minimax-m2.7":[2.55e-7,5.5e-7,null,null,null],"darkbloom/gemma-4-26b":[3e-8,1.65e-7,null,null,null],"gemma-4-26b":[3e-8,1.65e-7,null,null,null],"darkbloom/gpt-oss-20b":[1.45e-8,7e-8,null,null,null],"MiniMax-M2.7-highspeed":[6e-7,0.0000024,3.75e-7,6e-8],"claude-mythos-5":[0.00001,0.00005,0.0000125,0.000001]} \ No newline at end of file diff --git a/src/data/pricing-fallback.json b/src/data/pricing-fallback.json new file mode 100644 index 0000000..8acdb9c --- /dev/null +++ b/src/data/pricing-fallback.json @@ -0,0 +1 @@ +{"qvq-max":[0.0000012,0.0000048,null,null,null],"qwen-flash":[5.0000000000000004e-8,4.0000000000000003e-7,null,null,null],"qwen-mt-turbo":[1.6e-7,4.9e-7,null,null,null],"qwen-omni-turbo":[7e-8,2.7e-7,null,null,null],"qwen-omni-turbo-realtime":[2.7e-7,0.0000010700000000000001,null,null,null],"qwen-plus-character-ja":[5e-7,0.0000014,null,null,null],"qwen-vl-max":[8.000000000000001e-7,0.0000032000000000000003,null,null,null],"qwen-vl-ocr":[7.2e-7,7.2e-7,null,null,null],"qwen2-5-14b-instruct":[3.5e-7,0.0000014,null,null,null],"qwen2-5-32b-instruct":[7e-7,0.0000028,null,null,null],"qwen2-5-72b-instruct":[0.0000014,0.0000056,null,null,null],"qwen2-5-7b-instruct":[1.75e-7,7e-7,null,null,null],"qwen2-5-omni-7b":[1.0000000000000001e-7,4.0000000000000003e-7,null,null,null],"qwen2-5-vl-72b-instruct":[0.0000028,0.000008400000000000001,null,null,null],"qwen2-5-vl-7b-instruct":[3.5e-7,0.0000010500000000000001,null,null,null],"qwen3-asr-flash":[3.5e-8,3.5e-8,null,null,null],"qwen3-coder-flash":[3e-7,0.0000015,null,null,null],"qwen3-livetranslate-flash-realtime":[0.00001,0.00001,null,null,null],"qwen3-omni-flash":[4.3e-7,0.00000166,null,null,null],"qwen3-omni-flash-realtime":[5.2e-7,0.00000199,null,null,null],"qwen3-vl-235b-a22b":[7e-7,0.0000028,null,null,null],"qwen3-vl-30b-a3b":[2.0000000000000002e-7,8.000000000000001e-7,null,null,null],"qwen3-vl-plus":[2.0000000000000002e-7,0.0000016000000000000001,null,null,null],"qwen3.5-plus":[4.0000000000000003e-7,0.0000024,null,null,null],"qwen3.6-flash":[1.875e-7,0.000001125,2.34375e-7,null,null],"qwen3.6-max-preview":[0.0000013,0.0000078,0.000001625,1.3e-7,null],"qwen3.7-max":[0.0000025,0.0000075,0.000003125,5e-7,null],"qwen3.7-plus":[5e-7,0.000003,6.25e-7,5.0000000000000004e-8,null],"deepseek-v3-1":[5.739999999999999e-7,0.0000017210000000000001,null,null,null],"deepseek-v3-2-exp":[2.8699999999999996e-7,4.31e-7,null,null,null],"moonshot-kimi-k2-instruct":[5.739999999999999e-7,0.000002294,null,null,null],"qwen-deep-research":[0.000007742,0.000023367000000000002,null,null,null],"qwen-doc-turbo":[8.7e-8,1.44e-7,null,null,null],"qwen-long":[7.2e-8,2.8699999999999996e-7,null,null,null],"qwen-math-plus":[5.739999999999999e-7,0.0000017210000000000001,null,null,null],"qwen-math-turbo":[2.8699999999999996e-7,8.61e-7,null,null,null],"qwen-plus-character":[1.1500000000000001e-7,2.8699999999999996e-7,null,null,null],"qwen2-5-coder-32b-instruct":[2.8699999999999996e-7,8.61e-7,null,null,null],"qwen2-5-coder-7b-instruct":[1.44e-7,2.8699999999999996e-7,null,null,null],"qwen2-5-math-72b-instruct":[5.739999999999999e-7,0.0000017210000000000001,null,null,null],"qwen2-5-math-7b-instruct":[1.44e-7,2.8699999999999996e-7,null,null,null],"qwen3.5-flash":[1.7199999999999998e-7,0.00000172,null,null,null],"tongyi-intent-detect-v3":[5.8e-8,1.44e-7,null,null,null],"claude-opus-4-0":[0.000015,0.000075,0.00001875,0.0000015,null],"claude-sonnet-4-0":[0.000003,0.000015,0.00000375,3e-7,null],"command-a-plus-05-2026":[0.0000025,0.00001,null,null,null],"command-a-reasoning-08-2025":[0.0000025,0.00001,null,null,null],"command-a-translate-08-2025":[0.0000025,0.00001,null,null,null],"command-a-vision-07-2025":[0.0000025,0.00001,null,null,null],"command-r7b-arabic-02-2025":[3.75e-8,1.5e-7,null,null,null],"gemini-2.5-flash-tts":[5e-7,0.00001,null,null,null],"gemini-2.5-pro-tts":[0.000001,0.00002,null,null,null],"llama-3.3-70b-instruct-maas":[7.2e-7,7.2e-7,null,null,null],"MiniMax-M2.5-highspeed":[6e-7,0.0000024,3.75e-7,6e-8,null],"ministral-3b-latest":[4e-8,4e-8,null,null,null],"mistral-small-2506":[1.0000000000000001e-7,3e-7,null,null,null],"mistral-small-2603":[1.5e-7,6e-7,null,null,null],"kimi-k2.7-code-highspeed":[0.0000018999999999999998,0.000008,null,3.8e-7,null],"gpt-5.3-codex-spark":[0.00000175,0.000014,null,1.75e-7,null],"grok-4.20-0309-non-reasoning":[0.00000125,0.0000025,null,2.0000000000000002e-7,null],"grok-4.20-multi-agent-0309":[0.00000125,0.0000025,null,2.0000000000000002e-7,null],"grok-build-0.1":[0.000001,0.000002,null,2.0000000000000002e-7,null],"glm-4.7-flashx":[7e-8,4.0000000000000003e-7,0,1e-8,null],"glm-5v-turbo":[0.000005,0.000022,0,0.0000012,null],"gemini-3.1-flash-lite-image":[2.5e-7,0.0000015,null,null,null],"fugu-ultra":[0.000005,0.00003,null,5e-7,null],"claude-fable-latest":[0.00001,0.00005,0.0000125,0.000001,null],"nex-n2-pro":[2.5e-7,0.000001,null,2.5e-8,null],"nemotron-3-ultra-550b-a55b":[5e-7,0.0000022,null,1e-7,null],"step-3.7-flash":[2e-7,0.00000115,null,4e-8,null],"claude-opus-4.8-fast":[0.00001,0.00005,0.0000125,0.000001,null],"claude-opus-4.8":[0.000005,0.000025,0.00000625,5e-7,null],"claude-opus-4.7-fast":[0.00003,0.00015,0.0000375,0.000003,null],"perceptron-mk1":[1.5e-7,0.0000015,null,null,null],"ring-2.6-1t":[7.5e-8,6.25e-7,null,1.5e-8,null],"gpt-chat-latest":[0.000005,0.00003,null,5e-7,null],"granite-4.1-8b":[5e-8,1e-7,null,5e-8,null],"laguna-xs.2":[1e-7,2e-7,null,5e-8,null],"laguna-m.1":[2e-7,4e-7,null,1e-7,null],"claude-haiku-latest":[0.000001,0.000005,0.00000125,1e-7,null],"gpt-mini-latest":[7.5e-7,0.0000045,null,7.5e-8,null],"claude-sonnet-latest":[0.000002,0.00001,0.0000025,2e-7,null],"gpt-latest":[0.000005,0.00003,null,5e-7,null],"ling-2.6-1t":[7.5e-8,6.25e-7,null,1.5e-8,null],"hy3-preview":[6.3e-8,2.1e-7,null,2.1e-8,null],"gpt-5.4-image-2":[0.000008,0.000015,null,0.000002,null],"ling-2.6-flash":[1e-8,3e-8,null,2e-9,null],"claude-opus-latest":[0.000005,0.000025,0.00000625,5e-7,null],"trinity-large-thinking":[2.5e-7,8e-7,null,6e-8,null],"grok-4.20-multi-agent":[0.00000125,0.0000025,null,2e-7,null],"grok-4.20":[0.00000125,0.0000025,null,2e-7,null],"kat-coder-pro-v2":[3e-7,0.0000012,null,6e-8,null],"reka-edge":[1e-7,1e-7,null,null,null],"glm-5-turbo":[0.0000012,0.000004,null,2.4e-7,null],"nemotron-3-super-120b-a12b":[8.5e-8,4e-7,null,null,null],"seed-2.0-lite":[2.5e-7,0.000002,null,null,null],"qwen3.5-9b":[1e-7,1.5e-7,null,null,null],"seed-2.0-mini":[1e-7,4e-7,null,null,null],"lfm-2-24b-a2b":[3e-8,1.2e-7,null,null,null],"aion-2.0":[8e-7,0.0000016,null,2e-7,null],"qwen3-max-thinking":[7.8e-7,0.0000039,null,null,null],"qwen3-coder-next":[1.1e-7,8e-7,null,7e-8,null],"step-3.5-flash":[1e-7,3e-7,null,null,null],"solar-pro-3":[1.5e-7,6e-7,null,1.5e-8,null],"minimax-m2-her":[3e-7,0.0000012,null,3e-8,null],"palmyra-x5":[6e-7,0.000006,null,null,null],"seed-1.6-flash":[7.5e-8,3e-7,null,null,null],"seed-1.6":[2.5e-7,0.000002,null,null,null],"nemotron-3-nano-30b-a3b":[5e-8,2e-7,null,null,null],"relace-search":[0.000001,0.000003,null,null,null],"nova-2-lite-v1":[3e-7,0.0000025,null,null,null],"trinity-mini":[4.5e-8,1.5e-7,null,null,null],"cogito-v2.1-671b":[0.00000125,0.00000125,null,null,null],"sonar-pro-search":[0.000003,0.000015,null,null,null],"gpt-5-image-mini":[0.0000025,0.000002,null,2.5e-7,null],"qwen3-vl-8b-thinking":[1.17e-7,0.000001365,null,null,null],"gpt-5-image":[0.00001,0.00001,null,0.00000125,null],"cydonia-24b-v4.1":[3e-7,5e-7,null,1.5e-7,null],"relace-apply-3":[8.5e-7,0.00000125,null,null,null],"qwen-plus-2025-07-28:thinking":[2.6e-7,7.8e-7,3.25e-7,null,null],"qwen-plus-2025-07-28":[2.6e-7,7.8e-7,null,null,null],"hermes-4-70b":[1.3e-7,4e-7,null,null,null],"hermes-4-405b":[0.000001,0.000003,null,null,null],"mistral-medium-3.1":[4e-7,0.000002,null,4e-8,null],"hunyuan-a13b-instruct":[1.4e-7,5.7e-7,null,null,null],"minimax-m1":[4e-7,0.0000022,null,null,null],"gemini-2.5-pro-preview":[0.00000125,0.00001,3.75e-7,1.25e-7,null],"gemma-3n-e4b-it":[6e-8,1.2e-7,null,null,null],"gemini-2.5-pro-preview-05-06":[0.00000125,0.00001,3.75e-7,1.25e-7,null],"virtuoso-large":[7.5e-7,0.0000012,null,null,null],"coder-large":[5e-7,8e-7,null,null,null],"o4-mini-high":[0.0000011,0.0000044,null,2.75e-7,null],"reka-flash-3":[1e-7,2e-7,null,null,null],"skyfall-36b-v2":[5.5e-7,8e-7,null,2.5e-7,null],"mistral-saba":[2e-7,6e-7,null,2e-8,null],"aion-1.0":[0.000004,0.000008,null,null,null],"aion-1.0-mini":[7e-7,0.0000014,null,null,null],"aion-rp-llama-3.1-8b":[8e-7,0.0000016,null,null,null],"minimax-01":[2e-7,0.0000011,null,null,null],"l3.1-70b-hanami-x1":[0.000003,0.000003,null,null,null],"l3.3-euryale-70b":[6.5e-7,7.5e-7,null,null,null],"unslopnemo-12b":[4e-7,4e-7,null,null,null],"magnum-v4-72b":[0.000003,0.000005,null,null,null],"qwen-2.5-7b-instruct":[4e-8,1e-7,null,null,null],"inflection-3-pi":[0.0000025,0.00001,null,null,null],"inflection-3-productivity":[0.0000025,0.00001,null,null,null],"rocinante-12b":[2.5e-7,5e-7,null,null,null],"l3.1-euryale-70b":[8.5e-7,8.5e-7,null,null,null],"l3-lunaris-8b":[4e-8,5e-8,null,null,null],"gemma-2-27b-it":[6.5e-7,6.5e-7,null,null,null],"gpt-3.5-turbo-0613":[0.000001,0.000002,null,null,null]} \ No newline at end of file diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts new file mode 100644 index 0000000..f8cea1c --- /dev/null +++ b/src/day-aggregator.ts @@ -0,0 +1,154 @@ +import type { DailyEntry } from './daily-cache.js' +import type { PeriodData } from './menubar-json.js' +import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' + +function emptyEntry(date: string): DailyEntry { + return { + date, + cost: 0, + savingsUSD: 0, + calls: 0, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: {}, + categories: {}, + providers: {}, + } +} + +export function dateKey(iso: string): string { + const d = new Date(iso) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] { + const byDate = new Map() + const ensure = (date: string): DailyEntry => { + let d = byDate.get(date) + if (!d) { d = emptyEntry(date); byDate.set(date, d) } + return d + } + + for (const project of projects) { + for (const session of project.sessions) { + const sessionDate = dateKey(session.firstTimestamp) + ensure(sessionDate).sessions += 1 + + for (const turn of session.turns) { + if (turn.assistantCalls.length === 0) continue + const turnDate = dateKey(turn.assistantCalls[0]!.timestamp) + const turnDay = ensure(turnDate) + + const editTurns = turn.hasEdits ? 1 : 0 + const oneShotTurns = turn.hasEdits && turn.retries === 0 ? 1 : 0 + const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) + const turnSavings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0) + + turnDay.editTurns += editTurns + turnDay.oneShotTurns += oneShotTurns + + const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } + cat.turns += 1 + cat.cost += turnCost + cat.savingsUSD += turnSavings + cat.editTurns += editTurns + cat.oneShotTurns += oneShotTurns + turnDay.categories[turn.category] = cat + + for (const call of turn.assistantCalls) { + const callDate = dateKey(call.timestamp) + const callDay = ensure(callDate) + const callSavings = call.savingsUSD ?? 0 + + callDay.cost += call.costUSD + callDay.savingsUSD += callSavings + callDay.calls += 1 + callDay.inputTokens += call.usage.inputTokens + callDay.outputTokens += call.usage.outputTokens + callDay.cacheReadTokens += call.usage.cacheReadInputTokens + callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens + + const model = callDay.models[call.model] ?? { + calls: 0, cost: 0, savingsUSD: 0, + inputTokens: 0, outputTokens: 0, + cacheReadTokens: 0, cacheWriteTokens: 0, + } + model.calls += 1 + model.cost += call.costUSD + model.savingsUSD += callSavings + model.inputTokens += call.usage.inputTokens + model.outputTokens += call.usage.outputTokens + model.cacheReadTokens += call.usage.cacheReadInputTokens + model.cacheWriteTokens += call.usage.cacheCreationInputTokens + callDay.models[call.model] = model + + const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0, savingsUSD: 0 } + provider.calls += 1 + provider.cost += call.costUSD + provider.savingsUSD += callSavings + callDay.providers[call.provider] = provider + } + } + } + } + + return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)) +} + +export function buildPeriodDataFromDays(days: DailyEntry[], label: string): PeriodData { + let cost = 0, savingsUSD = 0, calls = 0, sessions = 0 + let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 + const catTotals: Record = {} + const modelTotals: Record = {} + + for (const d of days) { + cost += d.cost + savingsUSD += d.savingsUSD + calls += d.calls + sessions += d.sessions + inputTokens += d.inputTokens + outputTokens += d.outputTokens + cacheReadTokens += d.cacheReadTokens + cacheWriteTokens += d.cacheWriteTokens + + for (const [name, m] of Object.entries(d.models)) { + const acc = modelTotals[name] ?? { calls: 0, cost: 0, savingsUSD: 0 } + acc.calls += m.calls + acc.cost += m.cost + acc.savingsUSD += (m.savingsUSD ?? 0) + modelTotals[name] = acc + } + for (const [cat, c] of Object.entries(d.categories)) { + const acc = catTotals[cat] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } + acc.turns += c.turns + acc.cost += c.cost + acc.savingsUSD += (c.savingsUSD ?? 0) + acc.editTurns += c.editTurns + acc.oneShotTurns += c.oneShotTurns + catTotals[cat] = acc + } + } + + return { + label, + cost, + savingsUSD, + calls, + sessions, + 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 })), + } +} diff --git a/src/export.ts b/src/export.ts new file mode 100644 index 0000000..f946621 --- /dev/null +++ b/src/export.ts @@ -0,0 +1,421 @@ +import { writeFile, mkdir, readdir, open, stat, rm } from 'fs/promises' +import { dirname, join, resolve } from 'path' + +import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' +import { getCurrency, convertCost, roundForActiveCurrency } from './currency.js' +import { dateKey } from './day-aggregator.js' +import { aggregateModelEfficiency } from './model-efficiency.js' + +function escCsv(s: string): string { + const sanitized = /^[\t\r=+\-@]/.test(s) ? `'${s}` : s + if (sanitized.includes(',') || sanitized.includes('"') || sanitized.includes('\n')) { + return `"${sanitized.replace(/"/g, '""')}"` + } + return sanitized +} + +type Row = Record + +function rowsToCsv(rows: Row[]): string { + if (rows.length === 0) return '' + const headers = Object.keys(rows[0]) + const lines = [headers.map(escCsv).join(',')] + for (const row of rows) { + lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(',')) + } + return lines.join('\n') + '\n' +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} + +function pct(n: number, total: number): number { + return total > 0 ? round2((n / total) * 100) : 0 +} + +type DailyAgg = { + cost: number + savings: number + calls: number + input: number + output: number + cacheRead: number + cacheWrite: number + sessions: Set +} + +function buildDailyRows(projects: ProjectSummary[], period: string): Row[] { + const daily: Record = {} + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (!turn.timestamp) continue + const day = dateKey(turn.timestamp) + if (!daily[day]) { + daily[day] = { cost: 0, savings: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: new Set() } + } + daily[day].sessions.add(session.sessionId) + for (const call of turn.assistantCalls) { + daily[day].cost += call.costUSD + daily[day].savings += call.savingsUSD ?? 0 + daily[day].calls++ + daily[day].input += call.usage.inputTokens + daily[day].output += call.usage.outputTokens + daily[day].cacheRead += call.usage.cacheReadInputTokens + daily[day].cacheWrite += call.usage.cacheCreationInputTokens + } + } + } + } + const { code } = getCurrency() + return Object.entries(daily).sort().map(([date, d]) => ({ + Period: period, + Date: date, + [`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)), + [`Saved (${code})`]: roundForActiveCurrency(convertCost(d.savings)), + 'API Calls': d.calls, + Sessions: d.sessions.size, + 'Input Tokens': d.input, + 'Output Tokens': d.output, + 'Cache Read Tokens': d.cacheRead, + 'Cache Write Tokens': d.cacheWrite, + })) +} + +function buildActivityRows(projects: ProjectSummary[], period: string): Row[] { + const catTotals: Record = {} + for (const project of projects) { + for (const session of project.sessions) { + for (const [cat, d] of Object.entries(session.categoryBreakdown)) { + if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0 } + catTotals[cat].turns += d.turns + catTotals[cat].cost += d.costUSD + } + } + } + const totalCost = Object.values(catTotals).reduce((s, d) => s + d.cost, 0) + const { code } = getCurrency() + return Object.entries(catTotals) + .sort(([, a], [, b]) => b.cost - a.cost) + .map(([cat, d]) => ({ + Period: period, + Activity: CATEGORY_LABELS[cat as TaskCategory] ?? cat, + [`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)), + 'Share (%)': pct(d.cost, totalCost), + Turns: d.turns, + })) +} + +function buildModelRows(projects: ProjectSummary[], period: string): Row[] { + const modelTotals: Record = {} + const modelEfficiency = aggregateModelEfficiency(projects) + for (const project of projects) { + for (const session of project.sessions) { + for (const [model, d] of Object.entries(session.modelBreakdown)) { + if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savings: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + modelTotals[model].calls += d.calls + modelTotals[model].cost += d.costUSD + modelTotals[model].savings += d.savingsUSD + modelTotals[model].input += d.tokens.inputTokens + modelTotals[model].output += d.tokens.outputTokens + modelTotals[model].cacheRead += d.tokens.cacheReadInputTokens ?? 0 + modelTotals[model].cacheWrite += d.tokens.cacheCreationInputTokens ?? 0 + } + } + } + const totalCost = Object.values(modelTotals).reduce((s, d) => s + d.cost, 0) + const { code } = getCurrency() + return Object.entries(modelTotals) + .filter(([name]) => name !== '') + .sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)) + .map(([model, d]) => { + const efficiency = modelEfficiency.get(model) + return { + Period: period, + Model: model, + [`Cost (${code})`]: roundForActiveCurrency(convertCost(d.cost)), + [`Saved (${code})`]: roundForActiveCurrency(convertCost(d.savings)), + 'Share (%)': pct(d.cost, totalCost), + 'API Calls': d.calls, + 'Edit Turns': efficiency?.editTurns ?? 0, + 'One-shot Rate (%)': efficiency?.oneShotRate ?? '', + 'Retries/Edit': efficiency?.retriesPerEdit ?? '', + [`Cost/Edit (${code})`]: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined + ? roundForActiveCurrency(convertCost(efficiency.costPerEditUSD)) + : '', + 'Input Tokens': d.input, + 'Output Tokens': d.output, + 'Cache Read Tokens': d.cacheRead, + 'Cache Write Tokens': d.cacheWrite, + } + }) +} + +function buildToolRows(projects: ProjectSummary[]): Row[] { + const toolTotals: Record = {} + for (const project of projects) { + for (const session of project.sessions) { + for (const [tool, d] of Object.entries(session.toolBreakdown)) { + toolTotals[tool] = (toolTotals[tool] ?? 0) + d.calls + } + } + } + const total = Object.values(toolTotals).reduce((s, n) => s + n, 0) + return Object.entries(toolTotals) + .sort(([, a], [, b]) => b - a) + .map(([tool, calls]) => ({ + Tool: tool, + Calls: calls, + 'Share (%)': pct(calls, total), + })) +} + +function buildMcpRows(projects: ProjectSummary[]): Row[] { + const mcpTotals: Record = {} + 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 = {} + for (const project of projects) { + for (const session of project.sessions) { + for (const [cmd, d] of Object.entries(session.bashBreakdown)) { + bashTotals[cmd] = (bashTotals[cmd] ?? 0) + d.calls + } + } + } + const total = Object.values(bashTotals).reduce((s, n) => s + n, 0) + return Object.entries(bashTotals) + .sort(([, a], [, b]) => b - a) + .map(([cmd, calls]) => ({ + Command: cmd, + Calls: calls, + 'Share (%)': pct(calls, total), + })) +} + +function buildProjectRows(projects: ProjectSummary[]): Row[] { + const { code } = getCurrency() + const total = projects.reduce((s, p) => s + p.totalCostUSD, 0) + return projects + .slice() + .sort((a, b) => (b.totalCostUSD + b.totalSavingsUSD) - (a.totalCostUSD + a.totalSavingsUSD)) + .map(p => ({ + Project: p.projectPath, + [`Cost (${code})`]: roundForActiveCurrency(convertCost(p.totalCostUSD)), + [`Saved (${code})`]: roundForActiveCurrency(convertCost(p.totalSavingsUSD)), + [`Avg/Session (${code})`]: p.sessions.length > 0 ? roundForActiveCurrency(convertCost(p.totalCostUSD / p.sessions.length)) : '', + 'Share (%)': pct(p.totalCostUSD, total), + 'API Calls': p.totalApiCalls, + Sessions: p.sessions.length, + })) +} + +function buildSessionRows(projects: ProjectSummary[]): Row[] { + const { code } = getCurrency() + const rows: Row[] = [] + for (const p of projects) { + for (const s of p.sessions) { + rows.push({ + Project: p.projectPath, + 'Session ID': s.sessionId, + 'Started At': s.firstTimestamp ?? '', + [`Cost (${code})`]: roundForActiveCurrency(convertCost(s.totalCostUSD)), + [`Saved (${code})`]: roundForActiveCurrency(convertCost(s.totalSavingsUSD)), + 'API Calls': s.apiCalls, + Turns: s.turns.length, + }) + } + } + return rows.sort((a, b) => ((b[`Cost (${code})`] as number) + (b[`Saved (${code})`] as number)) - ((a[`Cost (${code})`] as number) + (a[`Saved (${code})`] as number))) +} + +export type PeriodExport = { + label: string + projects: ProjectSummary[] +} + +function buildSummaryRows(periods: PeriodExport[]): Row[] { + const { code } = getCurrency() + return periods.map(p => { + const cost = p.projects.reduce((s, proj) => s + proj.totalCostUSD, 0) + const savings = p.projects.reduce((s, proj) => s + proj.totalSavingsUSD, 0) + const calls = p.projects.reduce((s, proj) => s + proj.totalApiCalls, 0) + const sessions = p.projects.reduce((s, proj) => s + proj.sessions.length, 0) + const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0 || proj.totalSavingsUSD > 0).length + return { + Period: p.label, + [`Cost (${code})`]: roundForActiveCurrency(convertCost(cost)), + [`Saved (${code})`]: roundForActiveCurrency(convertCost(savings)), + 'API Calls': calls, + Sessions: sessions, + Projects: projectCount, + } + }) +} + +function buildReadme(periods: PeriodExport[]): string { + const { code } = getCurrency() + const generated = new Date().toISOString() + const lines = [ + 'CodeBurn Usage Export', + '====================', + '', + `Generated: ${generated}`, + `Currency: ${code}`, + `Periods: ${periods.map(p => p.label).join(', ')}`, + '', + 'Files', + '-----', + ' summary.csv One row per period. Headline totals.', + ' daily.csv Day-by-day breakdown, Period column distinguishes the window.', + ' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).', + ' models.csv Spend per model with token totals and cache usage.', + ' 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', + '-----', + ' Every cost column is already converted to the active currency. Tokens are raw integer', + ' counts from provider telemetry. Share (%) is relative to the period/table total.', + '', + ] + return lines.join('\n') +} + +/// Sentinel file dropped into every folder we create so we can safely overwrite an older +/// codeburn export without ever deleting a user's unrelated files by accident. +const EXPORT_MARKER_FILE = '.codeburn-export' + +async function isCodeburnExportFolder(path: string): Promise { + const markerStat = await stat(join(path, EXPORT_MARKER_FILE)).catch(() => null) + return markerStat?.isFile() ?? false +} + +async function clearCodeburnExportFolder(path: string): Promise { + const entries = await readdir(path) + for (const entry of entries) { + await rm(join(path, entry), { recursive: true, force: true }) + } +} + +/// Writes a folder of one-table-per-file CSVs. The outputPath is treated as a directory. If it +/// ends in `.csv` the extension is stripped to form the folder name. Refuses to delete a +/// pre-existing file or a non-codeburn folder, so a typo like `-o ~/.ssh/id_ed25519` can't +/// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally). +export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise { + const thirtyDays = periods.find(p => p.label === '30 Days') + const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? [] + + let folder = resolve(outputPath) + if (folder.toLowerCase().endsWith('.csv')) { + folder = folder.slice(0, -4) + } + + const existingStat = await stat(folder).catch(() => null) + if (existingStat?.isFile()) { + throw new Error(`Refusing to overwrite existing file at ${folder}. Pass a directory path instead.`) + } + if (existingStat?.isDirectory()) { + if (!(await isCodeburnExportFolder(folder))) { + throw new Error( + `Refusing to reuse non-empty directory ${folder}: no ${EXPORT_MARKER_FILE} marker. ` + + `Delete it manually or pick a different -o path.` + ) + } + await clearCodeburnExportFolder(folder) + } + await mkdir(folder, { recursive: true }) + await writeFile(join(folder, EXPORT_MARKER_FILE), '', 'utf-8') + + const dailyRows = periods.flatMap(p => buildDailyRows(p.projects, p.label)) + const activityRows = periods.flatMap(p => buildActivityRows(p.projects, p.label)) + const modelRows = periods.flatMap(p => buildModelRows(p.projects, p.label)) + + await writeFile(join(folder, 'README.txt'), buildReadme(periods), 'utf-8') + await writeFile(join(folder, 'summary.csv'), rowsToCsv(buildSummaryRows(periods)), 'utf-8') + await writeFile(join(folder, 'daily.csv'), rowsToCsv(dailyRows), 'utf-8') + await writeFile(join(folder, 'activity.csv'), rowsToCsv(activityRows), 'utf-8') + await writeFile(join(folder, 'models.csv'), rowsToCsv(modelRows), 'utf-8') + 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 +} + +export async function exportJson(periods: PeriodExport[], outputPath: string): Promise { + const thirtyDays = periods.find(p => p.label === '30 Days') + const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? [] + const { code, rate, symbol } = getCurrency() + + const data = { + schema: 'codeburn.export.v2', + generated: new Date().toISOString(), + currency: { code, rate, symbol }, + summary: buildSummaryRows(periods), + periods: periods.map(p => ({ + label: p.label, + daily: buildDailyRows(p.projects, p.label), + activity: buildActivityRows(p.projects, p.label), + models: buildModelRows(p.projects, p.label), + })), + projects: buildProjectRows(thirtyDayProjects), + sessions: buildSessionRows(thirtyDayProjects), + tools: buildToolRows(thirtyDayProjects), + mcp: buildMcpRows(thirtyDayProjects), + shellCommands: buildBashRows(thirtyDayProjects), + } + + const target = resolve(outputPath.toLowerCase().endsWith('.json') ? outputPath : `${outputPath}.json`) + // Refuse to overwrite an existing file that wasn't produced by codeburn + // export. CSV path has the same guard via the .codeburn-export marker; JSON + // was missing it, so a stray `-o ~/important.json` would silently clobber. + const existing = await stat(target).catch(() => null) + if (existing?.isFile()) { + // Read just the first 4KB to look for the schema marker. The schema key + // is the first field in the JSON object so a partial read is enough; + // loading the whole file (potentially gigabytes) into memory could OOM + // on Node's ~512MB string limit. + const fh = await open(target, 'r') + try { + const buf = Buffer.alloc(4096) + const { bytesRead } = await fh.read(buf, 0, buf.length, 0) + const head = buf.toString('utf-8', 0, bytesRead) + if (!head.includes('"schema": "codeburn.export.v')) { + throw new Error( + `Refusing to overwrite ${target}: file does not look like a codeburn export. ` + + `Delete it manually or pick a different -o path.` + ) + } + } finally { + await fh.close() + } + } + if (existing?.isDirectory()) { + throw new Error(`Refusing to overwrite directory at ${target}. Pass a file path instead.`) + } + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, JSON.stringify(data, null, 2), 'utf-8') + return target +} diff --git a/src/fetch-utils.ts b/src/fetch-utils.ts new file mode 100644 index 0000000..7c18f74 --- /dev/null +++ b/src/fetch-utils.ts @@ -0,0 +1,22 @@ +// Default ceiling for outbound HTTP. Every CLI command awaits loadPricing(), +// and the macOS menubar shells out to the CLI and blocks on its exit — so an +// unbounded fetch() on a half-open network (e.g. Wi-Fi/DNS not yet up after +// wake-from-sleep) wedges the menubar on its loading spinner indefinitely. +// 8s is generous for these small JSON endpoints while still failing fast. +export const DEFAULT_FETCH_TIMEOUT_MS = 8000 + +/// fetch() with a hard timeout. On timeout the returned promise rejects with a +/// TimeoutError (an AbortError subtype), which callers already handle via their +/// existing try/catch + bundled-snapshot fallback. A caller-supplied signal is +/// combined with the timeout so either can abort the request. +export async function fetchWithTimeout( + url: string, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS, +): Promise { + const timeoutSignal = AbortSignal.timeout(timeoutMs) + const signal = init.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal + return fetch(url, { ...init, signal }) +} diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..826c04c --- /dev/null +++ b/src/format.ts @@ -0,0 +1,63 @@ +import chalk from 'chalk' +import type { ProjectSummary } from './types.js' + +// Re-exported from currency.ts so existing imports from './format.js' keep working. +// The currency-aware version applies exchange rate and symbol automatically. +// Imported locally too since renderStatusBar below uses it directly. +import { formatCost } from './currency.js' +export { formatCost } + +export function formatTokens(n: number): string { + // Guard against Infinity / NaN / negatives that would otherwise leak into + // the UI as "Infinity" or "NaN" strings when an upstream calculation glitches. + if (!Number.isFinite(n)) return '?' + if (n < 0) return '0' + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return Math.round(n).toString() +} + +/// Returns YYYY-MM-DD for the given date in the process-local timezone. Cheaper than shelling +/// out to Intl.DateTimeFormat for every turn in a loop and avoids the UTC drift that bites +/// `Date.toISOString().slice(0,10)` whenever the user runs this between local midnight and +/// UTC midnight. +function localDateString(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +export function renderStatusBar(projects: ProjectSummary[]): string { + const now = new Date() + const today = localDateString(now) + const monthStart = `${today.slice(0, 7)}-01` + + let todayCost = 0, todayCalls = 0, monthCost = 0, monthCalls = 0 + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (turn.assistantCalls.length === 0) continue + // Bucket by the first assistant call's local date -- the moment the cost was + // incurred. Bucketing by `turn.timestamp` (the user message time) drops turns + // that straddle midnight (user asked at 23:58, response arrived at 00:30) and + // disagrees with parseAllSessions' dateRange filter which is also on assistant + // time. + const bucketTs = turn.assistantCalls[0]!.timestamp + if (!bucketTs) continue + const day = localDateString(new Date(bucketTs)) + const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) + const turnCalls = turn.assistantCalls.length + if (day === today) { todayCost += turnCost; todayCalls += turnCalls } + if (day >= monthStart) { monthCost += turnCost; monthCalls += turnCalls } + } + } + } + + const lines: string[] = [''] + lines.push(` ${chalk.bold('Today')} ${chalk.yellowBright(formatCost(todayCost))} ${chalk.dim(`${todayCalls} calls`)} ${chalk.bold('Month')} ${chalk.yellowBright(formatCost(monthCost))} ${chalk.dim(`${monthCalls} calls`)}`) + lines.push('') + + return lines.join('\n') +} diff --git a/src/fs-utils.ts b/src/fs-utils.ts new file mode 100644 index 0000000..757bdb4 --- /dev/null +++ b/src/fs-utils.ts @@ -0,0 +1,227 @@ +import { readFile, stat } from 'fs/promises' +import { readFileSync, statSync, createReadStream } from 'fs' + +// Hard cap well below V8's 512 MB string limit. Callers that need line-by-line +// processing should use readSessionLines(), which avoids materializing the +// whole file and can return large lines as Buffers. +export const MAX_SESSION_FILE_BYTES = 128 * 1024 * 1024 +export const LARGE_STREAM_LINE_BYTES = 32 * 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. 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' +} + +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`) +} + +export async function readSessionFile( + filePath: string, + encoding: BufferEncoding = 'utf-8' +): Promise { + let size: number + try { + size = (await stat(filePath)).size + } catch (err) { + warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return null + } + + if (size > MAX_SESSION_FILE_BYTES) { + warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`) + return null + } + + try { + return await readFile(filePath, encoding) + } catch (err) { + warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return null + } +} + +export function readSessionFileSync(filePath: string): string | null { + let size: number + try { + size = statSync(filePath).size + } catch (err) { + warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return null + } + + if (size > MAX_SESSION_FILE_BYTES) { + warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`) + return null + } + + try { + return readFileSync(filePath, 'utf-8') + } catch (err) { + warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return null + } +} + +export type SessionLine = string | Buffer + +type ReadSessionLinesOptions = { + largeLineAsBuffer?: boolean + largeLineThresholdBytes?: number + startByteOffset?: number + byteOffsetTracker?: { lastCompleteLineOffset: number } + maxBytes?: number +} + +export function readSessionLines( + filePath: string, + shouldSkipHead?: (head: string) => boolean, +): AsyncGenerator +export function readSessionLines( + filePath: string, + shouldSkipHead?: (head: string) => boolean, + options?: ReadSessionLinesOptions & { largeLineAsBuffer: true }, +): AsyncGenerator +export async function* readSessionLines( + filePath: string, + shouldSkipHead?: (head: string) => boolean, + options: ReadSessionLinesOptions = {}, +): AsyncGenerator { + let size: number + try { + size = (await stat(filePath)).size + } catch (err) { + warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return + } + + const maxBytes = options.maxBytes ?? MAX_STREAM_SESSION_FILE_BYTES + if (size > maxBytes) { + notice( + `skipped oversize session ${filePath} (${size} bytes > cap ${maxBytes}); its usage is NOT counted`, + ) + return + } + + const stream = createReadStream( + filePath, + options.startByteOffset !== undefined ? { start: options.startByteOffset } : undefined, + ) + const SKIP_HEAD = 2048 + const largeLineThreshold = options.largeLineThresholdBytes ?? LARGE_STREAM_LINE_BYTES + const formatLine = (buf: Buffer, lineLen: number, head?: string): SessionLine => { + if (options.largeLineAsBuffer && lineLen > largeLineThreshold) return buf + return head !== undefined && lineLen <= SKIP_HEAD ? head : buf.toString('utf-8') + } + let parts: Buffer[] = [] + let len = 0 + let skipping = false + let headChecked = false + let chunkBase = options.startByteOffset ?? 0 + const tracker = options.byteOffsetTracker + + try { + for await (const raw of stream) { + const chunk = raw as Buffer + let pos = 0 + + while (pos < chunk.length) { + const nl = chunk.indexOf(0x0a, pos) + + if (skipping) { + if (nl === -1) { + pos = chunk.length + } else { + if (tracker) tracker.lastCompleteLineOffset = chunkBase + nl + 1 + skipping = false + pos = nl + 1 + } + continue + } + + if (nl !== -1) { + if (pos < nl) { + parts.push(chunk.subarray(pos, nl)) + len += nl - pos + } + pos = nl + 1 + if (tracker) tracker.lastCompleteLineOffset = chunkBase + pos + + if (len === 0) { + parts = [] + headChecked = false + continue + } + + const buf = parts.length === 1 ? parts[0]! : Buffer.concat(parts, len) + const lineLen = len + parts = [] + len = 0 + headChecked = false + + if (shouldSkipHead) { + const head = lineLen > SKIP_HEAD + ? buf.subarray(0, SKIP_HEAD).toString('utf-8') + : buf.toString('utf-8') + if (shouldSkipHead(head)) continue + yield formatLine(buf, lineLen, head) + } else { + yield formatLine(buf, lineLen) + } + } else { + const slice = chunk.subarray(pos) + parts.push(slice) + len += slice.length + pos = chunk.length + + // Mid-line skip: once we have enough bytes to check the head, + // enter scanning mode — just look for \n without accumulating. + if (shouldSkipHead && !headChecked && len >= SKIP_HEAD) { + headChecked = true + const headBuf = parts.length === 1 + ? parts[0]!.subarray(0, SKIP_HEAD) + : Buffer.concat(parts, len).subarray(0, SKIP_HEAD) + if (shouldSkipHead(headBuf.toString('utf-8'))) { + skipping = true + parts = [] + len = 0 + } + } + } + } + chunkBase += chunk.length + } + + if (!skipping && len > 0) { + const buf = parts.length === 1 ? parts[0]! : Buffer.concat(parts, len) + const lineLen = len + if (shouldSkipHead) { + const head = lineLen > SKIP_HEAD + ? buf.subarray(0, SKIP_HEAD).toString('utf-8') + : buf.toString('utf-8') + if (!shouldSkipHead(head)) { + yield formatLine(buf, lineLen, head) + } + } else { + yield formatLine(buf, lineLen) + } + } + } catch (err) { + warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + } finally { + stream.destroy() + } +} diff --git a/src/guard/cli.ts b/src/guard/cli.ts new file mode 100644 index 0000000..4ac49ff --- /dev/null +++ b/src/guard/cli.ts @@ -0,0 +1,229 @@ +import type { Command } from 'commander' +import { readdir, stat, readFile } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import chalk from 'chalk' + +type Scope = { global?: boolean; project?: string } + +function readStdin(): Promise { + return new Promise(resolve => { + const stdin = process.stdin + if (stdin.isTTY) { resolve(''); return } + let data = '' + stdin.setEncoding('utf-8') + stdin.on('data', c => { data += c }) + stdin.on('end', () => resolve(data)) + stdin.on('error', () => resolve('')) + }) +} + +function usd(n: number | null): string { + return n === null ? 'off' : `$${n}` +} + +async function refreshFlags(): Promise { + const { parseAllSessions } = await import('../parser.js') + const { buildFlags, writeFlags } = await import('./flags.js') + const projects = await parseAllSessions() + const flags = await buildFlags(projects) + await writeFlags(flags) + return flags.projects.length +} + +async function doInstall(scope: Scope, statusline: boolean): Promise { + const { settingsPathFor, buildInstall } = await import('./settings.js') + const { runAction } = await import('../act/apply.js') + const { shortId } = await import('../act/journal.js') + const { readGuardConfig, writeGuardConfig, guardConfigPath, DEFAULT_GUARD_CONFIG } = await import('./store.js') + + const path = settingsPathFor({ ...scope, cwd: process.cwd() }) + const built = buildInstall(path, { statusline }) + for (const note of built.notes) console.log(chalk.yellow(` ! ${note}`)) + if (built.plan) { + // Capture a trailing-14-day yield baseline so `act report` can correlate + // the guard install against later yield. Best effort; a failure just + // leaves the guard row "not measurable". + try { + const { captureGuardBaseline } = await import('../act/report.js') + const baseline = await captureGuardBaseline({ cwd: process.cwd() }) + if (baseline) built.plan.baseline = baseline + } catch { /* baseline is optional */ } + const record = await runAction(built.plan) + console.log(` Installed ${chalk.bold(shortId(record.id))} ${built.plan.description}`) + console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + } else { + console.log(chalk.dim(` ${path}: nothing to change.`)) + } + + if (!existsSync(guardConfigPath())) { + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, updatedAt: new Date().toISOString() }) + const c = await readGuardConfig() + console.log(chalk.dim(` Wrote guard.json (soft ${usd(c.softUSD)}, hard ${usd(c.hardUSD)}, checkpoint ${usd(c.checkpointUSD)}).`)) + } + + try { + const flagged = await refreshFlags() + console.log(chalk.dim(` Flagged ${flagged} project${flagged === 1 ? '' : 's'} for session openers.`)) + } catch (e) { + console.log(chalk.yellow(` ! could not compute session-opener flags: ${e instanceof Error ? e.message : String(e)}`)) + } +} + +async function doUninstall(scope: Scope): Promise { + const { settingsPathFor, buildUninstall } = await import('./settings.js') + const { runAction } = await import('../act/apply.js') + const { shortId } = await import('../act/journal.js') + + const path = settingsPathFor({ ...scope, cwd: process.cwd() }) + const built = buildUninstall(path) + for (const note of built.notes) console.log(chalk.dim(` ${note}`)) + if (built.plan) { + const record = await runAction(built.plan) + console.log(` Uninstalled ${chalk.bold(shortId(record.id))} ${built.plan.description}`) + console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + } +} + +async function doStatus(): Promise { + const { readGuardConfig } = await import('./store.js') + const { inspectInstall, settingsPathFor } = await import('./settings.js') + const { readFlags, flagsAgeMs } = await import('./flags.js') + + const config = await readGuardConfig() + console.log(chalk.bold('\n codeburn guard')) + console.log(` soft cap: ${usd(config.softUSD)}`) + console.log(` hard cap: ${usd(config.hardUSD)}`) + console.log(` checkpoint: ${usd(config.checkpointUSD)}`) + console.log(` openers: ${config.openerEnabled ? 'on' : 'off'}`) + + const locations = [ + { label: 'global', path: settingsPathFor({ global: true }) }, + { label: 'project', path: settingsPathFor({ cwd: process.cwd() }) }, + ] + const found = locations + .map(l => ({ ...l, info: inspectInstall(l.path) })) + .filter(l => l.info.hooks.length > 0 || l.info.statusline) + if (found.length === 0) { + console.log(' installed: nowhere (run: codeburn guard install)') + } else { + for (const l of found) { + const bits = [...new Set(l.info.hooks)].join(', ') + console.log(` installed: ${l.label} ${l.path} [${bits}${l.info.statusline ? ', statusline' : ''}]`) + } + } + + const flags = await readFlags() + if (!flags) { + console.log(' flags: none (run: codeburn guard refresh)') + } else { + const ageDays = flagsAgeMs(flags) / 86_400_000 + console.log(` flags: ${flags.projects.length} project${flags.projects.length === 1 ? '' : 's'}, ${ageDays.toFixed(1)}d old`) + } + console.log() +} + +async function doAllow(sessionId: string | undefined): Promise { + const { sessionsDir } = await import('./store.js') + const { writeAllow } = await import('./usage.js') + let id = sessionId + if (!id) { + const dir = sessionsDir() + const names = (await readdir(dir).catch(() => [])).filter(f => f.endsWith('.json')) + let newest = { at: -1, id: '' } + for (const name of names) { + const st = await stat(join(dir, name)).catch(() => null) + if (!st) continue + if (st.mtimeMs > newest.at) { + try { + const cache = JSON.parse(await readFile(join(dir, name), 'utf-8')) as { sessionId?: string } + if (cache.sessionId) newest = { at: st.mtimeMs, id: cache.sessionId } + } catch { /* skip unreadable cache */ } + } + } + id = newest.id + } + if (!id) { + console.error(' No active guard session found. Pass the session id: codeburn guard allow .') + process.exitCode = 1 + return + } + await writeAllow(id) + console.log(` Lifted the guard hard cap for session ${id} (this session only).`) +} + +export function registerGuardCommands(program: Command): void { + const guard = program + .command('guard') + .description('Opt-in, removable session-time hooks for Claude Code (budget caps, openers, yield checkpoint)') + + guard + .command('install') + .description('Install the guard hooks into Claude Code settings (default: this project)') + .option('--global', 'Install into ~/.claude/settings.json instead of the project') + .option('--project ', 'Install into /.claude/settings.json') + .option('--statusline', 'Also configure the guard statusline (skipped if one already exists)') + .action(async (opts: { global?: boolean; project?: string; statusline?: boolean }) => { + try { + await doInstall({ global: opts.global, project: opts.project }, !!opts.statusline) + } catch (e) { + console.error(` ${e instanceof Error ? e.message : String(e)}`) + process.exitCode = 1 + } + }) + + guard + .command('uninstall') + .description('Remove the guard hooks, leaving any user hooks untouched') + .option('--global', 'Uninstall from ~/.claude/settings.json') + .option('--project ', 'Uninstall from /.claude/settings.json') + .action(async (opts: { global?: boolean; project?: string }) => { + try { + await doUninstall({ global: opts.global, project: opts.project }) + } catch (e) { + console.error(` ${e instanceof Error ? e.message : String(e)}`) + process.exitCode = 1 + } + }) + + guard + .command('status') + .description('Show resolved guard config, install locations, and the flag list') + .action(async () => { await doStatus() }) + + guard + .command('refresh') + .description('Recompute the per-project session-opener flag list from optimize signals') + .action(async () => { + try { + const n = await refreshFlags() + console.log(` Flagged ${n} project${n === 1 ? '' : 's'} for session openers.`) + } catch (e) { + console.error(` ${e instanceof Error ? e.message : String(e)}`) + process.exitCode = 1 + } + }) + + guard + .command('allow [sessionId]') + .description('Lift the hard budget cap for the current (or given) session') + .action(async (sessionId: string | undefined) => { await doAllow(sessionId) }) + + guard + .command('hook ') + .description('Internal: Claude Code invokes this with the hook payload on stdin') + .action(async (event: string) => { + const { runGuardHook } = await import('./hooks.js') + const out = await runGuardHook(event, await readStdin()) + if (out) process.stdout.write(out) + }) + + guard + .command('statusline') + .description('Internal: Claude Code statusline command; prints one line') + .action(async () => { + const { runGuardStatusline } = await import('./hooks.js') + const out = await runGuardStatusline(await readStdin()) + if (out) process.stdout.write(out + '\n') + }) +} diff --git a/src/guard/flags.ts b/src/guard/flags.ts new file mode 100644 index 0000000..0037045 --- /dev/null +++ b/src/guard/flags.ts @@ -0,0 +1,73 @@ +import { mkdir, readFile, writeFile } from 'fs/promises' +import { sep } from 'path' +import type { ProjectSummary } from '../types.js' +import { flagsPath, guardDir } from './store.js' + +// Per-project flag list computed at install / `guard refresh` time and read on +// SessionStart. The resolved opener text is stored here (not a code path into +// optimize) so the hot SessionStart handler never imports the analyzer. +export type ProjectFlag = { path: string; openers: string[] } +export type GuardFlags = { generatedAt: string; projects: ProjectFlag[] } + +export const FLAG_STALE_MS = 7 * 24 * 60 * 60 * 1000 + +// optimize is loaded lazily so importing this module (for readFlags/matchFlag, +// which the SessionStart hook needs) does not pull the analyzer. +export async function buildFlags(projects: ProjectSummary[]): Promise { + const { findLowWorthCandidates, findContextBloatCandidates, LOW_WORTH_OPENER, CONTEXT_HEAVY_OPENER } = + await import('../optimize.js') + const lowWorth = new Set(findLowWorthCandidates(projects).map(c => c.project)) + const contextHeavy = new Set(findContextBloatCandidates(projects).map(c => c.project)) + const flags: ProjectFlag[] = [] + for (const project of projects) { + const openers: string[] = [] + if (lowWorth.has(project.project)) openers.push(LOW_WORTH_OPENER) + if (contextHeavy.has(project.project)) openers.push(CONTEXT_HEAVY_OPENER) + if (openers.length > 0) flags.push({ path: project.projectPath, openers }) + } + return { generatedAt: new Date().toISOString(), projects: flags } +} + +export async function readFlags(base?: string): Promise { + let raw: string + try { + raw = await readFile(flagsPath(base), 'utf-8') + } catch { + return null + } + try { + const parsed = JSON.parse(raw) as GuardFlags + if (typeof parsed?.generatedAt !== 'string' || !Array.isArray(parsed.projects)) return null + return parsed + } catch { + return null + } +} + +export async function writeFlags(flags: GuardFlags, base?: string): Promise { + await mkdir(guardDir(base), { recursive: true }) + await writeFile(flagsPath(base), JSON.stringify(flags, null, 2) + '\n', 'utf-8') +} + +export function flagsAgeMs(flags: GuardFlags): number { + const t = Date.parse(flags.generatedAt) + return Number.isNaN(t) ? Number.POSITIVE_INFINITY : Date.now() - t +} + +function norm(p: string): string { + return p.length > 1 && p.endsWith(sep) ? p.slice(0, -1) : p +} + +// Openers for the flagged project the cwd sits in (exact match or a subdir of +// it); most-specific project wins. Empty when the cwd is not flagged. +export function matchFlag(flags: GuardFlags, cwd: string): string[] { + const target = norm(cwd) + let best: ProjectFlag | null = null + for (const flag of flags.projects) { + const base = norm(flag.path) + if (target === base || target.startsWith(base + sep)) { + if (!best || base.length > norm(best.path).length) best = flag + } + } + return best ? best.openers : [] +} diff --git a/src/guard/hooks.ts b/src/guard/hooks.ts new file mode 100644 index 0000000..767b666 --- /dev/null +++ b/src/guard/hooks.ts @@ -0,0 +1,167 @@ +// =========================================================================== +// Claude Code hook protocol, verified against the live docs on 2026-07-03 +// (https://docs.anthropic.com/en/docs/claude-code/hooks -> 301 -> +// https://code.claude.com/docs/en/hooks, and .../statusline). The spec's field +// names were NOT trusted; these are the doc's: +// +// Event names (exact casing): PreToolUse, SessionStart, Stop. +// stdin JSON (snake_case) common to every event: session_id, transcript_path, +// cwd, hook_event_name, permission_mode. PreToolUse adds tool_name + +// tool_input; SessionStart adds source (startup|resume|clear|compact). +// statusLine stdin differs: session_id, transcript_path, workspace.current_dir, +// cost.total_cost_usd (plain text out; each stdout line renders as its own +// status row, so we emit exactly one). +// Exit/stdout contract: exit 0 -> stdout parsed as JSON output; exit 2 -> +// blocking, stderr fed to the model. We ALWAYS exit 0 and encode any decision +// as JSON, so an internal error is indistinguishable from "no opinion" +// (fail-open). Empty stdout = no effect. +// PreToolUse block: { hookSpecificOutput: { hookEventName: "PreToolUse", +// permissionDecision: "deny", permissionDecisionReason } } (permissionDecision +// is allow|deny|ask|defer). Non-blocking user note anywhere: top-level +// systemMessage. +// SessionStart context: { hookSpecificOutput: { hookEventName: "SessionStart", +// additionalContext } }; SessionStart cannot block. +// Stop: may block via top-level { decision: "block", reason } or exit 2; we +// never do; a non-blocking nudge uses systemMessage (additionalContext on Stop +// would force the conversation to continue, which we do not want). +// settings.json shape: { hooks: { : [ { matcher?, hooks: [ { type: +// "command", command } ] } ] } }. Stop takes no matcher; SessionStart matcher +// is startup|resume|clear|compact; an omitted matcher matches all. Statusline +// is a top-level statusLine: { type: "command", command }. +// =========================================================================== +import { readGuardConfig } from './store.js' +import { computeSessionUsage, isAllowed, readCache, writeCache } from './usage.js' +import { FLAG_STALE_MS, flagsAgeMs, matchFlag, readFlags } from './flags.js' + +export type HookOpts = { base?: string } + +function str(obj: unknown, key: string): string | undefined { + if (obj && typeof obj === 'object') { + const v = (obj as Record)[key] + if (typeof v === 'string') return v + } + return undefined +} + +function usd(n: number): string { + return `$${n.toFixed(2)}` +} + +async function handlePreToolUse(input: unknown, opts: HookOpts): Promise { + const sessionId = str(input, 'session_id') + const transcript = str(input, 'transcript_path') + if (!sessionId || !transcript) return '' + + const config = await readGuardConfig(opts.base) + const prev = await readCache(sessionId, opts.base) + const { cache } = await computeSessionUsage(prev, transcript) + + let output = '' + if (config.hardUSD !== null && cache.costUSD >= config.hardUSD && !(await isAllowed(sessionId, opts.base))) { + // A block is a stronger signal than the soft nag; once blocked, don't also + // fire a soft warning on the next (e.g. post-`allow`) tool call. + cache.softWarned = true + output = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: + `Session cost passed ${usd(config.hardUSD)} (codeburn guard). Run 'codeburn guard allow' to lift the cap for this session, or raise hardUSD in guard.json.`, + }, + }) + } else if (config.softUSD !== null && cache.costUSD >= config.softUSD && !cache.softWarned) { + cache.softWarned = true + output = JSON.stringify({ + systemMessage: `codeburn guard: this session is ${usd(cache.costUSD)} (soft cap ${usd(config.softUSD)}).`, + }) + } + + await writeCache(cache, opts.base) + return output +} + +async function handleSessionStart(input: unknown, opts: HookOpts): Promise { + const cwd = str(input, 'cwd') + if (!cwd) return '' + const config = await readGuardConfig(opts.base) + if (!config.openerEnabled) return '' + const flags = await readFlags(opts.base) + if (!flags || flagsAgeMs(flags) > FLAG_STALE_MS) return '' + const openers = matchFlag(flags, cwd) + if (openers.length === 0) return '' + return JSON.stringify({ + hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: openers.join('\n\n') }, + }) +} + +async function handleStop(input: unknown, opts: HookOpts): Promise { + const sessionId = str(input, 'session_id') + const transcript = str(input, 'transcript_path') + if (!sessionId || !transcript) return '' + + const config = await readGuardConfig(opts.base) + const prev = await readCache(sessionId, opts.base) + const { cache } = await computeSessionUsage(prev, transcript) + + let output = '' + if ( + config.checkpointUSD !== null + && cache.costUSD > config.checkpointUSD + && !cache.sawEdit + && !cache.sawGitCommit + && !cache.stopNotified + ) { + cache.stopNotified = true + output = JSON.stringify({ + systemMessage: + `This session is ${usd(cache.costUSD)} with no edits or commits yet. If exploring is the goal, fine; otherwise consider a fresh session with a named deliverable.`, + }) + } + + await writeCache(cache, opts.base) + return output +} + +// The fail-open boundary: parse stdin, dispatch, and turn ANY error, malformed +// payload, or unknown event into exit-0-with-empty-output. A broken guard must +// never block a session. +export async function runGuardHook(event: string, raw: string, opts: HookOpts = {}): Promise { + try { + const input = JSON.parse(raw) as unknown + switch (event.toLowerCase()) { + case 'pretooluse': return await handlePreToolUse(input, opts) + case 'sessionstart': return await handleSessionStart(input, opts) + case 'stop': return await handleStop(input, opts) + default: return '' + } + } catch { + return '' + } +} + +// statusLine is a separate command, not a hook event. One line: guard's session +// cost and how stale the incremental cache is versus a 5-minute turn TTL. +export const STATUSLINE_TTL_MS = 5 * 60 * 1000 + +export async function runGuardStatusline(raw: string, opts: HookOpts = {}): Promise { + try { + const input = JSON.parse(raw) as unknown + const sessionId = str(input, 'session_id') + const transcript = str(input, 'transcript_path') + if (!sessionId || !transcript) return '' + const prev = await readCache(sessionId, opts.base) + const { cache } = await computeSessionUsage(prev, transcript) + await writeCache(cache, opts.base) + return `codeburn guard ${usd(cache.costUSD)} · ${freshness(cache.lastTurnAt)}` + } catch { + return '' + } +} + +function freshness(lastTurnAt: string | null): string { + if (!lastTurnAt) return 'no turns yet' + const age = Date.now() - Date.parse(lastTurnAt) + if (Number.isNaN(age) || age < 0) return 'fresh' + const label = age < 60_000 ? `${Math.round(age / 1000)}s` : `${Math.round(age / 60_000)}m` + return age > STATUSLINE_TTL_MS ? `idle ${label}` : label +} diff --git a/src/guard/settings.ts b/src/guard/settings.ts new file mode 100644 index 0000000..843c2df --- /dev/null +++ b/src/guard/settings.ts @@ -0,0 +1,199 @@ +import { existsSync, readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { sha256 } from '../act/backup.js' +import type { ActionPlan, PlannedChange } from '../act/types.js' + +// The hook entries `guard install` writes and `guard uninstall` removes. Every +// command carries the same recognizable prefix so uninstall can find exactly +// ours by substring even if the user later moved or reindented the file. +export const GUARD_HOOK_PREFIX = 'codeburn guard hook' +export const GUARD_STATUSLINE_COMMAND = 'codeburn guard statusline' + +const INSTALL_HOOKS: { event: string; matcher?: string; arg: string }[] = [ + { event: 'PreToolUse', arg: 'pretooluse' }, + { event: 'SessionStart', matcher: 'startup', arg: 'sessionstart' }, + { event: 'Stop', arg: 'stop' }, +] + +function hookCommand(arg: string): string { + return `${GUARD_HOOK_PREFIX} ${arg}` +} + +export function settingsPathFor(scope: { global?: boolean; project?: string; cwd?: string }): string { + const dir = scope.global ? homedir() : (scope.project ?? scope.cwd ?? process.cwd()) + return join(dir, '.claude', 'settings.json') +} + +type Loaded = { doc: Record; existed: boolean; rawHash: string | null } + +function load(path: string): Loaded { + if (!existsSync(path)) return { doc: {}, existed: false, rawHash: null } + const buf = readFileSync(path) + const rawHash = sha256(buf) + let raw = buf.toString('utf-8') + if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1) + let doc: unknown + try { + doc = JSON.parse(raw) + } catch (e) { + throw new Error(`could not parse ${path}: ${e instanceof Error ? e.message : String(e)}`) + } + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { + throw new Error(`${path} is not a JSON object`) + } + return { doc: doc as Record, existed: true, rawHash } +} + +type HookEntry = { type?: string; command?: string; [k: string]: unknown } +type MatcherGroup = { matcher?: string; hooks?: HookEntry[]; [k: string]: unknown } + +function asGroups(value: unknown): MatcherGroup[] { + return Array.isArray(value) ? (value as MatcherGroup[]) : [] +} + +function groupHasOurCommand(group: MatcherGroup, command: string): boolean { + return Array.isArray(group.hooks) && group.hooks.some(h => h?.command === command) +} + +export type SettingsBuild = { + plan: ActionPlan | null + path: string + existed: boolean + notes: string[] +} + +function change(path: string, existed: boolean, rawHash: string | null, doc: Record): PlannedChange { + return { + op: existed ? 'edit' : 'create', + path, + content: JSON.stringify(doc, null, 2) + '\n', + expectedHash: rawHash, + } +} + +export function buildInstall(path: string, opts: { statusline?: boolean } = {}): SettingsBuild { + const { doc, existed, rawHash } = load(path) + const notes: string[] = [] + const hooks = (doc.hooks && typeof doc.hooks === 'object' && !Array.isArray(doc.hooks)) + ? doc.hooks as Record + : {} + let added = false + + for (const { event, matcher, arg } of INSTALL_HOOKS) { + const command = hookCommand(arg) + const groups = asGroups(hooks[event]) + if (groups.some(g => groupHasOurCommand(g, command))) continue + groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] }) + hooks[event] = groups + added = true + } + if (added || Object.keys(hooks).length > 0) doc.hooks = hooks + + if (opts.statusline) { + const existing = doc.statusLine + if (existing && typeof existing === 'object' && (existing as HookEntry).command !== GUARD_STATUSLINE_COMMAND) { + notes.push('a statusline is already configured; left it untouched (remove it first to use the guard statusline)') + } else if ((existing as HookEntry | undefined)?.command === GUARD_STATUSLINE_COMMAND) { + // already ours + } else { + doc.statusLine = { type: 'command', command: GUARD_STATUSLINE_COMMAND } + added = true + } + } + + if (!added) { + notes.push('guard hooks already present; nothing to install') + return { plan: null, path, existed, notes } + } + return { + plan: { + kind: 'guard-install', + findingId: null, + description: `Install codeburn guard hooks into ${path}`, + changes: [change(path, existed, rawHash, doc)], + }, + path, + existed, + notes, + } +} + +export function buildUninstall(path: string): SettingsBuild { + if (!existsSync(path)) { + return { plan: null, path, existed: false, notes: ['no settings file at that location; nothing to uninstall'] } + } + const { doc, existed, rawHash } = load(path) + let removed = false + + const hooks = (doc.hooks && typeof doc.hooks === 'object' && !Array.isArray(doc.hooks)) + ? doc.hooks as Record + : null + if (hooks) { + for (const event of Object.keys(hooks)) { + const groups = asGroups(hooks[event]) + const kept: MatcherGroup[] = [] + for (const group of groups) { + if (!Array.isArray(group.hooks)) { kept.push(group); continue } + const keptHooks = group.hooks.filter(h => !(typeof h?.command === 'string' && h.command.includes(GUARD_HOOK_PREFIX))) + if (keptHooks.length !== group.hooks.length) removed = true + if (keptHooks.length === 0) continue // drop a group that was only ours + kept.push(keptHooks.length === group.hooks.length ? group : { ...group, hooks: keptHooks }) + } + if (kept.length === 0) delete hooks[event] + else hooks[event] = kept + } + if (Object.keys(hooks).length === 0) delete doc.hooks + } + + const statusLine = doc.statusLine as HookEntry | undefined + if (statusLine && typeof statusLine.command === 'string' && statusLine.command.includes(GUARD_STATUSLINE_COMMAND)) { + delete doc.statusLine + removed = true + } + + if (!removed) { + return { plan: null, path, existed, notes: ['no codeburn guard hooks found in that settings file'] } + } + return { + plan: { + kind: 'guard-uninstall', + findingId: null, + description: `Remove codeburn guard hooks from ${path}`, + changes: [change(path, existed, rawHash, doc)], + }, + path, + existed, + notes: [], + } +} + +// Report whether a settings file currently carries our hooks / statusline, for +// `guard status`. Never throws: a missing or malformed file reads as absent. +export function inspectInstall(path: string): { path: string; hooks: string[]; statusline: boolean } { + const out = { path, hooks: [] as string[], statusline: false } + if (!existsSync(path)) return out + let doc: Record + try { + let raw = readFileSync(path, 'utf-8') + if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1) + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return out + doc = parsed as Record + } catch { + return out + } + const hooks = doc.hooks + if (hooks && typeof hooks === 'object') { + for (const [event, value] of Object.entries(hooks as Record)) { + for (const group of asGroups(value)) { + if (Array.isArray(group.hooks) && group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(GUARD_HOOK_PREFIX))) { + out.hooks.push(event) + } + } + } + } + const sl = doc.statusLine as HookEntry | undefined + out.statusline = !!(sl && typeof sl.command === 'string' && sl.command.includes(GUARD_STATUSLINE_COMMAND)) + return out +} diff --git a/src/guard/store.ts b/src/guard/store.ts new file mode 100644 index 0000000..c9e30e6 --- /dev/null +++ b/src/guard/store.ts @@ -0,0 +1,92 @@ +import { mkdir, readFile, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { getConfigFilePath } from '../config.js' + +// Guard state lives beside config.json under the CodeBurn home dir (in practice +// ~/.config/codeburn): guard.json for thresholds, a guard/ subdir for the +// per-session incremental caches, the flag list, and per-session allow markers. +// Every path derives from an injectable base so tests point the whole thing at +// a fixture dir and the real config dir is never touched. +export function guardBase(base?: string): string { + return base ?? dirname(getConfigFilePath()) +} + +export function guardConfigPath(base?: string): string { + return join(guardBase(base), 'guard.json') +} + +export function guardDir(base?: string): string { + return join(guardBase(base), 'guard') +} + +export function flagsPath(base?: string): string { + return join(guardDir(base), 'flags.json') +} + +// Per-session state sits one level below the shared flags.json so a session id +// can never collide with it (e.g. a session literally named "flags"). +export function sessionsDir(base?: string): string { + return join(guardDir(base), 'sessions') +} + +export function sessionCachePath(sessionId: string, base?: string): string { + return join(sessionsDir(base), `${sanitizeId(sessionId)}.json`) +} + +export function allowPath(sessionId: string, base?: string): string { + return join(sessionsDir(base), `${sanitizeId(sessionId)}.allow`) +} + +// Session ids come from the hook payload; keep them to a filesystem-safe set so +// a malformed id can never escape the guard dir. +function sanitizeId(id: string): string { + return id.replace(/[^A-Za-z0-9._-]/g, '_') +} + +export type GuardConfig = { + softUSD: number | null + hardUSD: number | null + checkpointUSD: number | null + openerEnabled: boolean + updatedAt: string +} + +export const DEFAULT_GUARD_CONFIG: GuardConfig = { + softUSD: 5, + hardUSD: 15, + checkpointUSD: 3, + openerEnabled: true, + updatedAt: '', +} + +function coerceThreshold(v: unknown, fallback: number | null): number | null { + if (v === null) return null + return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback +} + +export async function readGuardConfig(base?: string): Promise { + let raw: string + try { + raw = await readFile(guardConfigPath(base), 'utf-8') + } catch { + return { ...DEFAULT_GUARD_CONFIG } + } + let parsed: Partial + try { + parsed = JSON.parse(raw) as Partial + } catch { + return { ...DEFAULT_GUARD_CONFIG } + } + return { + softUSD: coerceThreshold(parsed.softUSD, DEFAULT_GUARD_CONFIG.softUSD), + hardUSD: coerceThreshold(parsed.hardUSD, DEFAULT_GUARD_CONFIG.hardUSD), + checkpointUSD: coerceThreshold(parsed.checkpointUSD, DEFAULT_GUARD_CONFIG.checkpointUSD), + openerEnabled: parsed.openerEnabled !== false, + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '', + } +} + +export async function writeGuardConfig(config: GuardConfig, base?: string): Promise { + await mkdir(guardBase(base), { recursive: true }) + await writeFile(guardConfigPath(base), JSON.stringify(config, null, 2) + '\n', 'utf-8') +} diff --git a/src/guard/usage.ts b/src/guard/usage.ts new file mode 100644 index 0000000..eaafdc7 --- /dev/null +++ b/src/guard/usage.ts @@ -0,0 +1,157 @@ +import { mkdir, readFile, stat, writeFile } from 'fs/promises' +import { readSessionLines } from '../fs-utils.js' +import { parseApiCall, parseJsonlLine } from '../parser.js' +import { EDIT_TOOLS } from '../classifier.js' +import { allowPath, sessionCachePath, sessionsDir } from './store.js' + +// Per-session running totals. The transcript is append-only, so each invocation +// streams only the bytes after `byteOffset` (the offset of the last complete +// line parsed) and folds them into the totals; a cold parse of a multi-hundred- +// MB transcript on every tool call is what this avoids. +// +// Claude Code rewrites each assistant message several times as it streams, and +// every copy carries the full final usage. The shipped parser dedupes those +// copies last-wins (dedupeStreamingMessageIds); a plain sum here measured real +// sessions at ~3x their true cost. `perMessage` maps message id -> that id's +// current cost contribution, and each id-carrying line REPLACES its previous +// contribution instead of adding. This also self-heals the trailing-line case: +// a complete final line without its newline is folded but byteOffset stops +// before it, so the next invocation re-reads it as a replace, not a double add. +export type GuardCache = { + version: number + sessionId: string + byteOffset: number + costUSD: number + perMessage: Record + sawEdit: boolean + sawGitCommit: boolean + lastTurnAt: string | null + updatedAt: string + softWarned: boolean + stopNotified: boolean +} + +// v2: per-message-id replace fold (perMessage map, sawEdit boolean) and the +// guard/sessions/ cache location. v1 caches are ignored and cold-reparse once. +export const GUARD_CACHE_VERSION = 2 + +// `commit` must be the git subcommand: `git`, optionally flag tokens (long +// flags, or a short flag with an optional separate value like `-c k=v`), then +// `commit` as the next word, anchored at a command boundary: string/line start +// (multi-line Bash calls separate commands with newlines) or ; & |. The gaps +// inside the command never cross a newline, so "git diff\ncommit msg" is two +// commands, not a commit. "git log --grep commit" and "git diff && echo +// commit" don't match either. +const GIT_COMMIT = /(?:^|[;&|])[^\S\n]*git(?:[^\S\n]+(?:--\S+|-\w+(?:[^\S\n]+\S+)?))*[^\S\n]+commit(?![-\w])/m + +export function emptyCache(sessionId: string): GuardCache { + return { + version: GUARD_CACHE_VERSION, + sessionId, + byteOffset: 0, + costUSD: 0, + perMessage: {}, + sawEdit: false, + sawGitCommit: false, + lastTurnAt: null, + updatedAt: '', + softWarned: false, + stopNotified: false, + } +} + +export async function readCache(sessionId: string, base?: string): Promise { + let raw: string + try { + raw = await readFile(sessionCachePath(sessionId, base), 'utf-8') + } catch { + return emptyCache(sessionId) + } + try { + const parsed = JSON.parse(raw) as Partial + if ( + parsed.version !== GUARD_CACHE_VERSION + || typeof parsed.byteOffset !== 'number' + || !parsed.perMessage || typeof parsed.perMessage !== 'object' + ) { + return emptyCache(sessionId) + } + return { ...emptyCache(sessionId), ...parsed, sessionId } + } catch { + return emptyCache(sessionId) + } +} + +export async function writeCache(cache: GuardCache, base?: string): Promise { + await mkdir(sessionsDir(base), { recursive: true }) + await writeFile(sessionCachePath(cache.sessionId, base), JSON.stringify(cache), 'utf-8') +} + +// Fold the transcript tail into the totals. Reuses the streaming line reader +// (startByteOffset + a lastCompleteLineOffset tracker) and the shared per-call +// cost/pricing path (parseApiCall -> calculateCost), so the guard never +// reimplements cost math. `resumedFrom` is the offset the parse restarted at, +// which the test asserts to prove only the tail was read. +export async function computeSessionUsage( + prev: GuardCache, + transcriptPath: string, +): Promise<{ cache: GuardCache; resumedFrom: number }> { + let size: number + try { + size = (await stat(transcriptPath)).size + } catch { + return { cache: prev, resumedFrom: prev.byteOffset } + } + + // A shorter file than we last read means the transcript was rotated or + // truncated; start over from a clean total rather than trusting a stale + // offset into different bytes. + const cache = size < prev.byteOffset + ? { ...emptyCache(prev.sessionId), softWarned: prev.softWarned, stopNotified: prev.stopNotified } + : { ...prev, perMessage: { ...prev.perMessage } } + const resumedFrom = cache.byteOffset + + const tracker = { lastCompleteLineOffset: resumedFrom } + for await (const line of readSessionLines(transcriptPath, undefined, { + startByteOffset: resumedFrom, + byteOffsetTracker: tracker, + largeLineAsBuffer: true, + })) { + const entry = parseJsonlLine(line) + if (!entry) continue + const call = parseApiCall(entry) + if (!call) continue + // Last-wins per message id, matching the shipped dedupeStreamingMessageIds. + // Lines without an id (rare, and never streamed in copies) just add. + const msgId = (entry.message as { id?: string } | undefined)?.id + if (msgId) { + cache.costUSD += call.costUSD - (cache.perMessage[msgId] ?? 0) + cache.perMessage[msgId] = call.costUSD + } else { + cache.costUSD += call.costUSD + } + for (const tc of call.toolSequence?.flat() ?? []) { + if (!cache.sawEdit && EDIT_TOOLS.has(tc.tool)) cache.sawEdit = true + if (!cache.sawGitCommit && tc.command && GIT_COMMIT.test(tc.command)) cache.sawGitCommit = true + } + if (call.timestamp) cache.lastTurnAt = call.timestamp + } + + cache.byteOffset = tracker.lastCompleteLineOffset + cache.updatedAt = new Date().toISOString() + return { cache, resumedFrom } +} + +export async function isAllowed(sessionId: string, base?: string): Promise { + try { + await stat(allowPath(sessionId, base)) + return true + } catch { + return false + } +} + +export async function writeAllow(sessionId: string, base?: string): Promise { + await mkdir(sessionsDir(base), { recursive: true }) + await writeFile(allowPath(sessionId, base), '', 'utf-8') +} diff --git a/src/ink-win.ts b/src/ink-win.ts new file mode 100644 index 0000000..5fd4bad --- /dev/null +++ b/src/ink-win.ts @@ -0,0 +1,14 @@ +const BSU = '\x1b[?2026h' +const ESU = '\x1b[?2026l' +let patched = false + +export function patchStdoutForWindows(): void { + if (process.platform !== 'win32' || patched) return + patched = true + + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = function (chunk: unknown, ...args: unknown[]): boolean { + if (chunk === BSU || chunk === ESU) return true + return (origWrite as Function)(chunk, ...args) + } as typeof process.stdout.write +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..a4e435d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,1601 @@ +import { isAbsolute } from 'path' +import { Command, Option } from 'commander' +import { installMenubarApp } from './menubar-installer.js' +import { exportCsv, exportJson, type PeriodExport } from './export.js' +import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js' +import { allProviderNames } from './providers/index.js' +import { convertCost } from './currency.js' +import { renderStatusBar } from './format.js' +import { toDateString } from './daily-cache.js' +import { dateKey } from './day-aggregator.js' +import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' +import { aggregateModelEfficiency } from './model-efficiency.js' +import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js' +import { renderDashboard } from './dashboard.js' +import { renderOverview } from './overview.js' +import { runWebDashboard } from './web-dashboard.js' +import { hostname } from 'os' +import { runShareServer } from './sharing/share-run.js' +import { addRemote, linkRemote, pullDevices, renderDevices, summarizeDeviceUsage } from './sharing/host.js' +import { browse } from './sharing/discovery.js' +import { promptChoice } from './sharing/prompt.js' +import { loadRemotes, saveRemotes } from './sharing/store.js' +import type { UsageQuery } from './sharing/share-server.js' +import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js' +import { runOptimize } from './optimize.js' +import { registerActCommands } from './act/cli.js' +import { registerGuardCommands } from './guard/cli.js' +import { runContextCommand } from './context-tree.js' +import { renderCompare } from './compare.js' +import { + installAntigravityStatusLineHook, + runAgyStatusLineHook, + uninstallAntigravityStatusLineHook, +} from './antigravity-statusline.js' +import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type CodeburnConfig, type Plan, type PlanId, type PlanProvider } from './config.js' +import { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js' +import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const { version } = require('../package.json') +import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' + +// 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 +} + +function parseNumber(value: string): number { + return Number(value) +} + +function parseInteger(value: string): number { + return parseInt(value, 10) +} + +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(', ') +} + +type JsonPlanSummary = { + id: PlanId + provider: PlanProvider + budget: number + spent: number + percentUsed: number + status: 'under' | 'near' | 'over' + projectedMonthEnd: number + daysUntilReset: number + periodStart: string + periodEnd: string +} + +function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary { + return { + id: planUsage.plan.id, + provider: planUsage.plan.provider, + budget: convertCost(planUsage.budgetUsd), + spent: convertCost(planUsage.spentApiEquivalentUsd), + percentUsed: Math.round(planUsage.percentUsed * 10) / 10, + status: planUsage.status, + projectedMonthEnd: convertCost(planUsage.projectedMonthUsd), + daysUntilReset: planUsage.daysUntilReset, + periodStart: planUsage.periodStart.toISOString(), + periodEnd: planUsage.periodEnd.toISOString(), + } +} + +type JsonPlanSummaryMap = Partial> + +function toJsonPlanSummaryMap(planUsages: PlanUsage[]): JsonPlanSummaryMap { + const summaries: JsonPlanSummaryMap = {} + for (const usage of planUsages) { + summaries[usage.plan.provider] = toJsonPlanSummary(usage) + } + return summaries +} + +async function attachPlanSummaries(payload: T): Promise { + const planUsages = await getPlanUsages() + if (planUsages.length > 0) { + return { + ...payload, + plan: toJsonPlanSummary(planUsages[0]!), + plans: toJsonPlanSummaryMap(planUsages), + } + } + return payload +} + +function planLabel(plan: Plan): string { + const name = planDisplayName(plan.id) + return plan.id === 'custom' ? `${name} (${plan.provider})` : name +} + +function toPlanDisplay(plan: Plan) { + return { + id: plan.id, + monthlyUsd: plan.monthlyUsd, + provider: plan.provider, + resetDay: clampResetDay(plan.resetDay), + setAt: plan.setAt || null, + } +} + +function sortedPlans(plans: Partial>): Plan[] { + return PLAN_PROVIDERS + .map(provider => plans[provider]) + .filter((plan): plan is Plan => plan !== undefined) +} + +function assertFormat(value: string, allowed: readonly string[], command: string): void { + if (!allowed.includes(value)) { + process.stderr.write( + `codeburn ${command}: unknown format "${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) +} + +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) + } +} + +async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise { + await loadPricing() + const { range, label } = getDateRange(period) + const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude) + const report: ReturnType & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap } = await attachPlanSummaries(buildJsonReport(projects, label, period)) + console.log(JSON.stringify(report, null, 2)) +} + +const program = new Command() + .name('codeburn') + .description('See where your AI coding tokens go - by task, tool, model, and project') + .version(version) + .option('--verbose', 'print warnings to stderr on read failures and skipped files') + .option('--timezone ', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)') + +program.hook('preAction', async (thisCommand) => { + const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ'] + if (tz) { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }) + } catch { + console.error(`\n Invalid timezone: "${tz}". Use an IANA timezone like "America/New_York" or "Asia/Tokyo".\n`) + process.exit(1) + } + process.env.TZ = tz + } + const config = await readConfig() + setModelAliases(config.modelAliases ?? {}) + setPriceOverrides(config.priceOverrides ?? {}) + setLocalModelSavings(config.localModelSavings ?? {}) + setProxyPaths(config.proxyPaths ?? []) + if (thisCommand.opts<{ verbose?: boolean }>().verbose) { + process.env['CODEBURN_VERBOSE'] = '1' + } + await loadCurrency() +}) + +function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) { + const sessions = projects.flatMap(p => p.sessions) + const { code } = getCurrency() + + const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const totalSavingsUSD = projects.reduce((s, p) => s + p.totalSavingsUSD, 0) + // Subscription-covered (proxied) portion of totalCostUSD, and the resulting + // out-of-pocket figure. `cost` stays the full billable/would-be amount. + const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0) + const netCostUSD = totalCostUSD - totalProxiedUSD + const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) + const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) + const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0) + const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) + const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) + const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) + // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write + // counts tokens being stored, not served, so it doesn't belong in the denominator. + const cacheHitDenom = totalInput + totalCacheRead + const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0 + + // Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a + // consumer summing daily[].editTurns over a period gets the same total as + // sum(activities[].editTurns) for that period: every turn counts once for + // `turns`, edit turns count for `editTurns`, edit turns with zero retries + // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency + // dashboards need this without re-deriving from activity-level rollups. + const dailyMap: Record = {} + for (const sess of sessions) { + for (const turn of sess.turns) { + // Prefer the user-message timestamp on the turn; fall back to the first + // assistant-call timestamp when the user line is missing (continuation + // sessions where the JSONL begins mid-conversation). Previously these + // turns dropped from daily but stayed in activities, breaking the + // sum(daily[].editTurns) === sum(activities[].editTurns) invariant. + const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp + if (!ts) { continue } + const day = dateKey(ts) + if (!dailyMap[day]) { dailyMap[day] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } } + dailyMap[day].turns += 1 + if (turn.hasEdits) { + dailyMap[day].editTurns += 1 + if (turn.retries === 0) dailyMap[day].oneShotTurns += 1 + } + for (const call of turn.assistantCalls) { + dailyMap[day].cost += call.costUSD + dailyMap[day].savings += call.savingsUSD ?? 0 + dailyMap[day].calls += 1 + } + } + } + const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({ + date, + cost: convertCost(d.cost), + savings: convertCost(d.savings), + calls: d.calls, + turns: d.turns, + editTurns: d.editTurns, + oneShotTurns: d.oneShotTurns, + // Pre-computed convenience for dashboards that don't want to do the math. + // null when there are no edit turns (the rate is undefined, not zero — + // a day where the user only had Q&A turns shouldn't read as 0% one-shot). + oneShotRate: d.editTurns > 0 + ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 + : null, + })) + + const projectList = projects.map(p => ({ + name: p.project, + path: p.projectPath, + cost: convertCost(p.totalCostUSD), + savings: convertCost(p.totalSavingsUSD), + avgCostPerSession: p.sessions.length > 0 + ? convertCost(p.totalCostUSD / p.sessions.length) + : null, + calls: p.totalApiCalls, + sessions: p.sessions.length, + })) + + const modelMap: Record = {} + const modelEfficiency = aggregateModelEfficiency(projects) + for (const sess of sessions) { + for (const [model, d] of Object.entries(sess.modelBreakdown)) { + if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, savings: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, baselineModel: '' } } + modelMap[model].calls += d.calls + modelMap[model].cost += d.costUSD + modelMap[model].savings += d.savingsUSD + modelMap[model].inputTokens += d.tokens.inputTokens + modelMap[model].outputTokens += d.tokens.outputTokens + modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens + modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens + } + } + // Pull the active baseline model name out of the savings config so the + // report can show what the local calls were mapped against without + // forcing the consumer to cross-reference a separate file. Empty when + // no savings are configured for this period. + for (const [model, acc] of Object.entries(modelMap)) { + if (acc.savings <= 0) continue + for (const sess of sessions) { + const bucket = sess.modelBreakdown[model] + if (!bucket || bucket.savingsUSD <= 0) continue + for (const turn of sess.turns) { + for (const call of turn.assistantCalls) { + if (call.model === model && call.savingsBaselineModel) { + acc.baselineModel = call.savingsBaselineModel + break + } + } + if (acc.baselineModel) break + } + if (acc.baselineModel) break + } + } + const models = Object.entries(modelMap) + .sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)) + .map(([name, { cost, savings, baselineModel, ...rest }]) => { + const efficiency = modelEfficiency.get(name) + return { + name, + ...rest, + cost: convertCost(cost), + savings: convertCost(savings), + savingsBaselineModel: baselineModel, + editTurns: efficiency?.editTurns ?? 0, + oneShotTurns: efficiency?.oneShotTurns ?? 0, + oneShotRate: efficiency?.oneShotRate ?? null, + retriesPerEdit: efficiency?.retriesPerEdit ?? null, + costPerEdit: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined + ? convertCost(efficiency.costPerEditUSD) + : null, + } + }) + + const catMap: Record = {} + for (const sess of sessions) { + for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { + if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, savings: 0, editTurns: 0, oneShotTurns: 0 } } + catMap[cat].turns += d.turns + catMap[cat].cost += d.costUSD + catMap[cat].savings += d.savingsUSD + catMap[cat].editTurns += d.editTurns + catMap[cat].oneShotTurns += d.oneShotTurns + } + } + const activities = Object.entries(catMap) + .sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)) + .map(([cat, d]) => ({ + category: CATEGORY_LABELS[cat as TaskCategory] ?? cat, + cost: convertCost(d.cost), + savings: convertCost(d.savings), + turns: d.turns, + editTurns: d.editTurns, + oneShotTurns: d.oneShotTurns, + oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null, + })) + + const toolMap: Record = {} + const mcpMap: Record = {} + const bashMap: Record = {} + const skillMap: Record = {} + const subagentMap: Record = {} + // Claude Code only: real subagent-transcript spend grouped by agentType + // (workflow-subagent / Explore / general-purpose / …). Distinct from + // subagentMap, which is Task-tool-input based and never sees workflow agents. + const agentTypeMap: Record = {} + for (const sess of sessions) { + for (const [tool, d] of Object.entries(sess.toolBreakdown)) { + toolMap[tool] = (toolMap[tool] ?? 0) + d.calls + } + for (const [server, d] of Object.entries(sess.mcpBreakdown)) { + mcpMap[server] = (mcpMap[server] ?? 0) + d.calls + } + for (const [cmd, d] of Object.entries(sess.bashBreakdown)) { + bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls + } + for (const [skill, d] of Object.entries(sess.skillBreakdown)) { + if (!skillMap[skill]) skillMap[skill] = { turns: 0, cost: 0, savings: 0 } + skillMap[skill].turns += d.turns + skillMap[skill].cost += d.costUSD + skillMap[skill].savings += d.savingsUSD + } + for (const [sat, d] of Object.entries(sess.subagentBreakdown)) { + if (!subagentMap[sat]) subagentMap[sat] = { calls: 0, cost: 0, savings: 0 } + subagentMap[sat].calls += d.calls + subagentMap[sat].cost += d.costUSD + subagentMap[sat].savings += d.savingsUSD + } + if (sess.agentType) { + if (!agentTypeMap[sess.agentType]) agentTypeMap[sess.agentType] = { calls: 0, cost: 0, savings: 0 } + agentTypeMap[sess.agentType].calls += sess.apiCalls + agentTypeMap[sess.agentType].cost += sess.totalCostUSD + agentTypeMap[sess.agentType].savings += sess.totalSavingsUSD + } + } + + const sortedMap = (m: Record) => + Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls })) + + const topSessions = projects + .flatMap(p => p.sessions.map(s => ({ + project: p.project, + sessionId: s.sessionId, + date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, + cost: convertCost(s.totalCostUSD), + savings: convertCost(s.totalSavingsUSD), + calls: s.apiCalls, + }))) + .sort((a, b) => (b.cost + b.savings) - (a.cost + a.savings)) + .slice(0, 5) + + return { + generated: new Date().toISOString(), + currency: code, + period, + periodKey, + overview: { + cost: convertCost(totalCostUSD), + // Subscription-covered spend (config `proxyPaths`) and net out-of-pocket. + // `cost` is the full API-rate figure; `proxiedCost` is the part billed to + // a subscription; `netCost` = cost - proxiedCost. Both 0 with no proxy + // paths configured, so existing consumers are unaffected. + proxiedCost: convertCost(totalProxiedUSD), + netCost: convertCost(netCostUSD), + savings: convertCost(totalSavingsUSD), + calls: totalCalls, + sessions: totalSessions, + cacheHitPercent, + tokens: { + input: totalInput, + output: totalOutput, + cacheRead: totalCacheRead, + cacheWrite: totalCacheWrite, + }, + }, + daily, + projects: projectList, + models, + // Models with recorded usage that resolve to no pricing data right now + // (#638). Their calls contribute $0 to every cost figure above, so + // consumers can tell "cheap" from "uncounted". Empty when all models + // priced. Fix entries via `codeburn model-alias` or `price-override`. + unpricedModels: findUnpricedModels(Object.entries(modelMap).map(([model, d]) => ({ + model, + calls: d.calls, + cost: d.cost, + tokens: d.inputTokens + d.outputTokens + d.cacheReadTokens + d.cacheWriteTokens, + }))), + activities, + tools: sortedMap(toolMap), + mcpServers: sortedMap(mcpMap), + shellCommands: sortedMap(bashMap), + skills: Object.entries(skillMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, turns: d.turns, cost: convertCost(d.cost), savings: convertCost(d.savings) })), + subagents: Object.entries(subagentMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost), savings: convertCost(d.savings) })), + claudeAgentTypes: Object.entries(agentTypeMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost), savings: convertCost(d.savings) })), + topSessions, + } +} + +program + .command('report', { isDefault: true }) + .description('Interactive usage dashboard') + .option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week') + .option('--day ', 'Single day to review (YYYY-MM-DD, today, or yesterday). Overrides --period when set') + .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, gemini, cursor, copilot)', 'all') + .option('--format ', 'Output format: tui, json', 'tui') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .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 + let daySelection: ReturnType = null + try { + if (opts.day && (opts.from || opts.to)) { + throw new Error('--day cannot be combined with --from or --to') + } + daySelection = parseDayFlag(opts.day) + customRange = parseDateRangeFlags(opts.from, opts.to) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`\n Error: ${message}\n`) + process.exit(1) + } + + const period = toPeriod(opts.period) + if (opts.format === 'json') { + await loadPricing() + if (daySelection || customRange) { + const range = daySelection?.range ?? customRange! + const label = daySelection?.label ?? formatDateRangeLabel(opts.from, opts.to) + const periodKey = daySelection ? 'day' : 'custom' + const projects = filterProjectsByName( + await parseAllSessions(range, opts.provider), + opts.project, + opts.exclude, + ) + console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, periodKey)), null, 2)) + } else { + await runJsonReport(period, opts.provider, opts.project, opts.exclude) + } + return + } + const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined + await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day) + }) + +program + .command('share') + .description("Securely share this device's usage with your other devices on the same network") + .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)') + .action(async (opts) => { + await runShareServer({ port: opts.port, pair: !!opts.pair, always: !!opts.always }) + }) + +program + .command('devices [action] [target]') + .description('Combined usage across your devices. Actions: add (find nearby & pair) | add --pin (manual) | rm ') + .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) + .action(async (action: string | undefined, target: string | undefined, opts) => { + await loadPricing() + if (action === 'add') { + if (target && opts.pin) { + 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: { period?: string; from?: string; to?: string }) => { + 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(), {}) + process.stdout.write('\n' + renderDevices(results)) + }) + +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('web') + .description('Open the local web dashboard in your browser') + .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('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', 'local') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all', 'today') + .option('--day ', 'Single day for menubar-json (YYYY-MM-DD, today, or yesterday). Overrides --period when set') + .option('--from ', 'Start date (YYYY-MM-DD) for custom range') + .option('--to ', 'End date (YYYY-MM-DD) for custom range') + .option('--days ', 'Comma-separated dates (YYYY-MM-DD) for multi-day selection') + .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)') + .addOption(new Option('--claude-config-source ').hideHelp()) + .action(async (opts) => { + assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status') + assertScope(opts.scope, ['local', 'combined'], 'status') + assertProvider(opts.provider, 'status') + if (opts.day && (opts.from || opts.to)) { + process.stderr.write('error: --day cannot be combined with --from or --to\n') + process.exit(1) + } + if (opts.days && (opts.day || opts.from || opts.to)) { + process.stderr.write('error: --days cannot be combined with --day, --from, or --to\n') + process.exit(1) + } + if (opts.format === 'menubar-json' && opts.scope === 'combined' && opts.days) { + process.stderr.write('error: --scope combined cannot be combined with --days\n') + process.exit(1) + } + if (opts.format === 'menubar-json' && opts.scope === 'combined' && opts.claudeConfigSource) { + process.stderr.write('error: --scope combined cannot be combined with --claude-config-source\n') + process.exit(1) + } + // A Claude config source scopes Claude usage only, so it is contradictory + // with a non-Claude provider filter. 'all' is fine (it resolves to that + // config's Claude data). + if (opts.claudeConfigSource && opts.provider !== 'all' && opts.provider !== 'claude') { + process.stderr.write(`error: --claude-config-source cannot be combined with --provider ${opts.provider} (a Claude config scopes Claude usage only)\n`) + process.exit(1) + } + 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 fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) + if (opts.format === 'menubar-json') { + const daysSelection = parseDaysFlag(opts.days) + const customRange = daysSelection ? null : parseDateRangeFlags(opts.from, opts.to) + const daySelection = parseDayFlag(opts.day) + const periodInfo = daysSelection + ? { range: daysSelection.range, label: daysSelection.label } + : customRange + ? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) } + : daySelection ?? getDateRange(opts.period) + const payload = await buildMenubarPayloadForRange(periodInfo, { + provider: pf, + project: opts.project, + exclude: opts.exclude, + daysSelection, + optimize: opts.optimize !== false, + claudeConfigSourceId: opts.claudeConfigSource, + }) + 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 query: UsageQuery = customRange + ? { from: opts.from, to: opts.to } + : daySelection + ? { from: daySelection.day, to: daySelection.day } + : { period: opts.period } + const localGetUsage = async (): Promise => payload + const results = await pullDevices(localGetUsage, query, 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 + } + + if (opts.format === 'json') { + const todayProjects = fp(await parseAllSessions(getDateRange('today').range, pf)) + const todayData = buildPeriodData('today', todayProjects) + clearSessionCache() + const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf)) + const monthData = buildPeriodData('month', monthProjects) + clearSessionCache() + const { code, rate } = getCurrency() + const payload: { + currency: string + today: { cost: number; savings: number; calls: number } + month: { cost: number; savings: number; calls: number } + localModelSavings?: { today: number; month: number; callsToday: number; callsMonth: number } + plan?: JsonPlanSummary + plans?: JsonPlanSummaryMap + } = { + currency: code, + today: { cost: Math.round(todayData.cost * rate * 100) / 100, savings: Math.round(todayData.savingsUSD * rate * 100) / 100, calls: todayData.calls }, + month: { cost: Math.round(monthData.cost * rate * 100) / 100, savings: Math.round(monthData.savingsUSD * rate * 100) / 100, calls: monthData.calls }, + } + const savingsCallsToday = todayProjects.reduce((s, p) => s + p.sessions.reduce((s2, sess) => s2 + sess.turns.reduce((s3, turn) => s3 + turn.assistantCalls.reduce((s4, c) => s4 + (c.savingsUSD && c.savingsUSD > 0 ? 1 : 0), 0), 0), 0), 0) + const savingsCallsMonth = monthProjects.reduce((s, p) => s + p.sessions.reduce((s2, sess) => s2 + sess.turns.reduce((s3, turn) => s3 + turn.assistantCalls.reduce((s4, c) => s4 + (c.savingsUSD && c.savingsUSD > 0 ? 1 : 0), 0), 0), 0), 0) + if (todayData.savingsUSD > 0 || monthData.savingsUSD > 0) { + payload.localModelSavings = { + today: payload.today.savings, + month: payload.month.savings, + callsToday: savingsCallsToday, + callsMonth: savingsCallsMonth, + } + } + console.log(JSON.stringify(await attachPlanSummaries(payload))) + return + } + + const monthProjects2 = fp(await parseAllSessions(getDateRange('month').range, pf)) + clearSessionCache() + console.log(renderStatusBar(monthProjects2)) + }) + +program + .command('today') + .description('Today\'s usage dashboard') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--format ', 'Output format: tui, json', 'tui') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .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') + if (opts.format === 'json') { + await runJsonReport('today', opts.provider, opts.project, opts.exclude) + return + } + await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude) + }) + +program + .command('month') + .description('This month\'s usage dashboard') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--format ', 'Output format: tui, json', 'tui') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .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') + if (opts.format === 'json') { + await runJsonReport('month', opts.provider, opts.project, opts.exclude) + return + } + await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude) + }) + +program + .command('export') + .description('Export usage data to CSV or JSON') + .option('-f, --format ', 'Export format: csv, json', 'csv') + .option('-o, --output ', 'Output file path') + .option('--from ', 'Start date (YYYY-MM-DD). Exports a single custom period when set') + .option('--to ', 'End date (YYYY-MM-DD). Exports a single custom period when set') + .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') + .option('--project ', 'Show only projects matching name (repeatable)', collect, []) + .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .action(async (opts) => { + assertFormat(opts.format, ['csv', 'json'], 'export') + assertProvider(opts.provider, 'export') + await loadPricing() + const pf = opts.provider + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) + let customRange: DateRange | null = null + try { + customRange = parseDateRangeFlags(opts.from, opts.to) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`\n Error: ${message}\n`) + process.exit(1) + } + + let periods: PeriodExport[] + if (customRange) { + periods = [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }] + clearSessionCache() + } else { + const thirtyDayProjects = fp(await parseAllSessions(getDateRange('30days').range, pf)) + clearSessionCache() + periods = [ + { label: 'Today', projects: filterProjectsByDateRange(thirtyDayProjects, getDateRange('today').range) }, + { label: '7 Days', projects: filterProjectsByDateRange(thirtyDayProjects, getDateRange('week').range) }, + { label: '30 Days', projects: thirtyDayProjects }, + ] + } + + if (periods.every(p => p.projects.length === 0)) { + console.log('\n No usage data found.\n') + return + } + + const defaultName = `codeburn-${toDateString(new Date())}` + const outputPath = opts.output ?? `${defaultName}.${opts.format}` + + let savedPath: string + try { + if (opts.format === 'json') { + savedPath = await exportJson(periods, outputPath) + } else { + savedPath = await exportCsv(periods, outputPath) + } + } catch (err) { + // Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.) + // throw with a user-readable message. Print just the message, not the stack, so the CLI + // doesn't spray its internals at the user. + const message = err instanceof Error ? err.message : String(err) + console.error(`\n Export failed: ${message}\n`) + process.exit(1) + } + + const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days' + console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`) + }) + +program + .command('menubar') + .description('Install and launch the macOS menubar app (one command, no clone)') + .option('--force', 'Reinstall even if an older copy is already in ~/Applications') + .action(async (opts: { force?: boolean }) => { + try { + const result = await installMenubarApp({ force: opts.force, cliVersion: version }) + console.log(`\n Ready. ${result.installedPath}\n`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`\n Menubar install failed: ${message}\n`) + process.exit(1) + } + }) + +program + .command('currency [code]') + .description('Set display currency (e.g. codeburn currency GBP)') + .option('--symbol ', 'Override the currency symbol') + .option('--reset', 'Reset to USD (removes currency config)') + .action(async (code?: string, opts?: { symbol?: string; reset?: boolean }) => { + if (opts?.reset) { + const config = await readConfig() + delete config.currency + await saveConfig(config) + console.log('\n Currency reset to USD.\n') + return + } + + if (!code) { + const { code: activeCode, rate, symbol } = getCurrency() + if (activeCode === 'USD' && rate === 1) { + console.log('\n Currency: USD (default)') + console.log(` Config: ${getConfigFilePath()}\n`) + } else { + console.log(`\n Currency: ${activeCode}`) + console.log(` Symbol: ${symbol}`) + console.log(` Rate: 1 USD = ${rate} ${activeCode}`) + console.log(` Config: ${getConfigFilePath()}\n`) + } + return + } + + const upperCode = code.toUpperCase() + if (!isValidCurrencyCode(upperCode)) { + console.error(`\n "${code}" is not a valid ISO 4217 currency code.\n`) + process.exitCode = 1 + return + } + + const config = await readConfig() + config.currency = { + code: upperCode, + ...(opts?.symbol ? { symbol: opts.symbol } : {}), + } + await saveConfig(config) + + await loadCurrency() + const { rate, symbol } = getCurrency() + + console.log(`\n Currency set to ${upperCode}.`) + console.log(` Symbol: ${symbol}`) + console.log(` Rate: 1 USD = ${rate} ${upperCode}`) + console.log(` Config saved to ${getConfigFilePath()}\n`) + }) + +program + .command('model-alias [from] [to]') + .description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)') + .option('--remove ', 'Remove an alias') + .option('--list', 'List configured aliases') + .action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => { + const config = await readConfig() + const aliases = config.modelAliases ?? {} + + if (opts?.list || (!from && !opts?.remove)) { + const entries = Object.entries(aliases) + if (entries.length === 0) { + console.log('\n No model aliases configured.') + console.log(` Config: ${getConfigFilePath()}\n`) + } else { + console.log('\n Model aliases:') + for (const [src, dst] of entries) { + console.log(` ${src} -> ${dst}`) + } + console.log(` Config: ${getConfigFilePath()}\n`) + } + return + } + + if (opts?.remove) { + if (!(opts.remove in aliases)) { + console.error(`\n Alias not found: ${opts.remove}\n`) + process.exitCode = 1 + return + } + delete aliases[opts.remove] + config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined + await saveConfig(config) + console.log(`\n Removed alias: ${opts.remove}\n`) + return + } + + if (!from || !to) { + console.error('\n Usage: codeburn model-alias \n') + process.exitCode = 1 + return + } + + aliases[from] = to + config.modelAliases = aliases + await saveConfig(config) + console.log(`\n Alias saved: ${from} -> ${to}`) + console.log(` Config: ${getConfigFilePath()}\n`) + }) + +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('model-savings [local] [baseline]') + .description('Track a local model as "savings" rather than cost. Maps a local-model name to a paid baseline so the dashboard can show what the same tokens would have cost on the baseline (e.g. codeburn model-savings "llama3.1:8b" gpt-4o). The local call itself still costs $0 — actual cost is left untouched.') + .option('--remove ', 'Remove a savings mapping for the given local model') + .option('--list', 'List configured savings mappings') + .action(async (local?: string, baseline?: string, opts?: { remove?: string; list?: boolean }) => { + const config = await readConfig() + const mappings = { ...(config.localModelSavings ?? {}) } + + if (opts?.list || (!local && !opts?.remove)) { + const entries = Object.entries(mappings) + if (entries.length === 0) { + console.log('\n No local-model savings mappings configured.') + console.log(` Config: ${getConfigFilePath()}`) + console.log(' Add one with: codeburn model-savings \n') + } else { + console.log('\n Local-model savings mappings:') + for (const [src, dst] of entries) { + console.log(` ${src} -> ${dst}`) + } + console.log(` Config: ${getConfigFilePath()}\n`) + } + return + } + + if (opts?.remove) { + if (!(opts.remove in mappings)) { + console.error(`\n No savings mapping found for: ${opts.remove}\n`) + process.exitCode = 1 + return + } + delete mappings[opts.remove] + config.localModelSavings = Object.keys(mappings).length > 0 ? mappings : undefined + await saveConfig(config) + console.log(`\n Removed savings mapping: ${opts.remove}\n`) + return + } + + if (!local || !baseline) { + console.error('\n Usage: codeburn model-savings \n') + process.exitCode = 1 + return + } + + mappings[local] = baseline + config.localModelSavings = mappings + await saveConfig(config) + + // Warn when the same model is also in modelAliases so the user is + // not surprised that `savings` wins for actual cost. + if (config.modelAliases && Object.hasOwn(config.modelAliases, local)) { + console.log(`\n Note: ${local} is also in modelAliases (-> ${config.modelAliases[local]}).`) + console.log(' Local-model savings take precedence: the call is treated as $0 actual cost and the baseline is used for counterfactual savings.') + } + + console.log(`\n Savings mapping saved: ${local} -> ${baseline}`) + console.log(` Config: ${getConfigFilePath()}\n`) + }) + +program + .command('proxy-path [path]') + .description('Mark a project directory as routed through a subscription-backed LLM proxy (e.g. Claude Code over GitHub Copilot). Sessions whose canonical path is under it keep their full API-rate cost as the "would-be" figure, but that amount is reported as subscription-covered so the report can show net out-of-pocket (e.g. codeburn proxy-path ~/work/copilot-repo). Actual API-key sessions elsewhere are untouched.') + .option('--remove ', 'Remove a configured proxy path') + .option('--list', 'List configured proxy paths') + .action(async (path?: string, opts?: { remove?: string; list?: boolean }) => { + const config = await readConfig() + // Sanitize the on-disk shape the same way setProxyPaths does: a hand-edited + // config.json could have proxyPaths as a non-array or hold non-string + // entries, which would otherwise throw when spread or normalized below. + const paths = (Array.isArray(config.proxyPaths) ? config.proxyPaths : []) + .filter((p): p is string => typeof p === 'string') + const samePath = (a: string, b: string) => normalizeProxyPath(a) === normalizeProxyPath(b) + + if (opts?.list || (!path && !opts?.remove)) { + if (paths.length === 0) { + console.log('\n No proxy paths configured.') + console.log(` Config: ${getConfigFilePath()}`) + console.log(' Add one with: codeburn proxy-path \n') + } else { + console.log('\n Proxy paths (sessions under these are subscription-covered):') + for (const p of paths) console.log(` ${p}`) + console.log(` Config: ${getConfigFilePath()}\n`) + } + return + } + + if (opts?.remove) { + const idx = paths.findIndex(p => samePath(p, opts.remove!)) + if (idx === -1) { + console.error(`\n No proxy path found matching: ${opts.remove}\n`) + process.exitCode = 1 + return + } + paths.splice(idx, 1) + config.proxyPaths = paths.length > 0 ? paths : undefined + await saveConfig(config) + console.log(`\n Removed proxy path: ${opts.remove}\n`) + return + } + + if (!path) { + console.error('\n Usage: codeburn proxy-path \n') + process.exitCode = 1 + return + } + + const trimmed = path.trim() + if (!isAbsolute(trimmed) || normalizeProxyPath(trimmed) === '') { + console.error(`\n Proxy path must be an absolute project directory (got: ${path}).`) + console.error(' codeburn matches sessions by their recorded absolute cwd; the') + console.error(' filesystem root is too broad and is not accepted.\n') + process.exitCode = 1 + return + } + if (paths.some(p => samePath(p, trimmed))) { + console.log(`\n Proxy path already configured: ${trimmed}\n`) + return + } + paths.push(trimmed) + config.proxyPaths = paths + await saveConfig(config) + console.log(`\n Proxy path saved: ${trimmed}`) + console.log(' Sessions under it keep their full API-rate cost as the would-be figure; that amount is reported as subscription-covered (net out-of-pocket excludes it).') + console.log(` Config: ${getConfigFilePath()}\n`) + }) + +program + .command('plan [action] [id]') + .description('Show or configure a subscription plan for overage tracking') + .option('--format ', 'Output format: text or json', 'text') + .option('--monthly-usd ', 'Monthly plan price in USD (for custom)', parseNumber) + .option('--provider ', 'Provider scope: all, claude, codex, cursor') + .option('--reset-day ', 'Day of month plan resets (1-28)', parseInteger, 1) + .action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => { + assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan') + const mode = action ?? 'show' + const providerOption = opts?.provider + if (providerOption !== undefined && !isPlanProvider(providerOption)) { + console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${providerOption}".\n`) + process.exitCode = 1 + return + } + + if (mode === 'show') { + const plans = sortedPlans(await readPlans()) + .filter(plan => plan.id !== 'none') + .filter(plan => !providerOption || providerOption === 'all' || plan.provider === providerOption) + if (opts?.format === 'json') { + if (plans.length === 0) { + console.log(JSON.stringify({ id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null })) + return + } + console.log(JSON.stringify({ + ...toPlanDisplay(plans[0]!), + plans: Object.fromEntries(plans.map(plan => [plan.provider, toPlanDisplay(plan)])), + })) + return + } + if (plans.length === 0) { + console.log('\n Plan: none') + console.log(' API-pricing view is active.') + console.log(` Config: ${getConfigFilePath()}\n`) + return + } + console.log(`\n Plans: ${plans.length}`) + for (const plan of plans) { + console.log(` ${plan.provider}: ${planLabel(plan)} (${plan.id})`) + console.log(` Budget: $${plan.monthlyUsd}/month`) + console.log(` Reset day: ${clampResetDay(plan.resetDay)}`) + if (plan.setAt) console.log(` Set at: ${plan.setAt}`) + } + console.log(` Config: ${getConfigFilePath()}\n`) + return + } + + if (mode === 'reset') { + await clearPlan(providerOption) + if (providerOption) { + console.log(`\n Plan reset for ${providerOption}.\n`) + } else { + console.log('\n Plan reset. API-pricing view is active.\n') + } + return + } + + if (mode !== 'set') { + console.error('\n Usage: codeburn plan [set | reset]\n') + process.exitCode = 1 + return + } + + if (!id || !isPlanId(id)) { + console.error(`\n Plan id must be one of: ${PLAN_IDS.join(', ')}; got "${id ?? ''}".\n`) + process.exitCode = 1 + return + } + + const resetDay = opts?.resetDay ?? 1 + if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) { + console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`) + process.exitCode = 1 + return + } + + if (id === 'none') { + await clearPlan(providerOption) + if (providerOption) { + console.log(`\n Plan reset for ${providerOption}.\n`) + } else { + console.log('\n Plan reset. API-pricing view is active.\n') + } + return + } + + if (id === 'custom') { + if (opts?.monthlyUsd === undefined) { + console.error('\n Custom plans require --monthly-usd .\n') + process.exitCode = 1 + return + } + const monthlyUsd = opts.monthlyUsd + if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) { + console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`) + process.exitCode = 1 + return + } + const provider = providerOption ?? 'all' + await savePlan({ + id: 'custom', + monthlyUsd, + provider, + resetDay, + setAt: new Date().toISOString(), + }) + console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`) + console.log(` Config saved to ${getConfigFilePath()}\n`) + return + } + + const preset = getPresetPlan(id) + if (!preset) { + console.error(`\n Unknown preset "${id}".\n`) + process.exitCode = 1 + return + } + + if (providerOption === 'all') { + console.error(`\n ${id} is a ${preset.provider} plan; omit --provider or use --provider ${preset.provider}.\n`) + process.exitCode = 1 + return + } + + if (providerOption && providerOption !== preset.provider) { + console.error(`\n ${id} is a ${preset.provider} plan; use --provider ${preset.provider} or omit --provider.\n`) + process.exitCode = 1 + return + } + + await savePlan({ + ...preset, + resetDay, + setAt: new Date().toISOString(), + }) + console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`) + console.log(` Provider: ${preset.provider}`) + console.log(` Reset day: ${resetDay}`) + console.log(` Config saved to ${getConfigFilePath()}\n`) + }) + +program + .command('optimize') + .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('--format ', 'Output format: text, json', 'text') + .option('--json', 'Output findings as JSON (alias for --format json)') + .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') + const format = opts.json ? 'json' : opts.format + if (opts.apply && format === 'json') { + process.stderr.write('codeburn optimize: --apply cannot be combined with --json\n') + process.exit(2) + } + 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 + } + assertFormat(format, ['text', 'json'], 'optimize') + if (format === 'text') { + // Surface realized savings from applied actions. Best effort: optimize + // must never fail because of journal contents, so any error just drops + // the header. computeActReport returns fast without scanning when the + // journal has no eligible applied actions, so users who never opted in + // see identical output. + let appliedHeader: string | undefined + let previouslyApplied: Record | undefined + try { + const { computeActReport, buildOptimizeAppliedHeader } = await import('./act/report.js') + const applied = await computeActReport() + appliedHeader = buildOptimizeAppliedHeader(applied) ?? undefined + previouslyApplied = applied.appliedByFinding + } catch { /* the header is optional; never block the findings */ } + await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied }) + } else { + await runOptimize(projects, label, range, { format }) + } + }) + +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('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') + } + }) + +program + .command('models') + .description('Per-model token + cost table, optionally exploded by task type') + .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('--task ', 'Filter to one task type (e.g. feature, debugging, refactoring)') + .option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)') + .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) + .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) + .option('--no-totals', 'Suppress the footer totals row') + .option('--format ', 'Output format: table, markdown, json, csv', 'table') + .action(async (opts) => { + assertProvider(opts.provider, 'models') + const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-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 aggregateModels(projects, { + byTask: !!opts.byTask, + taskFilter: opts.task, + topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, + minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, + }) + + const fmt = (opts.format ?? 'table').toLowerCase() + if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { + process.stdout.write('No model usage found for the selected period.\n') + return + } + if (fmt === 'json') { + process.stdout.write(renderJson(rows) + '\n') + } else if (fmt === 'csv') { + process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n') + } else if (fmt === 'markdown' || fmt === 'md') { + process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') + } else if (fmt === 'table') { + process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') + } else { + process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`) + process.exit(1) + } + }) + +program + .command('yield') + .description('Track which AI spend shipped to main vs reverted/abandoned (experimental)') + .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'week') + .option('--format ', 'Output format: text, json', 'text') + .action(async (opts) => { + assertFormat(opts.format, ['text', 'json'], 'yield') + const { computeYield, formatYieldSummary, buildYieldJsonReport } = await import('./yield.js') + await loadPricing() + const { range, label } = getDateRange(opts.period) + if (opts.format !== 'json') { + console.log(`\n Analyzing yield for ${label}...\n`) + } + const summary = await computeYield(range, process.cwd()) + if (opts.format === 'json') { + console.log(JSON.stringify(buildYieldJsonReport(summary, label, range), null, 2)) + return + } + console.log(formatYieldSummary(summary)) + }) + +program + .command('antigravity-hook') + .description('Install or remove exact Antigravity CLI usage capture') + .argument('', 'install or uninstall') + .option('--force', 'Replace an existing custom Antigravity CLI statusLine command') + .action(async (action: string, opts: { force?: boolean }) => { + try { + if (action === 'install') { + const result = await installAntigravityStatusLineHook(!!opts.force) + console.log(result === 'already-installed' + ? '\n Antigravity CLI usage capture is already installed.\n' + : '\n Antigravity CLI usage capture installed.\n') + return + } + if (action === 'uninstall') { + const result = await uninstallAntigravityStatusLineHook() + console.log(result === 'not-installed' + ? '\n Antigravity CLI usage capture is not installed.\n' + : result === 'restored' + ? '\n Antigravity CLI usage capture removed; previous statusLine restored.\n' + : '\n Antigravity CLI usage capture removed.\n') + return + } + console.error('\n Usage: codeburn antigravity-hook \n') + process.exit(1) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`\n Antigravity hook failed: ${message}\n`) + process.exit(1) + } + }) + +program + .command('agy-statusline-hook', { hidden: true }) + .description('Internal Antigravity CLI statusLine hook') + .action(async () => { + await runAgyStatusLineHook() + }) + +program + .command('mcp') + .description('Run a Model Context Protocol server (stdio) exposing usage + savings to AI agents') + .action(async () => { + // stdout MUST carry only JSON-RPC; route stray logs to stderr. + // NOTE: only console.log is guarded here. process.stdout.write is left intact + // because the MCP StdioServerTransport relies on it for JSON-RPC output. + console.log = ((...args: unknown[]) => process.stderr.write(args.join(' ') + '\n')) as typeof console.log + const { startStdioServer } = await import('./mcp/server.js') + await startStdioServer(version) + }) + +registerActCommands(program) +registerGuardCommands(program) + +program.parse() diff --git a/src/mcp/redact.ts b/src/mcp/redact.ts new file mode 100644 index 0000000..b206c63 --- /dev/null +++ b/src/mcp/redact.ts @@ -0,0 +1,47 @@ +import { createHash, randomBytes } from 'node:crypto' +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import type { MenubarPayload } from '../menubar-json.js' + +let salt: string | undefined + +function getSalt(): string { + if (salt) return salt + const dir = join(homedir(), '.config', 'codeburn') + const saltPath = join(dir, '.mcp-salt') + try { + salt = readFileSync(saltPath, 'utf-8').trim() + if (salt) return salt + } catch { /* first run */ } + salt = randomBytes(32).toString('hex') + try { + mkdirSync(dir, { recursive: true }) + writeFileSync(saltPath, salt + '\n', { mode: 0o600 }) + } catch { /* best-effort */ } + return salt +} + +export function pseudonym(name: string): string { + return `project-${createHash('sha256').update(getSalt() + name).digest('hex').slice(0, 6)}` +} + +function redactSessionDetails(details: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }>): Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> { + return details.map(d => ({ ...d, date: '', models: [] })) +} + +export function redactProjectNames(payload: MenubarPayload, includeNames: boolean): MenubarPayload { + if (includeNames) return payload + return { + ...payload, + current: { + ...payload.current, + topProjects: payload.current.topProjects.map(p => ({ + ...p, + name: pseudonym(p.name), + sessionDetails: p.sessionDetails ? redactSessionDetails(p.sessionDetails) : [], + })), + topSessions: payload.current.topSessions.map(s => ({ ...s, project: pseudonym(s.project) })), + }, + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..41dd39c --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,136 @@ +import { z } from 'zod' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { getDateRange } from '../cli-date.js' +import { loadPricing } from '../models.js' +import { buildMenubarPayloadForRange, type PeriodInfo } from '../usage-aggregator.js' +import type { MenubarPayload } from '../menubar-json.js' +import { redactProjectNames } from './redact.js' +import { renderSummaryTable, renderBreakdownTable, renderSavingsTable, type BreakdownBy } from './tables.js' + +const PERIOD = { today: 'today', last_7_days: 'week', last_30_days: '30days', month_to_date: 'month', last_6_months: 'all' } as const +type McpPeriod = keyof typeof PERIOD +const periodSchema = z.enum(['today', 'last_7_days', 'last_30_days', 'month_to_date', 'last_6_months']) + +type Aggregate = (periodInfo: PeriodInfo, opts: { provider?: string; optimize?: boolean }) => Promise + +const INSTRUCTIONS = + 'CodeBurn exposes local AI-coding spend data. Use get_usage for spend/usage and breakdowns (fast); ' + + 'use get_savings to find cost reductions (slower — runs a deeper analysis). Project names are pseudonymized ' + + 'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' + + 'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.' + +function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> { + const c = p.current + if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost })) + if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost })) + if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost })) + return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost })) +} + +export function createServer(deps: { version: string; aggregate?: Aggregate }): McpServer { + const aggregate = deps.aggregate ?? buildMenubarPayloadForRange + const inflight = new Map>() + + const getPayload = (period: McpPeriod, optimize: boolean): Promise => { + const key = `${optimize ? 'sav' : 'use'}:${period}` + const existing = inflight.get(key) + if (existing) return existing + const { range, label } = getDateRange(PERIOD[period]) + const p = aggregate({ range, label }, { provider: 'all', optimize }).finally(() => inflight.delete(key)) + inflight.set(key, p) + return p + } + + const server = new McpServer({ name: 'codeburn', version: deps.version }, { instructions: INSTRUCTIONS }) + + server.registerTool( + 'get_usage', + { + title: 'CodeBurn — usage & cost', + description: + 'Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break ' + + 'it down by project, model, task, or provider (Claude Code / Cursor / Codex). Fast. Local to this machine.', + inputSchema: { + period: periodSchema.default('today'), + by: z.enum(['project', 'model', 'task', 'provider']).optional(), + limit: z.number().int().min(1).max(100).default(20), + include_project_names: z.boolean().default(false), + }, + outputSchema: { + period: z.string(), + empty: z.boolean(), + totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }), + breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(), + }, + annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true }, + }, + async ({ period, by, limit, include_project_names }) => { + try { + const payload = redactProjectNames(await getPayload(period, false), include_project_names) + const c = payload.current + const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate } + if (c.calls === 0) { + return { + content: [{ type: 'text' as const, text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }], + structuredContent: { period: c.label, empty: true, totals, breakdown: null }, + } + } + const text = by ? renderBreakdownTable(payload, by, limit) : renderSummaryTable(payload) + const breakdown = by ? breakdownRows(payload, by, limit) : null + return { + content: [{ type: 'text' as const, text }], + structuredContent: { period: c.label, empty: false, totals, breakdown }, + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }], + structuredContent: { period: 'unknown', empty: true, totals: { costUSD: 0, calls: 0, sessions: 0, cacheHitPercent: 0, oneShotRate: null }, breakdown: null }, + isError: true, + } + } + }, + ) + + server.registerTool( + 'get_savings', + { + title: 'CodeBurn — savings opportunities', + description: + 'Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing ' + + 'work), and routing waste (what you would have saved on a cheaper model). Slower than get_usage.', + inputSchema: { period: periodSchema.default('last_7_days'), include_project_names: z.boolean().default(false) }, + outputSchema: { + period: z.string(), + optimize: z.object({ findingCount: z.number(), savingsUSD: z.number(), topFindings: z.array(z.object({ title: z.string(), impact: z.string(), savingsUSD: z.number() })) }), + retryTaxUSD: z.number(), + routingWasteUSD: z.number(), + }, + annotations: { title: 'CodeBurn — savings opportunities', readOnlyHint: true, openWorldHint: false, idempotentHint: true }, + }, + async ({ period, include_project_names }) => { + try { + const payload = redactProjectNames(await getPayload(period, true), include_project_names) + const c = payload.current + return { + content: [{ type: 'text' as const, text: renderSavingsTable(payload) }], + structuredContent: { period: c.label, optimize: payload.optimize, retryTaxUSD: c.retryTax.totalUSD, routingWasteUSD: c.routingWaste.totalSavingsUSD }, + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `codeburn: failed to compute savings — ${err instanceof Error ? err.message : String(err)}` }], + structuredContent: { period: 'unknown', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, retryTaxUSD: 0, routingWasteUSD: 0 }, + isError: true, + } + } + }, + ) + + return server +} + +export async function startStdioServer(version: string): Promise { + await loadPricing() + const server = createServer({ version }) + await server.connect(new StdioServerTransport()) +} diff --git a/src/mcp/tables.ts b/src/mcp/tables.ts new file mode 100644 index 0000000..6ce27be --- /dev/null +++ b/src/mcp/tables.ts @@ -0,0 +1,56 @@ +import { formatCost, formatTokens } from '../format.js' +import type { MenubarPayload } from '../menubar-json.js' + +export type BreakdownBy = 'project' | 'model' | 'task' | 'provider' + +function mdTable(headers: string[], rows: string[][]): string { + const head = `| ${headers.join(' | ')} |` + const sep = `| ${headers.map(() => '---').join(' | ')} |` + if (rows.length === 0) return `${head}\n${sep}\n| _(no data)_ ${' | '.repeat(headers.length - 1)}|` + return [head, sep, ...rows.map(r => `| ${r.join(' | ')} |`)].join('\n') +} + +const pct = (n: number) => `${Math.round(n)}%` +const oneShot = (r: number | null) => (r === null ? 'n/a' : pct(r * 100)) + +export function renderSummaryTable(p: MenubarPayload): string { + const c = p.current + const unpriced = c.unpricedModels ?? [] + return [ + `**${c.label}** — ${formatCost(c.cost)} · ${c.calls} calls · ${c.sessions} sessions`, + `cache hit ${pct(c.cacheHitPercent)} · one-shot ${oneShot(c.oneShotRate)} · in ${formatTokens(c.inputTokens)} / out ${formatTokens(c.outputTokens)}`, + ...(unpriced.length > 0 + ? [`⚠ ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced, counted at $0: ${unpriced.map(u => `${u.model} (${u.calls} calls)`).join(', ')}. Cost above understates real spend; fix with \`codeburn model-alias\` or \`codeburn price-override\`.`] + : []), + '', + '_Top models_', + mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])), + '', + '_Top projects_', + mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])), + ].join('\n') +} + +export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string { + const c = p.current + if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)])) + if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)])) + if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)])) + return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)])) +} + +export function renderSavingsTable(p: MenubarPayload): string { + const c = p.current + const findings = mdTable(['Finding', 'Impact', 'Saves'], p.optimize.topFindings.slice(0, 10).map(f => [f.title, f.impact, formatCost(f.savingsUSD)])) + const retry = mdTable(['Model', 'Retry tax', 'Retries'], c.retryTax.byModel.map(m => [m.name, formatCost(m.taxUSD), String(m.retries)])) + const routing = mdTable(['Model', 'Overpaid', 'vs baseline'], c.routingWaste.byModel.map(m => [m.name, formatCost(m.savingsUSD), c.routingWaste.baselineModel])) + return [ + `**Savings — ${c.label}**`, + `Optimize findings: ${p.optimize.findingCount} (≈ ${formatCost(p.optimize.savingsUSD)})`, + findings, '', + `_Retry tax_ — ${formatCost(c.retryTax.totalUSD)} on ${c.retryTax.retries} retries`, + retry, '', + `_Routing waste_ — ${formatCost(c.routingWaste.totalSavingsUSD)} vs ${c.routingWaste.baselineModel || 'n/a'}`, + routing, + ].join('\n') +} diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts new file mode 100644 index 0000000..7dbed16 --- /dev/null +++ b/src/menubar-installer.ts @@ -0,0 +1,394 @@ +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { createWriteStream } from 'node:fs' +import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { homedir, platform, tmpdir } from 'node:os' +import { join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { Readable } from 'node:stream' +import { ProxyAgent, fetch as undiciFetch } from 'undici' + +import { + buildPersistentCodeburnLookupPath, + resolvePersistentCodeburnPathFromWhichOutput, +} from './persistent-codeburn.js' + +/// Public GitHub repo that hosts macOS release builds. Normal installs use direct +/// versioned release asset URLs; the API scan is only a fallback for missing assets. +const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20' +const RELEASE_DOWNLOAD_BASE = 'https://github.com/getagentseal/codeburn/releases/download' +const APP_BUNDLE_NAME = 'CodeBurnMenubar.app' +const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar' +const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/ +const APP_PROCESS_NAME = 'CodeBurnMenubar' +const SUPPORTED_OS = 'darwin' +const MIN_MACOS_MAJOR = 14 +const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1') +const PERSISTENT_CLI_REQUIRED_MESSAGE = + 'The menubar app needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn' + +export type InstallResult = { installedPath: string; launched: boolean } + +export type ReleaseAsset = { name: string; browser_download_url: string } +export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } +export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset } +export type InstallOptions = { force?: boolean; cliVersion?: string } +type ProxyEnv = Partial> +type FetchOptions = Parameters[1] +type HeaderGetter = { get(name: string): string | null } + +class HttpStatusError extends Error { + constructor(message: string, readonly status: number) { + super(message) + this.name = 'HttpStatusError' + } +} + +export function resolveProxyUrlForUrl(url: string, env: ProxyEnv = process.env): string | undefined { + const target = new URL(url) + if (matchesNoProxy(target.hostname, env.NO_PROXY ?? env.no_proxy)) return undefined + if (target.protocol === 'https:') return env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy + if (target.protocol === 'http:') return env.HTTP_PROXY ?? env.http_proxy + return undefined +} + +function matchesNoProxy(hostname: string, noProxy?: string): boolean { + if (!noProxy) return false + const host = hostname.toLowerCase() + return noProxy.split(',').some(entry => { + const rule = entry.trim().toLowerCase().split(':')[0] + if (!rule) return false + if (rule === '*') return true + if (rule.startsWith('.')) return host === rule.slice(1) || host.endsWith(rule) + return host === rule || host.endsWith(`.${rule}`) + }) +} + +function fetchWithProxy(url: string, options: FetchOptions = {}) { + const proxyUrl = resolveProxyUrlForUrl(url) + const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined + return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options) +} + +export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets { + const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name)) + if (!zip) { + throw new Error( + `No ${APP_BUNDLE_NAME} versioned zip found in release ${release.tag_name}. ` + + `Check https://github.com/getagentseal/codeburn/releases.` + ) + } + const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`) + if (!checksum) { + throw new Error(`Missing checksum asset ${zip.name}.sha256 in release ${release.tag_name}.`) + } + return { release, zip, checksum } +} + +export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): ResolvedAssets { + for (const release of releases) { + if (!release.tag_name.startsWith('mac-v')) continue + try { + return resolveMenubarReleaseAssets(release) + } catch { + continue + } + } + throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.') +} + +function normalizeCliVersion(cliVersion: string): string { + return cliVersion.trim().replace(/^v/, '') +} + +export function resolveVersionedMenubarReleaseAssets(cliVersion: string): ResolvedAssets { + const version = normalizeCliVersion(cliVersion) + if (!version) throw new Error('Cannot resolve CodeBurn Menubar release without a CLI version.') + + const tagName = `mac-v${version}` + const zipName = `CodeBurnMenubar-v${version}.zip` + const checksumName = `${zipName}.sha256` + const releaseBase = `${RELEASE_DOWNLOAD_BASE}/${tagName}` + const zip = { name: zipName, browser_download_url: `${releaseBase}/${zipName}` } + const checksum = { name: checksumName, browser_download_url: `${releaseBase}/${checksumName}` } + + return { + release: { tag_name: tagName, assets: [zip, checksum] }, + zip, + checksum, + } +} + +export function shouldFallbackToReleaseApi(status: number): boolean { + return status === 404 || status === 410 +} + +export function formatGitHubReleaseLookupError(status: number, headers?: HeaderGetter): string { + const base = `GitHub release lookup failed: HTTP ${status}` + if (status !== 403 && status !== 429) return base + + const details = ['GitHub may be rate limiting unauthenticated release API requests.'] + const retryAfter = headers?.get('retry-after') + const rateLimitReset = headers?.get('x-ratelimit-reset') + if (retryAfter) details.push(`retry-after=${retryAfter}`) + if (rateLimitReset) details.push(`x-ratelimit-reset=${rateLimitReset}`) + return `${base}. ${details.join(' ')}` +} + +function isMissingDirectAssetError(err: unknown): boolean { + return err instanceof HttpStatusError && shouldFallbackToReleaseApi(err.status) +} + +export { + buildPersistentCodeburnLookupPath, + resolvePersistentCodeburnPathFromWhichOutput, +} from './persistent-codeburn.js' + +function userApplicationsDir(): string { + return join(homedir(), 'Applications') +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +async function ensureSupportedPlatform(): Promise { + if (platform() !== SUPPORTED_OS) { + throw new Error(`The menubar app is macOS only (detected: ${platform()}).`) + } + const major = Number((process.env.CODEBURN_FORCE_MACOS_MAJOR ?? '') + || (await sysProductVersion()).split('.')[0]) + if (!Number.isFinite(major) || major < MIN_MACOS_MAJOR) { + throw new Error(`macOS ${MIN_MACOS_MAJOR}+ required (detected ${major}).`) + } +} + +async function sysProductVersion(): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('/usr/bin/sw_vers', ['-productVersion']) + let out = '' + proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() }) + proc.on('error', reject) + proc.on('close', (code) => { + if (code !== 0) reject(new Error(`sw_vers exited with ${code}`)) + else resolve(out.trim()) + }) + }) +} + +async function fetchLatestReleaseAssets(): Promise { + const response = await fetchWithProxy(RELEASE_API, { + headers: { + 'User-Agent': 'codeburn-menubar-installer', + Accept: 'application/vnd.github+json', + }, + }) + if (!response.ok) { + throw new HttpStatusError(formatGitHubReleaseLookupError(response.status, response.headers), response.status) + } + const body = await response.json() as ReleaseResponse[] + return resolveLatestMenubarReleaseAssets(body) +} + +async function verifyChecksum(archivePath: string, checksumUrl: string): Promise { + const response = await fetchWithProxy(checksumUrl, { + headers: { 'User-Agent': 'codeburn-menubar-installer' }, + redirect: 'follow', + }) + if (!response.ok) { + throw new HttpStatusError(`Checksum download failed: HTTP ${response.status}`, response.status) + } + const text = await response.text() + const expected = text.trim().split(/\s+/)[0]!.toLowerCase() + const fileBytes = await readFile(archivePath) + const actual = createHash('sha256').update(fileBytes).digest('hex') + if (actual !== expected) { + throw new Error( + `Checksum mismatch for ${archivePath}.\n` + + ` Expected: ${expected}\n` + + ` Got: ${actual}\n` + + `The download may be corrupted or tampered with.` + ) + } +} + +async function downloadToFile(url: string, destPath: string): Promise { + const response = await fetchWithProxy(url, { + headers: { 'User-Agent': 'codeburn-menubar-installer' }, + redirect: 'follow', + }) + if (!response.ok || response.body === null) { + throw new HttpStatusError(`Download failed: HTTP ${response.status}`, response.status) + } + // fetch's ReadableStream needs to be wrapped for Node streams. + const nodeStream = Readable.fromWeb(response.body as never) + await pipeline(nodeStream, createWriteStream(destPath)) +} + +async function stageMenubarApp(assets: ResolvedAssets, stagingDir: string): Promise { + const { zip, checksum } = assets + const archivePath = join(stagingDir, zip.name) + console.log(`Downloading ${zip.name}...`) + await downloadToFile(zip.browser_download_url, archivePath) + + console.log('Verifying checksum...') + await verifyChecksum(archivePath, checksum.browser_download_url) + + console.log('Unpacking...') + await runCommand('/usr/bin/ditto', ['-x', '-k', archivePath, stagingDir]) + + const unpackedApp = join(stagingDir, APP_BUNDLE_NAME) + if (!(await exists(unpackedApp))) { + throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`) + } + + console.log('Verifying app bundle...') + await verifyBundleIdentity(unpackedApp) + + // Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the + // "cannot verify developer" prompt even for a signed + notarized app when the bundle + // was delivered via curl/fetch instead of the Mac App Store. + await runCommand('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', unpackedApp]).catch(() => {}) + + return unpackedApp +} + +async function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: 'inherit' }) + proc.on('error', reject) + proc.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`${command} exited with status ${code}`)) + }) + }) +} + +async function captureCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let out = '' + let err = '' + proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() }) + proc.stderr.on('data', (chunk: Buffer) => { err += chunk.toString() }) + proc.on('error', reject) + proc.on('close', (code) => { + if (code === 0) resolve(out.trim()) + else reject(new Error(`${command} exited with status ${code}${err ? `: ${err.trim()}` : ''}`)) + }) + }) +} + +async function verifyBundleIdentity(appPath: string): Promise { + const bundleID = await captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + join(appPath, 'Contents', 'Info.plist'), + ]) + if (bundleID !== EXPECTED_BUNDLE_ID) { + throw new Error(`Unexpected menubar bundle id ${bundleID}; expected ${EXPECTED_BUNDLE_ID}.`) + } + await runCommand('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath]) +} + +async function resolvePersistentCodeburnPath(): Promise { + let output = '' + try { + output = await captureCommand('/usr/bin/env', [ + `PATH=${buildPersistentCodeburnLookupPath()}`, + 'which', + '-a', + 'codeburn', + ]) + } catch { + throw new Error(PERSISTENT_CLI_REQUIRED_MESSAGE) + } + + return resolvePersistentCodeburnPathFromWhichOutput(output, PERSISTENT_CLI_REQUIRED_MESSAGE) +} + +async function persistCodeburnPath(): Promise { + const cliPath = await resolvePersistentCodeburnPath() + await mkdir(join(homedir(), 'Library', 'Application Support', 'CodeBurn'), { recursive: true, mode: 0o700 }) + await writeFile(PERSISTED_CLI_PATH, `${cliPath}\n`, { mode: 0o600 }) + await chmod(PERSISTED_CLI_PATH, 0o600) +} + +async function isAppRunning(): Promise { + return new Promise((resolve) => { + const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME]) + proc.on('close', (code) => resolve(code === 0)) + proc.on('error', () => resolve(false)) + }) +} + +async function killRunningApp(): Promise { + await new Promise((resolve) => { + const proc = spawn('/usr/bin/pkill', ['-f', APP_PROCESS_NAME]) + proc.on('close', () => resolve()) + proc.on('error', () => resolve()) + }) + for (let i = 0; i < 10; i++) { + if (!(await isAppRunning())) return + await new Promise(r => setTimeout(r, 500)) + } +} + +export async function installMenubarApp(options: InstallOptions = {}): Promise { + await ensureSupportedPlatform() + await persistCodeburnPath() + + const appsDir = userApplicationsDir() + const targetPath = join(appsDir, APP_BUNDLE_NAME) + const alreadyInstalled = await exists(targetPath) + + if (alreadyInstalled && !options.force) { + if (!(await isAppRunning())) { + await runCommand('/usr/bin/open', [targetPath]) + } + return { installedPath: targetPath, launched: true } + } + + const cliVersion = options.cliVersion ? normalizeCliVersion(options.cliVersion) : '' + let assets: ResolvedAssets + if (cliVersion) { + console.log(`Resolving CodeBurn Menubar v${cliVersion}...`) + assets = resolveVersionedMenubarReleaseAssets(cliVersion) + } else { + console.log('Looking up the latest CodeBurn Menubar release...') + assets = await fetchLatestReleaseAssets() + } + + const stagingDir = await mkdtemp(join(tmpdir(), 'codeburn-menubar-')) + try { + let unpackedApp: string + try { + unpackedApp = await stageMenubarApp(assets, stagingDir) + } catch (err) { + if (!cliVersion || !isMissingDirectAssetError(err)) throw err + console.log(`CodeBurn Menubar v${cliVersion} assets were not found. Looking up the latest CodeBurn Menubar release...`) + assets = await fetchLatestReleaseAssets() + unpackedApp = await stageMenubarApp(assets, stagingDir) + } + + await mkdir(appsDir, { recursive: true }) + if (alreadyInstalled) { + // Kill the running copy before replacing its bundle so `mv` can proceed cleanly and the + // user ends up on the new version. + await killRunningApp() + await rm(targetPath, { recursive: true, force: true }) + } + await rename(unpackedApp, targetPath) + + console.log('Launching CodeBurn Menubar...') + await runCommand('/usr/bin/open', [targetPath]) + return { installedPath: targetPath, launched: true } + } finally { + await rm(stagingDir, { recursive: true, force: true }) + } +} diff --git a/src/menubar-json.ts b/src/menubar-json.ts new file mode 100644 index 0000000..fbd1360 --- /dev/null +++ b/src/menubar-json.ts @@ -0,0 +1,412 @@ +/// Rollup of one time window (today / 7 days / 30 days / month / all) used as the canonical +/// input to the menubar payload. Built inside the CLI and also consumed by the day-aggregator +/// when hydrating per-day cache entries. +export type PeriodData = { + label: string + cost: number + /// Counterfactual USD the same tokens would have cost on the paid + /// baseline configured for each local model. Stays `0` when no + /// `codeburn model-savings` mappings are active. Always shown + /// separately from `cost` so the two never get summed into a "real + /// spend" number by accident. + savingsUSD: number + calls: number + sessions: number + inputTokens: number + 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; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }> + models: Array<{ name: string; cost: number; savingsUSD: number; calls: number }> + /// Models with usage in the period whose pricing lookup fails against the + /// current tables (#638): their calls contribute $0 to `cost`. Optional so + /// PeriodData producers that predate the field keep compiling. + unpricedModels?: Array<{ model: string; calls: number; tokens: number }> + projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }> + modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }> + topSessions?: Array<{ project: string; cost: number; savingsUSD: number; calls: number; date: string }> +} + +export type ProviderCost = { + name: string + cost: number +} +import type { OptimizeResult } from './optimize.js' + +const TOP_ACTIVITIES_LIMIT = 20 +const TOP_MODELS_LIMIT = 20 +const TOP_FINDINGS_LIMIT = 10 +const HISTORY_DAYS_LIMIT = 365 +const SYNTHETIC_MODEL_NAME = '' +const TOP_PROJECTS_LIMIT = 5 +const TOP_SESSIONS_LIMIT = 3 +const MODEL_EFFICIENCY_LIMIT = 5 + +export type DailyModelBreakdown = { + name: string + cost: number + savingsUSD: number + calls: number + inputTokens: number + outputTokens: number +} + +export type DailyHistoryEntry = { + date: string + cost: number + savingsUSD: number + calls: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + topModels: DailyModelBreakdown[] +} + +export type LocalModelSavings = { + totalUSD: number + calls: number + byModel: Array<{ + name: string + calls: number + actualUSD: number + savingsUSD: number + baselineModel: string + inputTokens: number + outputTokens: number + }> + byProvider: Array<{ name: string; calls: number; savingsUSD: number }> +} + +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 +} + +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 + } +} + +export type ClaudeConfigOption = { + id: string + label: string + path: string +} + +export type ClaudeConfigSelector = { + selectedId: string | null + options: ClaudeConfigOption[] +} + +export type MenubarPayload = { + generated: string + current: { + label: string + cost: number + calls: number + sessions: number + oneShotRate: number | null + inputTokens: number + outputTokens: number + /// Period-scoped cache token totals. Kept separate from `history.daily` + /// (which is a 365-day backfill for the trend chart) so the web cache + /// cards read the same range as Cost/Calls/Tokens (issue #583). + 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 + savingsUSD: number + turns: number + oneShotRate: number | null + }> + topModels: Array<{ + name: string + cost: number + savingsUSD: number + savingsBaselineModel: string + calls: number + }> + /// See PeriodData.unpricedModels: usage priced at $0 for lack of pricing + /// data. Empty when every model in the period resolved a price. Optional + /// so payload producers that predate the field stay source-compatible. + unpricedModels?: Array<{ model: string; calls: number; tokens: number }> + /// Local-model savings rollup, distinct from the routing-waste / + /// optimize savings concepts which describe hypothetical optimization + /// opportunities. This block tracks counterfactual spend that was + /// already avoided because the user ran a local model mapped via + /// `codeburn model-savings`. + localModelSavings: LocalModelSavings + providers: Record + topProjects: Array<{ + name: string + cost: number + savingsUSD: number + sessions: number + avgCostPerSession: number + sessionDetails: Array<{ + cost: number + savingsUSD: number + calls: number + inputTokens: number + outputTokens: number + date: string + models: Array<{ name: string; cost: number; savingsUSD: number }> + }> + }> + modelEfficiency: Array<{ + name: string + costPerEdit: number | null + oneShotRate: number | null + }> + topSessions: Array<{ + project: string + cost: number + savingsUSD: number + calls: number + date: string + }> + retryTax: { + totalUSD: number + retries: number + editTurns: number + byModel: Array<{ + name: string + taxUSD: number + retries: number + retriesPerEdit: number | null + }> + } + routingWaste: { + totalSavingsUSD: number + baselineModel: string + baselineCostPerEdit: number + byModel: Array<{ + name: string + costPerEdit: number + editTurns: number + actualUSD: number + counterfactualUSD: number + savingsUSD: number + }> + } + tools: Array<{ name: string; calls: number }> + skills: Array<{ name: string; turns: number; cost: number }> + subagents: Array<{ name: string; calls: number; cost: number }> + mcpServers: Array<{ name: string; calls: number }> + } + optimize: { + findingCount: number + savingsUSD: number + topFindings: Array<{ + title: string + impact: 'high' | 'medium' | 'low' + savingsUSD: number + }> + } + history: { + daily: DailyHistoryEntry[] + } + combined?: CombinedUsage + claudeConfigs?: ClaudeConfigSelector +} + +function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null { + if (editTurns === 0) return null + return oneShotTurns / editTurns +} + +function aggregateOneShotRate(categories: PeriodData['categories']): number | null { + let edits = 0 + let oneShots = 0 + for (const cat of categories) { + edits += cat.editTurns + oneShots += cat.oneShotTurns + } + if (edits === 0) return null + return oneShots / edits +} + +function cacheHitPercent(inputTokens: number, cacheReadTokens: number): number { + const denom = inputTokens + cacheReadTokens + if (denom === 0) return 0 + return (cacheReadTokens / denom) * 100 +} + +function buildTopActivities(categories: PeriodData['categories']): MenubarPayload['current']['topActivities'] { + return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({ + name: cat.name, + cost: cat.cost, + savingsUSD: cat.savingsUSD, + turns: cat.turns, + oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns), + })) +} + +function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] { + return models + .filter(m => m.name !== SYNTHETIC_MODEL_NAME) + .slice(0, TOP_MODELS_LIMIT) + .map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '' })) +} + +function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] { + if (!optimize || optimize.findings.length === 0) { + return { findingCount: 0, savingsUSD: 0, topFindings: [] } + } + const { findings, costRate } = optimize + const totalSavingsUSD = findings.reduce((s, f) => s + f.tokensSaved * costRate, 0) + const topFindings = findings.slice(0, TOP_FINDINGS_LIMIT).map(f => ({ + title: f.title, + impact: f.impact, + savingsUSD: f.tokensSaved * costRate, + })) + return { + findingCount: findings.length, + savingsUSD: totalSavingsUSD, + topFindings, + } +} + +function buildProviders(providers: ProviderCost[]): Record { + const map: Record = {} + for (const p of providers) { + if (p.cost < 0) continue + map[p.name.toLowerCase()] = p.cost + } + return map +} + +function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['history'] { + if (!daily || daily.length === 0) return { daily: [] } + const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date)) + const trimmed = sorted.slice(-HISTORY_DAYS_LIMIT) + return { daily: trimmed } +} + +function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['current']['topProjects'] { + return (projects ?? []) + .filter(p => p.cost > 0 || p.savingsUSD > 0) + .sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD)) + .slice(0, TOP_PROJECTS_LIMIT) + .map(p => ({ + name: p.name, + cost: p.cost, + savingsUSD: p.savingsUSD, + sessions: p.sessions, + avgCostPerSession: p.sessions > 0 ? p.cost / p.sessions : 0, + sessionDetails: (p.sessionDetails ?? []).map(s => ({ + cost: s.cost, + savingsUSD: s.savingsUSD, + calls: s.calls, + inputTokens: s.inputTokens, + outputTokens: s.outputTokens, + date: s.date, + models: s.models, + })), + })) +} + +function buildModelEfficiency(models: PeriodData['modelEfficiency']): MenubarPayload['current']['modelEfficiency'] { + return (models ?? []) + .filter(m => m.costPerEdit !== null) + .sort((a, b) => (a.costPerEdit ?? Infinity) - (b.costPerEdit ?? Infinity)) + .slice(0, MODEL_EFFICIENCY_LIMIT) + .map(m => ({ name: m.name, costPerEdit: m.costPerEdit, oneShotRate: m.oneShotRate })) +} + +function buildTopSessions(sessions: PeriodData['topSessions']): MenubarPayload['current']['topSessions'] { + return (sessions ?? []) + .sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD)) + .slice(0, TOP_SESSIONS_LIMIT) + .map(s => ({ project: s.project, cost: s.cost, savingsUSD: s.savingsUSD, calls: s.calls, date: s.date })) +} + +export type BreakdownArrays = { + tools?: MenubarPayload['current']['tools'] + skills?: MenubarPayload['current']['skills'] + subagents?: MenubarPayload['current']['subagents'] + mcpServers?: MenubarPayload['current']['mcpServers'] + /// Optional rollup of per-model and per-provider local-model savings. + /// Computed by the CLI from the parsed projects (we have raw token + /// + baseline info there, not in `PeriodData`). When omitted, the + /// menubar payload defaults to an empty savings block — keeping the + /// schema stable for consumers that don't care about local savings. + localModelSavings?: LocalModelSavings +} + +export function buildMenubarPayload( + current: PeriodData, + providers: ProviderCost[], + optimize: OptimizeResult | null, + dailyHistory?: DailyHistoryEntry[], + retryTax?: MenubarPayload['current']['retryTax'], + routingWaste?: MenubarPayload['current']['routingWaste'], + breakdowns?: BreakdownArrays, + claudeConfigs?: ClaudeConfigSelector, +): MenubarPayload { + const payload: MenubarPayload = { + generated: new Date().toISOString(), + current: { + label: current.label, + cost: current.cost, + calls: current.calls, + sessions: current.sessions, + oneShotRate: aggregateOneShotRate(current.categories), + inputTokens: current.inputTokens, + outputTokens: current.outputTokens, + cacheReadTokens: current.cacheReadTokens, + cacheWriteTokens: current.cacheWriteTokens, + cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens), + codexCredits: current.codexCredits ?? 0, + topActivities: buildTopActivities(current.categories), + topModels: buildTopModels(current.models), + unpricedModels: current.unpricedModels ?? [], + localModelSavings: breakdowns?.localModelSavings ?? { totalUSD: 0, calls: 0, byModel: [], byProvider: [] }, + providers: buildProviders(providers), + topProjects: buildTopProjects(current.projects ?? []), + modelEfficiency: buildModelEfficiency(current.modelEfficiency ?? []), + topSessions: buildTopSessions(current.topSessions ?? []), + retryTax: retryTax ?? { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, + routingWaste: routingWaste ?? { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] }, + tools: breakdowns?.tools ?? [], + skills: breakdowns?.skills ?? [], + subagents: breakdowns?.subagents ?? [], + mcpServers: breakdowns?.mcpServers ?? [], + }, + optimize: buildOptimize(optimize), + history: buildHistory(dailyHistory), + } + if (claudeConfigs && claudeConfigs.options.length > 1) { + payload.claudeConfigs = claudeConfigs + } + return payload +} diff --git a/src/model-efficiency.ts b/src/model-efficiency.ts new file mode 100644 index 0000000..8073945 --- /dev/null +++ b/src/model-efficiency.ts @@ -0,0 +1,64 @@ +import { getShortModelName } from './models.js' +import type { ParsedApiCall, ProjectSummary } from './types.js' + +export type ModelEfficiency = { + model: string + editTurns: number + oneShotTurns: number + retries: number + editCostUSD: number + oneShotRate: number | null + retriesPerEdit: number | null + costPerEditUSD: number | null +} + +type MutableModelEfficiency = Omit + +function rate(num: number, den: number): number | null { + if (den === 0) return null + return Math.round((num / den) * 1000) / 10 +} + +function modelKey(call: ParsedApiCall): string { + return call.provider === 'devin' ? call.model : getShortModelName(call.model) +} + +export function aggregateModelEfficiency(projects: ProjectSummary[]): Map { + const byModel = new Map() + + function ensure(model: string): MutableModelEfficiency { + let stats = byModel.get(model) + if (!stats) { + stats = { model, editTurns: 0, oneShotTurns: 0, retries: 0, editCostUSD: 0 } + byModel.set(model, stats) + } + return stats + } + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (!turn.hasEdits || turn.assistantCalls.length === 0) continue + + const primaryCall = turn.assistantCalls.find(c => modelKey(c) !== '') + if (!primaryCall) continue + const primaryModel = modelKey(primaryCall) + + const stats = ensure(primaryModel) + stats.editTurns++ + if (turn.retries === 0) stats.oneShotTurns++ + stats.retries += turn.retries + stats.editCostUSD += turn.assistantCalls.reduce((sum, call) => { + return modelKey(call) === '' ? sum : sum + call.costUSD + }, 0) + } + } + } + + return new Map([...byModel.entries()].map(([model, stats]) => [model, { + ...stats, + oneShotRate: rate(stats.oneShotTurns, stats.editTurns), + retriesPerEdit: stats.editTurns > 0 ? Math.round((stats.retries / stats.editTurns) * 10) / 10 : null, + costPerEditUSD: stats.editTurns > 0 ? stats.editCostUSD / stats.editTurns : null, + }])) +} diff --git a/src/models-report.ts b/src/models-report.ts new file mode 100644 index 0000000..fc041ec --- /dev/null +++ b/src/models-report.ts @@ -0,0 +1,702 @@ +import chalk from 'chalk' +import stripAnsi from 'strip-ansi' + +import { codexCredits } from './codex-credits.js' +import { formatCost, formatTokens } from './format.js' +import { getProvider } from './providers/index.js' +import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' + +export type ModelReportRow = { + provider: string + providerDisplayName: string + model: string + modelDisplayName: string + category: TaskCategory | null + inputTokens: number + outputTokens: number + cacheWriteTokens: number + cacheReadTokens: number + totalTokens: number + costUSD: number + savingsUSD: number + savingsBaselineModel: string + calls: number + /// Codex credit consumption (issues #408/#495). null for non-Codex models or + /// Codex models without a known credit rate. + credits: number | null + topCategory?: TaskCategory + topCategoryCost?: number + topCategoryShare?: number +} + +export type AggregateOptions = { + byTask?: boolean + taskFilter?: TaskCategory + topN?: number + /// Threshold for the `cost`-based filter. The default `0.01` would + /// hide local-only models whose `costUSD` is 0 but `savingsUSD` is + /// meaningful. The implementation ORs in `savingsUSD >= minCost` + /// so saved-only rows still surface by default. + minCost?: number +} + +type Bucket = { + provider: string + model: string + category: TaskCategory | null + inputTokens: number + outputTokens: number + cacheWriteTokens: number + cacheReadTokens: number + costUSD: number + savingsUSD: number + savingsBaselineModel: string + calls: number +} + +type ModelKey = string +type CategoryKey = TaskCategory + +function bucketKey(provider: string, model: string, category: TaskCategory | null): string { + return `${provider} ${model} ${category ?? ''}` +} + +/// Walks every parsed turn, attributes each assistant call to a +/// (provider, model, category) bucket, and returns rows keyed by either +/// (provider, model) when `byTask` is false or (provider, model, category) when true. +/// +/// Default view: rows sorted by cost descending. +/// byTask view: rows grouped by (provider, model) so the renderer can blank +/// repeated provider/model cells. Group order follows total cost across that +/// model; within each group, rows go by cost descending. +export async function aggregateModels(projects: ProjectSummary[], opts: AggregateOptions = {}): Promise { + const buckets = new Map() + const perModelCategoryCost = new Map>() + const perModelTotalCost = new Map() + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + if (opts.taskFilter && turn.category !== opts.taskFilter) continue + for (const call of turn.assistantCalls) { + const provider = call.provider || 'unknown' + const model = call.model || 'unknown' + const category: TaskCategory | null = opts.byTask ? turn.category : null + const key = bucketKey(provider, model, category) + let bucket = buckets.get(key) + if (!bucket) { + bucket = { + provider, + model, + category, + inputTokens: 0, + outputTokens: 0, + cacheWriteTokens: 0, + cacheReadTokens: 0, + costUSD: 0, + savingsUSD: 0, + savingsBaselineModel: '', + calls: 0, + } + buckets.set(key, bucket) + } + bucket.inputTokens += call.usage.inputTokens + bucket.outputTokens += call.usage.outputTokens + call.usage.reasoningTokens + bucket.cacheWriteTokens += call.usage.cacheCreationInputTokens + // cacheReadInputTokens (Anthropic vocab) and cachedInputTokens (OpenAI vocab) + // are two names for the same thing. Providers populate one or set both to the + // same value, so take the max — summing double-counts cache reads for every + // provider that fills both fields. + bucket.cacheReadTokens += Math.max(call.usage.cacheReadInputTokens, call.usage.cachedInputTokens) + bucket.costUSD += call.costUSD + bucket.savingsUSD += call.savingsUSD ?? 0 + if (!bucket.savingsBaselineModel && call.savingsBaselineModel) { + bucket.savingsBaselineModel = call.savingsBaselineModel + } + bucket.calls += 1 + + const modelKey = `${provider} ${model}` + let perCat = perModelCategoryCost.get(modelKey) + if (!perCat) { + perCat = new Map() + perModelCategoryCost.set(modelKey, perCat) + } + perCat.set(turn.category, (perCat.get(turn.category) ?? 0) + call.costUSD) + perModelTotalCost.set(modelKey, (perModelTotalCost.get(modelKey) ?? 0) + call.costUSD) + } + } + } + } + + 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: ModelReportRow[] = [] + for (const bucket of buckets.values()) { + const meta = await resolveProvider(bucket.provider) + const total = bucket.inputTokens + bucket.outputTokens + bucket.cacheWriteTokens + bucket.cacheReadTokens + const row: ModelReportRow = { + provider: bucket.provider, + providerDisplayName: meta.displayName, + model: bucket.model, + modelDisplayName: meta.formatModel(bucket.model), + category: bucket.category, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheWriteTokens: bucket.cacheWriteTokens, + cacheReadTokens: bucket.cacheReadTokens, + totalTokens: total, + costUSD: bucket.costUSD, + savingsUSD: bucket.savingsUSD, + savingsBaselineModel: bucket.savingsBaselineModel, + calls: bucket.calls, + // outputTokens already includes reasoning (folded in above), and for Codex + // inputTokens is non-cached with cacheReadTokens holding cached input, which + // is exactly what the credit rates expect. + credits: bucket.provider === 'codex' + ? codexCredits(bucket.model, { + inputTokens: bucket.inputTokens, + cachedReadTokens: bucket.cacheReadTokens, + outputTokens: bucket.outputTokens, + }) + : null, + } + + if (!opts.byTask) { + const perCat = perModelCategoryCost.get(`${bucket.provider} ${bucket.model}`) + if (perCat && perCat.size > 0) { + let topCat: TaskCategory = 'general' + let topCost = -1 + let totalCost = 0 + for (const [cat, cost] of perCat.entries()) { + totalCost += cost + if (cost > topCost) { + topCost = cost + topCat = cat + } + } + row.topCategory = topCat + row.topCategoryCost = topCost + row.topCategoryShare = totalCost > 0 ? topCost / totalCost : 0 + } + } + + rows.push(row) + } + + if (opts.byTask) { + rows.sort((a, b) => { + const aTotal = perModelTotalCost.get(`${a.provider} ${a.model}`) ?? 0 + const bTotal = perModelTotalCost.get(`${b.provider} ${b.model}`) ?? 0 + if (aTotal !== bTotal) return bTotal - aTotal + if (a.provider !== b.provider) return a.provider.localeCompare(b.provider) + if (a.model !== b.model) return a.model.localeCompare(b.model) + return (b.costUSD + b.savingsUSD) - (a.costUSD + a.savingsUSD) + }) + } else { + rows.sort((a, b) => (b.costUSD + b.savingsUSD) - (a.costUSD + a.savingsUSD)) + } + + let filtered = rows + if (opts.minCost !== undefined) { + // OR with savings so local models with $0 actual cost but > 0 saved + // counterfactual still surface in the default `models` view. + filtered = filtered.filter(r => r.costUSD >= opts.minCost! || r.savingsUSD >= opts.minCost!) + } + if (opts.topN !== undefined) { + filtered = filtered.slice(0, opts.topN) + } + return filtered +} + +function visibleLength(text: string): number { + return stripAnsi(text).length +} + +function pad(text: string, width: number, align: 'left' | 'right' = 'left'): string { + const visible = visibleLength(text) + if (visible >= width) return text + const filler = ' '.repeat(width - visible) + return align === 'left' ? text + filler : filler + text +} + +function categoryLabel(c: TaskCategory): string { + return CATEGORY_LABELS[c] ?? c +} + +/// Box-drawing preset matching tokscale's comfy-table layout. Pure Unicode; +/// every modern terminal handles these. JSON / CSV / Markdown formats already +/// cover the no-Unicode case for downstream tooling. +const BOX = { + topLeft: '┌', + topRight: '┐', + bottomLeft: '└', + bottomRight: '┘', + topT: '┬', + bottomT: '┴', + leftT: '├', + rightT: '┤', + cross: '┼', + horizontal: '─', + vertical: '│', +} + +type Column = { + header: string + align: 'left' | 'right' + width: number + /// Drop priority. 0 = always shown; higher numbers get dropped first when + /// the terminal is narrow. + priority: number + key: 'provider' | 'model' | 'task' | 'input' | 'output' | 'cacheWrite' | 'cacheRead' | 'total' | 'cost' | 'saved' +} + +type TableRenderOptions = { + byTask?: boolean + showTotals?: boolean + terminalWidth?: number + fullWidth?: boolean +} + +const DROP_COLUMN_GROUPS: Array> = [ + ['cacheWrite', 'cacheRead'], + ['input', 'output'], + ['task'], + ['saved'], +] + +function defaultColumns(byTask: boolean, showSaved: boolean): Column[] { + // Higher priority numbers drop FIRST when the terminal is narrow. + // Cache columns are the cheapest to lose, then input/output, then top-task. + // Provider/Model/Total/Cost stay regardless. The Saved column only appears + // when local-model savings actually exist (a `codeburn model-savings` + // mapping produced nonzero avoided spend); otherwise it would be a column of + // dashes for the majority of users, so it is omitted entirely. + // Widths are MINIMUMS; sizeColumnsToContent() expands them to fit cell text. + return [ + { key: 'provider', header: 'Provider', align: 'left', width: 8, priority: 0 }, + { key: 'model', header: 'Model', align: 'left', width: 8, priority: 0 }, + { key: 'task', header: byTask ? 'Task' : 'Top Task', align: 'left', width: 8, priority: 1 }, + { key: 'input', header: 'Input', align: 'right', width: 6, priority: 2 }, + { key: 'output', header: 'Output', align: 'right', width: 6, priority: 2 }, + { key: 'cacheWrite', header: 'Cache Write', align: 'right', width: 11, priority: 3 }, + { key: 'cacheRead', header: 'Cache Read', align: 'right', width: 10, priority: 3 }, + { key: 'total', header: 'Total', align: 'right', width: 6, priority: 0 }, + { key: 'cost', header: 'Cost', align: 'right', width: 6, priority: 0 }, + ...(showSaved ? [{ key: 'saved' as const, header: 'Saved', align: 'right' as const, width: 6, priority: 0 }] : []), + ] +} + +/// Expands each column's width to fit the widest cell in that column, so a +/// short header (e.g. "Task") in a fixed 18-wide cell does not leave 14 chars +/// of trailing whitespace. Mirrors cli-table3 / comfy-table auto-sizing. +function sizeColumnsToContent(columns: Column[], rows: string[][]): Column[] { + return columns.map((col, i) => { + let maxLen = visibleLength(col.header) + for (const row of rows) { + const cell = row[i] ?? '' + const len = visibleLength(cell) + if (len > maxLen) maxLen = len + } + return { ...col, width: Math.max(col.width, maxLen) } + }) +} + +function frameWidth(columns: Column[]): number { + if (columns.length === 0) return 0 + // 1 (left border) + sum(col + 2 padding) + (N-1) inner separators + 1 (right border) + return 2 + columns.reduce((acc, c) => acc + c.width + 2, 0) + (columns.length - 1) +} + +function chooseColumns(byTask: boolean, available: number, showSaved: boolean): Column[] { + const all = defaultColumns(byTask, showSaved) + if (frameWidth(all) <= available) return all + + // Drop in this order so the table degrades sensibly. Cache columns drop as + // a pair (showing only one of cache write / cache read looks broken). + const kept = new Set(all) + for (const group of DROP_COLUMN_GROUPS) { + for (const key of group) { + const col = all.find(c => c.key === key) + if (col) kept.delete(col) + } + const remaining = all.filter(c => kept.has(c)) + if (frameWidth(remaining) <= available) return remaining + } + return all.filter(c => c.priority === 0) +} + +function expandedColumnWeight(col: Column): number { + switch (col.key) { + case 'task': + case 'model': + return 3 + case 'provider': + return 2 + default: + return 1 + } +} + +/// Expands a fitted table to the available terminal width. The extra cells are +/// spread across all visible columns, weighted toward text columns so grouped +/// model/task rows breathe on wide terminals without turning numeric columns +/// into huge empty gutters. +function expandColumnsToWidth(columns: Column[], targetWidth: number): Column[] { + let remaining = targetWidth - frameWidth(columns) + if (remaining <= 0 || columns.length === 0) return columns + + const expanded = columns.map(c => ({ ...c })) + const weights = expanded.map(expandedColumnWeight) + const totalWeight = weights.reduce((sum, w) => sum + w, 0) + + for (let i = 0; i < expanded.length; i++) { + const add = Math.floor((targetWidth - frameWidth(columns)) * (weights[i]! / totalWeight)) + if (add <= 0) continue + expanded[i]!.width += add + remaining -= add + } + + // Hand out rounding leftovers in the same preference order. + const preferred: Column['key'][] = ['task', 'model', 'provider', 'total', 'cost', 'input', 'output', 'cacheRead', 'cacheWrite'] + while (remaining > 0) { + let changed = false + for (const key of preferred) { + const col = expanded.find(c => c.key === key) + if (!col) continue + col.width += 1 + remaining -= 1 + changed = true + if (remaining === 0) break + } + if (!changed) break + } + + return expanded +} + +function renderRow(cells: string[], columns: Column[]): string { + const padded = cells.map((c, i) => pad(c, columns[i]!.width, columns[i]!.align)) + return BOX.vertical + ' ' + padded.join(' ' + BOX.vertical + ' ') + ' ' + BOX.vertical +} + +function renderBorder(columns: Column[], left: string, mid: string, right: string): string { + const segments = columns.map(c => BOX.horizontal.repeat(c.width + 2)) + return left + segments.join(mid) + right +} + +function defaultTerminalWidth(): number { + const cols = process.stdout.columns + if (typeof cols === 'number' && cols > 0) return cols + // Honor $COLUMNS when stdout is not a TTY (piped, tee'd, etc.); some + // shells set it even when isTTY is false. + const envCols = process.env['COLUMNS'] ? parseInt(process.env['COLUMNS'], 10) : NaN + if (Number.isFinite(envCols) && envCols > 0) return envCols + // Conservative fallback. 100 keeps the table readable on the most common + // terminal sizes (80, 100, 120) without trying to fit cache columns into + // a window that cannot hold them. + return 100 +} + +/// Renders a Unicode box-drawn table. Columns are auto-sized to their content +/// (with declared `width` as a minimum). When the terminal is narrow, drops +/// the lowest-priority columns (cache first, then input/output, then top-task) +/// so the table fits without wrapping. +export function renderTable( + rows: ModelReportRow[], + opts: TableRenderOptions = {}, +): string { + const byTask = opts.byTask ?? false + const showTotals = opts.showTotals ?? true + const available = opts.terminalWidth ?? defaultTerminalWidth() + const fullWidth = opts.fullWidth ?? true + // Only render the Saved column when something was actually saved. + const showSaved = rows.some(r => r.savingsUSD > 0) + + const valueOf = (row: ModelReportRow, key: Column['key'], isNewGroup: boolean): string => { + switch (key) { + case 'provider': return isNewGroup ? row.providerDisplayName : '' + case 'model': return isNewGroup ? row.modelDisplayName : '' + case 'task': + if (byTask) return row.category ? categoryLabel(row.category) : '' + return row.topCategory + ? `${categoryLabel(row.topCategory)} ${chalk.dim(`(${Math.round((row.topCategoryShare ?? 0) * 100)}%)`)}` + : chalk.dim('-') + case 'input': return formatTokens(row.inputTokens) + case 'output': return formatTokens(row.outputTokens) + case 'cacheWrite': return formatTokens(row.cacheWriteTokens) + case 'cacheRead': return formatTokens(row.cacheReadTokens) + case 'total': return formatTokens(row.totalTokens) + case 'cost': return formatCost(row.costUSD) + case 'saved': return row.savingsUSD > 0 ? formatCost(row.savingsUSD) : chalk.dim('-') + } + } + + // Build all cell content first so we can size columns to fit. + type RowCells = { kind: 'data' | 'totals'; cells: string[]; isNewGroup: boolean } + const rowEntries: RowCells[] = [] + let prevProviderModel = '' + for (const row of rows) { + const groupKey = `${row.provider} ${row.model}` + const isNewGroup = !byTask || groupKey !== prevProviderModel + prevProviderModel = groupKey + const allCells = defaultColumns(byTask, showSaved).map(col => { + const raw = valueOf(row, col.key, isNewGroup) + if (col.key === 'provider' && raw) return chalk.dim(raw) + return raw + }) + rowEntries.push({ kind: 'data', cells: allCells, isNewGroup }) + } + + let totalsEntry: RowCells | null = null + if (showTotals && rows.length > 0) { + const totals = rows.reduce( + (acc, r) => { + acc.input += r.inputTokens + acc.output += r.outputTokens + acc.cacheWrite += r.cacheWriteTokens + acc.cacheRead += r.cacheReadTokens + acc.total += r.totalTokens + acc.cost += r.costUSD + acc.savings += r.savingsUSD + return acc + }, + { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 }, + ) + const cells = defaultColumns(byTask, showSaved).map(col => { + switch (col.key) { + case 'provider': return '' + case 'model': return chalk.yellow.bold('Total') + case 'task': return '' + case 'input': return chalk.yellow(formatTokens(totals.input)) + case 'output': return chalk.yellow(formatTokens(totals.output)) + case 'cacheWrite': return chalk.yellow(formatTokens(totals.cacheWrite)) + case 'cacheRead': return chalk.yellow(formatTokens(totals.cacheRead)) + case 'total': return chalk.yellow.bold(formatTokens(totals.total)) + case 'cost': return chalk.yellow.bold(formatCost(totals.cost)) + case 'saved': return totals.savings > 0 ? chalk.yellow.bold(formatCost(totals.savings)) : chalk.dim('-') + } + }) + totalsEntry = { kind: 'totals', cells, isNewGroup: true } + } + + // Pick which columns to include based on terminal width, then size them. + // We index into `cells` by the column key to avoid object-identity pitfalls + // across defaultColumns() invocations. + const allKeys = defaultColumns(byTask, showSaved).map(c => c.key) + const indexByKey = new Map(allKeys.map((k, i) => [k, i])) + const columns = chooseColumns(byTask, available, showSaved) + const projectColumns = (cols: Column[], entry: RowCells) => + cols.map(c => entry.cells[indexByKey.get(c.key)!] ?? '') + const cellMatrix = [ + ...rowEntries.map(e => projectColumns(columns, e)), + ...(totalsEntry ? [projectColumns(columns, totalsEntry)] : []), + ] + const sized = sizeColumnsToContent(columns, cellMatrix) + + // If content sizing pushed the table back over budget, keep dropping the + // same low-value column groups until the rendered frame fits. + let final = sized + if (frameWidth(final) > available) { + let reduced = columns + for (const group of DROP_COLUMN_GROUPS) { + reduced = reduced.filter(c => !group.includes(c.key)) + const reducedMatrix = [ + ...rowEntries.map(e => projectColumns(reduced, e)), + ...(totalsEntry ? [projectColumns(reduced, totalsEntry)] : []), + ] + const candidate = sizeColumnsToContent(reduced, reducedMatrix) + final = candidate + if (frameWidth(candidate) <= available) break + } + } + + if (fullWidth && frameWidth(final) < available) { + final = expandColumnsToWidth(final, available) + } + + const lines: string[] = [] + lines.push(renderBorder(final, BOX.topLeft, BOX.topT, BOX.topRight)) + lines.push(renderRow(final.map(c => chalk.cyan.bold(c.header)), final)) + lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT)) + + let isFirstRow = true + for (const entry of rowEntries) { + if (byTask && entry.isNewGroup && !isFirstRow) { + lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT)) + } + isFirstRow = false + lines.push(renderRow(projectColumns(final, entry), final)) + } + + if (totalsEntry) { + lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT)) + lines.push(renderRow(projectColumns(final, totalsEntry), final)) + } + + lines.push(renderBorder(final, BOX.bottomLeft, BOX.bottomT, BOX.bottomRight)) + return lines.join('\n') +} + +export function renderJson(rows: ModelReportRow[]): string { + return JSON.stringify( + rows.map(r => ({ + provider: r.provider, + providerDisplayName: r.providerDisplayName, + model: r.model, + modelDisplayName: r.modelDisplayName, + category: r.category ?? r.topCategory ?? null, + topCategory: r.topCategory ?? null, + topCategoryShare: r.topCategoryShare ?? null, + inputTokens: r.inputTokens, + outputTokens: r.outputTokens, + cacheWriteTokens: r.cacheWriteTokens, + cacheReadTokens: r.cacheReadTokens, + totalTokens: r.totalTokens, + calls: r.calls, + costUSD: r.costUSD, + savingsUSD: r.savingsUSD, + savingsBaselineModel: r.savingsBaselineModel, + credits: r.credits, + })), + null, + 2, + ) +} + +function csvEscape(value: string | undefined | null): string { + if (value === undefined || value === null) return '' + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"` + } + return value +} + +function mdEscape(value: string): string { + // Pipes break GitHub-flavored markdown tables; escape them. + return value.replace(/\|/g, '\\|') +} + +/// GitHub-flavored markdown table. Renders cleanly on GitHub, Notion, and most +/// chat platforms that understand markdown. Always shows provider/model on +/// every row (no blank-repeat trick) so the table remains useful when copied +/// into a context that loses whitespace alignment. +export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean; showTotals?: boolean } = {}): string { + const byTask = opts.byTask ?? false + const showTotals = opts.showTotals ?? true + + const header = byTask + ? ['Provider', 'Model', 'Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved'] + : ['Provider', 'Model', 'Top Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved'] + const align = ['---', '---', '---', '---:', '---:', '---:', '---:', '---:', '---:', '---:'] + + const lines: string[] = [] + lines.push(`| ${header.join(' | ')} |`) + lines.push(`| ${align.join(' | ')} |`) + + for (const row of rows) { + const taskCell = byTask + ? row.category ? categoryLabel(row.category) : '' + : row.topCategory + ? `${categoryLabel(row.topCategory)} (${Math.round((row.topCategoryShare ?? 0) * 100)}%)` + : '-' + const cells = [ + mdEscape(row.providerDisplayName), + `\`${mdEscape(row.modelDisplayName)}\``, + taskCell, + formatTokens(row.inputTokens), + formatTokens(row.outputTokens), + formatTokens(row.cacheWriteTokens), + formatTokens(row.cacheReadTokens), + formatTokens(row.totalTokens), + formatCost(row.costUSD), + row.savingsUSD > 0 ? formatCost(row.savingsUSD) : '-', + ] + lines.push(`| ${cells.join(' | ')} |`) + } + + if (showTotals && rows.length > 0) { + const totals = rows.reduce( + (acc, r) => { + acc.input += r.inputTokens + acc.output += r.outputTokens + acc.cacheWrite += r.cacheWriteTokens + acc.cacheRead += r.cacheReadTokens + acc.total += r.totalTokens + acc.cost += r.costUSD + acc.savings += r.savingsUSD + return acc + }, + { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 }, + ) + const totalCells = [ + '', + '**Total**', + '', + `**${formatTokens(totals.input)}**`, + `**${formatTokens(totals.output)}**`, + `**${formatTokens(totals.cacheWrite)}**`, + `**${formatTokens(totals.cacheRead)}**`, + `**${formatTokens(totals.total)}**`, + `**${formatCost(totals.cost)}**`, + totals.savings > 0 ? `**${formatCost(totals.savings)}**` : '-', + ] + lines.push(`| ${totalCells.join(' | ')} |`) + } + + return lines.join('\n') +} + +export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean } = {}): string { + const byTask = opts.byTask ?? false + // CSV intentionally repeats provider/model on every row so downstream + // consumers can sort/filter without first reconstructing the grouping. + const header = byTask + ? ['provider', 'model', 'task', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model'] + : ['provider', 'model', 'top_task', 'top_task_share', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model'] + const lines: string[] = [header.join(',')] + for (const r of rows) { + const cells = byTask + ? [ + csvEscape(r.providerDisplayName), + csvEscape(r.modelDisplayName), + r.category ? categoryLabel(r.category) : '', + String(r.inputTokens), + String(r.outputTokens), + String(r.cacheWriteTokens), + String(r.cacheReadTokens), + String(r.totalTokens), + String(r.calls), + r.costUSD.toFixed(6), + (r.savingsUSD ?? 0).toFixed(6), + csvEscape(r.savingsBaselineModel), + ] + : [ + csvEscape(r.providerDisplayName), + csvEscape(r.modelDisplayName), + r.topCategory ? categoryLabel(r.topCategory) : '', + r.topCategoryShare !== undefined ? r.topCategoryShare.toFixed(4) : '', + String(r.inputTokens), + String(r.outputTokens), + String(r.cacheWriteTokens), + String(r.cacheReadTokens), + String(r.totalTokens), + String(r.calls), + r.costUSD.toFixed(6), + (r.savingsUSD ?? 0).toFixed(6), + csvEscape(r.savingsBaselineModel), + ] + lines.push(cells.join(',')) + } + return lines.join('\n') +} diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..b314ae9 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,924 @@ +import { readFile, writeFile, mkdir } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' +import snapshotData from './data/litellm-snapshot.json' +import fallbackData from './data/pricing-fallback.json' +import { fetchWithTimeout } from './fetch-utils.js' + +export type ModelCosts = { + inputCostPerToken: number + outputCostPerToken: number + cacheWriteCostPerToken: number + cacheReadCostPerToken: number + webSearchCostPerRequest: number + fastMultiplier: number +} + +type PriceOverrideRates = { + input: number + output: number + cacheRead?: number + cacheCreation?: number +} + +type LiteLLMEntry = { + input_cost_per_token?: number + output_cost_per_token?: number + cache_creation_input_token_cost?: number + cache_read_input_token_cost?: number + provider_specific_entry?: { fast?: number } +} + +// [input, output, cacheWrite, cacheRead, fastMultiplier]. The trailing fast +// multiplier is carried straight from LiteLLM's provider_specific_entry.fast so +// new models pick it up automatically — no hand-maintained per-model table. +type SnapshotEntry = [number, number, number | null, number | null, (number | null)?] + +const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const WEB_SEARCH_COST = 0.01 +const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6 + +// Explicit USD/token prices that must override LiteLLM/cache data. Cursor +// publishes house-model rates in the 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. +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], +} + +// Assemble a ModelCosts, applying the cache-cost heuristics (write = 1.25x +// input, read = 0.1x input) when a source omits them. Shared by the bundled +// tuple path (tupleToCosts) and the live LiteLLM path (parseLiteLLMEntry) so the +// multipliers live in exactly one place. +function buildCosts( + input: number, + output: number, + cacheWrite: number | null | undefined, + cacheRead: number | null | undefined, + fast: number | null | undefined, +): ModelCosts { + return { + inputCostPerToken: input, + outputCostPerToken: output, + cacheWriteCostPerToken: cacheWrite ?? input * 1.25, + cacheReadCostPerToken: cacheRead ?? input * 0.1, + webSearchCostPerRequest: WEB_SEARCH_COST, + fastMultiplier: fast ?? 1, + } +} + +function tupleToCosts(raw: SnapshotEntry): ModelCosts { + const [input, output, cacheWrite, cacheRead, fast] = raw + return buildCosts(input, output, cacheWrite, cacheRead, fast) +} + +function applyBuiltinPriceOverrides(pricing: Map): Map { + for (const [name, raw] of Object.entries(BUILTIN_PRICE_OVERRIDES)) { + pricing.set(name, tupleToCosts(raw)) + } + return pricing +} + +function loadSnapshot(): Map { + const map = new Map() + for (const [name, raw] of Object.entries(snapshotData as unknown as Record)) { + map.set(name, tupleToCosts(raw)) + } + return map +} + +// Gap-fill pricing from models.dev / OpenRouter, keyed lowercase. Consulted ONLY +// as the last-resort fallback in getModelCosts (never for exact/canonical/prefix +// matches), so a reseller variant name can't shadow a real canonical entry. +const fallbackCosts: Map = (() => { + const map = new Map() + for (const [name, raw] of Object.entries(fallbackData as unknown as Record)) { + const lk = name.toLowerCase() + if (!map.has(lk)) map.set(lk, tupleToCosts(raw)) + } + return map +})() + +let pricingCache: Map = applyBuiltinPriceOverrides(loadSnapshot()) +let sortedPricingKeys: string[] | null = null +let lowercasePricingIndex: Map | null = null + +function getSortedPricingKeys(): string[] { + if (sortedPricingKeys === null) { + sortedPricingKeys = Array.from(pricingCache.keys()).sort((a, b) => b.length - a.length) + } + return sortedPricingKeys +} + +// Case-insensitive index, built lazily. Lets a session model like `MiniMax-M3` +// resolve to a gap-filled OpenRouter key like `minimax-m3` (lowercase slug). +// First key wins on a lowercase collision so it stays deterministic. +// +// Zero-priced entries are excluded: LiteLLM ships `[0,0]` stubs (e.g. +// `GigaChat-2-Max`) for models it lists but has no price for. Indexing those +// would let a case-mismatched query (`gigachat-2-max`) resolve to a silent $0 +// instead of returning null, which suppresses the unknown-model warning and +// hides real spend. A case-EXACT query still finds the stub via the normal +// pipeline; only the fuzzy case-insensitive path skips them. +function getLowercasePricingIndex(): Map { + if (lowercasePricingIndex === null) { + lowercasePricingIndex = new Map() + const priced = (c: ModelCosts) => c.inputCostPerToken > 0 || c.outputCostPerToken > 0 + // The live pricing data wins on any lowercase collision; the gap-fill only + // fills names that resolve to nothing through the normal pipeline. + for (const [key, costs] of pricingCache) { + const lk = key.toLowerCase() + if (priced(costs) && !lowercasePricingIndex.has(lk)) lowercasePricingIndex.set(lk, costs) + } + for (const [lk, costs] of fallbackCosts) { + if (priced(costs) && !lowercasePricingIndex.has(lk)) lowercasePricingIndex.set(lk, costs) + } + } + return lowercasePricingIndex +} + +function getCacheDir(): string { + if (process.env['CODEBURN_CACHE_DIR']) return process.env['CODEBURN_CACHE_DIR'] + return join(homedir(), '.cache', 'codeburn') +} + +function getCachePath(): string { + return join(getCacheDir(), 'litellm-pricing.json') +} + +/// Clamp a per-token rate to a sane non-negative value. Defense in depth +/// against a tampered LiteLLM JSON shipping a negative `input_cost_per_token`, +/// which would otherwise produce negative costs that subtract from totals. +/// We use Number.isFinite to also reject NaN/Infinity, and cap at $1/token +/// (well above the most expensive frontier model) so a stray decimal-place +/// shift in the upstream JSON can't wildly inflate spend numbers either. +function safePerTokenRate(n: number | undefined): number | null { + if (n === undefined || !Number.isFinite(n) || n < 0) return null + if (n > 1) return 1 + return n +} + +function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null { + const inputCost = safePerTokenRate(entry.input_cost_per_token) + const outputCost = safePerTokenRate(entry.output_cost_per_token) + if (inputCost === null || outputCost === null) return null + return buildCosts( + inputCost, + outputCost, + safePerTokenRate(entry.cache_creation_input_token_cost), + safePerTokenRate(entry.cache_read_input_token_cost), + entry.provider_specific_entry?.fast, + ) +} + +async function fetchAndCachePricing(): Promise> { + // Bounded: runs on every CLI invocation (the menubar shells out and blocks on + // it). Without a timeout a half-open network after wake-from-sleep makes + // fetch() hang forever, wedging the menubar's loading spinner. On timeout the + // caller's catch falls back to the bundled price snapshot. + const response = await fetchWithTimeout(LITELLM_URL) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const data = await response.json() as Record + const pricing = new Map() + + for (const [name, entry] of Object.entries(data)) { + const costs = parseLiteLLMEntry(entry) + if (!costs) continue + pricing.set(name, costs) + // Also index by stripped name so lookups work without provider prefix: + // 'anthropic/claude-opus-4-6' is also queryable as 'claude-opus-4-6'. + // First write wins so direct-provider entries take precedence over re-hosters. + const stripped = name.replace(/^[^/]+\//, '') + if (stripped !== name && !pricing.has(stripped)) pricing.set(stripped, costs) + } + + await mkdir(getCacheDir(), { recursive: true }) + await writeFile(getCachePath(), JSON.stringify({ + timestamp: Date.now(), + data: Object.fromEntries(pricing), + })) + + return pricing +} + +async function loadCachedPricing(): Promise | null> { + try { + const raw = await readFile(getCachePath(), 'utf-8') + const cached = JSON.parse(raw) as { timestamp: number; data: Record } + if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null + return new Map(Object.entries(cached.data)) + } catch { + return null + } +} + +function mergeSnapshotFallbacks(pricing: Map): Map { + for (const [name, costs] of loadSnapshot()) { + if (!pricing.has(name)) pricing.set(name, costs) + } + return applyBuiltinPriceOverrides(pricing) +} + +export async function loadPricing(): Promise { + const cached = await loadCachedPricing() + if (cached) { + pricingCache = mergeSnapshotFallbacks(cached) + sortedPricingKeys = null + lowercasePricingIndex = null + return + } + + try { + pricingCache = mergeSnapshotFallbacks(await fetchAndCachePricing()) + sortedPricingKeys = null + lowercasePricingIndex = null + } catch { + // snapshot already loaded at init; nothing more to do + } +} + +// Known model name variants that providers emit but LiteLLM/fallback don't index under. +// OMP emits 'anthropic--claude-4.6-opus' (double-dash, dot version, tier-last). +// getCanonicalName strips any 'provider/' prefix first, so only the post-strip +// forms need to be listed here. +const BUILTIN_ALIASES: Record = { + 'anthropic--claude-4.6-opus': 'claude-opus-4-6', + 'anthropic--claude-4.6-sonnet': 'claude-sonnet-4-6', + 'anthropic--claude-4.5-opus': 'claude-opus-4-5', + 'anthropic--claude-4.5-sonnet': 'claude-sonnet-4-5', + 'anthropic--claude-4.5-haiku': 'claude-haiku-4-5', + 'claude-sonnet-4.6': 'claude-sonnet-4-6', + 'claude-sonnet-4.5': 'claude-sonnet-4-5', + 'claude-opus-4.7': 'claude-opus-4-7', + 'claude-opus-4.6': 'claude-opus-4-6', + 'claude-opus-4.5': 'claude-opus-4-5', + 'cursor-auto': 'claude-sonnet-4-5', + 'cursor-agent-auto': 'claude-sonnet-4-5', + 'copilot-auto': 'claude-sonnet-4-5', + 'copilot-openai-auto': 'gpt-5.3-codex', + 'copilot-anthropic-auto': 'claude-sonnet-4-5', + 'openai-codex:gpt-5.5': 'gpt-5.5', + 'ibm-bob-auto': 'claude-sonnet-4-5', + 'kiro-auto': 'claude-sonnet-4-5', + 'cline-auto': 'claude-sonnet-4-5', + 'openclaw-auto': 'claude-sonnet-4-5', + 'warp-auto-efficient': 'gpt-5.3-codex', + 'warp-auto-powerful': 'claude-opus-4-6', + 'grok-build': 'grok-build-0.1', + 'GPT-5.3 Codex (low reasoning)': 'gpt-5.3-codex', + 'GPT-5.3 Codex (medium reasoning)': 'gpt-5.3-codex', + 'GPT-5.3 Codex (high reasoning)': 'gpt-5.3-codex', + 'GPT-5.3 Codex (extra high reasoning)': 'gpt-5.3-codex', + 'Claude Sonnet 4.6': 'claude-sonnet-4-6', + 'Claude Sonnet 4.5': 'claude-sonnet-4-5', + 'Claude Haiku 4.5': 'claude-haiku-4-5', + 'Claude Opus 4.6': 'claude-opus-4-6', + 'claude-4-6-sonnet-high': 'claude-sonnet-4-6', + 'claude-4-6-sonnet-low': 'claude-sonnet-4-6', + 'claude-4-6-sonnet-medium': 'claude-sonnet-4-6', + 'claude-4-6-sonnet-high-fast': 'claude-sonnet-4-6', + 'claude-4-7-opus-xhigh': 'claude-opus-4-7', + 'claude-4-7-opus-xhigh-fast': 'claude-opus-4-7', + 'qwen-auto': 'claude-sonnet-4-5', + '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', + // 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 + // the dashboard for users on non-Auto models (issue #159). Sources: the + // display map at `src/providers/cursor.ts:modelDisplayNames`, Cursor's + // public model docs at https://cursor.com/docs/models, and forum bug + // reports that quote literal slugs (e.g. forum.cursor.com/t/154933). + 'claude-4-sonnet': 'claude-sonnet-4', + 'claude-4-sonnet-1m': 'claude-sonnet-4', + 'claude-4-sonnet-thinking': 'claude-sonnet-4-5', + 'claude-4.5-sonnet': 'claude-sonnet-4-5', + 'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5', + 'claude-4.6-sonnet': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-high': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-low': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-thinking': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-high-thinking':'claude-sonnet-4-6', + 'claude-4-opus': 'claude-opus-4', + 'claude-4.5-opus': 'claude-opus-4-5', + 'claude-4.5-opus-high': 'claude-opus-4-5', + 'claude-4.5-opus-low': 'claude-opus-4-5', + 'claude-4.5-opus-medium': 'claude-opus-4-5', + 'claude-4.5-opus-high-thinking': 'claude-opus-4-5', + 'claude-4.6-opus': 'claude-opus-4-6', + 'claude-4.6-opus-fast-mode': 'claude-opus-4-6', + 'claude-4.6-opus-high': 'claude-opus-4-6', + 'claude-4.6-opus-low': 'claude-opus-4-6', + 'claude-4.6-opus-medium': 'claude-opus-4-6', + 'claude-4.6-opus-high-thinking': 'claude-opus-4-6', + 'claude-4.7-opus': 'claude-opus-4-7', + // Dash form (NOT dot) seen in forum.cursor.com/t/158597. + 'claude-opus-4-7-thinking-high': 'claude-opus-4-7', + 'claude-4.5-haiku': 'claude-haiku-4-5', + 'claude-4.6-haiku': 'claude-haiku-4-5', + // Cursor house composer models use Cursor-published rates in + // BUILTIN_PRICE_OVERRIDES; keep them out of this alias map so they do not + // inherit Claude Sonnet proxy pricing. + // Cursor's "fast" routing variant of GPT-5 is the same model behind a + // lower-latency endpoint; price as base GPT-5 until LiteLLM tracks it. + 'gpt-5-fast': 'gpt-5', + 'gpt-4.1': 'gpt-4.1', + 'gpt-5.2-low': 'gpt-5', + 'gpt-5.1-codex-high': 'gpt-5.3-codex', + // Antigravity Gemini model IDs resolve to preview-priced entries. + 'gemini-3.1-pro': 'gemini-3.1-pro-preview', + 'gemini-3-flash': 'gemini-3-flash-preview', + 'gemini-3.1-pro-high': 'gemini-3.1-pro-preview', + 'gemini-3.1-pro-low': 'gemini-3.1-pro-preview', + 'gemini-3-flash-agent': 'gemini-3-flash-preview', + 'gemini-3.5-flash-high': 'gemini-3.5-flash', + 'gemini-3.5-flash-medium': 'gemini-3.5-flash', + 'gemini-3.5-flash-low': 'gemini-3.5-flash', + 'Gemini 3.5 Flash (High)': 'gemini-3.5-flash', + 'Gemini 3.5 Flash (Medium)': 'gemini-3.5-flash', + 'Gemini 3.5 Flash (Low)': 'gemini-3.5-flash', + 'gemini-3-pro': 'gemini-3-pro-preview', + 'gemini-3.1-flash-image': 'gemini-3.1-flash-image-preview', + 'gemini-3.1-flash-lite': 'gemini-3.1-flash-lite-preview', + // ZCode runs GLM-5.2 through z.ai's start-plan subscription; it isn't in + // LiteLLM yet. Price as the nearest released sibling (GLM-5.1) until it is. + 'GLM-5.2': 'glm-5p1', + // Hermes Agent stores the same model id lowercased (`glm-5.2`) in its + // sessions table, so it misses the capitalized alias above and goes + // unpriced. Map the lowercase spelling to the same sibling. + 'glm-5.2': 'glm-5p1', +} + +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. +export function setModelAliases(aliases: Record): void { + userAliases = aliases +} + +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 + next.set(model, buildCosts( + input, + output, + priceOverrideRatePerToken(rates.cacheCreation), + priceOverrideRatePerToken(rates.cacheRead), + undefined, + )) + } + 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 +} + +// Local-model savings config. Kept separate from userAliases: a `modelAliases` +// entry rewrites a model's identity for actual cost; a `localModelSavings` +// entry keeps the model cost at $0 and reports the *avoided* spend against a +// paid baseline. Set during preAction from `config.localModelSavings`. +let userLocalModelSavings: Record = {} + +export function setLocalModelSavings(mappings: Record): void { + userLocalModelSavings = { ...mappings } +} + +export function getLocalSavingsBaseline(rawModel: string): string | undefined { + if (!rawModel || typeof rawModel !== 'string') return undefined + // Defensive: bracket-accessing user-controlled keys on a plain object + // exposes the prototype chain (`__proto__` would resolve to Object.prototype). + // Use Object.hasOwn so a hostile JSONL model name cannot piggyback into + // Object.prototype either through the alias map or here. + if (!Object.hasOwn(userLocalModelSavings, rawModel)) return undefined + return userLocalModelSavings[rawModel] +} + +/// Compute the hypothetical baseline cost for a local call. The baseline +/// model is priced through the normal `calculateCost` pipeline (so it can +/// be aliased / canonicalized). Returns `null` when the source model has +/// no savings mapping, the baseline is unknown to the pricing snapshot, or +/// any input is unusable — callers should treat null as "no savings +/// recorded for this call" rather than a hard error. +export function calculateLocalModelSavings( + rawModel: string, + inputTokens: number, + outputTokens: number, + cacheCreationTokens: number, + cacheReadTokens: number, + webSearchRequests: number, + speed: 'standard' | 'fast' = 'standard', + oneHourCacheCreationTokens = 0, +): { savingsUSD: number; baselineModel: string } | null { + const baseline = getLocalSavingsBaseline(rawModel) + if (!baseline) return null + if (!getModelCosts(baseline)) return null + const savingsUSD = calculateCost( + baseline, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + webSearchRequests, + speed, + oneHourCacheCreationTokens, + ) + return { savingsUSD, baselineModel: baseline } +} + +/// Stable hash of the current savings config so the daily cache can detect +/// "user changed their baseline mapping" and rebuild instead of presenting +/// stale saved-spend numbers. Two configs with the same key→baseline pairs +/// in any order collapse to the same hash. +export function getLocalModelSavingsConfigHash(): string { + const keys = Object.keys(userLocalModelSavings).sort() + if (keys.length === 0) return '' + const parts = keys.map(k => `${k}\u0001${userLocalModelSavings[k]}`) + return parts.join('\u0002') +} + +export function getPriceOverridesConfigHash(): string { + // The builtin overrides participate so editing BUILTIN_PRICE_OVERRIDES in a + // release invalidates cached daily costs the same way a user override does. + const builtin = `builtin:${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('\u0001') + }) + return [builtin, ...parts].join('\u0002') +} + +// Absolute directory prefixes whose sessions are routed through a +// subscription-backed proxy (config `proxyPaths`). Stored already-normalized so +// the per-project match is a cheap compare. Set during preAction. See +// CodeburnConfig.proxyPaths for the product rationale. +let userProxyPaths: string[] = [] + +/// Normalize a path for prefix comparison: backslashes -> forward slashes +/// (Windows configs / cwds), strip leading AND trailing slashes, fold case on +/// case-insensitive filesystems. Leading slashes are stripped because provider +/// project paths arrive in two forms — Claude keeps the absolute "/Users/x" +/// while Codex (sanitizeProject) and the unsanitizePath fallback drop the +/// leading slash to "Users/x". Folding both to a slashless form (mirroring +/// crossProviderKey) makes matching agnostic to which provider produced the +/// path, so the same directory is flagged whether or not a Claude session +/// happens to co-exist there. Case is folded only on macOS/Windows; on Linux +/// "/home/Me" and "/home/me" are different dirs, so folding would risk +/// crediting unrelated spend. A path that normalizes to empty (e.g. "/" or "") +/// is dropped by callers so it can never match everything. Exported so the CLI +/// dedupes with the same rule. +export function normalizeProxyPath(p: string): string { + const s = p.trim().replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '') + return (process.platform === 'darwin' || process.platform === 'win32') ? s.toLowerCase() : s +} + +export function setProxyPaths(paths: string[]): void { + userProxyPaths = (Array.isArray(paths) ? paths : []) + .filter((p): p is string => typeof p === 'string') + .map(normalizeProxyPath) + .filter(p => p !== '') +} + +/// True when `cwd` is at or under a configured proxy path. Prefix match is +/// anchored to a path-segment boundary so "/a/proj" matches "/a/proj" and +/// "/a/proj/sub" but NOT "/a/project-x". Empty/undefined cwd or empty config +/// never matches (so a misconfig can't silently zero unrelated spend). +export function isProxiedPath(cwd: string | undefined | null): boolean { + if (!cwd || typeof cwd !== 'string') return false + if (userProxyPaths.length === 0) return false + const c = normalizeProxyPath(cwd) + if (c === '') return false + return userProxyPaths.some(p => c === p || c.startsWith(p + '/')) +} + +/// Stable hash of the active proxy-path config. Project-level proxy attribution +/// is computed live from this set and then cached in the in-memory session +/// cache, so the cache key must vary with it — otherwise a long-lived process +/// (menubar) that re-reads config could serve attribution from a stale set. +export function getProxyPathsConfigHash(): string { + if (userProxyPaths.length === 0) return '' + return [...userProxyPaths].sort().join('') +} + +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 { + return model + .replace(/@.*$/, '') // strip pin: claude-sonnet-4-6@20250929 -> claude-sonnet-4-6 + .replace(/-\d{8}$/, '') // strip date: claude-sonnet-4-20250514 -> claude-sonnet-4 + .replace(/^[^/]+\//, '') // strip provider prefix: anthropic/foo -> foo +} + +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) + + const override = getPriceOverrideExact(model, withPrefix, canonicalName, canonical) + if (override) return override + + // An explicit alias for a bare (un-prefixed) model name is authoritative: it + // must win over a coincidental stripped reseller key of the same name. LiteLLM + // ships `snowflake/claude-4-opus` ($5), which the bundler strips to a bare + // `claude-4-opus` key; without this, that would shadow the curated alias + // `claude-4-opus -> claude-opus-4` ($15 official Anthropic price). + if (canonical !== canonicalName && withPrefix === canonicalName && pricingCache.has(canonical)) { + return pricingCache.get(canonical)! + } + + if (pricingCache.has(withPrefix)) return pricingCache.get(withPrefix)! + + if (pricingCache.has(canonical)) return pricingCache.get(canonical)! + + 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. + for (const key of getSortedPricingKeys()) { + if (canonical.startsWith(key + '-') || canonical === key) { + return pricingCache.get(key)! + } + } + + const caseInsensitiveOverride = getPriceOverrideCaseInsensitive(canonical, withPrefix) + if (caseInsensitiveOverride) return caseInsensitiveOverride + + // Case-insensitive fallback: gap-filled keys from OpenRouter are lowercase + // slugs (e.g. `minimax-m3`), but sessions report `MiniMax-M3`. Only consulted + // after the exact/canonical/prefix attempts, so it never changes a match that + // already resolved above. + const lowerIndex = getLowercasePricingIndex() + const byCanonical = lowerIndex.get(canonical.toLowerCase()) + if (byCanonical) return byCanonical + const byPrefix = lowerIndex.get(withPrefix.toLowerCase()) + if (byPrefix) return byPrefix + + 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 +} + +// Warn at most once per unknown model name per process. Without this, a model +// missing from the pricing snapshot would silently price at $0 for every +// session that used it, hiding real spend until the user noticed. +const warnedUnknownModels = new Set() + +/// Heuristic for "this looks like a local model that will never be in LiteLLM's +/// pricing JSON". We suppress the unknown-model warning for these because the +/// "update codeburn" advice can't help — local Ollama models, llama.cpp tags, +/// LM Studio loads, etc. are billed locally and don't have public pricing. +/// Users still get $0 in cost reports for them (correct — local inference is +/// effectively free); the warning was just noise. +function looksLikeLocalModel(name: string): boolean { + // Ollama and LM Studio tags include `:tag` (e.g. qwen3.6:35b-a3b-bf16). + if (name.includes(':') && !name.startsWith('http')) return true + // GGUF / quantized fingerprints commonly seen in local inference. + if (/[-_](q[2-8](_[a-z0-9]+)?|bf16|fp16|gguf|f16|f32)$/i.test(name)) return true + return false +} + +export interface UnpricedModelUsage { + model: string + calls: number + tokens: number +} + +function hasBillableRate(costs: ModelCosts): boolean { + return costs.inputCostPerToken > 0 + || costs.outputCostPerToken > 0 + || costs.cacheWriteCostPerToken > 0 + || costs.cacheReadCostPerToken > 0 +} + +// Exact-override lookup with the same key derivation getModelCosts uses. Lets +// the unpriced detector distinguish "explicitly declared free by the user" (a +// zero-rate override) from a zero-rate LiteLLM stub, which means "listed but +// unknown price" and must still be flagged. Only the EXACT override form is +// consulted: getModelCosts checks it before any table hit, so when one exists +// it is provably what priced the model. Prefix and case-insensitive overrides +// resolve AFTER table hits and so cannot prove the $0 was intentional; a +// zero-rate stub shadowed by one still gets flagged (the honest direction). +function exactPriceOverrideFor(model: string): ModelCosts | null { + const withPrefix = model.replace(/@.*$/, '').replace(/-\d{8}$/, '') + const canonicalName = getCanonicalName(model) + const canonical = resolveAlias(canonicalName) + return getPriceOverrideExact(model, withPrefix, canonicalName, canonical) +} + +// Render-time unpriced detection (#638): flag aggregated model rows that carry +// usage but $0 cost AND whose pricing lookup yields no billable rate right +// now. Cost is computed at parse time and cached, so a parse-time registry +// would miss cached sessions; a render-time check covers both and heals the +// moment pricing data, an alias, or a price override arrives. +// +// Rows with cost > 0 are never flagged: aggregation keys rows by DISPLAY name +// (parser.ts keys modelBreakdown via getShortModelName), which the pricing +// lookup misses, so a priced model like "Opus 4.8" would otherwise false-flag. +// $0 display-name rows ARE flagged even when the raw id would price today: +// those tokens really did enter the report at $0 (a provider priced a +// transformed name, or the session was cached before its model's pricing +// landed). Conservative by design: a display key merging priced and unpriced +// raw ids carries cost > 0 and is not flagged. Local-looking models and +// models with a local-savings mapping are excluded because $0 is their +// correct cost, as are zero-rate USER overrides (explicitly declared free). +export function findUnpricedModels( + rows: Iterable<{ model: string; calls: number; cost: number; tokens?: number }>, +): UnpricedModelUsage[] { + const out: UnpricedModelUsage[] = [] + for (const row of rows) { + const { model } = row + const tokens = row.tokens ?? 0 + if (!model || model === '') continue + if (row.calls <= 0 && tokens <= 0) continue + if (row.cost > 0) continue + if (looksLikeLocalModel(model)) continue + if (getLocalSavingsBaseline(model)) continue + const costs = getModelCosts(model) + if (costs && hasBillableRate(costs)) continue + if (costs && exactPriceOverrideFor(model)) continue + out.push({ model, calls: row.calls, tokens }) + } + return out.sort((a, b) => (b.tokens - a.tokens) || (b.calls - a.calls) + || (a.model < b.model ? -1 : a.model > b.model ? 1 : 0)) +} + +function shouldWarnAboutUnknownModel(name: string): boolean { + if (!name || name === '') return false + if (warnedUnknownModels.has(name)) return false + // Suppress for local/quantized models — the "update codeburn" hint is + // actively misleading there. Users who need cost visibility for local + // inference can still set an alias via `codeburn model-alias`. + if (looksLikeLocalModel(name)) return false + // The warning fired on every CLI invocation (including the default + // dashboard) which made first launches look broken — three "no pricing + // data" lines greet a user before the dashboard even draws. Now opt-in + // via --verbose. The unknown model still costs $0 in reports; users who + // suspect missing models run `codeburn --verbose` to see the list. + if (process.env['CODEBURN_VERBOSE'] !== '1') return false + return true +} + +export function calculateCost( + model: string, + inputTokens: number, + outputTokens: number, + cacheCreationTokens: number, + cacheReadTokens: number, + webSearchRequests: number, + speed: 'standard' | 'fast' = 'standard', + oneHourCacheCreationTokens = 0, +): number { + const costs = getModelCosts(model) + if (!costs) { + if (shouldWarnAboutUnknownModel(model)) { + warnedUnknownModels.add(model) + // Strip control characters and cap length: model names come from JSONL + // payloads written by external tools, so a hostile or corrupt file + // could embed terminal escape sequences here. + const safeName = model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200) + const aliasHint = `Map it with: codeburn model-alias "${safeName}" , or track local-model savings with: codeburn model-savings "${safeName}" ` + process.stderr.write( + `codeburn: no pricing data for model "${safeName}" — costs for this model will show $0. ` + + `${aliasHint}, or update with: npx codeburn@latest.\n` + ) + } + return 0 + } + + const multiplier = speed === 'fast' ? costs.fastMultiplier : 1 + + // Clamp negative inputs to 0. A corrupt JSONL that emits a negative token + // count would otherwise produce a negative cost that silently subtracts + // from real spend in aggregate totals. NaN is also handled here; the + // arithmetic below short-circuits to 0 when any operand is non-finite. + const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0) + const safeOneHourCacheCreation = safe(oneHourCacheCreationTokens) + const safeCacheCreation = Math.max(safe(cacheCreationTokens), safeOneHourCacheCreation) + const safeFiveMinuteCacheCreation = Math.max(0, safeCacheCreation - safeOneHourCacheCreation) + + return multiplier * ( + safe(inputTokens) * costs.inputCostPerToken + + safe(outputTokens) * costs.outputCostPerToken + + safeFiveMinuteCacheCreation * costs.cacheWriteCostPerToken + + safeOneHourCacheCreation * costs.cacheWriteCostPerToken * ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE + + safe(cacheReadTokens) * costs.cacheReadCostPerToken + + safe(webSearchRequests) * costs.webSearchCostPerRequest + ) +} + +const autoModelNames: Record = { + 'cursor-auto': 'Cursor (auto)', + 'cursor-agent-auto': 'Cursor (auto)', + 'copilot-auto': 'Copilot (auto)', + 'copilot-openai-auto': 'Copilot (OpenAI)', + 'copilot-anthropic-auto': 'Copilot (Anthropic)', + 'ibm-bob-auto': 'IBM Bob (auto)', + 'kiro-auto': 'Kiro (auto)', + 'cline-auto': 'Cline (auto)', + 'openclaw-auto': 'OpenClaw (auto)', + 'qwen-auto': 'Qwen (auto)', + 'kimi-auto': 'Kimi (auto)', +} + +const SHORT_NAMES: Record = { + // claude-fable-5 and claude-mythos-5 are outside the opus/sonnet/haiku families deriveClaudeShortName covers. + 'claude-fable-5': 'Fable 5', + 'claude-mythos-5': 'Mythos 5', + // Modern claude--- ids are derived in deriveClaudeShortName. + // Only the legacy 3.x ids (family-last) need explicit mapping. + 'claude-3-7-sonnet': 'Sonnet 3.7', + 'claude-3-5-sonnet': 'Sonnet 3.5', + 'claude-3-5-haiku': 'Haiku 3.5', + 'gpt-4o-mini': 'GPT-4o Mini', + 'gpt-4o': 'GPT-4o', + 'gpt-4.1-nano': 'GPT-4.1 Nano', + 'gpt-4.1-mini': 'GPT-4.1 Mini', + 'gpt-4.1': 'GPT-4.1', + 'codex-auto-review': 'Codex Auto Review', + 'gpt-5.5-pro': 'GPT-5.5 Pro', + 'gpt-5.5': 'GPT-5.5', + 'gpt-5.4-pro': 'GPT-5.4 Pro', + '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', + 'gpt-5.2-low': 'GPT-5.2 Low', + 'gpt-5.2': 'GPT-5.2', + 'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini', + 'gpt-5.1-codex': 'GPT-5.1 Codex', + 'gpt-5.1': 'GPT-5.1', + 'gpt-5-pro': 'GPT-5 Pro', + 'gpt-5-nano': 'GPT-5 Nano', + 'gpt-5-mini': 'GPT-5 Mini', + 'gpt-5': 'GPT-5', + 'gemini-3.5-flash': 'Gemini 3.5 Flash', + 'gemini-3.1-pro-preview': 'Gemini 3.1 Pro', + 'gemini-3-flash-preview': 'Gemini 3 Flash', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo', + 'kimi-k2-thinking': 'Kimi K2 Thinking', + 'kimi-thinking-preview': 'Kimi Thinking', + 'kimi-k2.6': 'Kimi K2.6', + 'kimi-k2.5': 'Kimi K2.5', + 'kimi-k2p5': 'Kimi K2.5', + 'kimi-k2-instruct': 'Kimi K2 Instruct', + 'kimi-k2-0905': 'Kimi K2', + 'kimi-k2': 'Kimi K2', + 'kimi-latest': 'Kimi Latest', + 'moonshot-v1': 'Moonshot v1', + 'deepseek-v4-pro': 'DeepSeek v4 Pro', + 'deepseek-v4-flash': 'DeepSeek v4 Flash', + 'deepseek-coder-max': 'DeepSeek Coder Max', + 'deepseek-coder': 'DeepSeek Coder', + 'deepseek-r1': 'DeepSeek R1', + 'o4-mini': 'o4-mini', + 'o3': 'o3', + 'MiniMax-M2.7-highspeed': 'MiniMax M2.7 Highspeed', + 'MiniMax-M2.7': 'MiniMax M2.7', + // Grok (xAI) and GLM ids that otherwise surface raw or as a pricing key in + // reports. grok-build and GLM-5.2 price via sibling aliases, so + // getShortModelName resolves to the pricing key before this lookup; map each + // back to the real model name. grok-composer has no alias, it just lacked an + // entry. + 'glm-5p1': 'GLM-5.2', // ZCode/Hermes run GLM-5.2 (priced as the GLM-5.1 sibling) + 'grok-build-0.1': 'Grok Build', // Grok Build prices through the 0.1 sibling + 'grok-composer-2.5-fast': 'Grok Composer 2.5 Fast', +} + +// Sorted longest-first so more-specific prefixes match before shorter ones. +// Without this, `gpt-5-mini` could resolve to "GPT-5" (the entry for `gpt-5`) +// if it happened to be iterated before `gpt-5-mini`, hiding a distinct model +// behind the wrong display name and pricing tier. +const SORTED_SHORT_NAMES: [string, string][] = Object.entries(SHORT_NAMES) + .sort((a, b) => b[0].length - a[0].length) + +// Anthropic's id scheme is `claude--[-]`, so every new +// version is derivable — no hand-maintained entry per release. (Legacy 3.x ids +// put the family last, e.g. `claude-3-5-sonnet`, and stay in SHORT_NAMES.) +const CLAUDE_FAMILY: Record = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' } +function deriveClaudeShortName(canonical: string): string | undefined { + const m = canonical.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d+))?/) + if (!m) return undefined + const [, family, major, minor] = m + return `${CLAUDE_FAMILY[family]} ${major}${minor ? `.${minor}` : ''}` +} + +export function getShortModelName(model: string): string { + if (autoModelNames[model]) return autoModelNames[model] + const canonical = resolveAlias(getCanonicalName(model)) + const claude = deriveClaudeShortName(canonical) + if (claude) return claude + for (const [key, name] of SORTED_SHORT_NAMES) { + // Match on a version boundary, not a bare prefix: an unlisted future minor + // (e.g. gpt-5.6) must NOT collapse into the base "gpt-5" entry — it should + // fall through to its raw id rather than show a wrong name/tier. + if (canonical === key || canonical.startsWith(key + '-')) return name + } + return canonical +} diff --git a/src/optimize.ts b/src/optimize.ts new file mode 100644 index 0000000..8c59e0e --- /dev/null +++ b/src/optimize.ts @@ -0,0 +1,2654 @@ +import chalk from 'chalk' +import { readdir, stat } from 'fs/promises' +import { existsSync, statSync } from 'fs' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionLines, readSessionFileSync } from './fs-utils.js' +import { discoverAllSessions } from './providers/index.js' +import { parseJsonlLine, shouldSkipLine } from './parser.js' +import type { DateRange, ProjectSummary } from './types.js' +import { formatCost } from './currency.js' +import { formatTokens } from './format.js' + +// ============================================================================ +// Display constants +// ============================================================================ + +const ORANGE = '#FF8C42' +const DIM = '#666666' +const GOLD = '#FFD700' +const CYAN = '#5BF5E0' +const GREEN = '#5BF5A0' +const RED = '#F55B5B' + +// ============================================================================ +// Token estimation constants +// ============================================================================ + +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 + +// ============================================================================ +// Detector thresholds +// ============================================================================ + +const CLAUDEMD_HEALTHY_LINES = 200 +const CLAUDEMD_HIGH_THRESHOLD_LINES = 400 +const MIN_JUNK_READS_TO_FLAG = 3 +const JUNK_READS_HIGH_THRESHOLD = 20 +const JUNK_READS_MEDIUM_THRESHOLD = 5 +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 +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 +const CACHE_EXCESS_HIGH_THRESHOLD = 15000 +const UNUSED_MCP_HIGH_THRESHOLD = 3 +// MCP tool coverage detector thresholds. A server only earns a finding when +// every condition holds: the inventory is large enough to matter, real-world +// usage is poor, and we observed it in enough sessions to trust the signal. +const MCP_COVERAGE_MIN_TOOLS = 10 +const MCP_COVERAGE_MIN_SESSIONS = 2 +const MCP_COVERAGE_LOW_THRESHOLD = 0.20 +const MCP_COVERAGE_HIGH_IMPACT_TOKENS = 200_000 +const MCP_PROFILE_MIN_PROJECTS = 3 +const MCP_PROFILE_MIN_HOT_INVOCATIONS = 2 +const MCP_PROFILE_HOT_INVOCATION_SHARE = 0.80 +const MCP_PROFILE_MIN_COLD_LOADED_SESSIONS = 2 +const MCP_PROFILE_HIGH_IMPACT_TOKENS = 200_000 +const MCP_PROFILE_PREVIEW = 3 +// Anthropic prices cache writes at 125% of base input and cache reads at +// roughly 10% of base input. We use these to keep overhead estimates honest: +// most MCP schema bytes live in the cached prefix and only get charged at +// the discount rate after the first turn of a session. +const CACHE_WRITE_MULTIPLIER = 1.25 +const CACHE_READ_DISCOUNT = 0.10 +const GHOST_AGENTS_HIGH_THRESHOLD = 5 +const GHOST_AGENTS_MEDIUM_THRESHOLD = 2 +const GHOST_SKILLS_HIGH_THRESHOLD = 10 +const GHOST_SKILLS_MEDIUM_THRESHOLD = 5 +const GHOST_COMMANDS_MEDIUM_THRESHOLD = 10 +const MCP_NEW_CONFIG_GRACE_MS = 24 * 60 * 60 * 1000 +const BASH_DEFAULT_LIMIT = 30000 +const BASH_RECOMMENDED_LIMIT = 15000 +const MIN_SESSIONS_FOR_OUTLIER = 3 +const SESSION_OUTLIER_MULTIPLIER = 2 +const MIN_SESSION_OUTLIER_COST_USD = 1 +const SESSION_OUTLIER_PREVIEW = 5 +const CONTEXT_BLOAT_MIN_INPUT_TOKENS = 75_000 +const CONTEXT_BLOAT_MIN_RATIO = 25 +const CONTEXT_BLOAT_TARGET_RATIO = 15 +const CONTEXT_BLOAT_PREVIEW = 5 +const CONTEXT_BLOAT_LOW_INPUT_TOKENS = 200_000 +const CONTEXT_BLOAT_HIGH_INPUT_TOKENS = 500_000 +const CONTEXT_BLOAT_LOW_MAX_CANDIDATES = 2 +const CONTEXT_BLOAT_HIGH_MIN_CANDIDATES = 10 +const CONTEXT_BLOAT_GROWTH_RATIO = 2 +const CONTEXT_BLOAT_GROWTH_MAX_GAP_MS = 7 * 24 * 60 * 60 * 1000 +const CONTEXT_BLOAT_RATIO_DISPLAY_CAP = 1000 +const WORTH_IT_MIN_COST_USD = 2 +const WORTH_IT_NO_EDIT_MIN_COST_USD = 3 +const WORTH_IT_MIN_RETRIES = 3 +const WORTH_IT_RETRY_WITH_EDIT_MIN_RETRIES = 2 +const WORTH_IT_PREVIEW = 5 +const WORTH_IT_LOW_MAX_CANDIDATES = 2 +const WORTH_IT_LOW_MAX_TOTAL_COST_USD = 10 +const WORTH_IT_HIGH_MIN_CANDIDATES = 10 +const WORTH_IT_HIGH_TOTAL_COST_USD = 50 +const CAPABILITY_RELIABILITY_MIN_EDIT_TURNS = 5 +const CAPABILITY_RELIABILITY_MIN_RETRY_TURNS = 3 +const CAPABILITY_RELIABILITY_MIN_RETRY_RATE = 0.50 +const CAPABILITY_RELIABILITY_RECOVERY_FRACTION = 0.50 +const CAPABILITY_RELIABILITY_PREVIEW = 5 +const CAPABILITY_RELIABILITY_LOW_MAX_CANDIDATES = 1 +const CAPABILITY_RELIABILITY_LOW_MAX_TOKENS = 50_000 +const CAPABILITY_RELIABILITY_HIGH_MIN_CANDIDATES = 5 +const CAPABILITY_RELIABILITY_HIGH_IMPACT_TOKENS = 200_000 + +// ============================================================================ +// Scoring constants +// ============================================================================ + +const HEALTH_WEIGHT_HIGH = 15 +const HEALTH_WEIGHT_MEDIUM = 7 +const HEALTH_WEIGHT_LOW = 3 +const HEALTH_MAX_PENALTY = 80 +const GRADE_A_MIN = 90 +const GRADE_B_MIN = 75 +const GRADE_C_MIN = 55 +const GRADE_D_MIN = 30 +// Rebalanced so a high-impact finding with zero observed tokens (e.g. +// detectGhostAgents firing on five files but tokensSaved=400) cannot +// outrank a medium-impact finding with many millions of tokens. +// Old: 0.7/0.3 → high+0 = 0.70, medium+1B = 0.65 (high+0 won). +// New: 0.5/0.5 → high+0 = 0.50, medium+1B = 0.75 (medium+1B wins). +// Token normalize lifted to 5M so the rank scales over a realistic range. +const URGENCY_IMPACT_WEIGHT = 0.5 +const URGENCY_TOKEN_WEIGHT = 0.5 +const URGENCY_TOKEN_NORMALIZE = 5_000_000 + +// ============================================================================ +// File system constants +// ============================================================================ + +const MAX_IMPORT_DEPTH = 5 +const IMPORT_PATTERN = /^@(\.\.?\/[^\s]+|\/[^\s]+)/gm +const COMMAND_PATTERN = /([^<]+)<\/command-name>|(?:^|\s)\/([a-zA-Z][\w-]*)/gm + +const JUNK_DIRS = [ + 'node_modules', '.git', 'dist', 'build', '__pycache__', '.next', + '.nuxt', '.output', 'coverage', '.cache', '.tsbuildinfo', + '.venv', 'venv', '.svn', '.hg', +] +const JUNK_PATTERN = new RegExp(`/(?:${JUNK_DIRS.join('|')})/`) + +const SHELL_PROFILES = ['.zshrc', '.bashrc', '.bash_profile', '.profile'] + +const TOP_ITEMS_PREVIEW = 3 +const GHOST_NAMES_PREVIEW = 5 +const GHOST_CLEANUP_COMMANDS_LIMIT = 10 +const OPTIMIZE_TEXT_CAP = 2000 +const OPTIMIZE_FIELD_CAP = 500 + +// ============================================================================ +// Types +// ============================================================================ + +export type Impact = 'high' | 'medium' | 'low' +export type HealthGrade = 'A' | 'B' | 'C' | 'D' | 'F' + +/// Where a paste-style suggestion belongs. Without this, users couldn't tell +/// whether a prompt should go into CLAUDE.md (permanent rule), be pasted at +/// the start of a future session (one-time constraint), be asked of Claude +/// in the current chat (one-time prompt), or be added to a shell config file. +/// Issue #277 — users were dropping one-time session openers into CLAUDE.md +/// permanently because the destination wasn't clearly stated. +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; 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 = { + findings: WasteFinding[] + costRate: number + healthScore: number + 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 + sessionId: string + project: string + recent?: boolean +} + +export type ApiCallMeta = { + cacheCreationTokens: number + version: string + recent?: boolean +} + +type ScanData = { + toolCalls: ToolCall[] + projectCwds: Set + apiCalls: ApiCallMeta[] + userMessages: string[] +} + +// ============================================================================ +// JSONL scanner +// ============================================================================ + +function cappedString(value: unknown, cap = OPTIMIZE_FIELD_CAP): string | undefined { + return typeof value === 'string' ? value.slice(0, cap) : undefined +} + +function compactOptimizeInput(name: string, input: unknown): Record { + if (!input || typeof input !== 'object') return {} + const raw = input as Record + if (isReadTool(name)) { + const filePath = cappedString(raw['file_path'], OPTIMIZE_TEXT_CAP) + return filePath ? { file_path: filePath } : {} + } + if (name === 'Agent' || name === 'Task') { + const subagentType = cappedString(raw['subagent_type']) + return subagentType ? { subagent_type: subagentType } : {} + } + if (name === 'Skill') { + const skill = cappedString(raw['skill']) + const skillName = cappedString(raw['name']) + return { + ...(skill ? { skill } : {}), + ...(skillName ? { name: skillName } : {}), + } + } + return {} +} + +const FILE_READ_CONCURRENCY = 4 +const RESULT_CACHE_TTL_MS = 60_000 +const RECENT_WINDOW_HOURS = 48 +const RECENT_WINDOW_MS = RECENT_WINDOW_HOURS * 60 * 60 * 1000 +const DEFAULT_TREND_PERIOD_DAYS = 30 +const DEFAULT_TREND_PERIOD_MS = DEFAULT_TREND_PERIOD_DAYS * 24 * 60 * 60 * 1000 +const IMPROVING_THRESHOLD = 0.5 + +async function collectJsonlFiles(dirPath: string): Promise { + const files = await readdir(dirPath).catch(() => []) + const result = files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f)) + for (const entry of files) { + if (entry.endsWith('.jsonl')) continue + const subPath = join(dirPath, entry, 'subagents') + const subFiles = await readdir(subPath).catch(() => []) + for (const sf of subFiles) { + if (sf.endsWith('.jsonl')) result.push(join(subPath, sf)) + } + } + return result +} + +async function isFileStaleForRange(filePath: string, range: DateRange | undefined): Promise { + if (!range) return false + try { + const s = await stat(filePath) + return s.mtimeMs < range.start.getTime() + } catch { return false } +} + +async function runWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + let idx = 0 + async function next(): Promise { + while (idx < items.length) { + const current = idx++ + await worker(items[current]) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => next())) +} + +type ScanFileResult = { + calls: ToolCall[] + cwds: string[] + apiCalls: ApiCallMeta[] + userMessages: string[] +} + +function inRange(timestamp: string | undefined, range: DateRange | undefined): boolean { + if (!range) return true + if (!timestamp) return false + const ts = new Date(timestamp) + return ts >= range.start && ts <= range.end +} + +function isRecent(timestamp: string | undefined, cutoff: number): boolean { + if (!timestamp) return false + return new Date(timestamp).getTime() >= cutoff +} + +export async function scanJsonlFile( + filePath: string, + project: string, + dateRange: DateRange | undefined, + recentCutoffMs = Date.now() - RECENT_WINDOW_MS, +): Promise { + const calls: ToolCall[] = [] + const cwds: string[] = [] + const apiCalls: ApiCallMeta[] = [] + const userMessages: string[] = [] + const sessionId = basename(filePath, '.jsonl') + let lastVersion = '' + + const skipThreshold = dateRange + ? new Date(dateRange.start.getTime() - 86_400_000).toISOString() + : null + const skipFn = dateRange + ? (head: string) => shouldSkipLine(head, skipThreshold!) + : undefined + const lines = readSessionLines(filePath, skipFn, { largeLineAsBuffer: true }) + for await (const line of lines) { + if (typeof line === 'string' && !line.trim()) continue + if (Buffer.isBuffer(line) && line.length === 0) continue + const parsed = parseJsonlLine(line) + if (!parsed) continue + const entry = parsed as Record + + if (entry.version && typeof entry.version === 'string') lastVersion = entry.version + + const ts = typeof entry.timestamp === 'string' ? entry.timestamp : undefined + const withinRange = inRange(ts, dateRange) + const recent = isRecent(ts, recentCutoffMs) + + if (entry.cwd && typeof entry.cwd === 'string' && withinRange) cwds.push(entry.cwd) + + if (entry.type === 'user') { + if (!withinRange) continue + const msg = entry.message as Record | undefined + const msgContent = msg?.content + if (typeof msgContent === 'string') { + userMessages.push(msgContent.slice(0, OPTIMIZE_TEXT_CAP)) + } else if (Array.isArray(msgContent)) { + let remaining = OPTIMIZE_TEXT_CAP + for (const block of msgContent) { + if (remaining <= 0) break + if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') { + const text = block.text.slice(0, remaining) + userMessages.push(text) + remaining -= text.length + } + } + } + continue + } + + if (entry.type !== 'assistant') continue + if (!withinRange) continue + + const msg = entry.message as Record | undefined + const usage = msg?.usage as Record | undefined + if (usage) { + const cacheCreate = (usage.cache_creation_input_tokens as number) ?? 0 + if (cacheCreate > 0) apiCalls.push({ cacheCreationTokens: cacheCreate, version: lastVersion, recent }) + } + + const blocks = msg?.content + if (!Array.isArray(blocks)) continue + + for (const block of blocks) { + if (block.type !== 'tool_use') continue + const name = typeof block.name === 'string' ? block.name : '' + calls.push({ + name, + input: compactOptimizeInput(name, block.input), + sessionId, + project, + recent, + }) + } + } + + return { calls, cwds, apiCalls, userMessages } +} + +async function scanSessions(dateRange?: DateRange): Promise { + const sources = await discoverAllSessions('claude') + const allCalls: ToolCall[] = [] + const allCwds = new Set() + const allApiCalls: ApiCallMeta[] = [] + const allUserMessages: string[] = [] + + const tasks: Array<{ file: string; project: string }> = [] + for (const source of sources) { + const files = await collectJsonlFiles(source.path) + for (const file of files) { + if (await isFileStaleForRange(file, dateRange)) continue + tasks.push({ file, project: source.project }) + } + } + + await runWithConcurrency(tasks, FILE_READ_CONCURRENCY, async ({ file, project }) => { + const { calls, cwds, apiCalls, userMessages } = await scanJsonlFile(file, project, dateRange) + allCalls.push(...calls) + for (const cwd of cwds) allCwds.add(cwd) + allApiCalls.push(...apiCalls) + allUserMessages.push(...userMessages) + }) + + return { toolCalls: allCalls, projectCwds: allCwds, apiCalls: allApiCalls, userMessages: allUserMessages } +} + +// ============================================================================ +// Shared helpers +// ============================================================================ + +function readJsonFile(path: string): Record | null { + const raw = readSessionFileSync(path) + if (raw === null) return null + try { return JSON.parse(raw) } catch { return null } +} + +function shortHomePath(absPath: string): string { + const home = homedir() + return absPath.startsWith(home) ? '~' + absPath.slice(home.length) : absPath +} + +function isReadTool(name: string): boolean { + return name === 'Read' || name === 'FileReadTool' +} + +type McpConfigEntry = { normalized: string; original: string; mtime: number } + +export function loadMcpConfigs(projectCwds: Iterable): Map { + const servers = new Map() + const configPaths = [ + join(homedir(), '.claude', 'settings.json'), + join(homedir(), '.claude', 'settings.local.json'), + ] + for (const cwd of projectCwds) { + configPaths.push(join(cwd, '.mcp.json')) + configPaths.push(join(cwd, '.claude', 'settings.json')) + configPaths.push(join(cwd, '.claude', 'settings.local.json')) + } + + for (const p of configPaths) { + if (!existsSync(p)) continue + const config = readJsonFile(p) + if (!config) continue + let mtime = 0 + try { mtime = statSync(p).mtimeMs } catch {} + const serversObj = (config.mcpServers ?? {}) as Record + for (const name of Object.keys(serversObj)) { + const normalized = name.replace(/:/g, '_') + const existing = servers.get(normalized) + if (!existing || existing.mtime < mtime) { + servers.set(normalized, { normalized, original: name, mtime }) + } + } + } + return servers +} + +// ============================================================================ +// Detectors +// ============================================================================ + +export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + const dirCounts = new Map() + let totalJunkReads = 0 + let recentJunkReads = 0 + + for (const call of calls) { + if (!isReadTool(call.name)) continue + const filePath = call.input.file_path as string | undefined + if (!filePath || !JUNK_PATTERN.test(filePath)) continue + totalJunkReads++ + if (call.recent) recentJunkReads++ + for (const dir of JUNK_DIRS) { + if (filePath.includes(`/${dir}/`)) { + dirCounts.set(dir, (dirCounts.get(dir) ?? 0) + 1) + break + } + } + } + + if (totalJunkReads < MIN_JUNK_READS_TO_FLAG) return null + + const hasRecentActivity = calls.some(c => c.recent) + const trend = sessionTrend(recentJunkReads, totalJunkReads, dateRange, hasRecentActivity) + if (trend === 'resolved') return null + + const sorted = [...dirCounts.entries()].sort((a, b) => b[1] - a[1]) + const dirList = sorted.slice(0, TOP_ITEMS_PREVIEW).map(([d, n]) => `${d}/ (${n}x)`).join(', ') + const tokensSaved = totalJunkReads * AVG_TOKENS_PER_READ + + const detected = sorted.map(([d]) => d) + const commonDefaults = ['node_modules', '.git', 'dist', '__pycache__'] + const extras = commonDefaults.filter(d => !dirCounts.has(d)).slice(0, Math.max(0, 6 - detected.length)) + 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', + tokensSaved, + fix: { + type: 'paste', + destination: 'claude-md', + label: 'Append to your project CLAUDE.md:', + text: `Do not read or search files under these directories unless I explicitly ask: ${dirsToAvoid}.`, + }, + trend, + } +} + +export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + const sessionFiles = new Map>() + + for (const call of calls) { + if (!isReadTool(call.name)) continue + const filePath = call.input.file_path as string | undefined + if (!filePath || JUNK_PATTERN.test(filePath)) continue + const key = `${call.project}:${call.sessionId}` + if (!sessionFiles.has(key)) sessionFiles.set(key, new Map()) + const fm = sessionFiles.get(key)! + const entry = fm.get(filePath) ?? { count: 0, recent: 0 } + entry.count++ + if (call.recent) entry.recent++ + fm.set(filePath, entry) + } + + let totalDuplicates = 0 + let recentDuplicates = 0 + const fileDupes = new Map() + + for (const fm of sessionFiles.values()) { + for (const [file, entry] of fm) { + if (entry.count <= 1) continue + const extra = entry.count - 1 + totalDuplicates += extra + if (entry.recent > 1) recentDuplicates += entry.recent - 1 + const name = basename(file) + fileDupes.set(name, (fileDupes.get(name) ?? 0) + extra) + } + } + + if (totalDuplicates < MIN_DUPLICATE_READS_TO_FLAG) return null + + const hasRecentActivity = calls.some(c => c.recent) + const trend = sessionTrend(recentDuplicates, totalDuplicates, dateRange, hasRecentActivity) + if (trend === 'resolved') return null + + const worst = [...fileDupes.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_ITEMS_PREVIEW) + .map(([name, n]) => `${name} (${n + 1}x)`) + .join(', ') + + 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', + tokensSaved, + fix: { + type: 'paste', + destination: 'prompt', + label: 'Point Claude at exact locations in your prompt, for example:', + text: 'In lines -, look at the function.', + }, + trend, + } +} + +/** + * Per-server breakdown of MCP tool inventory vs invocations, computed from the + * `mcpInventory` field captured by the Claude parser. + * + * Each session that loaded a server contributes its observed tool list to + * the union for that server. Invocations come from the existing + * `mcpBreakdown` per-call counts plus the parser's `call.tools` stream. + */ +export type McpServerCoverage = { + server: string + toolsAvailable: number + toolsInvoked: number + unusedTools: string[] + invocations: number + loadedSessions: number + coverageRatio: number +} + +type McpSchemaCostEstimate = { + cacheWriteTokens: number + cacheReadTokens: number + effectiveInputTokens: number +} + +/** + * Aggregate MCP inventory and invocations across the projects in scope. + * + * Returns one entry per `mcp____*` namespace observed in any + * session's `mcpInventory`. Counts of invocations come from + * `session.mcpBreakdown` (per-server call totals already maintained by the + * parser). + */ +export function aggregateMcpCoverage(projects: ProjectSummary[]): McpServerCoverage[] { + type ServerAcc = { + inventory: Set + invokedTools: Set + invocations: number + loadedSessions: number + } + const servers = new Map() + + function getOrInit(server: string): ServerAcc { + let acc = servers.get(server) + if (!acc) { + acc = { inventory: new Set(), invokedTools: new Set(), invocations: 0, loadedSessions: 0 } + servers.set(server, acc) + } + return acc + } + + for (const project of projects) { + for (const session of project.sessions) { + // Only sessions with an observed inventory count toward `loadedSessions`. + // Pure invocation-only sessions (server seen via `call.mcpTools` or + // `session.mcpBreakdown` without any matching `deferred_tools_delta`) + // could otherwise satisfy the `MCP_COVERAGE_MIN_SESSIONS` threshold + // without giving us evidence that the schema was actually loaded. + const inventoriedServers = new Set() + const sessionInvoked = new Map>() + + // Inventory: union of tools observed available in this session. + for (const fqn of session.mcpInventory ?? []) { + const parts = fqn.split('__') + if (parts.length < 3 || parts[0] !== 'mcp') continue + const server = parts[1] + if (!server) continue + const tool = parts.slice(2).join('__') + if (!tool) continue + const acc = getOrInit(server) + acc.inventory.add(fqn) + inventoriedServers.add(server) + } + + // Invoked tools: walk turns to collect per-tool invocations. We can't + // get this from session.mcpBreakdown alone because that's keyed by + // server, not tool. + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + for (const fqn of call.mcpTools) { + const parts = fqn.split('__') + if (parts.length < 3 || parts[0] !== 'mcp') continue + const server = parts[1] + if (!server) continue + let invoked = sessionInvoked.get(server) + if (!invoked) { + invoked = new Set() + sessionInvoked.set(server, invoked) + } + invoked.add(fqn) + } + } + } + + // Invocation totals: trust mcpBreakdown which was already aggregated + // turn-by-turn, including any invocations the inventory pass missed. + for (const [server, data] of Object.entries(session.mcpBreakdown)) { + const acc = getOrInit(server) + acc.invocations += data.calls + } + + for (const [server, invoked] of sessionInvoked) { + const acc = getOrInit(server) + for (const fqn of invoked) acc.invokedTools.add(fqn) + } + + for (const server of inventoriedServers) { + getOrInit(server).loadedSessions += 1 + } + } + } + + const result: McpServerCoverage[] = [] + for (const [server, acc] of servers) { + if (acc.inventory.size === 0) continue + // Coverage is only meaningful against tools we actually observed in the + // inventory: invocations of tools never inventoried (older config, typo, + // etc.) would otherwise inflate the numerator and could even drive + // `unusedCount` negative. + const invokedInInventory = new Set() + for (const fqn of acc.invokedTools) { + if (acc.inventory.has(fqn)) invokedInInventory.add(fqn) + } + const unusedTools = Array.from(acc.inventory).filter(t => !invokedInInventory.has(t)).sort() + const toolsInvoked = acc.inventory.size - unusedTools.length + result.push({ + server, + toolsAvailable: acc.inventory.size, + toolsInvoked, + unusedTools, + invocations: acc.invocations, + loadedSessions: acc.loadedSessions, + coverageRatio: acc.inventory.size === 0 ? 0 : toolsInvoked / acc.inventory.size, + }) + } + result.sort((a, b) => b.toolsAvailable - a.toolsAvailable) + return result +} + +/** + * Cache-aware token cost estimate for the unused-tool overhead of one or + * more servers, summed across all sessions that loaded any of them. + * + * Returns three buckets: + * - `cacheWriteTokens`: schema bytes paid at full input price (each + * cache-creation event in a session that loaded one of the servers). + * - `cacheReadTokens`: schema bytes carried at the cache-read discount on + * subsequent turns (ongoing overhead). + * - `effectiveInputTokens`: equivalent fresh-input tokens, weighted by + * cache pricing. Used to estimate dollar cost downstream by multiplying + * by the project's input rate. + * + * We cap each call's contribution at the observed cache-creation / + * cache-read totals for that call: it is not meaningful to claim more MCP + * overhead than the call's own cache bucket could possibly contain. The + * cap is applied once across the combined unused-schema budget for all + * flagged servers, not per server, so two flagged servers cannot both + * independently claim the same call's cache bucket. + * + * Anthropic caches expire after roughly 5 minutes of inactivity, so a long + * session can rebuild the cache multiple times. Every call that reports + * `cacheCreationInputTokens > 0` is treated as another rebuild, not just + * the very first one. + * + * "Loaded" is defined exclusively by observed inventory: a session that + * invoked a server without ever emitting a `deferred_tools_delta` for it + * does not count, matching the invariant `aggregateMcpCoverage` uses for + * `loadedSessions`. + */ +export function estimateMcpSchemaCost( + unusedToolCount: number, + projects: ProjectSummary[], + server: string, +): McpSchemaCostEstimate +export function estimateMcpSchemaCost( + unusedToolCountsByServer: Record, + projects: ProjectSummary[], + servers: string[], +): McpSchemaCostEstimate +export function estimateMcpSchemaCost( + unusedToolCounts: Record | number, + projects: ProjectSummary[], + serverOrServers: string | string[], +): McpSchemaCostEstimate { + let servers: string[] + let counts: Record + if (typeof unusedToolCounts === 'number') { + if (typeof serverOrServers !== 'string') { + throw new TypeError('single-server MCP cost estimates require a string server name') + } + servers = [serverOrServers] + counts = { [serverOrServers]: unusedToolCounts } + } else { + if (!Array.isArray(serverOrServers)) { + throw new TypeError('multi-server MCP cost estimates require a string[] server list') + } + servers = serverOrServers + counts = unusedToolCounts + } + + const totalUnusedSchemaTokens = servers.reduce( + (s, srv) => s + (counts[srv] ?? 0) * TOKENS_PER_MCP_TOOL, + 0, + ) + if (totalUnusedSchemaTokens === 0) { + return { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 } + } + + const serverSet = new Set(servers) + let cacheWriteTokens = 0 + let cacheReadTokens = 0 + + for (const project of projects) { + for (const session of project.sessions) { + // A session counts only if its observed inventory included at least + // one of the flagged servers — same invariant `aggregateMcpCoverage` + // uses for `loadedSessions`. + let loaded = false + for (const fqn of session.mcpInventory ?? []) { + const seg = fqn.split('__')[1] + if (seg && serverSet.has(seg)) { loaded = true; break } + } + if (!loaded) continue + + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + // Both buckets can be non-zero on the same call (cache rebuild + // alongside a partial read), so account for them independently. + // The cap is applied to the combined unused-schema budget so + // multiple flagged servers cannot all claim the same call. + if (call.usage.cacheCreationInputTokens > 0) { + cacheWriteTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheCreationInputTokens) + } + if (call.usage.cacheReadInputTokens > 0) { + cacheReadTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheReadInputTokens) + } + } + } + } + } + + const effectiveInputTokens = cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT + return { cacheWriteTokens, cacheReadTokens, effectiveInputTokens } +} + +/** + * Find MCP servers whose tool inventory is largely unused. Replaces the + * older server-only `detectUnusedMcp` (which only flagged servers with + * literal zero invocations). + * + * A server is flagged when, taken together: + * - it exposed more than `MCP_COVERAGE_MIN_TOOLS` tools, + * - we saw it loaded in at least `MCP_COVERAGE_MIN_SESSIONS` sessions, + * - the coverage ratio is below `MCP_COVERAGE_LOW_THRESHOLD`. + * + * Token-savings estimates use the cache-aware accounting from + * `estimateMcpSchemaCost` so we don't mistake cached-prefix carry-over for + * fresh-input billing. + */ +export function detectMcpToolCoverage( + projects: ProjectSummary[], + coverage = aggregateMcpCoverage(projects), +): WasteFinding | null { + if (coverage.length === 0) return null + + const flagged = coverage.filter(c => + c.toolsAvailable > MCP_COVERAGE_MIN_TOOLS + && c.loadedSessions >= MCP_COVERAGE_MIN_SESSIONS + && c.coverageRatio < MCP_COVERAGE_LOW_THRESHOLD, + ) + if (flagged.length === 0) return null + + flagged.sort((a, b) => (b.toolsAvailable - b.toolsInvoked) - (a.toolsAvailable - a.toolsInvoked)) + + const lines: string[] = [] + const removeCommands: string[] = [] + const unusedCountsByServer: Record = {} + const flaggedServers: string[] = [] + + for (const c of flagged) { + unusedCountsByServer[c.server] = c.toolsAvailable - c.toolsInvoked + flaggedServers.push(c.server) + const pct = Math.round(c.coverageRatio * 100) + lines.push( + `${c.server}: ${c.toolsInvoked}/${c.toolsAvailable} tools used (${pct}% coverage) across ${c.loadedSessions} session${c.loadedSessions === 1 ? '' : 's'}`, + ) + removeCommands.push(`claude mcp remove '${c.server}'`) + } + + // Single combined cost pass: caps each call's contribution at the + // total unused-schema budget across all flagged servers, so two + // flagged servers cannot independently claim the same call's cache + // bucket and overstate `tokensSaved`. + const cost = estimateMcpSchemaCost(unusedCountsByServer, projects, flaggedServers) + const tokensSaved = Math.round(cost.effectiveInputTokens) + const impact: Impact = tokensSaved >= MCP_COVERAGE_HIGH_IMPACT_TOKENS + ? 'high' + : flagged.length >= UNUSED_MCP_HIGH_THRESHOLD + ? 'high' + : '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 ` + + `carried in the cached prefix on every turn. ` + + `${lines.join('; ')}.`, + impact, + tokensSaved, + fix: { + type: 'command', + label: flagged.length === 1 + ? 'Remove the underused server, or trim its tools in your MCP config:' + : 'Remove underused servers, or trim their tools in your MCP config:', + text: removeCommands.join('\n'), + }, + apply: { kind: 'mcp-remove', servers: flaggedServers }, + } +} + +type McpProjectProfileStats = { + project: string + projectKey: string + projectPath: string + loadedSessions: number + invocations: number +} + +type McpProfileCandidate = { + server: string + toolsAvailable: number + hotProjects: McpProjectProfileStats[] + coldProjects: McpProjectProfileStats[] + coldProjectKeys: Set + loadedProjects: number + loadedSessions: number + invocations: number + hotShare: number + estimatedTokensSaved: number +} + +function projectProfileLabel(project: ProjectSummary): string { + return project.projectPath || project.project +} + +function projectProfileKey(project: ProjectSummary): string { + return projectProfileLabel(project) +} + +function sessionLoadedMcpServer( + session: ProjectSummary['sessions'][number], + server: string, +): boolean { + for (const fqn of session.mcpInventory ?? []) { + const parts = fqn.split('__') + if (parts.length >= 3 && parts[0] === 'mcp' && parts[1] === server) return true + } + return false +} + +function lowCoverageMcpServers(coverage: McpServerCoverage[]): Set { + return new Set( + coverage + .filter(c => + c.toolsAvailable > MCP_COVERAGE_MIN_TOOLS + && c.loadedSessions >= MCP_COVERAGE_MIN_SESSIONS + && c.coverageRatio < MCP_COVERAGE_LOW_THRESHOLD, + ) + .map(c => c.server), + ) +} + +function estimateMcpProfileColdSchemaCost( + projects: ProjectSummary[], + serverToolCounts: Map, + coldProjectKeysByServer: Map>, +): McpSchemaCostEstimate { + if (serverToolCounts.size === 0 || coldProjectKeysByServer.size === 0) { + return { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 } + } + + let cacheWriteTokens = 0 + let cacheReadTokens = 0 + for (const project of projects) { + const projectKey = projectProfileKey(project) + for (const session of project.sessions) { + let schemaTokens = 0 + for (const [server, toolsAvailable] of serverToolCounts) { + if (!coldProjectKeysByServer.get(server)?.has(projectKey)) continue + if (!sessionLoadedMcpServer(session, server)) continue + schemaTokens += toolsAvailable * TOKENS_PER_MCP_TOOL + } + if (schemaTokens === 0) continue + + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + if (call.usage.cacheCreationInputTokens > 0) { + cacheWriteTokens += Math.min(schemaTokens, call.usage.cacheCreationInputTokens) + } + if (call.usage.cacheReadInputTokens > 0) { + cacheReadTokens += Math.min(schemaTokens, call.usage.cacheReadInputTokens) + } + } + } + } + } + + const effectiveInputTokens = cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT + return { cacheWriteTokens, cacheReadTokens, effectiveInputTokens } +} + +function collectMcpProjectProfiles( + projects: ProjectSummary[], + coverage: McpServerCoverage[], +): McpProfileCandidate[] { + const suppressedServers = lowCoverageMcpServers(coverage) + const coverageByServer = new Map(coverage.map(c => [c.server, c])) + const byServer = new Map>() + + function getProjectStats(server: string, project: ProjectSummary): McpProjectProfileStats { + let serverProjects = byServer.get(server) + if (!serverProjects) { + serverProjects = new Map() + byServer.set(server, serverProjects) + } + const key = projectProfileKey(project) + let stats = serverProjects.get(key) + if (!stats) { + stats = { + project: project.project, + projectKey: key, + projectPath: projectProfileLabel(project), + loadedSessions: 0, + invocations: 0, + } + serverProjects.set(key, stats) + } + return stats + } + + for (const project of projects) { + for (const session of project.sessions) { + const loadedServers = new Set() + for (const fqn of session.mcpInventory ?? []) { + const parts = fqn.split('__') + if (parts.length >= 3 && parts[0] === 'mcp' && parts[1]) loadedServers.add(parts[1]) + } + for (const server of loadedServers) { + getProjectStats(server, project).loadedSessions++ + } + for (const [server, data] of Object.entries(session.mcpBreakdown)) { + getProjectStats(server, project).invocations += data.calls + } + } + } + + const candidates: McpProfileCandidate[] = [] + for (const [server, projectStats] of byServer) { + if (suppressedServers.has(server)) continue + const coverageStats = coverageByServer.get(server) + if (!coverageStats) continue + if (coverageStats.toolsAvailable === 0) continue + + const loaded = Array.from(projectStats.values()).filter(p => p.loadedSessions > 0) + if (loaded.length < MCP_PROFILE_MIN_PROJECTS) continue + const invocations = loaded.reduce((sum, p) => sum + p.invocations, 0) + if (invocations < MCP_PROFILE_MIN_HOT_INVOCATIONS) continue + + loaded.sort((a, b) => + b.invocations - a.invocations + || b.loadedSessions - a.loadedSessions + || a.projectPath.localeCompare(b.projectPath), + ) + const invokedProjects = loaded.filter(p => p.invocations > 0) + if (invokedProjects.length === 0) continue + const hotProjects = invokedProjects.slice(0, 2) + const hotInvocations = hotProjects.reduce((sum, p) => sum + p.invocations, 0) + const hotShare = hotInvocations / invocations + if (hotShare < MCP_PROFILE_HOT_INVOCATION_SHARE) continue + + const coldProjects = loaded.filter(p => p.invocations === 0) + const coldLoadedSessions = coldProjects.reduce((sum, p) => sum + p.loadedSessions, 0) + if (coldLoadedSessions < MCP_PROFILE_MIN_COLD_LOADED_SESSIONS) continue + + const coldProjectKeys = new Set(coldProjects.map(project => project.projectKey)) + const cost = estimateMcpProfileColdSchemaCost( + projects, + new Map([[server, coverageStats.toolsAvailable]]), + new Map([[server, coldProjectKeys]]), + ) + + candidates.push({ + server, + toolsAvailable: coverageStats.toolsAvailable, + hotProjects, + coldProjects, + coldProjectKeys, + loadedProjects: loaded.length, + loadedSessions: loaded.reduce((sum, p) => sum + p.loadedSessions, 0), + invocations, + hotShare, + estimatedTokensSaved: Math.round(cost.effectiveInputTokens), + }) + } + + candidates.sort((a, b) => + b.estimatedTokensSaved - a.estimatedTokensSaved + || b.coldProjects.length - a.coldProjects.length + || b.loadedSessions - a.loadedSessions + || a.server.localeCompare(b.server), + ) + return candidates +} + +export function detectMcpProfileAdvisor( + projects: ProjectSummary[], + coverage = aggregateMcpCoverage(projects), +): WasteFinding | null { + const candidates = collectMcpProjectProfiles(projects, coverage) + if (candidates.length === 0) return null + + const preview = candidates.slice(0, MCP_PROFILE_PREVIEW) + const lines = preview.map(candidate => { + const hot = candidate.hotProjects + .slice(0, 2) + .map(p => `${p.projectPath} (${p.invocations} call${p.invocations === 1 ? '' : 's'})`) + .join(', ') + const cold = candidate.coldProjects + .slice(0, 3) + .map(p => `${p.projectPath} (${p.loadedSessions} loaded session${p.loadedSessions === 1 ? '' : 's'})`) + .join(', ') + const coldExtra = candidate.coldProjects.length > 3 ? `, +${candidate.coldProjects.length - 3} more` : '' + return `${candidate.server}: ${Math.round(candidate.hotShare * 100)}% of ${candidate.invocations} calls in ${hot}; loaded but unused in ${cold}${coldExtra}` + }) + const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : '' + const serverToolCounts = new Map(candidates.map(c => [c.server, c.toolsAvailable])) + const coldProjectKeysByServer = new Map(candidates.map(c => [c.server, c.coldProjectKeys])) + const combinedCost = estimateMcpProfileColdSchemaCost(projects, serverToolCounts, coldProjectKeysByServer) + const tokensSaved = Math.round(combinedCost.effectiveInputTokens) + const impact: Impact = tokensSaved >= MCP_PROFILE_HIGH_IMPACT_TOKENS + || candidates.length >= UNUSED_MCP_HIGH_THRESHOLD + ? 'high' + : 'medium' + + return { + id: 'mcp-project-scope', + title: `${candidates.length} MCP server${candidates.length === 1 ? '' : 's'} should be project-scoped`, + explanation: + `These MCP servers look useful in a small set of projects but are loaded into other projects where they are not invoked. ` + + `Project-scoping them keeps the hot-project workflow while avoiding schema overhead elsewhere. ${lines.join('; ')}${extra}.`, + impact, + tokensSaved, + fix: { + type: 'paste', + destination: 'prompt', + label: 'Ask Claude to turn this into a project-scoped MCP profile:', + text: [ + `Review these MCP profile recommendations before changing config (${preview.length} of ${candidates.length} shown):`, + ...preview.map(candidate => { + const hot = candidate.hotProjects.map(p => p.projectPath).join(', ') + const cold = candidate.coldProjects.slice(0, 3).map(p => p.projectPath).join(', ') + return `- Keep ${candidate.server} available for ${hot}; remove or project-scope it away from ${cold}. Re-add it only in projects that actually need it.` + }), + ].join('\n'), + }, + apply: { + kind: 'mcp-project-scope', + servers: candidates.map(c => ({ + server: c.server, + keepProjects: c.hotProjects.map(p => p.projectPath), + removeProjects: c.coldProjects.map(p => p.projectPath), + })), + }, + } +} + +type CapabilityKind = 'mcp' | 'skill' + +type CapabilityRef = { + kind: CapabilityKind + name: string +} + +type CapabilityReliabilityAccumulator = CapabilityRef & { + editTurns: number + retryTurns: number + oneShotTurns: number + retries: number + tokensTouched: number + projects: Set + retryTurnSavings: Map +} + +export type CapabilityReliabilityCandidate = { + kind: CapabilityKind + name: string + editTurns: number + retryTurns: number + oneShotTurns: number + retries: number + retryRate: number + tokensTouched: number + tokensSaved: number + projects: string[] +} + +function capabilityKey(ref: CapabilityRef): string { + return `${ref.kind}:${ref.name}` +} + +function formatCapabilityKind(kind: CapabilityKind): string { + return kind === 'mcp' ? 'MCP server' : 'skill' +} + +function mcpServerFromToolName(fqn: string): string | null { + const parts = fqn.split('__') + if (parts.length < 3 || parts[0] !== 'mcp') return null + return parts[1] || null +} + +function collectReliabilityCapabilities(turn: ProjectSummary['sessions'][number]['turns'][number]): Map { + const capabilities = new Map() + + for (const call of turn.assistantCalls) { + for (const fqn of call.mcpTools) { + const server = mcpServerFromToolName(fqn) + if (!server) continue + const ref: CapabilityRef = { kind: 'mcp', name: server } + capabilities.set(capabilityKey(ref), ref) + } + for (const rawSkill of call.skills ?? []) { + const skill = rawSkill.trim() + if (!skill) continue + const ref: CapabilityRef = { kind: 'skill', name: skill } + capabilities.set(capabilityKey(ref), ref) + } + } + + return capabilities +} + +function turnEffectiveTokenTotal(turn: ProjectSummary['sessions'][number]['turns'][number]): number { + return Math.round(turn.assistantCalls.reduce((sum, call) => + sum + + call.usage.inputTokens + + call.usage.outputTokens + + call.usage.cacheCreationInputTokens * CACHE_WRITE_MULTIPLIER + + call.usage.cacheReadInputTokens * CACHE_READ_DISCOUNT, + 0)) +} + +function reliabilityTurnKey( + project: ProjectSummary, + session: ProjectSummary['sessions'][number], + turn: ProjectSummary['sessions'][number]['turns'][number], + turnIndex: number, +): string { + return `${project.projectPath || project.project}:${session.sessionId}:${turn.timestamp}:${turnIndex}` +} + +function getReliabilityAccumulator( + stats: Map, + ref: CapabilityRef, +): CapabilityReliabilityAccumulator { + const key = capabilityKey(ref) + let acc = stats.get(key) + if (!acc) { + acc = { + ...ref, + editTurns: 0, + retryTurns: 0, + oneShotTurns: 0, + retries: 0, + tokensTouched: 0, + projects: new Set(), + retryTurnSavings: new Map(), + } + stats.set(key, acc) + } + return acc +} + +function findCapabilityReliabilityCandidates(projects: ProjectSummary[]): CapabilityReliabilityCandidate[] { + const stats = new Map() + + for (const project of projects) { + for (const session of project.sessions) { + for (let turnIndex = 0; turnIndex < session.turns.length; turnIndex++) { + const turn = session.turns[turnIndex]! + if (!turn.hasEdits) continue + + const capabilities = collectReliabilityCapabilities(turn) + if (capabilities.size === 0) continue + + const turnTokens = turnEffectiveTokenTotal(turn) + const turnKey = reliabilityTurnKey(project, session, turn, turnIndex) + const recoverableTokens = turn.retries > 0 + ? Math.round(turnTokens * CAPABILITY_RELIABILITY_RECOVERY_FRACTION) + : 0 + + for (const ref of capabilities.values()) { + const acc = getReliabilityAccumulator(stats, ref) + acc.editTurns++ + acc.tokensTouched += turnTokens + acc.projects.add(project.project) + if (turn.retries > 0) { + acc.retryTurns++ + acc.retries += turn.retries + acc.retryTurnSavings.set(turnKey, recoverableTokens) + } else { + acc.oneShotTurns++ + } + } + } + } + } + + const candidates: CapabilityReliabilityCandidate[] = [] + for (const acc of stats.values()) { + if (acc.editTurns < CAPABILITY_RELIABILITY_MIN_EDIT_TURNS) continue + if (acc.retryTurns < CAPABILITY_RELIABILITY_MIN_RETRY_TURNS) continue + const retryRate = acc.retryTurns / acc.editTurns + if (retryRate < CAPABILITY_RELIABILITY_MIN_RETRY_RATE) continue + + candidates.push({ + kind: acc.kind, + name: acc.name, + editTurns: acc.editTurns, + retryTurns: acc.retryTurns, + oneShotTurns: acc.oneShotTurns, + retries: acc.retries, + retryRate, + tokensTouched: acc.tokensTouched, + tokensSaved: Array.from(acc.retryTurnSavings.values()).reduce((sum, tokens) => sum + tokens, 0), + projects: Array.from(acc.projects).sort(), + }) + } + + candidates.sort((a, b) => + b.retryRate - a.retryRate + || b.retries - a.retries + || b.tokensSaved - a.tokensSaved + || a.kind.localeCompare(b.kind) + || a.name.localeCompare(b.name) + ) + return candidates +} + +export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFinding | null { + const candidates = findCapabilityReliabilityCandidates(projects) + if (candidates.length === 0) return null + + const candidateKeys = new Set(candidates.map(c => capabilityKey(c))) + const uniqueRetryTurnSavings = new Map() + for (const project of projects) { + for (const session of project.sessions) { + for (let turnIndex = 0; turnIndex < session.turns.length; turnIndex++) { + const turn = session.turns[turnIndex]! + if (!turn.hasEdits || turn.retries <= 0) continue + const capabilities = collectReliabilityCapabilities(turn) + if (capabilities.size === 0) continue + + const hasFlaggedCapability = Array.from(capabilities.keys()).some(key => candidateKeys.has(key)) + if (!hasFlaggedCapability) continue + + const key = reliabilityTurnKey(project, session, turn, turnIndex) + const tokens = Math.round(turnEffectiveTokenTotal(turn) * CAPABILITY_RELIABILITY_RECOVERY_FRACTION) + uniqueRetryTurnSavings.set(key, Math.max(uniqueRetryTurnSavings.get(key) ?? 0, tokens)) + } + } + } + + const tokensSaved = Array.from(uniqueRetryTurnSavings.values()).reduce((sum, tokens) => sum + tokens, 0) + const preview = candidates.slice(0, CAPABILITY_RELIABILITY_PREVIEW) + const list = preview.map(c => { + const percent = Math.round(c.retryRate * 100) + const projects = c.projects.length > 1 ? ` across ${c.projects.length} projects` : ` in ${c.projects[0] ?? 'one project'}` + return `${formatCapabilityKind(c.kind)} ${c.name}: ${c.retryTurns}/${c.editTurns} edit turns retried (${percent}%), ${c.retries} retries${projects}` + }).join('; ') + const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : '' + + const names = preview + .map(c => `${formatCapabilityKind(c.kind)} ${c.name}`) + .join(', ') + + let impact: Impact + if (candidates.length >= CAPABILITY_RELIABILITY_HIGH_MIN_CANDIDATES || tokensSaved >= CAPABILITY_RELIABILITY_HIGH_IMPACT_TOKENS) { + impact = 'high' + } else if (candidates.length <= CAPABILITY_RELIABILITY_LOW_MAX_CANDIDATES && tokensSaved < CAPABILITY_RELIABILITY_LOW_MAX_TOKENS) { + impact = 'low' + } else { + impact = 'medium' + } + + const kindSet = new Set(candidates.map(c => c.kind)) + const noun = kindSet.size === 1 + ? (kindSet.has('mcp') ? 'MCP server' : 'skill') + : 'MCP/skill capability' + const pluralNoun = noun === 'MCP/skill capability' ? 'MCP/skill capabilities' : `${noun}s` + const verb = candidates.length === 1 ? 'correlates' : 'correlate' + + return { + id: 'retry-heavy-capabilities', + title: `${candidates.length} ${candidates.length === 1 ? noun : pluralNoun} ${verb} with retry-heavy edits`, + explanation: `Edit turns using these capabilities are retry-heavy: ${list}${extra}. This is a correlation report, not proof of causation; compare the retry-heavy turns with one-shot turns before changing MCP scope or skill instructions.`, + impact, + tokensSaved, + fix: { + type: 'paste', + destination: 'prompt', + label: 'Ask Claude to audit the retry-heavy capability before changing config:', + text: `Investigate these retry-correlated capabilities: ${names}. Compare edit turns with retries against one-shot edit turns, identify whether the MCP server or skill actually caused rework, then propose a scoped MCP config or skill-instruction change with session evidence. Do not remove a capability solely because it appears in this report.`, + }, + } +} + +export function detectUnusedMcp( + calls: ToolCall[], + projects: ProjectSummary[], + projectCwds: Set, + mcpCoverage = aggregateMcpCoverage(projects), +): WasteFinding | null { + const configured = loadMcpConfigs(projectCwds) + if (configured.size === 0) return null + + const calledServers = new Set() + for (const call of calls) { + if (!call.name.startsWith('mcp__')) continue + const seg = call.name.split('__')[1] + if (seg) calledServers.add(seg) + } + for (const p of projects) { + for (const s of p.sessions) { + for (const server of Object.keys(s.mcpBreakdown)) calledServers.add(server) + } + } + + // Servers that the new coverage detector will flag fall under its + // jurisdiction (per-tool granularity, cache-aware costing) and we + // suppress them here to avoid double-flagging. Importantly, we suppress + // only the servers that actually clear the coverage detector's + // thresholds — a small, inventoried-but-uninvoked server that the + // coverage detector skips would otherwise become a blind spot. + const coverageReportedServers = new Set( + mcpCoverage + .filter(c => + c.toolsAvailable > MCP_COVERAGE_MIN_TOOLS + && c.loadedSessions >= MCP_COVERAGE_MIN_SESSIONS + && c.coverageRatio < MCP_COVERAGE_LOW_THRESHOLD, + ) + .map(c => c.server), + ) + + const now = Date.now() + const unused: string[] = [] + for (const entry of configured.values()) { + if (calledServers.has(entry.normalized)) continue + if (coverageReportedServers.has(entry.normalized)) continue + if (entry.mtime > 0 && now - entry.mtime < MCP_NEW_CONFIG_GRACE_MS) continue + unused.push(entry.original) + } + + if (unused.length === 0) return null + + const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) + const schemaTokensPerSession = unused.length * TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL + 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' : ''}:`, + text: unused.map(s => `claude mcp remove ${s}`).join('\n'), + }, + } +} + +function expandImports(filePath: string, seen: Set, depth: number): { totalLines: number; importedFiles: number } { + if (depth > MAX_IMPORT_DEPTH || seen.has(filePath)) return { totalLines: 0, importedFiles: 0 } + seen.add(filePath) + const content = readSessionFileSync(filePath) + if (content === null) return { totalLines: 0, importedFiles: 0 } + + let totalLines = content.split('\n').length + let importedFiles = 0 + const dir = join(filePath, '..') + + IMPORT_PATTERN.lastIndex = 0 + for (const match of content.matchAll(IMPORT_PATTERN)) { + const rawPath = match[1] + if (!rawPath) continue + const resolved = rawPath.startsWith('/') ? rawPath : join(dir, rawPath) + if (!existsSync(resolved)) continue + const nested = expandImports(resolved, seen, depth + 1) + totalLines += nested.totalLines + importedFiles += 1 + nested.importedFiles + } + + return { totalLines, importedFiles } +} + +export function detectBloatedClaudeMd(projectCwds: Set): WasteFinding | null { + const bloated: { path: string; expandedLines: number; imports: number }[] = [] + + for (const cwd of projectCwds) { + for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) { + const fullPath = join(cwd, name) + if (!existsSync(fullPath)) continue + const { totalLines, importedFiles } = expandImports(fullPath, new Set(), 0) + if (totalLines > CLAUDEMD_HEALTHY_LINES) { + bloated.push({ path: `${shortHomePath(cwd)}/${name}`, expandedLines: totalLines, imports: importedFiles }) + } + } + } + + if (bloated.length === 0) return null + + const sorted = bloated.sort((a, b) => b.expandedLines - a.expandedLines) + const worst = sorted[0] + const totalExtraLines = sorted.reduce((s, b) => s + (b.expandedLines - CLAUDEMD_HEALTHY_LINES), 0) + const tokensSaved = totalExtraLines * CLAUDEMD_TOKENS_PER_LINE + + const list = sorted.slice(0, TOP_ITEMS_PREVIEW).map(b => { + const importNote = b.imports > 0 ? ` with ${b.imports} @-import${b.imports > 1 ? 's' : ''}` : '' + return `${b.path} (${b.expandedLines} lines${importNote})` + }).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', + tokensSaved, + fix: { + type: 'paste', + destination: 'prompt', + label: 'Ask Claude in the current session to trim it:', + text: `Review CLAUDE.md and all @-imported files. Cut total expanded content to under ${CLAUDEMD_HEALTHY_LINES} lines. Remove anything Claude can figure out from the code itself. Keep only rules, gotchas, and non-obvious conventions.`, + }, + } +} + +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 + let edits = 0 + let recentEdits = 0 + let recentReads = 0 + for (const call of calls) { + if (READ_TOOL_NAMES.has(call.name)) { + reads++ + if (call.recent) recentReads++ + } else if (EDIT_TOOL_NAMES.has(call.name)) { + edits++ + if (call.recent) recentEdits++ + } + } + + if (edits < MIN_EDITS_FOR_RATIO) return null + const ratio = reads / edits + if (ratio >= HEALTHY_READ_EDIT_RATIO) return null + + const impact: Impact = ratio < LOW_RATIO_HIGH_THRESHOLD ? 'high' : ratio < LOW_RATIO_MEDIUM_THRESHOLD ? 'medium' : 'low' + const extraReadsNeeded = Math.max(Math.round(edits * HEALTHY_READ_EDIT_RATIO) - reads, 0) + const tokensSaved = extraReadsNeeded * AVG_TOKENS_PER_READ + + let trend: Trend | 'resolved' = 'active' + if (recentEdits >= MIN_EDITS_FOR_RATIO) { + const recentRatio = recentReads / recentEdits + if (recentRatio >= HEALTHY_READ_EDIT_RATIO) trend = 'resolved' + else if (recentRatio > ratio * (1 / IMPROVING_THRESHOLD)) trend = 'improving' + } + 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, + tokensSaved, + fix: { + type: 'paste', + destination: 'claude-md', + label: 'Add to your CLAUDE.md:', + text: 'Before editing any file, read it first. Before modifying a function, grep for all callers. Research before you edit.', + }, + trend, + } +} + +const DEFAULT_CACHE_BASELINE_TOKENS = 50_000 +const CACHE_BASELINE_QUANTILE = 0.25 +const CACHE_BLOAT_MULTIPLIER = 1.4 +const CACHE_VERSION_MIN_SAMPLES = 5 +const CACHE_VERSION_DIFF_THRESHOLD = 10_000 + +function computeBudgetAwareCacheBaseline(projects: ProjectSummary[]): number { + const sessions = projects.flatMap(p => p.sessions) + if (sessions.length === 0) return DEFAULT_CACHE_BASELINE_TOKENS + const cacheWrites = sessions.map(s => s.totalCacheWriteTokens).filter(n => n > 0) + if (cacheWrites.length < MIN_API_CALLS_FOR_CACHE) return DEFAULT_CACHE_BASELINE_TOKENS + const sorted = cacheWrites.sort((a, b) => a - b) + return sorted[Math.floor(sorted.length * CACHE_BASELINE_QUANTILE)] || DEFAULT_CACHE_BASELINE_TOKENS +} + +export function detectCacheBloat(apiCalls: ApiCallMeta[], projects: ProjectSummary[], dateRange?: DateRange): WasteFinding | null { + if (apiCalls.length < MIN_API_CALLS_FOR_CACHE) return null + + const sorted = apiCalls.map(c => c.cacheCreationTokens).sort((a, b) => a - b) + const median = sorted[Math.floor(sorted.length / 2)] + const baseline = computeBudgetAwareCacheBaseline(projects) + const bloatThreshold = baseline * CACHE_BLOAT_MULTIPLIER + + if (median < bloatThreshold) return null + + const recentCalls = apiCalls.filter(c => c.recent) + const totalBloated = apiCalls.filter(c => c.cacheCreationTokens > bloatThreshold).length + const recentBloated = recentCalls.filter(c => c.cacheCreationTokens > bloatThreshold).length + const trend = sessionTrend(recentBloated, totalBloated, dateRange, recentCalls.length > 0) + if (trend === 'resolved') return null + + const versionCounts = new Map() + for (const call of apiCalls) { + if (!call.version) continue + const entry = versionCounts.get(call.version) ?? { total: 0, count: 0 } + entry.total += call.cacheCreationTokens + entry.count++ + versionCounts.set(call.version, entry) + } + const versionAvgs = [...versionCounts.entries()] + .filter(([, d]) => d.count >= CACHE_VERSION_MIN_SAMPLES) + .map(([v, d]) => ({ version: v, avg: Math.round(d.total / d.count) })) + .sort((a, b) => b.avg - a.avg) + + const excess = median - baseline + const tokensSaved = excess * apiCalls.length + + let versionNote = '' + if (versionAvgs.length >= 2) { + const [high, ...rest] = versionAvgs + const low = rest[rest.length - 1] + if (high.avg - low.avg > CACHE_VERSION_DIFF_THRESHOLD) { + versionNote = ` Version ${high.version} averages ${formatTokens(high.avg)} vs ${low.version} at ${formatTokens(low.avg)}.` + } + } + + 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', + tokensSaved, + fix: { + type: 'paste', + destination: 'shell-config', + label: 'Check for recent Claude Code updates or heavy MCP/skill additions. As a workaround (not officially supported), add to ~/.zshrc or ~/.bashrc:', + text: 'export ANTHROPIC_CUSTOM_HEADERS=\'User-Agent: claude-cli/2.1.98 (external, sdk-cli)\'', + }, + trend, + } +} + +async function listMarkdownFiles(dir: string): Promise { + if (!existsSync(dir)) return [] + try { + const entries = await readdir(dir) + return entries.filter(e => e.endsWith('.md')).map(e => e.replace(/\.md$/, '')) + } catch { return [] } +} + +async function listSkillDirs(dir: string): Promise { + if (!existsSync(dir)) return [] + try { + const entries = await readdir(dir) + const names: string[] = [] + for (const entry of entries) { + if (existsSync(join(dir, entry, 'SKILL.md'))) names.push(entry) + } + return names + } catch { return [] } +} + +export async function detectGhostAgents(calls: ToolCall[]): Promise { + const defined = await listMarkdownFiles(join(homedir(), '.claude', 'agents')) + if (defined.length === 0) return null + + const invoked = new Set() + for (const call of calls) { + if (call.name !== 'Agent' && call.name !== 'Task') continue + const subType = call.input.subagent_type as string | undefined + if (subType) invoked.add(subType) + } + + const ghosts = defined.filter(name => !invoked.has(name)) + if (ghosts.length === 0) return null + + const tokensSaved = ghosts.length * TOKENS_PER_AGENT_DEF + const list = ghosts.slice(0, GHOST_NAMES_PREVIEW).join(', ') + (ghosts.length > 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', + tokensSaved, + fix: { + type: 'command', + label: `Archive unused agent${ghosts.length > 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 }, + } +} + +export async function detectGhostSkills(calls: ToolCall[]): Promise { + const defined = await listSkillDirs(join(homedir(), '.claude', 'skills')) + if (defined.length === 0) return null + + const invoked = new Set() + for (const call of calls) { + if (call.name !== 'Skill') continue + const skillName = (call.input.skill as string) || (call.input.name as string) + if (skillName) invoked.add(skillName) + } + + const ghosts = defined.filter(name => !invoked.has(name)) + if (ghosts.length === 0) return null + + const tokensSaved = ghosts.length * TOKENS_PER_SKILL_DEF + const list = ghosts.slice(0, GHOST_NAMES_PREVIEW).join(', ') + (ghosts.length > 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', + tokensSaved, + fix: { + type: 'command', + label: `Archive unused skill${ghosts.length > 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 }, + } +} + +export async function detectGhostCommands(userMessages: string[]): Promise { + const defined = await listMarkdownFiles(join(homedir(), '.claude', 'commands')) + if (defined.length === 0) return null + + const invoked = new Set() + for (const msg of userMessages) { + COMMAND_PATTERN.lastIndex = 0 + for (const m of msg.matchAll(COMMAND_PATTERN)) { + const name = (m[1] || m[2] || '').trim() + if (name) invoked.add(name) + } + } + + const ghosts = defined.filter(name => !invoked.has(name)) + if (ghosts.length === 0) return null + + const tokensSaved = ghosts.length * TOKENS_PER_COMMAND_DEF + const list = ghosts.slice(0, GHOST_NAMES_PREVIEW).join(', ') + (ghosts.length > 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', + tokensSaved, + fix: { + type: 'command', + label: `Archive unused command${ghosts.length > 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 }, + } +} + +function readShellProfileLimit(): number | null { + for (const profile of SHELL_PROFILES) { + const path = join(homedir(), profile) + if (!existsSync(path)) continue + const content = readSessionFileSync(path) + if (content === null) continue + const match = content.match(/^\s*export\s+BASH_MAX_OUTPUT_LENGTH\s*=\s*['"]?(\d+)['"]?/m) + if (match) return parseInt(match[1], 10) + } + return null +} + +export function detectBashBloat(): WasteFinding | null { + const profileLimit = readShellProfileLimit() + const envLimit = process.env['BASH_MAX_OUTPUT_LENGTH'] + const configured = profileLimit ?? (envLimit ? parseInt(envLimit, 10) : null) + + if (configured !== null && configured <= BASH_RECOMMENDED_LIMIT) return null + + const limit = configured ?? BASH_DEFAULT_LIMIT + const extraChars = limit - BASH_RECOMMENDED_LIMIT + 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}`, + }, + } +} + +function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number { + return session.totalInputTokens + + session.totalOutputTokens + + session.totalCacheReadTokens + + session.totalCacheWriteTokens +} + +function sessionEffectiveContextTokens(session: ProjectSummary['sessions'][number]): number { + return session.totalInputTokens + + session.totalCacheReadTokens * CACHE_READ_DISCOUNT + + session.totalCacheWriteTokens * CACHE_WRITE_MULTIPLIER +} + +function formatContextRatio(ratio: number): string { + if (ratio >= CONTEXT_BLOAT_RATIO_DISPLAY_CAP) return `${CONTEXT_BLOAT_RATIO_DISPLAY_CAP}+` + return ratio.toFixed(1) +} + +// ============================================================================ +// Worth-it / low-worth-session detector helpers +// ============================================================================ + +// Use (\s|$|--) instead of \b after commit/push so `git commit-tree` and +// `git commit-graph` are not treated as deliveries. The `--` clause keeps +// `git commit --amend` matching as a real delivery command. +const DELIVERY_COMMAND_PATTERNS = [ + /(?:^|[;&|]\s*)git\s+(?:commit|push)(?=\s|$|--)(?![^;&|]*--dry-run)/, + /(?:^|[;&|]\s*)gh\s+pr\s+(?:create|merge)(?=\s|$|--)(?![^;&|]*--dry-run)/, +] + +function sessionDeliveryCommand(session: ProjectSummary['sessions'][number]): string | null { + const commands = Object.keys(session.bashBreakdown) + return commands.find(command => DELIVERY_COMMAND_PATTERNS.some(pattern => pattern.test(command))) ?? null +} + +function hasCategoryBreakdownData(session: ProjectSummary['sessions'][number]): boolean { + return Object.values(session.categoryBreakdown).some(category => + category.turns > 0 + || category.costUSD > 0 + || category.retries > 0 + || category.editTurns > 0 + || category.oneShotTurns > 0 + ) +} + +function sessionEditTurns(session: ProjectSummary['sessions'][number]): number { + if (hasCategoryBreakdownData(session)) { + return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.editTurns, 0) + } + return session.turns.filter(turn => turn.hasEdits).length +} + +function sessionOneShotTurns(session: ProjectSummary['sessions'][number]): number { + if (hasCategoryBreakdownData(session)) { + return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.oneShotTurns, 0) + } + return session.turns.filter(turn => turn.hasEdits && turn.retries === 0).length +} + +function sessionRetryCount(session: ProjectSummary['sessions'][number]): number { + if (hasCategoryBreakdownData(session)) { + return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.retries, 0) + } + return session.turns.reduce((sum, turn) => sum + turn.retries, 0) +} + +function sessionTotalTurns(session: ProjectSummary['sessions'][number]): number { + if (hasCategoryBreakdownData(session)) { + return Object.values(session.categoryBreakdown).reduce((sum, c) => sum + c.turns, 0) + } + return session.turns.length +} + +// Token-savings estimate for a low-worth candidate. Two regimes: +// - No-edit sessions: full session tokens are at risk (the session produced +// no apparent output to weigh against the spend). +// - Sessions with edits but with retries / no one-shot: only the retry +// fraction is counted as recoverable. Edits may still have been useful; +// we credit the model with that and only flag the retry overhead. +// Ratio is bounded to [0, 1] so retry-heavy sessions with weird turn counts +// can't claim more than the full session token total. +function estimateLowWorthRecoverableTokens( + session: ProjectSummary['sessions'][number], + editTurns: number, + retries: number, +): number { + const tokens = sessionTokenTotal(session) + if (editTurns === 0) return tokens + const totalTurns = sessionTotalTurns(session) + if (totalTurns === 0) return 0 + const fraction = Math.min(1, Math.max(0, retries / totalTurns)) + return Math.round(tokens * fraction) +} + +// Session-opener texts, the single source of truth shared by the optimize +// findings below and by `codeburn guard` (SessionStart hook). Kept as +// constants so the two surfaces can never drift. +export const LOW_WORTH_OPENER = 'Before continuing, name the deliverable in one sentence (PR title, file changed, command output you expect). Stop and check with me if (a) you spend more than 10 minutes without an edit, or (b) the same approach fails twice. Do not retry past two attempts on any single fix.' +export const CONTEXT_HEAVY_OPENER = 'Start fresh before continuing. Use only the current goal, the relevant files, the failing command/output, and the constraints below. Restate the working context in under 10 bullets before editing.' + +export type LowWorthCandidate = { + project: string + sessionId: string + date: string + cost: number + tokens: number + reasons: string[] +} + +export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCandidate[] { + const candidates: LowWorthCandidate[] = [] + + for (const project of projects) { + for (const session of project.sessions) { + if (session.totalCostUSD < WORTH_IT_MIN_COST_USD) continue + if (sessionDeliveryCommand(session)) continue + + const editTurns = sessionEditTurns(session) + const oneShotTurns = sessionOneShotTurns(session) + const retries = sessionRetryCount(session) + const reasons: string[] = [] + + if (editTurns === 0 && session.totalCostUSD >= WORTH_IT_NO_EDIT_MIN_COST_USD) { + reasons.push('no edit turns') + } + if (retries >= WORTH_IT_MIN_RETRIES) { + reasons.push(`${retries} retries`) + } + if ( + editTurns > 0 + && oneShotTurns === 0 + && retries >= WORTH_IT_RETRY_WITH_EDIT_MIN_RETRIES + ) { + reasons.push('no one-shot edit turns') + } + + if (reasons.length === 0) continue + + candidates.push({ + project: project.project, + sessionId: session.sessionId, + date: session.firstTimestamp.slice(0, 10), + cost: session.totalCostUSD, + tokens: estimateLowWorthRecoverableTokens(session, editTurns, retries), + reasons, + }) + } + } + + candidates.sort((a, b) => + b.cost - a.cost + || a.date.localeCompare(b.date) + || a.project.localeCompare(b.project) + || a.sessionId.localeCompare(b.sessionId) + ) + return candidates +} + +export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding | null { + const candidates = findLowWorthCandidates(projects) + if (candidates.length === 0) return null + + const preview = candidates.slice(0, WORTH_IT_PREVIEW) + const list = preview + .map(s => `${s.project}/${s.sessionId} on ${s.date}: ${formatCost(s.cost)} (${s.reasons.join(', ')})`) + .join('; ') + const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : '' + // Per-candidate `tokens` is already the recoverable estimate (full session + // for no-edit, retry-fraction for edit-with-retries). Sum across candidates. + const tokensSaved = Math.round(candidates.reduce((sum, s) => sum + s.tokens, 0)) + const totalCost = candidates.reduce((sum, s) => sum + s.cost, 0) + + // Three tiers consistent with detectContextBloat: high at >=10 candidates + // or >=$50 total spend at risk; low at <=2 candidates AND <$10 total; + // medium in between. + let impact: Impact + if (candidates.length >= WORTH_IT_HIGH_MIN_CANDIDATES || totalCost >= WORTH_IT_HIGH_TOTAL_COST_USD) { + impact = 'high' + } else if (candidates.length <= WORTH_IT_LOW_MAX_CANDIDATES && totalCost < WORTH_IT_LOW_MAX_TOTAL_COST_USD) { + impact = 'low' + } else { + impact = 'medium' + } + + 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, + tokensSaved, + fix: { + type: 'paste', + destination: 'session-opener', + label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', + text: LOW_WORTH_OPENER, + }, + } +} + +export type ContextBloatCandidate = { + project: string + sessionId: string + date: string + effectiveInputTokens: number + outputTokens: number + ratio: number + excessInputTokens: number + growthRatio: number | null +} + +export function findContextBloatCandidates(projects: ProjectSummary[]): ContextBloatCandidate[] { + const candidates: ContextBloatCandidate[] = [] + + for (const project of projects) { + const sessions = [...project.sessions].sort((a, b) => + new Date(a.firstTimestamp).getTime() - new Date(b.firstTimestamp).getTime() + ) + let previousInputTokens: number | null = null + let previousTimestampMs: number | null = null + + for (const session of sessions) { + const inputTokens = sessionEffectiveContextTokens(session) + const outputTokens = session.totalOutputTokens + const ratio = inputTokens / Math.max(outputTokens, 1) + const currentMs = new Date(session.firstTimestamp).getTime() + const gapMs = previousTimestampMs !== null ? currentMs - previousTimestampMs : null + // Suppress growth ratio when the previous session is too far back to be + // a meaningful baseline (e.g. a small test run weeks before a real + // working session would otherwise produce alarming "1000x" figures). + const growthRatio = previousInputTokens !== null + && previousInputTokens > 0 + && gapMs !== null + && gapMs <= CONTEXT_BLOAT_GROWTH_MAX_GAP_MS + ? inputTokens / previousInputTokens + : null + + // Anchor growth to the immediately previous project session, even if + // that session is below threshold and never becomes a finding. + previousInputTokens = inputTokens + previousTimestampMs = currentMs + + if (inputTokens < CONTEXT_BLOAT_MIN_INPUT_TOKENS) continue + if (ratio < CONTEXT_BLOAT_MIN_RATIO) continue + + candidates.push({ + project: project.project, + sessionId: session.sessionId, + date: session.firstTimestamp.slice(0, 10), + effectiveInputTokens: inputTokens, + outputTokens, + ratio, + excessInputTokens: Math.max(0, inputTokens - outputTokens * CONTEXT_BLOAT_TARGET_RATIO), + growthRatio, + }) + } + } + + candidates.sort((a, b) => + b.excessInputTokens - a.excessInputTokens + || a.date.localeCompare(b.date) + || a.project.localeCompare(b.project) + || a.sessionId.localeCompare(b.sessionId) + ) + return candidates +} + +export function detectContextBloat(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet): WasteFinding | null { + const candidates = findContextBloatCandidates(projects) + .filter(c => !excludedSessionIds?.has(c.sessionId)) + if (candidates.length === 0) return null + + const preview = candidates.slice(0, CONTEXT_BLOAT_PREVIEW) + const list = preview + .map(c => { + const growth = c.growthRatio !== null && c.growthRatio >= CONTEXT_BLOAT_GROWTH_RATIO + ? `, ${c.growthRatio.toFixed(1)}x previous session input` + : '' + return `${c.project}/${c.sessionId} on ${c.date}: ${formatTokens(c.effectiveInputTokens)} effective input/cache vs ${formatTokens(c.outputTokens)} output (${formatContextRatio(c.ratio)}:1${growth})` + }) + .join('; ') + const extra = candidates.length > preview.length ? `; +${candidates.length - preview.length} more` : '' + // Savings estimate only counts context above a healthier 15:1 input-output ratio. + // Detection stays stricter at 25:1 so borderline sessions are not shown. + const tokensSaved = Math.round(candidates.reduce((sum, c) => sum + c.excessInputTokens, 0)) + const totalInputTokens = candidates.reduce((sum, c) => sum + c.effectiveInputTokens, 0) + + // Tier on candidate count first, total context size second. A single 600K + // session is "high"; 1-2 modest-sized sessions are "low"; everything in + // between is "medium". + let impact: Impact + if (candidates.length >= CONTEXT_BLOAT_HIGH_MIN_CANDIDATES || totalInputTokens >= CONTEXT_BLOAT_HIGH_INPUT_TOKENS) { + impact = 'high' + } else if (candidates.length <= CONTEXT_BLOAT_LOW_MAX_CANDIDATES && totalInputTokens < CONTEXT_BLOAT_LOW_INPUT_TOKENS) { + impact = 'low' + } else { + impact = 'medium' + } + + 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, + tokensSaved, + fix: { + type: 'paste', + destination: 'session-opener', + label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', + text: CONTEXT_HEAVY_OPENER, + }, + } +} + +export function detectSessionOutliers(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet): WasteFinding | null { + type Outlier = { + project: string + sessionId: string + date: string + cost: number + avgCost: number + ratio: number + tokenExcess: number + } + + const outliers: Outlier[] = [] + + for (const project of projects) { + const sessions = project.sessions.filter(s => s.totalCostUSD > 0) + if (sessions.length < MIN_SESSIONS_FOR_OUTLIER) continue + + const totalCost = sessions.reduce((sum, s) => sum + s.totalCostUSD, 0) + const totalTokens = sessions.reduce((sum, s) => sum + sessionTokenTotal(s), 0) + for (const session of sessions) { + const avgCost = (totalCost - session.totalCostUSD) / (sessions.length - 1) + const avgTokens = (totalTokens - sessionTokenTotal(session)) / (sessions.length - 1) + if (avgCost <= 0) continue + + const ratio = session.totalCostUSD / avgCost + if (ratio <= SESSION_OUTLIER_MULTIPLIER) continue + if (session.totalCostUSD < MIN_SESSION_OUTLIER_COST_USD) continue + // Avoid reporting the same session under both this finding and the + // context-bloat finding. Context-bloat takes priority because its + // suggested fix ("start fresh") is more concrete than the generic + // "tighter constraint" advice here. + if (excludedSessionIds?.has(session.sessionId)) continue + + outliers.push({ + project: project.project, + sessionId: session.sessionId, + date: session.firstTimestamp.slice(0, 10), + cost: session.totalCostUSD, + avgCost, + ratio, + tokenExcess: Math.max(0, sessionTokenTotal(session) - avgTokens), + }) + } + } + + if (outliers.length === 0) return null + + outliers.sort((a, b) => b.cost - a.cost) + const preview = outliers.slice(0, SESSION_OUTLIER_PREVIEW) + const list = preview + .map(o => `${o.project}/${o.sessionId} on ${o.date}: ${formatCost(o.cost)} (${o.ratio.toFixed(1)}x avg)`) + .join('; ') + const extra = outliers.length > preview.length ? `; +${outliers.length - preview.length} more` : '' + const tokensSaved = Math.round(outliers.reduce((sum, o) => sum + o.tokenExcess, 0)) + 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', + tokensSaved, + fix: { + type: 'paste', + destination: 'session-opener', + label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', + text: 'Before making changes, summarize the smallest viable plan. Keep context narrow, avoid broad searches, and stop after the first working patch so I can review before continuing.', + }, + } +} + +// ============================================================================ +// Scoring +// ============================================================================ + +const HEALTH_WEIGHTS: Record = { + high: HEALTH_WEIGHT_HIGH, + medium: HEALTH_WEIGHT_MEDIUM, + low: HEALTH_WEIGHT_LOW, +} + +export function computeHealth(findings: WasteFinding[]): { score: number; grade: HealthGrade } { + if (findings.length === 0) return { score: 100, grade: 'A' } + let penalty = 0 + for (const f of findings) penalty += HEALTH_WEIGHTS[f.impact] ?? 0 + const score = Math.max(0, 100 - Math.min(HEALTH_MAX_PENALTY, penalty)) + const grade: HealthGrade = + score >= GRADE_A_MIN ? 'A' : + score >= GRADE_B_MIN ? 'B' : + score >= GRADE_C_MIN ? 'C' : + score >= GRADE_D_MIN ? 'D' : 'F' + return { score, grade } +} + +const URGENCY_WEIGHTS: Record = { high: 1, medium: 0.5, low: 0.2 } + +function urgencyScore(f: WasteFinding): number { + const normalizedTokens = Math.min(1, f.tokensSaved / URGENCY_TOKEN_NORMALIZE) + return URGENCY_WEIGHTS[f.impact] * URGENCY_IMPACT_WEIGHT + normalizedTokens * URGENCY_TOKEN_WEIGHT +} + +type TrendInputs = { + recentCount: number + recentWindowMs: number + baselineCount: number + baselineWindowMs: number + hasRecentActivity: boolean +} + +export function computeTrend(inputs: TrendInputs): Trend | 'resolved' { + const { recentCount, recentWindowMs, baselineCount, baselineWindowMs, hasRecentActivity } = inputs + if (baselineCount === 0) return 'active' + if (recentCount === 0 && hasRecentActivity) return 'resolved' + if (!hasRecentActivity) return 'active' + const baselineRate = baselineCount / baselineWindowMs + const recentRate = recentCount / Math.max(recentWindowMs, 1) + if (recentRate < baselineRate * IMPROVING_THRESHOLD) return 'improving' + return 'active' +} + +function sessionTrend( + recentItemCount: number, + totalItemCount: number, + dateRange: DateRange | undefined, + hasRecentActivity: boolean, +): Trend | 'resolved' { + const now = Date.now() + const baselineCount = totalItemCount - recentItemCount + const periodStart = dateRange ? dateRange.start.getTime() : now - DEFAULT_TREND_PERIOD_MS + const recentStart = now - RECENT_WINDOW_MS + const baselineWindowMs = Math.max(recentStart - periodStart, 1) + return computeTrend({ + recentCount: recentItemCount, + recentWindowMs: RECENT_WINDOW_MS, + baselineCount, + baselineWindowMs, + hasRecentActivity, + }) +} + +// ============================================================================ +// Cost estimation +// ============================================================================ + +const INPUT_COST_RATIO = 0.7 +const DEFAULT_COST_PER_TOKEN = 0 + +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) => + s + sess.totalInputTokens + sess.totalCacheReadTokens + sess.totalCacheWriteTokens, 0) + if (totalTokens === 0 || totalCost === 0) return DEFAULT_COST_PER_TOKEN + return (totalCost * INPUT_COST_RATIO) / totalTokens +} + +// ============================================================================ +// Main entry points +// ============================================================================ + +type CacheEntry = { data: OptimizeResult; ts: number } +const resultCache = new Map() + +function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { + const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all' + const fingerprint = projects.length + ':' + projects.reduce((s, p) => s + p.totalApiCalls, 0) + return `${dr}:${fingerprint}` +} + +export async function scanAndDetect( + projects: ProjectSummary[], + dateRange?: DateRange, +): Promise { + if (projects.length === 0) { + return { findings: [], costRate: 0, healthScore: 100, healthGrade: 'A' } + } + + const key = cacheKey(projects, dateRange) + const cached = resultCache.get(key) + if (cached && Date.now() - cached.ts < RESULT_CACHE_TTL_MS) return cached.data + + const costRate = computeInputCostRate(projects) + const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange) + const mcpCoverage = aggregateMcpCoverage(projects) + + const findings: WasteFinding[] = [] + // Priority order for the per-session findings: low-worth → context-bloat → + // outliers. Each later detector excludes sessions already named by an + // earlier one so a single session is not listed in three findings. + const lowWorthSessionIds = new Set(findLowWorthCandidates(projects).map(c => c.sessionId)) + const contextBloatVisibleIds = new Set( + findContextBloatCandidates(projects) + .filter(c => !lowWorthSessionIds.has(c.sessionId)) + .map(c => c.sessionId), + ) + const outlierExclusions = new Set([...lowWorthSessionIds, ...contextBloatVisibleIds]) + const syncDetectors: Array<() => WasteFinding | null> = [ + () => detectCacheBloat(apiCalls, projects, dateRange), + () => detectLowReadEditRatio(toolCalls), + () => detectJunkReads(toolCalls, dateRange), + () => detectDuplicateReads(toolCalls, dateRange), + () => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage), + () => detectMcpToolCoverage(projects, mcpCoverage), + () => detectMcpProfileAdvisor(projects, mcpCoverage), + () => detectCapabilityReliability(projects), + () => detectLowWorthSessions(projects), + () => detectContextBloat(projects, lowWorthSessionIds), + () => detectSessionOutliers(projects, outlierExclusions), + () => detectBloatedClaudeMd(projectCwds), + () => detectBashBloat(), + ] + for (const detect of syncDetectors) { + const finding = detect() + if (finding) findings.push(finding) + } + + const ghostResults = await Promise.all([ + detectGhostAgents(toolCalls), + detectGhostSkills(toolCalls), + detectGhostCommands(userMessages), + ]) + for (const f of ghostResults) if (f) findings.push(f) + + findings.sort((a, b) => urgencyScore(b) - urgencyScore(a)) + const { score, grade } = computeHealth(findings) + const result: OptimizeResult = { findings, costRate, healthScore: score, healthGrade: grade } + resultCache.set(key, { data: result, ts: Date.now() }) + return result +} + +// ============================================================================ +// CLI rendering +// ============================================================================ + +const PANEL_WIDTH = 62 +const SEP = '\u2500' +const IMPACT_COLORS: Record = { high: RED, medium: ORANGE, low: DIM } +const GRADE_COLORS: Record = { A: GREEN, B: GREEN, C: GOLD, D: ORANGE, F: RED } + +function wrap(text: string, width: number, indent: string): string { + const words = text.split(' ') + const lines: string[] = [] + let current = '' + for (const word of words) { + if (current && current.length + word.length + 1 > width) { + lines.push(indent + current) + current = word + } else { + current = current ? current + ' ' + word : word + } + } + if (current) lines.push(indent + current) + return lines.join('\n') +} + +/// Section header for a finding's fix block, declaring its intended +/// destination. Issue #277: users were dropping one-time session openers +/// into CLAUDE.md as permanent rules because the prompts had no labeled +/// home in the output. +function renderActionHeader(action: WasteAction): string { + const headerWidth = PANEL_WIDTH - 4 + const fillTo = (label: string): string => { + const inner = ` ${label} ` + const trailing = Math.max(2, headerWidth - inner.length - 4) + return `--${inner}${SEP.repeat(trailing)}`.padEnd(headerWidth) + } + switch (action.type) { + case 'file-content': + return fillTo(`Suggested ${action.path} addition`) + case 'command': + return fillTo('Run this command') + case 'paste': + switch (action.destination) { + case 'claude-md': return fillTo('Suggested CLAUDE.md addition (permanent rule)') + case 'session-opener': return fillTo('One-time session opener (do NOT add to CLAUDE.md)') + case 'prompt': return fillTo('Ask Claude in the current session') + case 'shell-config': return fillTo('Add to your shell config') + default: return fillTo('Suggested action') + } + } +} + +function renderFinding(n: number, f: WasteFinding, costRate: number): string[] { + const lines: string[] = [] + const costSaved = f.tokensSaved * costRate + const impactLabel = f.impact.charAt(0).toUpperCase() + f.impact.slice(1) + const trendBadge = f.trend === 'improving' ? ' improving \u2193 ' : '' + const savings = `~${formatTokens(f.tokensSaved)} tokens (~${formatCost(costSaved)})` + const titlePad = PANEL_WIDTH - f.title.length - impactLabel.length - trendBadge.length - 8 + const pad = titlePad > 0 ? ' ' + SEP.repeat(titlePad) + ' ' : ' ' + + lines.push(chalk.hex(DIM)(` ${SEP}${SEP}${SEP} `) + + chalk.bold(`${n}. ${f.title}`) + + chalk.hex(DIM)(pad) + + chalk.hex(IMPACT_COLORS[f.impact])(impactLabel) + + (trendBadge ? chalk.hex(GREEN)(trendBadge) : '') + + chalk.hex(DIM)(` ${SEP}${SEP}${SEP}`)) + lines.push('') + lines.push(wrap(f.explanation, PANEL_WIDTH - 4, ' ')) + lines.push('') + lines.push(chalk.hex(GOLD)(` Potential savings: ${savings}`)) + lines.push('') + + // Destination header — issue #277. Tells the user where each suggestion + // belongs (CLAUDE.md / session opener / current chat / shell config) so + // permanent rules and one-time prompts are no longer interchangeable in + // the output. + const a = f.fix + lines.push(chalk.hex(ORANGE)(` ${renderActionHeader(a)}`)) + lines.push(chalk.hex(DIM)(` ${a.label}`)) + if (a.type === 'file-content') { + for (const line of a.content.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`)) + } else if (a.type === 'command') { + for (const line of a.text.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`)) + } else { + for (const line of a.text.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`)) + } + lines.push('') + return lines +} + +function renderOptimize( + findings: WasteFinding[], + costRate: number, + periodLabel: string, + periodCost: number, + sessionCount: number, + callCount: number, + healthScore: number, + healthGrade: HealthGrade, + appliedHeader?: string, + previouslyApplied?: Record, +): string { + const lines: string[] = [] + lines.push('') + lines.push(` ${chalk.bold.hex(ORANGE)('CodeBurn config health')}${chalk.dim(' ' + periodLabel)}`) + lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH))) + + const issueSuffix = findings.length > 0 ? `, ${findings.length} issue${findings.length > 1 ? 's' : ''}` : '' + lines.push(' ' + [ + `${sessionCount} sessions`, + `${callCount.toLocaleString()} calls`, + chalk.hex(GOLD)(formatCost(periodCost)), + `Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`, + ].join(chalk.hex(DIM)(' '))) + if (appliedHeader) lines.push(' ' + chalk.hex(GREEN)(appliedHeader)) + lines.push('') + + if (findings.length === 0) { + lines.push(chalk.hex(GREEN)(' Nothing to fix. Your setup is lean.')) + lines.push('') + lines.push(chalk.dim(' CodeBurn optimize scans your Claude Code sessions and config for')) + lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused')) + lines.push(chalk.dim(' agents/skills/MCP servers, bloated CLAUDE.md, and more.')) + lines.push('') + return lines.join('\n') + } + + const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0) + const totalCost = totalTokens * costRate + const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0 + const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1) + + const costText = costRate > 0 ? ` (~${formatCost(totalCost)}, ~${pct}% of spend)` : '' + lines.push(chalk.hex(GREEN)(` Potential savings: ~${formatTokens(totalTokens)} tokens${costText}`)) + lines.push('') + + for (let i = 0; i < findings.length; i++) { + 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))) + lines.push(chalk.dim(' Estimates only.')) + lines.push('') + return lines.join('\n') +} + +export async function runOptimize( + projects: ProjectSummary[], + periodLabel: string, + dateRange?: DateRange, + opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record } = {}, +): Promise { + 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 + } + + if (format === 'text') { + process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) + } + + 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) + + 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..074d8b0 --- /dev/null +++ b/src/overview.ts @@ -0,0 +1,278 @@ +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 { findUnpricedModels, getShortModelName } from './models.js' +import { dateKey } from './day-aggregator.js' + +// Display-only helpers. The shared formatters omit thousands separators and +// abbreviate; here we show full, comma-grouped numbers so the tables read like +// a precise statement. Aggregation uses raw numbers; these only affect render. +function formatCost(usd: number): string { + return baseCost(usd).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,') +} +function formatTokens(n: number): string { + // Pin the locale so grouping is deterministic regardless of the host's + // locale (e.g. en-IN groups as 2,00,20,00,000 instead of 2,002,000,000). + return Math.round(n).toLocaleString('en-US') +} +// Integer counts (calls, sessions, turns, tool uses) — same locale pin so the +// overview output is byte-identical across machines. +function formatCount(n: number): string { + return n.toLocaleString('en-US') +} +function isAbsoluteProjectPath(path: string): boolean { + return path.startsWith('/') || path.startsWith('\\') || /^[a-zA-Z]:[/\\]/.test(path) +} +function projectName(p: ProjectSummary): string { + const path = p.projectPath + if (path) { + if (path === homedir()) return 'Home' + if (!isAbsoluteProjectPath(path)) return p.project || path + 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 gap = ' ' // 2-space cell padding so columns breathe + const sep = gap + c.dim('│') + gap + const edge = c.dim('│') + const bar = (l: string, mid: string, r: string): string => + c.dim(l + widths.map((w) => '─'.repeat(w + 4)).join(mid) + r) + const line = (cells: string[], header = false): string => + edge + gap + cells.map((cell, i) => { + const padded = pad(cell, widths[i]!, cols[i]!.right) + return header ? c.bold(padded) : padded + }).join(sep) + gap + 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, savings = 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 + savings += p.totalSavingsUSD + 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(' (breakdown below)'))) + out.push(kv('Calls', formatCount(calls) + c.dim(' sessions ') + formatCount(sessions))) + out.push(kv('Cache hit', `${cacheHit.toFixed(1)}%`)) + if (savings > 0) out.push(kv('Savings', formatCost(savings) + c.dim(' (local models)'))) + const unpriced = findUnpricedModels( + [...byModel.entries()].map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens })), + ) + if (unpriced.length > 0) { + const shown = unpriced.slice(0, 3) + .map((u) => `${u.model} (${formatTokens(u.tokens)} tok)`) + .join(', ') + const more = unpriced.length > 3 ? ` +${unpriced.length - 3} more` : '' + out.push(kv('Unpriced', c.yellow(`${unpriced.length} model${unpriced.length === 1 ? '' : 's'} at $0: `) + shown + more)) + out.push(kv('', c.dim('Fix: codeburn model-alias "" '))) + } + out.push('') + + // Tokens breakdown: input / output / cache in (written) / cache out (read) + if (totalTokens > 0) { + const share = (n: number): string => `${Math.round((n / totalTokens) * 100)}%` + out.push(heading('Tokens')) + out.push(renderTable(c, + [{ header: 'Type' }, { header: 'Tokens', right: true }, { header: 'Share', right: true }], + [ + ['Input', formatTokens(inTok), share(inTok)], + ['Output', formatTokens(outTok), share(outTok)], + ['Cache in', formatTokens(cacheW), share(cacheW)], + ['Cache out', formatTokens(cacheR), share(cacheR)], + ['Total', formatTokens(totalTokens), '100%'], + ], + )) + 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), formatCount(v.calls), 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), formatCount(v.sessions)]), + )) + 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), formatCount(v.turns)]), + )) + 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, formatCount(n)]), + )) + 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 new file mode 100644 index 0000000..2f5de98 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,2537 @@ +import { lstat, readFile, readdir, stat } from 'fs/promises' +import { basename, dirname, join, resolve, sep } from 'path' +import { readSessionLines } from './fs-utils.js' +import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash } from './models.js' +import { normalizeContentBlocks } from './content-utils.js' +import { discoverAllSessions, getProvider } from './providers/index.js' +import { flushCodexCache } from './codex-cache.js' +import { antigravityCascadeIdFromPath, flushAntigravityCache, shouldReparseAntigravitySource } from './providers/antigravity.js' +import { getDesktopSessionsDir } from './providers/claude.js' +import { isSqliteBusyError } from './sqlite.js' +import { + type CachedCall, + type CachedFile, + type CachedTurn, + type ProviderSection, + type SessionCache, + cleanupOrphanedTempFiles, + computeEnvFingerprint, + DURABLE_PROVIDER_NAMES, + fingerprintFile, + loadCache, + reconcileFile, + saveCache, +} from './session-cache.js' +import type { ParsedProviderCall, SessionSource } from './providers/types.js' +import type { + AssistantMessageContent, + ClassifiedTurn, + ContentBlock, + DateRange, + JournalEntry, + ParsedApiCall, + ParsedTurn, + ProjectSummary, + SessionSummary, + SessionSourceMetadata, + TokenUsage, + ToolCall, + ToolUseBlock, +} from './types.js' +import { classifyTurn, BASH_TOOLS, EDIT_TOOLS } from './classifier.js' +import { extractBashCommands } from './bash-utils.js' + +function unsanitizePath(dirName: string): string { + return dirName.replace(/-/g, '/') +} + +function claudeSlugFallbackPath(dirName: string): string { + // Claude project directory names are lossy: a dash may be either a path + // separator from the original cwd or a literal dash in the leaf name. + // Without cwd metadata, keep the slug intact instead of inventing segments. + return dirName +} + +function normalizeProjectPathKey(projectPath: string): string { + const normalized = projectPath.trim().replace(/\\/g, '/') + return (normalized.replace(/\/+$/, '') || normalized).toLowerCase() +} + +function projectNameFromPath(projectPath: string, fallback: string): string { + const normalized = projectPath.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return normalized.split('/').filter(Boolean).pop() ?? fallback +} + + +// Returns true for sessions whose canonical project key must NOT be derived +// from the cwd. Cowork sessions come in two flavours: +// 1. Local-mode: cwd is an ephemeral per-session outputs/ dir inside the +// desktop sessions directory (detected by checking the cwd). +// 2. Container-mode: the session runs inside a Docker container so cwd is +// something like /sessions/ — not a real path on the host. +// We detect these by checking the JSONL file path instead: if the file +// lives inside the desktop sessions directory, the cwd is container-local +// and must not become the canonical project key. +// In both cases the grouping key comes from the Cowork space name resolved in +// claude.ts::discoverSessions(). +function isCoworkSession(cwd: string, filePath: string): boolean { + const base = resolve(getDesktopSessionsDir()) + const inBase = (p: string) => p.startsWith(base + sep) || p.startsWith(base + '/') + return inBase(resolve(cwd)) || inBase(resolve(filePath)) +} + +async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> { + const trimmed = cwd.trim() + if (!trimmed) return { path: cwd, isWorktree: false } + + // Walk up the directory tree to find a real git worktree marker. Ordinary + // repos use a .git directory; linked worktrees use a .git file pointing back + // to
/.git/worktrees/. Only the latter should canonicalize to + // the main repo. A parent directory with a stray .git directory must not + // absorb sibling projects. + // Guard against foreign paths (e.g. a Windows path recorded on a machine + // that now runs macOS): only walk paths that look like absolute paths on the + // current platform. A relative or foreign-format path cannot be walked on + // the current filesystem without risking false positives. + const isAbsoluteOnCurrentPlatform = process.platform === 'win32' + ? /^[a-zA-Z]:[/\\]/.test(trimmed) + : trimmed.startsWith('/') + if (!isAbsoluteOnCurrentPlatform) return { path: cwd, isWorktree: false } + + let dir = trimmed + while (true) { + const gitEntry = join(dir, '.git') + const entryStat = await lstat(gitEntry).catch(() => null) + if (entryStat?.isDirectory()) { + return { path: dir === trimmed ? dir : cwd, isWorktree: false } + } + if (entryStat?.isFile()) { + const gitFile = await readFile(gitEntry, 'utf-8').catch(() => null) + if (gitFile === null) return { path: dir === trimmed ? dir : cwd, isWorktree: false } + const match = gitFile.match(/^gitdir:\s*(.+?)\s*$/m) + if (!match?.[1]) return { path: dir === trimmed ? dir : cwd, isWorktree: false } + const gitDir = resolve(dir, match[1]) + const normalizedGitDir = gitDir.replace(/\\/g, '/') + const worktreeMarker = '/.git/worktrees/' + const markerIndex = normalizedGitDir.lastIndexOf(worktreeMarker) + if (markerIndex === -1) return { path: dir === trimmed ? dir : cwd, isWorktree: false } + return { path: normalizedGitDir.slice(0, markerIndex), isWorktree: true } + } + const parent = dirname(dir) + if (parent === dir) return { path: cwd, isWorktree: false } + dir = parent + } +} + +const LARGE_JSONL_LINE_BYTES = 32 * 1024 + +export function parseJsonlLine(line: string | Buffer): JournalEntry | null { + if (Buffer.isBuffer(line)) { + if (line.length > LARGE_JSONL_LINE_BYTES) return parseLargeJsonlBuffer(line) + try { + return JSON.parse(line.toString('utf-8')) as JournalEntry + } catch { + return null + } + } + if (line.length > LARGE_JSONL_LINE_BYTES) return parseLargeJsonlLine(line) + try { + return JSON.parse(line) as JournalEntry + } catch { + return null + } +} + +const RAW_HEAD_BYTES = 2048 + +type JsonValueBounds = { + start: number + end: number + kind: 'string' | 'object' | 'array' | 'scalar' +} + +function findJsonStringEnd(source: string, start: number, limit = source.length): number { + for (let i = start + 1; i < limit; i++) { + const ch = source.charCodeAt(i) + if (ch === 0x5c) { + i++ + continue + } + if (ch === 0x22) return i + } + return -1 +} + +function findJsonContainerEnd(source: string, start: number, open: number, close: number, limit = source.length): number { + let depth = 0 + let inString = false + for (let i = start; i < limit; i++) { + const ch = source.charCodeAt(i) + if (inString) { + if (ch === 0x5c) { + i++ + } else if (ch === 0x22) { + inString = false + } + continue + } + if (ch === 0x22) { + inString = true + } else if (ch === open) { + depth++ + } else if (ch === close) { + depth-- + if (depth === 0) return i + } + } + return -1 +} + +function findJsonValueBounds(source: string, start: number, limit = source.length): JsonValueBounds | null { + let i = start + while (i < limit && /\s/.test(source[i]!)) i++ + if (i >= limit) return null + const ch = source.charCodeAt(i) + if (ch === 0x22) { + const end = findJsonStringEnd(source, i, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'string' } + } + if (ch === 0x7b) { + const end = findJsonContainerEnd(source, i, 0x7b, 0x7d, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'object' } + } + if (ch === 0x5b) { + const end = findJsonContainerEnd(source, i, 0x5b, 0x5d, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'array' } + } + let end = i + while (end < limit) { + const c = source.charCodeAt(end) + if (c === 0x2c || c === 0x7d || c === 0x5d || /\s/.test(source[end]!)) break + end++ + } + return { start: i, end, kind: 'scalar' } +} + +function findObjectFieldValue(source: string, objectStart: number, objectEnd: number, field: string): JsonValueBounds | null { + if (source.charCodeAt(objectStart) !== 0x7b) return null + let i = objectStart + 1 + while (i < objectEnd - 1) { + while (i < objectEnd && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (source.charCodeAt(i) !== 0x22) { + i++ + continue + } + const keyEnd = findJsonStringEnd(source, i, objectEnd) + if (keyEnd === -1) return null + const key = source.slice(i + 1, keyEnd) + i = keyEnd + 1 + while (i < objectEnd && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) !== 0x3a) continue + const value = findJsonValueBounds(source, i + 1, objectEnd) + if (!value) return null + if (key === field) return value + i = value.end + } + return null +} + +function readJsonString(source: string, bounds: JsonValueBounds | null, cap = Number.POSITIVE_INFINITY): string | undefined { + if (!bounds || bounds.kind !== 'string') return undefined + let out = '' + for (let i = bounds.start + 1; i < bounds.end - 1 && out.length < cap; i++) { + const ch = source[i]! + if (ch !== '\\') { + out += ch + continue + } + const next = source[++i] + if (!next) break + if (next === 'n') out += '\n' + else if (next === 'r') out += '\r' + else if (next === 't') out += '\t' + else if (next === 'b') out += '\b' + else if (next === 'f') out += '\f' + else if (next === 'u' && i + 4 < bounds.end) { + const hex = source.slice(i + 1, i + 5) + const code = Number.parseInt(hex, 16) + if (Number.isFinite(code)) out += String.fromCharCode(code) + i += 4 + } else { + out += next + } + } + return out +} + +function readJsonNumberField(source: string, objectBounds: JsonValueBounds | null, field: string): number | undefined { + if (!objectBounds || objectBounds.kind !== 'object') return undefined + const bounds = findObjectFieldValue(source, objectBounds.start, objectBounds.end, field) + if (!bounds) return undefined + const value = Number(source.slice(bounds.start, bounds.end)) + return Number.isFinite(value) ? value : undefined +} + +function parseLargeUsage(source: string, usageBounds: JsonValueBounds | null) { + const usage: AssistantMessageContent['usage'] = { + input_tokens: readJsonNumberField(source, usageBounds, 'input_tokens') ?? 0, + output_tokens: readJsonNumberField(source, usageBounds, 'output_tokens') ?? 0, + cache_creation_input_tokens: readJsonNumberField(source, usageBounds, 'cache_creation_input_tokens'), + cache_read_input_tokens: readJsonNumberField(source, usageBounds, 'cache_read_input_tokens'), + } + + if (usageBounds?.kind === 'object') { + const cacheCreation = findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'cache_creation') + const ephemeral5m = readJsonNumberField(source, cacheCreation, 'ephemeral_5m_input_tokens') + const ephemeral1h = readJsonNumberField(source, cacheCreation, 'ephemeral_1h_input_tokens') + if (ephemeral5m !== undefined || ephemeral1h !== undefined) { + ;(usage as AssistantMessageContent['usage']).cache_creation = { + ...(ephemeral5m !== undefined ? { ephemeral_5m_input_tokens: ephemeral5m } : {}), + ...(ephemeral1h !== undefined ? { ephemeral_1h_input_tokens: ephemeral1h } : {}), + } + } + + const serverToolUse = findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'server_tool_use') + const webSearch = readJsonNumberField(source, serverToolUse, 'web_search_requests') + const webFetch = readJsonNumberField(source, serverToolUse, 'web_fetch_requests') + if (webSearch !== undefined || webFetch !== undefined) { + ;(usage as AssistantMessageContent['usage']).server_tool_use = { + ...(webSearch !== undefined ? { web_search_requests: webSearch } : {}), + ...(webFetch !== undefined ? { web_fetch_requests: webFetch } : {}), + } + } + + const speed = readJsonString(source, findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'speed')) + if (speed === 'standard' || speed === 'fast') usage.speed = speed + } + + return usage +} + +function extractLargeToolBlocks(source: string, contentBounds: JsonValueBounds | null): ToolUseBlock[] { + if (!contentBounds || contentBounds.kind !== 'array') return [] + const tools: ToolUseBlock[] = [] + let i = contentBounds.start + 1 + while (i < contentBounds.end - 1 && tools.length < MAX_TOOL_BLOCKS) { + while (i < contentBounds.end && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (source.charCodeAt(i) !== 0x7b) { + i++ + continue + } + const objectEnd = findJsonContainerEnd(source, i, 0x7b, 0x7d, contentBounds.end) + if (objectEnd === -1) break + const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const } + const blockType = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'type')) + if (blockType === 'tool_use') { + const name = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'name')) ?? '' + const id = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'id')) ?? '' + const inputBounds = findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'input') + const input: Record = {} + if (inputBounds?.kind === 'object') { + if (name === 'Skill') { + const skill = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'skill'), 200) + const skillName = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'name'), 200) + if (skill !== undefined) input['skill'] = skill + if (skillName !== undefined) input['name'] = skillName + } else if (name === 'Read' || name === 'FileReadTool' || EDIT_TOOLS.has(name)) { + const filePath = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'file_path'), BASH_COMMAND_CAP) + if (filePath !== undefined) input['file_path'] = filePath + } else if (name === 'Agent' || name === 'Task') { + const subagentType = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'subagent_type'), 200) + if (subagentType !== undefined) input['subagent_type'] = subagentType + } else if (BASH_TOOLS.has(name)) { + const command = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'command'), BASH_COMMAND_CAP) + if (command !== undefined) input['command'] = command + } + } + tools.push({ type: 'tool_use', id, name, input }) + } + i = objectEnd + 1 + } + return tools +} + +function extractLargeUserText(source: string, contentBounds: JsonValueBounds | null): string | undefined { + if (!contentBounds) return undefined + if (contentBounds.kind === 'string') return readJsonString(source, contentBounds, USER_TEXT_CAP) + if (contentBounds.kind !== 'array') return undefined + + let text = '' + let i = contentBounds.start + 1 + while (i < contentBounds.end - 1 && text.length < USER_TEXT_CAP) { + while (i < contentBounds.end && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (source.charCodeAt(i) !== 0x7b) { + i++ + continue + } + const objectEnd = findJsonContainerEnd(source, i, 0x7b, 0x7d, contentBounds.end) + if (objectEnd === -1) break + const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const } + const type = readJsonString(source, findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'type')) + if (type === 'text' || type === 'input_text') { + const part = readJsonString( + source, + findObjectFieldValue(source, objectBounds.start, objectBounds.end, 'text'), + USER_TEXT_CAP - text.length, + ) + if (part) text += (text ? ' ' : '') + part + } + i = objectEnd + 1 + } + return text || undefined +} + +function extractLargeAddedNames(source: string, attachmentBounds: JsonValueBounds | null): string[] { + if (!attachmentBounds || attachmentBounds.kind !== 'object') return [] + const attachmentType = readJsonString(source, findObjectFieldValue(source, attachmentBounds.start, attachmentBounds.end, 'type')) + if (attachmentType !== 'deferred_tools_delta') return [] + const addedNames = findObjectFieldValue(source, attachmentBounds.start, attachmentBounds.end, 'addedNames') + if (!addedNames || addedNames.kind !== 'array') return [] + const names: string[] = [] + let i = addedNames.start + 1 + while (i < addedNames.end - 1 && names.length < MAX_ADDED_NAMES) { + while (i < addedNames.end && /\s/.test(source[i]!)) i++ + if (source.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (source.charCodeAt(i) !== 0x22) { + i++ + continue + } + const end = findJsonStringEnd(source, i, addedNames.end) + if (end === -1) break + const name = readJsonString(source, { start: i, end: end + 1, kind: 'string' }, 500) + if (name) names.push(name) + i = end + 1 + } + return names +} + +function parseLargeJsonlLine(line: string): JournalEntry | null { + const rootEnd = findJsonContainerEnd(line, 0, 0x7b, 0x7d) + if (rootEnd === -1) return null + const rootStart = 0 + const rootLimit = rootEnd + 1 + const type = readJsonString(line, findObjectFieldValue(line, rootStart, rootLimit, 'type')) + if (!type) return null + + const entry: JournalEntry = { type } + const timestamp = readJsonString(line, findObjectFieldValue(line, rootStart, rootLimit, 'timestamp')) + const sessionId = readJsonString(line, findObjectFieldValue(line, rootStart, rootLimit, 'sessionId')) + const cwd = readJsonString(line, findObjectFieldValue(line, rootStart, rootLimit, 'cwd')) + if (timestamp !== undefined) entry.timestamp = timestamp + if (sessionId !== undefined) entry.sessionId = sessionId + if (cwd !== undefined) entry.cwd = cwd + const addedNames = extractLargeAddedNames(line, findObjectFieldValue(line, rootStart, rootLimit, 'attachment')) + if (addedNames.length > 0) { + ;(entry as Record)['attachment'] = { type: 'deferred_tools_delta', addedNames } + } + + if (type === 'user') { + const message = findObjectFieldValue(line, rootStart, rootLimit, 'message') + if (message?.kind === 'object') { + const content = findObjectFieldValue(line, message.start, message.end, 'content') + const text = extractLargeUserText(line, content) + if (text !== undefined) entry.message = { role: 'user', content: text } + } + return entry + } + + if (type !== 'assistant') return entry + const message = findObjectFieldValue(line, rootStart, rootLimit, 'message') + if (message?.kind !== 'object') return entry + const model = readJsonString(line, findObjectFieldValue(line, message.start, message.end, 'model')) + const usageBounds = findObjectFieldValue(line, message.start, message.end, 'usage') + if (!model || usageBounds?.kind !== 'object') return entry + const id = readJsonString(line, findObjectFieldValue(line, message.start, message.end, 'id')) + const contentBounds = findObjectFieldValue(line, message.start, message.end, 'content') + + entry.message = { + type: 'message', + role: 'assistant', + model, + ...(id !== undefined ? { id } : {}), + content: extractLargeToolBlocks(line, contentBounds), + usage: parseLargeUsage(line, usageBounds), + } + + return entry +} + +type BufferJsonValueBounds = { + start: number + end: number + kind: 'string' | 'object' | 'array' | 'scalar' +} + +function isJsonWhitespaceByte(ch: number | undefined): boolean { + return ch === 0x20 || ch === 0x0a || ch === 0x0d || ch === 0x09 +} + +function findJsonStringEndBuffer(source: Buffer, start: number, limit = source.length): number { + for (let i = start + 1; i < limit; i++) { + const ch = source[i] + if (ch === 0x5c) { + i++ + continue + } + if (ch === 0x22) return i + } + return -1 +} + +function findJsonContainerEndBuffer(source: Buffer, start: number, open: number, close: number, limit = source.length): number { + let depth = 0 + let inString = false + for (let i = start; i < limit; i++) { + const ch = source[i] + if (inString) { + if (ch === 0x5c) { + i++ + } else if (ch === 0x22) { + inString = false + } + continue + } + if (ch === 0x22) { + inString = true + } else if (ch === open) { + depth++ + } else if (ch === close) { + depth-- + if (depth === 0) return i + } + } + return -1 +} + +function findJsonValueBoundsBuffer(source: Buffer, start: number, limit = source.length): BufferJsonValueBounds | null { + let i = start + while (i < limit && isJsonWhitespaceByte(source[i])) i++ + if (i >= limit) return null + const ch = source[i] + if (ch === 0x22) { + const end = findJsonStringEndBuffer(source, i, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'string' } + } + if (ch === 0x7b) { + const end = findJsonContainerEndBuffer(source, i, 0x7b, 0x7d, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'object' } + } + if (ch === 0x5b) { + const end = findJsonContainerEndBuffer(source, i, 0x5b, 0x5d, limit) + return end === -1 ? null : { start: i, end: end + 1, kind: 'array' } + } + let end = i + while (end < limit) { + const c = source[end] + if (c === 0x2c || c === 0x7d || c === 0x5d || isJsonWhitespaceByte(c)) break + end++ + } + return { start: i, end, kind: 'scalar' } +} + +function bufferKeyEquals(source: Buffer, keyStart: number, keyEnd: number, field: string): boolean { + if (keyEnd - keyStart !== field.length) return false + return source.subarray(keyStart, keyEnd).equals(Buffer.from(field)) +} + +function findObjectFieldValueBuffer(source: Buffer, objectStart: number, objectEnd: number, field: string): BufferJsonValueBounds | null { + if (source[objectStart] !== 0x7b) return null + let i = objectStart + 1 + while (i < objectEnd - 1) { + while (i < objectEnd && isJsonWhitespaceByte(source[i])) i++ + if (source[i] === 0x2c) { + i++ + continue + } + if (source[i] !== 0x22) { + i++ + continue + } + const keyEnd = findJsonStringEndBuffer(source, i, objectEnd) + if (keyEnd === -1) return null + const keyStart = i + 1 + i = keyEnd + 1 + while (i < objectEnd && isJsonWhitespaceByte(source[i])) i++ + if (source[i] !== 0x3a) continue + const value = findJsonValueBoundsBuffer(source, i + 1, objectEnd) + if (!value) return null + if (bufferKeyEquals(source, keyStart, keyEnd, field)) return value + i = value.end + } + return null +} + +function appendBufferJsonSegment(source: Buffer, start: number, end: number, current: string, cap: number): string { + if (start >= end || current.length >= cap) return current + const remaining = cap - current.length + const cappedEnd = Number.isFinite(cap) ? Math.min(end, start + remaining * 4) : end + return current + source.subarray(start, cappedEnd).toString('utf-8').slice(0, remaining) +} + +function readJsonStringBuffer(source: Buffer, bounds: BufferJsonValueBounds | null, cap = Number.POSITIVE_INFINITY): string | undefined { + if (!bounds || bounds.kind !== 'string') return undefined + let out = '' + let segmentStart = bounds.start + 1 + for (let i = bounds.start + 1; i < bounds.end - 1 && out.length < cap; i++) { + const ch = source[i] + if (ch !== 0x5c) continue + + out = appendBufferJsonSegment(source, segmentStart, i, out, cap) + if (out.length >= cap) break + const next = source[++i] + if (next === undefined) break + if (next === 0x6e) out += '\n' + else if (next === 0x72) out += '\r' + else if (next === 0x74) out += '\t' + else if (next === 0x62) out += '\b' + else if (next === 0x66) out += '\f' + else if (next === 0x75 && i + 4 < bounds.end) { + const hex = source.subarray(i + 1, i + 5).toString('ascii') + const code = Number.parseInt(hex, 16) + if (Number.isFinite(code)) out += String.fromCharCode(code) + i += 4 + } else { + out += String.fromCharCode(next) + } + segmentStart = i + 1 + } + return appendBufferJsonSegment(source, segmentStart, bounds.end - 1, out, cap) +} + +function readJsonNumberFieldBuffer(source: Buffer, objectBounds: BufferJsonValueBounds | null, field: string): number | undefined { + if (!objectBounds || objectBounds.kind !== 'object') return undefined + const bounds = findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, field) + if (!bounds) return undefined + const value = Number(source.subarray(bounds.start, bounds.end).toString('ascii')) + return Number.isFinite(value) ? value : undefined +} + +function parseLargeUsageBuffer(source: Buffer, usageBounds: BufferJsonValueBounds | null) { + const usage: AssistantMessageContent['usage'] = { + input_tokens: readJsonNumberFieldBuffer(source, usageBounds, 'input_tokens') ?? 0, + output_tokens: readJsonNumberFieldBuffer(source, usageBounds, 'output_tokens') ?? 0, + cache_creation_input_tokens: readJsonNumberFieldBuffer(source, usageBounds, 'cache_creation_input_tokens'), + cache_read_input_tokens: readJsonNumberFieldBuffer(source, usageBounds, 'cache_read_input_tokens'), + } + + if (usageBounds?.kind === 'object') { + const cacheCreation = findObjectFieldValueBuffer(source, usageBounds.start, usageBounds.end, 'cache_creation') + const ephemeral5m = readJsonNumberFieldBuffer(source, cacheCreation, 'ephemeral_5m_input_tokens') + const ephemeral1h = readJsonNumberFieldBuffer(source, cacheCreation, 'ephemeral_1h_input_tokens') + if (ephemeral5m !== undefined || ephemeral1h !== undefined) { + ;(usage as AssistantMessageContent['usage']).cache_creation = { + ...(ephemeral5m !== undefined ? { ephemeral_5m_input_tokens: ephemeral5m } : {}), + ...(ephemeral1h !== undefined ? { ephemeral_1h_input_tokens: ephemeral1h } : {}), + } + } + + const serverToolUse = findObjectFieldValueBuffer(source, usageBounds.start, usageBounds.end, 'server_tool_use') + const webSearch = readJsonNumberFieldBuffer(source, serverToolUse, 'web_search_requests') + const webFetch = readJsonNumberFieldBuffer(source, serverToolUse, 'web_fetch_requests') + if (webSearch !== undefined || webFetch !== undefined) { + ;(usage as AssistantMessageContent['usage']).server_tool_use = { + ...(webSearch !== undefined ? { web_search_requests: webSearch } : {}), + ...(webFetch !== undefined ? { web_fetch_requests: webFetch } : {}), + } + } + + const speed = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, usageBounds.start, usageBounds.end, 'speed')) + if (speed === 'standard' || speed === 'fast') usage.speed = speed + } + + return usage +} + +function extractLargeToolBlocksBuffer(source: Buffer, contentBounds: BufferJsonValueBounds | null): ToolUseBlock[] { + if (!contentBounds || contentBounds.kind !== 'array') return [] + const tools: ToolUseBlock[] = [] + let i = contentBounds.start + 1 + while (i < contentBounds.end - 1 && tools.length < MAX_TOOL_BLOCKS) { + while (i < contentBounds.end && isJsonWhitespaceByte(source[i])) i++ + if (source[i] === 0x2c) { + i++ + continue + } + if (source[i] !== 0x7b) { + i++ + continue + } + const objectEnd = findJsonContainerEndBuffer(source, i, 0x7b, 0x7d, contentBounds.end) + if (objectEnd === -1) break + const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const } + const blockType = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'type')) + if (blockType === 'tool_use') { + const name = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'name')) ?? '' + const id = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'id')) ?? '' + const inputBounds = findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'input') + const input: Record = {} + if (inputBounds?.kind === 'object') { + if (name === 'Skill') { + const skill = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'skill'), 200) + const skillName = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'name'), 200) + if (skill !== undefined) input['skill'] = skill + if (skillName !== undefined) input['name'] = skillName + } else if (name === 'Read' || name === 'FileReadTool' || EDIT_TOOLS.has(name)) { + const filePath = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'file_path'), BASH_COMMAND_CAP) + if (filePath !== undefined) input['file_path'] = filePath + } else if (name === 'Agent' || name === 'Task') { + const subagentType = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'subagent_type'), 200) + if (subagentType !== undefined) input['subagent_type'] = subagentType + } else if (BASH_TOOLS.has(name)) { + const command = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'command'), BASH_COMMAND_CAP) + if (command !== undefined) input['command'] = command + } + } + tools.push({ type: 'tool_use', id, name, input }) + } + i = objectEnd + 1 + } + return tools +} + +function extractLargeUserTextBuffer(source: Buffer, contentBounds: BufferJsonValueBounds | null): string | undefined { + if (!contentBounds) return undefined + if (contentBounds.kind === 'string') return readJsonStringBuffer(source, contentBounds, USER_TEXT_CAP) + if (contentBounds.kind !== 'array') return undefined + + let text = '' + let i = contentBounds.start + 1 + while (i < contentBounds.end - 1 && text.length < USER_TEXT_CAP) { + while (i < contentBounds.end && isJsonWhitespaceByte(source[i])) i++ + if (source[i] === 0x2c) { + i++ + continue + } + if (source[i] !== 0x7b) { + i++ + continue + } + const objectEnd = findJsonContainerEndBuffer(source, i, 0x7b, 0x7d, contentBounds.end) + if (objectEnd === -1) break + const objectBounds = { start: i, end: objectEnd + 1, kind: 'object' as const } + const type = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'type')) + if (type === 'text' || type === 'input_text') { + const part = readJsonStringBuffer( + source, + findObjectFieldValueBuffer(source, objectBounds.start, objectBounds.end, 'text'), + USER_TEXT_CAP - text.length, + ) + if (part) text += (text ? ' ' : '') + part + } + i = objectEnd + 1 + } + return text || undefined +} + +function extractLargeAddedNamesBuffer(source: Buffer, attachmentBounds: BufferJsonValueBounds | null): string[] { + if (!attachmentBounds || attachmentBounds.kind !== 'object') return [] + const attachmentType = readJsonStringBuffer( + source, + findObjectFieldValueBuffer(source, attachmentBounds.start, attachmentBounds.end, 'type'), + ) + if (attachmentType !== 'deferred_tools_delta') return [] + const addedNames = findObjectFieldValueBuffer(source, attachmentBounds.start, attachmentBounds.end, 'addedNames') + if (!addedNames || addedNames.kind !== 'array') return [] + const names: string[] = [] + let i = addedNames.start + 1 + while (i < addedNames.end - 1 && names.length < MAX_ADDED_NAMES) { + while (i < addedNames.end && isJsonWhitespaceByte(source[i])) i++ + if (source[i] === 0x2c) { + i++ + continue + } + if (source[i] !== 0x22) { + i++ + continue + } + const end = findJsonStringEndBuffer(source, i, addedNames.end) + if (end === -1) break + const name = readJsonStringBuffer(source, { start: i, end: end + 1, kind: 'string' }, 500) + if (name) names.push(name) + i = end + 1 + } + return names +} + +function parseLargeJsonlBuffer(line: Buffer): JournalEntry | null { + let rootStart = 0 + while (rootStart < line.length && isJsonWhitespaceByte(line[rootStart])) rootStart++ + if (line[rootStart] !== 0x7b) return null + const rootEnd = findJsonContainerEndBuffer(line, rootStart, 0x7b, 0x7d) + if (rootEnd === -1) return null + const rootLimit = rootEnd + 1 + const type = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'type')) + if (!type) return null + + const entry: JournalEntry = { type } + const timestamp = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'timestamp')) + const sessionId = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'sessionId')) + const cwd = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'cwd')) + if (timestamp !== undefined) entry.timestamp = timestamp + if (sessionId !== undefined) entry.sessionId = sessionId + if (cwd !== undefined) entry.cwd = cwd + const addedNames = extractLargeAddedNamesBuffer(line, findObjectFieldValueBuffer(line, rootStart, rootLimit, 'attachment')) + if (addedNames.length > 0) { + ;(entry as Record)['attachment'] = { type: 'deferred_tools_delta', addedNames } + } + + if (type === 'user') { + const message = findObjectFieldValueBuffer(line, rootStart, rootLimit, 'message') + if (message?.kind === 'object') { + const content = findObjectFieldValueBuffer(line, message.start, message.end, 'content') + const text = extractLargeUserTextBuffer(line, content) + if (text !== undefined) entry.message = { role: 'user', content: text } + } + return entry + } + + if (type !== 'assistant') return entry + const message = findObjectFieldValueBuffer(line, rootStart, rootLimit, 'message') + if (message?.kind !== 'object') return entry + const model = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, message.start, message.end, 'model')) + const usageBounds = findObjectFieldValueBuffer(line, message.start, message.end, 'usage') + if (!model || usageBounds?.kind !== 'object') return entry + const id = readJsonStringBuffer(line, findObjectFieldValueBuffer(line, message.start, message.end, 'id')) + const contentBounds = findObjectFieldValueBuffer(line, message.start, message.end, 'content') + + entry.message = { + type: 'message', + role: 'assistant', + model, + ...(id !== undefined ? { id } : {}), + content: extractLargeToolBlocksBuffer(line, contentBounds), + usage: parseLargeUsageBuffer(line, usageBounds), + } + + return entry +} + +function getTopLevelRawJsonStringField(head: string, field: string): string | null { + let i = 0 + while (i < head.length && /\s/.test(head[i]!)) i++ + if (head.charCodeAt(i) !== 0x7b) return null + i++ + while (i < head.length) { + while (i < head.length && /\s/.test(head[i]!)) i++ + if (head.charCodeAt(i) === 0x2c) { + i++ + continue + } + if (head.charCodeAt(i) === 0x7d) return null + if (head.charCodeAt(i) !== 0x22) return null + const keyEnd = findJsonStringEnd(head, i) + if (keyEnd === -1) return null + const key = head.slice(i + 1, keyEnd) + i = keyEnd + 1 + while (i < head.length && /\s/.test(head[i]!)) i++ + if (head.charCodeAt(i) !== 0x3a) return null + const value = findJsonValueBounds(head, i + 1) + if (!value) return null + if (key === field) return readJsonString(head, value) ?? null + i = value.end + } + return null +} + +export function shouldSkipLine(line: string, threshold: string): boolean { + const head = line.length > RAW_HEAD_BYTES ? line.slice(0, RAW_HEAD_BYTES) : line + const type = getTopLevelRawJsonStringField(head, 'type') + if (type !== 'user' && type !== 'assistant') return false + const ts = getTopLevelRawJsonStringField(head, 'timestamp') + if (!ts || ts.length < 10) return false + return ts < threshold +} + +const USER_TEXT_CAP = 2000 +const BASH_COMMAND_CAP = 2000 +const MAX_TOOL_BLOCKS = 500 +const MAX_ADDED_NAMES = 1000 + +export function compactEntry(raw: JournalEntry): JournalEntry { + const entry: JournalEntry = { type: raw.type } + + if (raw.timestamp !== undefined) entry.timestamp = raw.timestamp + if (raw.sessionId !== undefined) entry.sessionId = raw.sessionId + if (raw.cwd !== undefined) entry.cwd = raw.cwd + + const att = (raw as Record)['attachment'] + if (att && typeof att === 'object') { + const a = att as Record + if (a['type'] === 'deferred_tools_delta' && Array.isArray(a['addedNames'])) { + const names: string[] = [] + for (let i = 0; i < Math.min(a['addedNames'].length, MAX_ADDED_NAMES); i++) { + const n = a['addedNames'][i] + if (typeof n === 'string') names.push(n) + } + ;(entry as Record)['attachment'] = { type: 'deferred_tools_delta', addedNames: names } + } + } + + if (!raw.message) return entry + + if (raw.message.role === 'user') { + const content = raw.message.content + if (typeof content === 'string') { + entry.message = { role: 'user', content: content.slice(0, USER_TEXT_CAP) } + } else if (Array.isArray(content)) { + let remaining = USER_TEXT_CAP + const blocks: { type: 'text'; text: string }[] = [] + for (const b of content) { + if (remaining <= 0) break + if (!b || typeof b !== 'object' || b.type !== 'text') continue + const text = (b as { text?: unknown }).text + if (typeof text !== 'string') continue + const sliced = text.slice(0, remaining) + blocks.push({ type: 'text', text: sliced }) + remaining -= sliced.length + } + entry.message = { role: 'user', content: blocks } + } + return entry + } + + const msg = raw.message as AssistantMessageContent + if (!msg.usage || !msg.model) return entry + + const rawContent = msg.content + const contentArr = Array.isArray(rawContent) ? rawContent : [] + const toolBlocks = contentArr.filter((b): b is ToolUseBlock => b != null && typeof b === 'object' && b.type === 'tool_use') + const compactContent: ContentBlock[] = toolBlocks.slice(0, MAX_TOOL_BLOCKS).map(tb => { + let input: Record = {} + if (tb.name === 'Skill') { + const ri = (tb.input ?? {}) as Record + if (typeof ri['skill'] === 'string') input['skill'] = (ri['skill'] as string).slice(0, 200) + if (typeof ri['name'] === 'string') input['name'] = (ri['name'] as string).slice(0, 200) + } else if (tb.name === 'Read' || tb.name === 'FileReadTool' || EDIT_TOOLS.has(tb.name)) { + const ri = (tb.input ?? {}) as Record + if (typeof ri['file_path'] === 'string') input['file_path'] = (ri['file_path'] as string).slice(0, BASH_COMMAND_CAP) + } else if (tb.name === 'Agent' || tb.name === 'Task') { + const ri = (tb.input ?? {}) as Record + if (typeof ri['subagent_type'] === 'string') input['subagent_type'] = (ri['subagent_type'] as string).slice(0, 200) + } else if (BASH_TOOLS.has(tb.name)) { + const ri = (tb.input ?? {}) as Record + if (typeof ri['command'] === 'string') { + input['command'] = (ri['command'] as string).slice(0, BASH_COMMAND_CAP) + } + } + return { type: 'tool_use' as const, id: tb.id ?? '', name: tb.name, input } + }) + + const u = msg.usage + const compactUsage: AssistantMessageContent['usage'] = { + input_tokens: u.input_tokens, + output_tokens: u.output_tokens, + } + if (u.cache_creation_input_tokens) compactUsage.cache_creation_input_tokens = u.cache_creation_input_tokens + if (u.cache_creation) { + compactUsage.cache_creation = { + ...(u.cache_creation.ephemeral_5m_input_tokens ? { ephemeral_5m_input_tokens: u.cache_creation.ephemeral_5m_input_tokens } : {}), + ...(u.cache_creation.ephemeral_1h_input_tokens ? { ephemeral_1h_input_tokens: u.cache_creation.ephemeral_1h_input_tokens } : {}), + } + } + if (u.cache_read_input_tokens) compactUsage.cache_read_input_tokens = u.cache_read_input_tokens + if (u.server_tool_use) { + compactUsage.server_tool_use = { + ...(u.server_tool_use.web_search_requests ? { web_search_requests: u.server_tool_use.web_search_requests } : {}), + ...(u.server_tool_use.web_fetch_requests ? { web_fetch_requests: u.server_tool_use.web_fetch_requests } : {}), + } + } + if (u.speed) compactUsage.speed = u.speed + + entry.message = { + type: 'message', + role: 'assistant', + model: msg.model, + usage: compactUsage, + content: compactContent, + ...(msg.id ? { id: msg.id } : {}), + } + + return entry +} + +function extractToolNames(content: ContentBlock[]): string[] { + return content + .filter((b): b is ToolUseBlock => b.type === 'tool_use') + .map(b => b.name) +} + +function extractMcpTools(tools: string[]): string[] { + return tools.filter(t => t.startsWith('mcp__')) +} + +function extractSkillNames(content: ContentBlock[]): string[] { + return content + .filter((b): b is ToolUseBlock => b.type === 'tool_use' && b.name === 'Skill') + .map(b => { + const input = (b.input ?? {}) as Record + const raw = input['skill'] ?? input['name'] + return typeof raw === 'string' ? raw.trim() : '' + }) + .filter(name => name.length > 0) +} + +function extractSubagentTypes(content: ContentBlock[]): string[] { + return content + .filter((b): b is ToolUseBlock => b.type === 'tool_use' && (b.name === 'Agent' || b.name === 'Task')) + .map(b => { + const input = (b.input ?? {}) as Record + const raw = input['subagent_type'] + return typeof raw === 'string' ? raw.trim() : '' + }) + .filter(name => name.length > 0) +} + +function extractCoreTools(tools: string[]): string[] { + return tools.filter(t => !t.startsWith('mcp__')) +} + +function extractBashCommandsFromContent(content: ContentBlock[]): string[] { + return content + .filter((b): b is ToolUseBlock => b.type === 'tool_use' && BASH_TOOLS.has((b as ToolUseBlock).name)) + .flatMap(b => { + const command = (b.input as Record)?.command + return typeof command === 'string' ? extractBashCommands(command) : [] + }) +} + +function getUserMessageText(entry: JournalEntry): string { + if (!entry.message || entry.message.role !== 'user') return '' + const content = entry.message.content + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .filter((b): b is { type: 'text'; text: string } => b.type === 'text') + .map(b => b.text) + .join(' ') + } + return '' +} + +function getMessageId(entry: JournalEntry): string | null { + if (entry.type !== 'assistant') return null + const msg = entry.message as AssistantMessageContent | undefined + return msg?.id ?? null +} + +export function safeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +export function isPositiveNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +function extractClaudeCacheCreation(usage: AssistantMessageContent['usage']): { totalTokens: number; oneHourTokens: number } { + const legacyTotal = safeNumber(usage.cache_creation_input_tokens) + const cacheCreation = usage.cache_creation + const fiveMinuteTokens = safeNumber(cacheCreation?.ephemeral_5m_input_tokens) + const oneHourTokens = safeNumber(cacheCreation?.ephemeral_1h_input_tokens) + const splitTotal = fiveMinuteTokens + oneHourTokens + + if (splitTotal === 0) return { totalTokens: legacyTotal, oneHourTokens: 0 } + + // Valid Claude usage reports the legacy total and split total as equal. + // Keep the larger value so malformed partial splits do not drop tokens. + const totalTokens = Math.max(legacyTotal, splitTotal) + return { + totalTokens, + oneHourTokens: Math.min(oneHourTokens, totalTokens), + } +} + +/// Apply local-model savings accounting to a call. If the raw model name is +/// mapped via `codeburn model-savings`, the call's actual cost is forced +/// to $0 and the hypothetical baseline cost is recorded as `savingsUSD`. +/// Returns the input unchanged when no mapping is configured for the +/// model — keeps the hot path branch-free for the common paid-only case. +function applyLocalModelSavings(call: ParsedApiCall): ParsedApiCall { + const u = call.usage + const savings = calculateLocalModelSavings( + call.model, + u.inputTokens, + u.outputTokens, + u.cacheCreationInputTokens, + u.cacheReadInputTokens, + u.webSearchRequests, + call.speed, + call.cacheCreationOneHourTokens ?? 0, + ) + if (!savings) return call + return { + ...call, + costUSD: 0, + savingsUSD: savings.savingsUSD, + savingsBaselineModel: savings.baselineModel, + isLocalSavings: true, + } +} + +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 + + const usage = msg.usage + const cacheCreation = extractClaudeCacheCreation(usage) + const tokens: TokenUsage = { + inputTokens: usage.input_tokens ?? 0, + outputTokens: usage.output_tokens ?? 0, + cacheCreationInputTokens: cacheCreation.totalTokens, + cacheReadInputTokens: usage.cache_read_input_tokens ?? 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: usage.server_tool_use?.web_search_requests ?? 0, + } + + // Defensive: a message whose `content` is a string (not an array of blocks) + // would crash the helpers below; normalize so one bad record can't abort the + // whole backfill (issue #441). + const contentBlocks = normalizeContentBlocks(msg.content) + const tools = extractToolNames(contentBlocks) + const skills = extractSkillNames(contentBlocks) + const subagentTypes = extractSubagentTypes(contentBlocks) + const costUSD = calculateCost( + msg.model, + tokens.inputTokens, + tokens.outputTokens, + tokens.cacheCreationInputTokens, + tokens.cacheReadInputTokens, + tokens.webSearchRequests, + usage.speed ?? 'standard', + cacheCreation.oneHourTokens, + ) + + const bashCmds = extractBashCommandsFromContent(contentBlocks) + + const toolSeq: ToolCall[][] = contentBlocks + .filter((b): b is ToolUseBlock => b.type === 'tool_use') + .map(b => { + const call: ToolCall = { tool: b.name } + const inp = (b.input ?? {}) as Record + if (typeof inp['file_path'] === 'string') call.file = inp['file_path'] as string + if (typeof inp['command'] === 'string') call.command = inp['command'] as string + return [call] + }) + + return applyLocalModelSavings({ + provider: 'claude', + model: msg.model, + usage: tokens, + costUSD, + tools, + mcpTools: extractMcpTools(tools), + skills, + subagentTypes, + hasAgentSpawn: tools.includes('Agent'), + hasPlanMode: tools.includes('EnterPlanMode'), + speed: usage.speed ?? 'standard', + timestamp: entry.timestamp ?? '', + bashCommands: bashCmds, + deduplicationKey: msg.id ?? `claude:${entry.timestamp}`, + cacheCreationOneHourTokens: cacheCreation.oneHourTokens || undefined, + toolSequence: toolSeq.length > 0 ? toolSeq : undefined, + }) +} + +export function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] { + const firstIdxById = new Map() + const lastIdxById = new Map() + for (let i = 0; i < entries.length; i++) { + const id = getMessageId(entries[i]!) + if (!id) continue + if (!firstIdxById.has(id)) firstIdxById.set(id, i) + lastIdxById.set(id, i) + } + if (lastIdxById.size === 0) return entries + const result: JournalEntry[] = [] + for (let i = 0; i < entries.length; i++) { + const id = getMessageId(entries[i]!) + if (id && lastIdxById.get(id) !== i) continue + if (id && firstIdxById.get(id) !== i) { + const firstTs = entries[firstIdxById.get(id)!]!.timestamp + result.push({ ...entries[i]!, timestamp: firstTs ?? entries[i]!.timestamp }) + continue + } + result.push(entries[i]!) + } + return result +} + +function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set): ParsedTurn[] { + const turns: ParsedTurn[] = [] + let currentUserMessage = '' + let currentCalls: ParsedApiCall[] = [] + let currentTimestamp = '' + let currentSessionId = '' + + for (const entry of entries) { + if (entry.type === 'user') { + const text = getUserMessageText(entry) + if (text.trim()) { + if (currentCalls.length > 0) { + turns.push({ + userMessage: currentUserMessage, + assistantCalls: currentCalls, + timestamp: currentTimestamp, + sessionId: currentSessionId, + }) + } + currentUserMessage = text + currentCalls = [] + currentTimestamp = entry.timestamp ?? '' + currentSessionId = entry.sessionId ?? '' + } + } else if (entry.type === 'assistant') { + const msgId = getMessageId(entry) + if (msgId && seenMsgIds.has(msgId)) continue + if (msgId) seenMsgIds.add(msgId) + const call = parseApiCall(entry) + if (call) currentCalls.push(call) + } + } + + if (currentCalls.length > 0) { + turns.push({ + userMessage: currentUserMessage, + assistantCalls: currentCalls, + timestamp: currentTimestamp, + sessionId: currentSessionId, + }) + } + + return turns +} + +/** + * Extract MCP tool inventory observed across a session's JSONL entries. + * + * Claude Code emits `attachment.type === "deferred_tools_delta"` entries whose + * `addedNames` array lists every tool currently available at that turn (built-in + * tools plus all `mcp____` names exposed by configured MCP + * servers). Tool inventory can change mid-session if the user reloads MCP + * config, so we union every occurrence rather than trusting only the first. + * + * Built-in tools are filtered out: only `mcp__*` identifiers survive. + */ +// Fully-qualified MCP tool name shape: `mcp____`. Both server +// and tool segments must be non-empty. Names like `mcp__server` (no tool +// segment) or `mcp__server__` (trailing empty tool) would silently pollute +// the inventory and break downstream `split('__')` consumers, so they're +// rejected here. +function isMcpToolName(name: string): boolean { + if (!name.startsWith('mcp__')) return false + const rest = name.slice(5) // strip `mcp__` + const sep = rest.indexOf('__') + if (sep <= 0) return false // missing or empty server + if (sep >= rest.length - 2) return false // missing or empty tool + return true +} + +export function extractMcpInventory(entries: JournalEntry[]): string[] { + const inventory = new Set() + for (const entry of entries) { + const att = entry['attachment'] + if (!att || typeof att !== 'object') continue + const a = att as { type?: unknown; addedNames?: unknown } + if (a.type !== 'deferred_tools_delta') continue + if (!Array.isArray(a.addedNames)) continue + for (const name of a.addedNames) { + if (typeof name !== 'string') continue + if (!isMcpToolName(name)) continue + inventory.add(name) + } + } + if (inventory.size === 0) return [] + return Array.from(inventory).sort() +} + +function extractCanonicalCwd(entries: JournalEntry[]): string | undefined { + for (const entry of entries) { + if (typeof entry.cwd !== 'string') continue + const cwd = entry.cwd.trim() + if (cwd) return cwd + } + return undefined +} + +function buildSessionSummary( + sessionId: string, + project: string, + turns: ClassifiedTurn[], + mcpInventory?: string[], + source?: SessionSourceMetadata, +): SessionSummary { + const modelBreakdown: SessionSummary['modelBreakdown'] = Object.create(null) + const toolBreakdown: SessionSummary['toolBreakdown'] = Object.create(null) + const mcpBreakdown: SessionSummary['mcpBreakdown'] = Object.create(null) + const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null) + const categoryBreakdown: SessionSummary['categoryBreakdown'] = Object.create(null) + const skillBreakdown: SessionSummary['skillBreakdown'] = Object.create(null) + const subagentBreakdown: SessionSummary['subagentBreakdown'] = Object.create(null) + + let totalCost = 0 + let totalSavings = 0 + let totalInput = 0 + let totalOutput = 0 + let totalReasoning = 0 + let totalCacheRead = 0 + let totalCacheWrite = 0 + let apiCalls = 0 + let firstTs = '' + let lastTs = '' + + for (const turn of turns) { + const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) + const turnSavings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0) + + if (!categoryBreakdown[turn.category]) { + categoryBreakdown[turn.category] = { turns: 0, costUSD: 0, savingsUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 } + } + categoryBreakdown[turn.category].turns++ + categoryBreakdown[turn.category].costUSD += turnCost + categoryBreakdown[turn.category].savingsUSD += turnSavings + if (turn.hasEdits) { + categoryBreakdown[turn.category].editTurns++ + categoryBreakdown[turn.category].retries += turn.retries + if (turn.retries === 0) categoryBreakdown[turn.category].oneShotTurns++ + } + + if (turn.subCategory) { + const skillKey = turn.subCategory + if (!skillBreakdown[skillKey]) { + skillBreakdown[skillKey] = { turns: 0, costUSD: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } + } + skillBreakdown[skillKey].turns++ + skillBreakdown[skillKey].costUSD += turnCost + skillBreakdown[skillKey].savingsUSD += turnSavings + if (turn.hasEdits) { + skillBreakdown[skillKey].editTurns++ + if (turn.retries === 0) skillBreakdown[skillKey].oneShotTurns++ + } + } + + for (const call of turn.assistantCalls) { + const callSavings = call.savingsUSD ?? 0 + totalCost += call.costUSD + totalSavings += callSavings + totalInput += call.usage.inputTokens + totalOutput += call.usage.outputTokens + totalReasoning += call.usage.reasoningTokens + totalCacheRead += call.usage.cacheReadInputTokens + totalCacheWrite += call.usage.cacheCreationInputTokens + apiCalls++ + + const modelKey = call.provider === 'devin' ? call.model : getShortModelName(call.model) + if (!modelBreakdown[modelKey]) { + modelBreakdown[modelKey] = { + calls: 0, + costUSD: 0, + savingsUSD: 0, + tokens: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }, + } + } + modelBreakdown[modelKey].calls++ + modelBreakdown[modelKey].costUSD += call.costUSD + modelBreakdown[modelKey].savingsUSD += callSavings + modelBreakdown[modelKey].tokens.inputTokens += call.usage.inputTokens + modelBreakdown[modelKey].tokens.outputTokens += call.usage.outputTokens + modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens + modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens + modelBreakdown[modelKey].tokens.reasoningTokens += call.usage.reasoningTokens + + for (const tool of extractCoreTools(call.tools)) { + toolBreakdown[tool] = toolBreakdown[tool] ?? { calls: 0 } + toolBreakdown[tool].calls++ + } + for (const mcp of call.mcpTools) { + const server = mcp.split('__')[1] ?? mcp + mcpBreakdown[server] = mcpBreakdown[server] ?? { calls: 0 } + mcpBreakdown[server].calls++ + } + for (const cmd of call.bashCommands) { + bashBreakdown[cmd] = bashBreakdown[cmd] ?? { calls: 0 } + bashBreakdown[cmd].calls++ + } + for (const sat of call.subagentTypes) { + subagentBreakdown[sat] = subagentBreakdown[sat] ?? { calls: 0, costUSD: 0, savingsUSD: 0 } + subagentBreakdown[sat].calls++ + subagentBreakdown[sat].costUSD += call.costUSD + subagentBreakdown[sat].savingsUSD += callSavings + } + + if (!firstTs || call.timestamp < firstTs) firstTs = call.timestamp + if (!lastTs || call.timestamp > lastTs) lastTs = call.timestamp + } + } + + return { + sessionId, + project, + firstTimestamp: firstTs || turns[0]?.timestamp || '', + lastTimestamp: lastTs || turns[turns.length - 1]?.timestamp || '', + totalCostUSD: totalCost, + totalSavingsUSD: totalSavings, + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + totalReasoningTokens: totalReasoning, + totalCacheReadTokens: totalCacheRead, + totalCacheWriteTokens: totalCacheWrite, + apiCalls, + turns, + modelBreakdown, + toolBreakdown, + mcpBreakdown, + bashBreakdown, + categoryBreakdown, + skillBreakdown, + subagentBreakdown, + ...(source ? { source } : {}), + ...(mcpInventory && mcpInventory.length > 0 ? { mcpInventory } : {}), + } +} + +async function parseSessionFile( + filePath: string, + project: string, + seenMsgIds: Set, + dateRange?: DateRange, +): Promise<{ session: SessionSummary; canonicalCwd?: string } | null> { + // Skip files whose mtime is older than the range start. A session file + // can only contain entries up to its last-modified time; if that predates + // the requested range, nothing in this file can match. + if (dateRange) { + try { + const s = await stat(filePath) + if (s.mtimeMs < dateRange.start.getTime()) return null + } catch { /* fall through to normal read; missing stat shouldn't break parsing */ } + } + const entries: JournalEntry[] = [] + let hasLines = false + + // When a dateRange is given, skip user/assistant lines whose timestamp + // is older than range.start - 24h without calling JSON.parse. Huge lines + // that cannot be skipped are yielded as Buffers and compact-parsed without + // converting the whole line into a V8 string. + const earlySkipThreshold = dateRange + ? new Date(dateRange.start.getTime() - 86_400_000).toISOString() + : null + const skipFn = earlySkipThreshold + ? (head: string) => shouldSkipLine(head, earlySkipThreshold) + : undefined + + for await (const line of readSessionLines(filePath, skipFn, { largeLineAsBuffer: true })) { + hasLines = true + const entry = parseJsonlLine(line) + if (entry) entries.push(compactEntry(entry)) + } + + if (!hasLines) return null + + if (entries.length === 0) return null + + const sessionId = basename(filePath, '.jsonl') + const dedupedEntries = dedupeStreamingMessageIds(entries) + let turns = groupIntoTurns(dedupedEntries, seenMsgIds) + if (dateRange) { + // Bucket a turn by the timestamp of its first assistant call (when the cost was + // actually incurred). Filtering entries directly produced orphan assistant calls + // when a user message sat in one day and the response landed in another -- those + // got pushed as turns with empty timestamps, which some code paths counted and + // others dropped, producing inconsistent Today totals. + turns = turns.filter(turn => { + if (turn.assistantCalls.length === 0) return false + const firstCallTs = turn.assistantCalls[0]!.timestamp + if (!firstCallTs) return false + const ts = new Date(firstCallTs) + return ts >= dateRange.start && ts <= dateRange.end + }) + if (turns.length === 0) return null + } + const classified = turns.map(classifyTurn) + + // Inventory is extracted from the full entry stream, not just the + // turns we kept after date filtering: tool availability is set up + // once at the start of a session (with possible mid-session reloads), + // and we want to reflect what was loaded even if the user only ran + // turns inside a narrow date window. + const mcpInventory = extractMcpInventory(entries) + const canonicalCwd = extractCanonicalCwd(entries) + + return { + session: buildSessionSummary(sessionId, project, classified, mcpInventory), + ...(canonicalCwd ? { canonicalCwd } : {}), + } +} + +// Recursively collect every `.jsonl` under `dir`. Subagent transcripts live in +// `subagents/`, and workflow/ultracode runs nest a further level deep +// (`subagents/workflows//agent-*.jsonl`); a flat scan misses those, so their +// usage went uncounted whenever the workflow feature was on. (#470) +async function collectJsonlInto(dir: string, out: Set): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + for (const e of entries) { + const p = join(dir, e.name) + if (e.isDirectory()) await collectJsonlInto(p, out) + else if (e.name.endsWith('.jsonl')) out.add(p) + } +} + +export async function collectJsonlFiles(dirPath: string): Promise { + const files = await readdir(dirPath).catch(() => []) + const jsonlFiles = new Set(files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f))) + + await collectJsonlInto(join(dirPath, 'subagents'), jsonlFiles) + for (const entry of files) { + if (entry.endsWith('.jsonl')) continue + await collectJsonlInto(join(dirPath, entry, 'subagents'), jsonlFiles) + } + + return [...jsonlFiles] +} + +// Claude Code subagent transcripts (`subagents/.../agent-*.jsonl`) have a sibling +// `.meta.json` carrying the `agentType` (e.g. `workflow-subagent`, `Explore`). +// Returns undefined for ordinary session files, which carry no agent type. +export async function readAgentType(filePath: string): Promise { + if (!/[\\/]subagents[\\/]/.test(filePath)) return undefined + const metaPath = filePath.replace(/\.jsonl$/, '.meta.json') + try { + const t = (JSON.parse(await readFile(metaPath, 'utf8')) as { agentType?: unknown }).agentType + if (typeof t === 'string' && t.trim()) return t.trim().slice(0, 100) + } catch { /* missing or unreadable meta */ } + // Workflow agents always live under `subagents/workflows/`, so fall back to that + // even when the meta sidecar is absent. + return /[\\/]subagents[\\/]workflows[\\/]/.test(filePath) ? 'workflow-subagent' : undefined +} + +async function scanProjectDirs( + dirs: Array<{ path: string; name: string; source?: SessionSourceMetadata }>, + seenMsgIds: Set, + diskCache: SessionCache, + dateRange?: DateRange, +): Promise { + const section = getOrCreateProviderSection(diskCache, 'claude') + const allDiscoveredFiles = new Set() + + type FileInfo = { dirName: string; fp: NonNullable>>; source?: SessionSourceMetadata } + const unchangedFiles: Array<{ filePath: string; dirName: string; source?: SessionSourceMetadata; cached: CachedFile }> = [] + const changedFiles: Array<{ filePath: string; info: FileInfo }> = [] + + const discoverProgress = createScanProgress('scanning claude project dirs', dirs.length) + let dirsDone = 0 + for (const { path: dirPath, name: dirName, source } of dirs) { + const jsonlFiles = await collectJsonlFiles(dirPath) + for (const filePath of jsonlFiles) { + allDiscoveredFiles.add(filePath) + const fp = await fingerprintFile(filePath) + if (!fp) continue + + const action = reconcileFile(fp, section.files[filePath]) + if (action.action === 'unchanged') { + unchangedFiles.push({ filePath, dirName, source, cached: section.files[filePath]! }) + } else { + changedFiles.push({ filePath, info: { dirName, fp, source } }) + } + } + dirsDone++ + await discoverProgress.tick(dirsDone) + } + discoverProgress.finish() + + // Pre-seed dedup set from cached (unchanged) files + for (const { cached } of unchangedFiles) { + for (const turn of cached.turns) { + for (const call of turn.calls) { + seenMsgIds.add(call.deduplicationKey) + } + } + } + + const parseProgress = createScanProgress('parsing changed claude sessions', changedFiles.length) + let filesDone = 0 + for (const { filePath, info } of changedFiles) { + delete section.files[filePath] + + try { + const tracker = { lastCompleteLineOffset: 0 } + const entries = await parseClaudeEntries(filePath, tracker) + if (!entries) { filesDone++; await parseProgress.tick(filesDone); continue } + + const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds) + const cwd = extractCanonicalCwd(entries) + const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined + section.files[filePath] = { + fingerprint: info.fp, + lastCompleteLineOffset: tracker.lastCompleteLineOffset, + canonicalCwd: canonical?.path, + canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined, + mcpInventory: extractMcpInventory(entries), + turns: turns.map(parsedTurnToCachedTurn), + agentType: await readAgentType(filePath), + } + ;(diskCache as { _dirty?: boolean })._dirty = true + } catch (err) { + // A single malformed Claude session file must not abort the whole run — that + // would empty the daily-cache backfill and wipe the trend/history (issue #441, + // same isolation the provider path already has). Record a failure marker keyed + // by the current fingerprint so it isn't re-read and re-thrown every run; it + // re-parses only if the file changes. + section.files[filePath] = { fingerprint: info.fp, mcpInventory: [], turns: [], failed: true } + ;(diskCache as { _dirty?: boolean })._dirty = true + warnProviderParseFailure('claude', filePath, err) + } + filesDone++ + await parseProgress.tick(filesDone) + } + parseProgress.finish() + + if (dirs.length > 0) { + for (const cachedPath of Object.keys(section.files)) { + if (!allDiscoveredFiles.has(cachedPath)) { + delete section.files[cachedPath] + ;(diskCache as { _dirty?: boolean })._dirty = true + } + } + } + + const projectMap = new Map }>() + + const allFiles = [ + ...unchangedFiles.map(f => ({ filePath: f.filePath, dirName: f.dirName, source: f.source })), + ...changedFiles.map(f => ({ filePath: f.filePath, dirName: f.info.dirName, source: f.info.source })), + ] + + for (const { filePath, dirName, source } of allFiles) { + const cachedFile = section.files[filePath] + if (!cachedFile || cachedFile.turns.length === 0) continue + + let classifiedTurns = cachedFile.turns.map(cachedTurnToClassified) + + if (dateRange) { + classifiedTurns = classifiedTurns.filter(turn => { + if (turn.assistantCalls.length === 0) return false + const firstCallTs = turn.assistantCalls[0]!.timestamp + if (!firstCallTs) return false + const ts = new Date(firstCallTs) + return ts >= dateRange.start && ts <= dateRange.end + }) + } + + if (classifiedTurns.length === 0) continue + + const sessionId = basename(filePath, '.jsonl') + const projectPath = cachedFile.canonicalCwd ?? claudeSlugFallbackPath(dirName) + const projectName = cachedFile.canonicalProjectName ?? dirName + const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined + const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv, source) + session.agentType = cachedFile.agentType + + if (session.apiCalls > 0) { + const projectKey = cachedFile.canonicalCwd + ? normalizeProjectPathKey(cachedFile.canonicalCwd) + : `slug:${dirName}` + const existing = projectMap.get(projectKey) + if (existing) { + existing.sessions.push(session) + existing.dirNames.add(dirName) + } else { + projectMap.set(projectKey, { project: projectName, projectPath, sessions: [session], dirNames: new Set([dirName]) }) + } + } + } + + // Fold slug-keyed entries into cwd-keyed entries + const cwdKeyByDirName = new Map() + for (const [key, entry] of projectMap) { + if (key.startsWith('slug:')) continue + for (const dirName of entry.dirNames) { + if (!cwdKeyByDirName.has(dirName)) cwdKeyByDirName.set(dirName, key) + } + } + for (const [key, entry] of [...projectMap]) { + if (!key.startsWith('slug:')) continue + const cwdKey = cwdKeyByDirName.get(entry.project) + if (!cwdKey) continue + const target = projectMap.get(cwdKey)! + target.sessions.push(...entry.sessions) + projectMap.delete(key) + } + + const projects: ProjectSummary[] = [] + for (const { project, projectPath, sessions } of projectMap.values()) { + projects.push(summarizeProject(project, projectPath, sessions)) + } + + return projects +} + +/// Build a ProjectSummary from its sessions, rolling up cost/savings/calls and +/// deriving the proxy attribution. This is the single place proxy matching +/// happens: a project whose canonical path is under a configured `proxyPaths` +/// prefix keeps its full API-rate `totalCostUSD` but records that amount as +/// `totalProxiedCostUSD` (subscription-covered). All ProjectSummary callers go +/// through here so the rule stays consistent across the fresh, cached, and +/// date/day-filtered paths. +function summarizeProject(project: string, projectPath: string, sessions: SessionSummary[]): ProjectSummary { + const totalCostUSD = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0) + return { + project, + projectPath, + sessions, + totalCostUSD, + totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0), + totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0), + totalProxiedCostUSD: isProxiedPath(projectPath) ? totalCostUSD : 0, + } +} + +function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { + const tools = call.tools + const usage: TokenUsage = { + inputTokens: call.inputTokens, + outputTokens: call.outputTokens, + cacheCreationInputTokens: call.cacheCreationInputTokens, + cacheReadInputTokens: call.cacheReadInputTokens, + cachedInputTokens: call.cachedInputTokens, + reasoningTokens: call.reasoningTokens, + webSearchRequests: call.webSearchRequests, + } + + const apiCall: ParsedApiCall = applyLocalModelSavings({ + provider: call.provider, + model: call.model, + usage, + costUSD: call.costUSD, + tools, + mcpTools: extractMcpTools(tools), + skills: call.skills ?? [], + subagentTypes: call.subagentTypes ?? [], + hasAgentSpawn: tools.includes('Agent'), + hasPlanMode: tools.includes('EnterPlanMode'), + speed: call.speed, + timestamp: call.timestamp, + bashCommands: call.bashCommands, + deduplicationKey: call.deduplicationKey, + }) + + return { + userMessage: call.userMessage, + assistantCalls: [apiCall], + timestamp: call.timestamp, + sessionId: call.sessionId, + } +} + +// ── Cache Conversion ─────────────────────────────────────────────────── + +function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { + return { + provider: call.provider, + model: call.model, + usage: { + inputTokens: call.inputTokens, + outputTokens: call.outputTokens, + cacheCreationInputTokens: call.cacheCreationInputTokens, + cacheReadInputTokens: call.cacheReadInputTokens, + cachedInputTokens: call.cachedInputTokens, + reasoningTokens: call.reasoningTokens, + webSearchRequests: call.webSearchRequests, + cacheCreationOneHourTokens: 0, + }, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes') ? call.costUSD : undefined, + speed: call.speed, + timestamp: call.timestamp, + tools: call.tools, + bashCommands: call.bashCommands, + skills: call.skills ?? [], + subagentTypes: call.subagentTypes ?? [], + deduplicationKey: call.deduplicationKey, + project: call.project, + projectPath: call.projectPath, + toolSequence: call.toolSequence, + } +} + +async function canonicalizeProviderCallProject(call: ParsedProviderCall): Promise { + if (!call.projectPath) return call + + const canonical = await resolveCanonicalProjectPath(call.projectPath) + if (!canonical.isWorktree) return call + + return { + ...call, + project: projectNameFromPath(canonical.path, call.project ?? canonical.path), + projectPath: canonical.path, + } +} + +function apiCallToCachedCall(call: ParsedApiCall): CachedCall { + return { + provider: call.provider, + model: call.model, + usage: { ...call.usage, cacheCreationOneHourTokens: call.cacheCreationOneHourTokens ?? 0 }, + speed: call.speed, + timestamp: call.timestamp, + tools: call.tools, + bashCommands: call.bashCommands, + skills: call.skills, + subagentTypes: call.subagentTypes, + deduplicationKey: call.deduplicationKey, + toolSequence: call.toolSequence, + } +} + +function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn { + return { + timestamp: turn.timestamp, + sessionId: turn.sessionId, + userMessage: turn.userMessage.slice(0, 2000), + calls: turn.assistantCalls.map(apiCallToCachedCall), + } +} + +function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn { + return { + timestamp: call.timestamp, + sessionId: call.sessionId, + userMessage: call.userMessage.slice(0, 2000), + calls: [providerCallToCachedCall(call)], + } +} + +function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] { + const turns: CachedTurn[] = [] + const grouped = new Map() + + for (const call of calls) { + if (!call.turnId) { + turns.push(providerCallToCachedTurn(call)) + continue + } + + const key = `${call.sessionId}\0${call.turnId}` + let turn = grouped.get(key) + if (!turn) { + turn = { + timestamp: call.timestamp, + sessionId: call.sessionId, + userMessage: call.userMessage.slice(0, 2000), + calls: [], + } + grouped.set(key, turn) + turns.push(turn) + } + turn.calls.push(providerCallToCachedCall(call)) + } + + return turns +} + +function cachedCallToApiCall(call: CachedCall): ParsedApiCall { + const u = call.usage + const outputForCost = call.provider === 'claude' + ? u.outputTokens + : u.outputTokens + u.reasoningTokens + const costUSD = calculateCost( + call.model, u.inputTokens, outputForCost, + u.cacheCreationInputTokens, u.cacheReadInputTokens, + u.webSearchRequests, call.speed, u.cacheCreationOneHourTokens, + ) + return applyLocalModelSavings({ + provider: call.provider, + model: call.model, + usage: { + inputTokens: u.inputTokens, + outputTokens: u.outputTokens, + cacheCreationInputTokens: u.cacheCreationInputTokens, + cacheReadInputTokens: u.cacheReadInputTokens, + cachedInputTokens: u.cachedInputTokens, + reasoningTokens: u.reasoningTokens, + webSearchRequests: u.webSearchRequests, + }, + costUSD: call.costUSD ?? costUSD, + tools: call.tools, + mcpTools: extractMcpTools(call.tools), + skills: call.skills, + subagentTypes: call.subagentTypes ?? [], + hasAgentSpawn: call.tools.includes('Agent'), + hasPlanMode: call.tools.includes('EnterPlanMode'), + speed: call.speed, + timestamp: call.timestamp, + bashCommands: call.bashCommands, + deduplicationKey: call.deduplicationKey, + cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined, + toolSequence: call.toolSequence, + }) +} + +function cachedTurnToClassified(turn: CachedTurn): ClassifiedTurn { + const parsed: ParsedTurn = { + userMessage: turn.userMessage, + assistantCalls: turn.calls.map(cachedCallToApiCall), + timestamp: turn.timestamp, + sessionId: turn.sessionId, + } + return classifyTurn(parsed) +} + +// ── Cache-Aware Parsing Helpers ──────────────────────────────────────── + +async function parseClaudeEntries( + filePath: string, + tracker: { lastCompleteLineOffset: number }, +): Promise { + const entries: JournalEntry[] = [] + let hasLines = false + for await (const line of readSessionLines(filePath, undefined, { + largeLineAsBuffer: true, + byteOffsetTracker: tracker, + })) { + hasLines = true + const entry = parseJsonlLine(line) + if (entry) entries.push(compactEntry(entry)) + } + if (!hasLines || entries.length === 0) return null + return entries +} + +function getOrCreateProviderSection(cache: SessionCache, provider: string): ProviderSection { + const envFp = computeEnvFingerprint(provider) + const existing = cache.providers[provider] + if (existing && existing.envFingerprint === envFp) return existing + const section = { envFingerprint: envFp, files: {} } + cache.providers[provider] = section + return section +} + +function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string, cached: CachedFile): boolean { + // Antigravity data comes from the live server, not from the conversation file. + // A 0-turn cache entry may just mean the server was unavailable last run. + if (providerName === 'antigravity') return shouldReparseAntigravitySource(sourcePath, cached.turns.length) + + // Devin transcript usage is enriched from sessions.db. The cache fingerprint + // only tracks the transcript JSON, so reparse to pick up DB-side project, + // title, model, and timestamp changes. + if (providerName === 'devin') return true + + if (providerName !== 'gemini') return false + + return cached.turns.some(turn => + turn.calls.some(call => call.deduplicationKey === `gemini:${turn.sessionId}`), + ) +} + +const warnedProviderReadFailures = new Set() + +function warnProviderReadFailureOnce(providerName: string, err: unknown): void { + const key = `${providerName}:sqlite-busy` + if (warnedProviderReadFailures.has(key)) return + warnedProviderReadFailures.add(key) + if (isSqliteBusyError(err)) { + process.stderr.write( + `codeburn: skipped ${providerName} data because its SQLite database is temporarily locked; will retry on the next refresh.\n` + ) + } +} + +// Warn per offending file (so a systemic break surfaces more than one path), +// but cap per provider per run to avoid a flood. Cached failure markers mean a +// given broken file is only re-encountered when it changes, so this stays quiet +// across refreshes. +const parseFailureCounts = new Map() +const PARSE_FAILURE_WARN_CAP = 5 + +function warnProviderParseFailure(providerName: string, sourcePath: string, err: unknown): void { + const n = (parseFailureCounts.get(providerName) ?? 0) + 1 + parseFailureCounts.set(providerName, n) + if (n > PARSE_FAILURE_WARN_CAP) return + const msg = err instanceof Error ? err.message : String(err) + const tail = n === PARSE_FAILURE_WARN_CAP + ? ` (further ${providerName} parse failures this run are suppressed)` + : '' + process.stderr.write( + `codeburn: skipped ${providerName} session that failed to parse: ${sourcePath} (${msg})${tail}\n` + ) +} + +// A cold-cache scan over a large ~/.claude/projects tree (hundreds of project +// dirs, e.g. a git-worktree-per-task workflow) can run long enough that it +// looks hung, and is CPU-heavy enough on a single thread to visibly compete +// with anything else running interactively on the same machine. Two cheap +// mitigations, neither of which reduces total CPU work: (1) a `\r`-updated +// progress line so a long cold run reads as "working" instead of "stuck", +// gated on isTTY so it never corrupts piped/captured output (export.ts, the +// --no-color path, or a subprocess capturing stderr); (2) yielding to the +// event loop every YIELD_EVERY items so the OS scheduler gets regular break +// points instead of one long uninterrupted synchronous block. This does NOT +// fix CPU contention with a separate process (that's the OS scheduler's job +// regardless), it only keeps this process itself responsive and honest about +// progress during the scan. +const YIELD_EVERY = 25 + +function yieldToEventLoop(): Promise { + return new Promise(resolve => setImmediate(resolve)) +} + +// Suppress the scan-progress line while an interactive Ink UI is live. The +// dashboard and compare render to stdout on the same terminal, and their scans +// run (dashboard) or re-run every 30s (dashboard auto-refresh, including the +// getPlanUsages → parseAllSessions path) AFTER render() has painted a frame, so +// a `\r` progress line on stderr prints over it and garbles the screen. isTTY +// alone can't tell them apart from a plain CLI command. The interactive +// entrypoints call setInteractiveScanUI() right before render(); a pre-render +// scan (e.g. compare's cold start) still shows progress and finish() clears the +// line before Ink paints. +let interactiveScanUI = false +export function setInteractiveScanUI(active = true): void { + interactiveScanUI = active +} + +export function createScanProgress(label: string, total: number) { + const show = !interactiveScanUI && total > 20 && process.stderr.isTTY === true + let lastWrite = 0 + return { + async tick(done: number): Promise { + if (done % YIELD_EVERY === 0) await yieldToEventLoop() + if (!show) return + const now = Date.now() + if (done !== total && now - lastWrite < 100) return + lastWrite = now + process.stderr.write(`\rcodeburn: ${label} ${done}/${total}…`) + }, + finish(): void { + if (!show) return + process.stderr.write('\r\x1b[K') + }, + } +} + +async function parseProviderSources( + providerName: string, + sources: SessionSource[], + seenKeys: Set, + diskCache: SessionCache, + dateRange?: DateRange, +): Promise { + const provider = await getProvider(providerName) + if (!provider) return [] + + const section = getOrCreateProviderSection(diskCache, providerName) + const allDiscoveredFiles = new Set() + + type SourceInfo = { source: SessionSource; fp: NonNullable>> } + const unchangedSources: Array<{ source: SessionSource; cached: CachedFile }> = [] + const changedSources: SourceInfo[] = [] + + for (const source of sources) { + allDiscoveredFiles.add(source.path) + + // Network providers (e.g. Vercel AI Gateway) have no on-disk file — their data + // comes from a live API fetch in createSessionParser. There's nothing to + // fingerprint or incrementally cache, so re-fetch every run with a synthetic + // fingerprint (mtime=now so the date-range filter below never excludes it). + if (provider.network) { + changedSources.push({ source, fp: { dev: 0, ino: 0, mtimeMs: Date.now(), sizeBytes: 0 } }) + continue + } + + const fp = await fingerprintFile(source.path) + if (!fp) continue + + const cached = section.files[source.path] + const action = reconcileFile(fp, cached) + // A cached parse failure at this same fingerprint stays skipped — don't + // re-read a file that already threw and hasn't changed. It re-parses only + // when the file changes (then `reconcileFile` reports non-'unchanged'). + if (action.action === 'unchanged' && cached && (cached.failed || !cachedFileNeedsProviderReparse(providerName, source.path, cached))) { + unchangedSources.push({ source, cached }) + } else { + changedSources.push({ source, fp }) + } + } + + // Parser dedup: cross-provider keys + cached file keys. + // Separate from seenKeys so parsing doesn't suppress query-time output. + const parserDedup = new Set(seenKeys) + for (const { cached } of unchangedSources) { + for (const turn of cached.turns) { + for (const call of turn.calls) { + parserDedup.add(call.deduplicationKey) + } + } + } + + // Parse changed files, update cache + let didParse = false + // Track which paths have already been cleared this pass so that subsequent + // sources sharing the same path (e.g. multiple OTel conversations from one + // agent-traces.db) can accumulate via the merge logic below rather than + // being wiped on every iteration. + const clearedPaths = new Set() + try { + for (const { source, fp } of changedSources) { + if (dateRange) { + if (fp.mtimeMs < dateRange.start.getTime()) continue + } + + // Clear stale entry before parse — but only once per path so that + // multiple sources mapping to the same file path can merge their turns. + // Durable providers (e.g. copilot OTel) never clear existing entries so + // that pruned-away data is preserved for monotonic monthly totals. + if (!provider.durableSources && !clearedPaths.has(source.path)) { + delete section.files[source.path] + clearedPaths.add(source.path) + } + + const parser = provider.createSessionParser(source, parserDedup, dateRange) + + try { + const providerCalls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + providerCalls.push(call) + } + const canonicalCalls = await Promise.all(providerCalls.map(canonicalizeProviderCallProject)) + const turns = providerCallsToCachedTurns(canonicalCalls) + + // Store/merge parsed turns into the cache. + // Durable providers use a union-by-deduplicationKey merge: existing turns + // are NEVER deleted (preserves data for spans pruned from the DB), and + // only turns whose dedup keys are not already cached are appended. + // Non-durable providers keep the original overwrite-or-append behaviour. + if (provider.durableSources) { + const existingEntry = section.files[source.path] + if (existingEntry) { + const existingKeys = new Set( + existingEntry.turns.flatMap(t => t.calls.map(c => c.deduplicationKey)) + ) + const newTurns = turns.filter(t => + t.calls.every(c => !existingKeys.has(c.deduplicationKey)) + ) + existingEntry.turns = [...existingEntry.turns, ...newTurns] + existingEntry.fingerprint = fp + } else { + section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns } + } + } else { + // Non-durable: overwrite (clearedPaths already deleted stale entry above) + // or append when multiple sources map to the same path. NOTE: the append + // path assumes discoverSessions yields a unique path per source, which all + // current providers do; it only fires for same-path multi-source providers. + const existingCacheEntry = section.files[source.path] + if (existingCacheEntry) { + existingCacheEntry.turns = [...existingCacheEntry.turns, ...turns] + } else { + section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns } + } + } + didParse = true + ;(diskCache as { _dirty?: boolean })._dirty = true + } catch (err) { + if (isSqliteBusyError(err)) { + warnProviderReadFailureOnce(providerName, err) + continue + } + // A single malformed session file must not abort the entire run — that + // would silently empty the daily-cache backfill and wipe the trend / + // history (issue #441). Record a negative-result marker keyed by the + // current fingerprint so we don't re-read + re-throw this unchanged file + // on every refresh; it re-parses only if it changes. Empty turns => no + // usage contributed. + section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns: [], failed: true } + ;(diskCache as { _dirty?: boolean })._dirty = true + warnProviderParseFailure(providerName, source.path, err) + continue + } + } + } finally { + if (didParse && providerName === 'codex') await flushCodexCache() + if (didParse && providerName === 'antigravity') { + const liveIds = new Set(sources.map(s => antigravityCascadeIdFromPath(s.path))) + await flushAntigravityCache(liveIds) + } + } + + // Stamp the durable flag into the cache section so the orphan-bootstrap in + // parseAllSessions can fast-check without a getProvider() round-trip. + if (provider.durableSources && !section.durable) { + section.durable = true + ;(diskCache as { _dirty?: boolean })._dirty = true + } + + if (sources.length > 0 && !provider.durableSources) { + for (const cachedPath of Object.keys(section.files)) { + if (!allDiscoveredFiles.has(cachedPath)) { + delete section.files[cachedPath] + ;(diskCache as { _dirty?: boolean })._dirty = true + } + } + } + + // 90-day age-out for durable providers: remove entries whose newest call is + // older than 90 days so the cache doesn't grow unboundedly over time. + if (provider.durableSources) { + const cutoffMs = Date.now() - 90 * 24 * 60 * 60 * 1000 + for (const [cachedPath, cachedFile] of Object.entries(section.files)) { + const newestTs = cachedFile.turns + .flatMap(t => t.calls) + .map(c => new Date(c.timestamp).getTime()) + .filter(ts => !isNaN(ts)) + .reduce((max, ts) => Math.max(max, ts), 0) + if (newestTs > 0 && newestTs < cutoffMs) { + delete section.files[cachedPath] + ;(diskCache as { _dirty?: boolean })._dirty = true + } + } + } + + // Query-time: derive SessionSummary from all cached turns. + // Uses seenKeys (shared across providers) for cross-provider dedup. + const sessionMap = new Map() + + for (const source of sources) { + const cachedFile = section.files[source.path] + if (!cachedFile) continue + + for (const turn of cachedFile.turns) { + const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey)) + if (hasDup) continue + + for (const c of turn.calls) seenKeys.add(c.deduplicationKey) + + if (dateRange) { + const callTs = turn.calls[0]?.timestamp + if (!callTs) continue + const ts = new Date(callTs) + if (ts < dateRange.start || ts > dateRange.end) continue + } + + const classified = cachedTurnToClassified(turn) + const project = turn.calls[0]?.project ?? source.project + const key = `${providerName}:${turn.sessionId}:${project}` + + const existing = sessionMap.get(key) + if (existing) { + existing.turns.push(classified) + if (!existing.projectPath && turn.calls[0]?.projectPath) { + existing.projectPath = turn.calls[0]!.projectPath + } + } else { + sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, turns: [classified] }) + } + } + } + + // Second pass: durable orphans — cache entries for paths that are no longer + // discovered (e.g. OTel conversations pruned from the DB). Their turns are + // counted here so the monthly total never drops. + if (provider.durableSources) { + for (const [cachedPath, cachedFile] of Object.entries(section.files)) { + if (allDiscoveredFiles.has(cachedPath)) continue // already counted above + + for (const turn of cachedFile.turns) { + const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey)) + if (hasDup) continue + + for (const c of turn.calls) seenKeys.add(c.deduplicationKey) + + if (dateRange) { + const callTs = turn.calls[0]?.timestamp + if (!callTs) continue + const ts = new Date(callTs) + if (ts < dateRange.start || ts > dateRange.end) continue + } + + const classified = cachedTurnToClassified(turn) + const project = turn.calls[0]?.project ?? providerName + const key = `${providerName}:${turn.sessionId}:${project}` + + const existingEntry = sessionMap.get(key) + if (existingEntry) { + existingEntry.turns.push(classified) + if (!existingEntry.projectPath && turn.calls[0]?.projectPath) { + existingEntry.projectPath = turn.calls[0]!.projectPath + } + } else { + sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, turns: [classified] }) + } + } + } + } + + const projectMap = new Map() + for (const [key, { project, projectPath, turns }] of sessionMap) { + const sessionId = key.split(':')[1] ?? key + const session = buildSessionSummary(sessionId, project, turns) + if (session.apiCalls > 0) { + const existing = projectMap.get(project) + if (existing) { + existing.sessions.push(session) + if (!existing.projectPath && projectPath) existing.projectPath = projectPath + } else { + projectMap.set(project, { projectPath, sessions: [session] }) + } + } + } + + const projects: ProjectSummary[] = [] + for (const [dirName, { projectPath, sessions }] of projectMap) { + projects.push(summarizeProject(dirName, projectPath ?? unsanitizePath(dirName), sessions)) + } + + return projects +} + +const CACHE_TTL_MS = 180_000 +const MAX_CACHE_ENTRIES = 10 +const sessionCache = new Map() + +function cacheKey(dateRange?: DateRange, providerFilter?: string): string { + const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none' + // Include the Claude config-dir env so a config change in a long-lived + // process (menubar / GNOME extension / test workers) does not return + // stale data keyed under a previous configuration. + const claudeEnv = (process.env['CLAUDE_CONFIG_DIRS'] ?? '') + '|' + (process.env['CLAUDE_CONFIG_DIR'] ?? '') + // Proxy attribution (totalProxiedCostUSD) is computed live from proxyPaths and + // then cached, so the key must change when that config changes. + return `${s}:${providerFilter ?? 'all'}:${claudeEnv}:${getProxyPathsConfigHash()}` +} + +export function clearSessionCache(): void { + sessionCache.clear() +} + +function cachePut(key: string, data: ProjectSummary[]) { + const now = Date.now() + for (const [k, v] of sessionCache) { + if (now - v.ts > CACHE_TTL_MS) sessionCache.delete(k) + } + if (sessionCache.size >= MAX_CACHE_ENTRIES) { + const oldest = [...sessionCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0] + if (oldest) sessionCache.delete(oldest[0]) + } + sessionCache.set(key, { data, ts: now }) +} + +export function filterProjectsByName( + projects: ProjectSummary[], + include?: string[], + exclude?: string[], +): ProjectSummary[] { + let result = projects + if (include && include.length > 0) { + const patterns = include.map(s => s.toLowerCase()) + result = result.filter(p => { + const name = p.project.toLowerCase() + const path = p.projectPath.toLowerCase() + return patterns.some(pat => name.includes(pat) || path.includes(pat)) + }) + } + if (exclude && exclude.length > 0) { + const patterns = exclude.map(s => s.toLowerCase()) + result = result.filter(p => { + const name = p.project.toLowerCase() + const path = p.projectPath.toLowerCase() + return !patterns.some(pat => name.includes(pat) || path.includes(pat)) + }) + } + return result +} + +function turnIsInDateRange(turn: ClassifiedTurn, dateRange: DateRange): boolean { + if (turn.assistantCalls.length === 0) return false + const firstCallTs = turn.assistantCalls[0]!.timestamp + if (!firstCallTs) return false + const ts = new Date(firstCallTs) + return ts >= dateRange.start && ts <= dateRange.end +} + +function turnDayString(turn: ClassifiedTurn): string | null { + if (turn.assistantCalls.length === 0) return null + const ts = turn.assistantCalls[0]!.timestamp + if (!ts) return null + const d = new Date(ts) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +export function filterProjectsByDays(projects: ProjectSummary[], days: Set): ProjectSummary[] { + const filtered: ProjectSummary[] = [] + for (const project of projects) { + const sessions: SessionSummary[] = [] + for (const session of project.sessions) { + const turns = session.turns.filter(turn => { + const ds = turnDayString(turn) + return ds !== null && days.has(ds) + }) + if (turns.length === 0) continue + sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory, session.source)) + } + if (sessions.length === 0) continue + filtered.push(summarizeProject(project.project, project.projectPath, sessions)) + } + return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD) +} + +export function filterProjectsByClaudeConfigSource(projects: ProjectSummary[], sourceId: string): ProjectSummary[] { + const filtered: ProjectSummary[] = [] + for (const project of projects) { + // Match by source id across both claude-config and claude-desktop kinds so + // the Claude Desktop bucket is selectable too. + const sessions = project.sessions.filter(session => + session.source?.id === sourceId + ) + if (sessions.length === 0) continue + filtered.push(summarizeProject(project.project, project.projectPath, sessions)) + } + return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD) +} + +export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: DateRange): ProjectSummary[] { + const filtered: ProjectSummary[] = [] + for (const project of projects) { + const sessions: SessionSummary[] = [] + for (const session of project.sessions) { + const turns = session.turns.filter(turn => turnIsInDateRange(turn, dateRange)) + if (turns.length === 0) continue + sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory, session.source)) + } + if (sessions.length === 0) continue + filtered.push(summarizeProject(project.project, project.projectPath, sessions)) + } + return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD) +} + +export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise { + const key = cacheKey(dateRange, providerFilter) + const cached = sessionCache.get(key) + if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data + + const diskCache = await loadCache() + await cleanupOrphanedTempFiles() + + const seenMsgIds = new Set() + const seenKeys = new Set() + const allSources = await discoverAllSessions(providerFilter) + + const claudeSources = allSources.filter(s => s.provider === 'claude') + const nonClaudeSources = allSources.filter(s => s.provider !== 'claude') + + const claudeDirs = claudeSources.map(s => ({ + path: s.path, + name: s.project, + source: s.sourceId && s.sourceLabel && s.sourcePath && s.sourceKind + ? { id: s.sourceId, label: s.sourceLabel, path: s.sourcePath, kind: s.sourceKind } + : undefined, + })) + const claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange) + + const providerGroups = new Map() + for (const source of nonClaudeSources) { + const existing = providerGroups.get(source.provider) ?? [] + existing.push(source) + providerGroups.set(source.provider, existing) + } + + const otherProjects: ProjectSummary[] = [] + for (const [providerName, sources] of providerGroups) { + const projects = await parseProviderSources(providerName, sources, seenKeys, diskCache, dateRange) + otherProjects.push(...projects) + } + + // Durable providers with cached data but NO discovered sources (all files pruned + // by VS Code / the external tool) still need their orphan pass to run so the + // monthly total never drops. Call parseProviderSources with empty sources for + // any such provider found in the disk cache. + const processedProviders = new Set(providerGroups.keys()) + for (const providerName of Object.keys(diskCache.providers)) { + if (processedProviders.has(providerName)) continue + // Skip if filtered to a different provider + if (providerFilter && providerFilter !== 'all' && providerFilter !== providerName) continue + const section = diskCache.providers[providerName] + if (!section || Object.keys(section.files).length === 0) continue + // Use the persisted durable flag (set by parseProviderSources when it first + // processes a durableSources provider) OR the static DURABLE_PROVIDER_NAMES + // constant — both checks are O(1) and avoid a getProvider() dynamic-import + // round-trip for every unprocessed provider in the disk cache. + if (!section.durable && !DURABLE_PROVIDER_NAMES.has(providerName)) continue + const projects = await parseProviderSources(providerName, [], seenKeys, diskCache, dateRange) + otherProjects.push(...projects) + } + + if ((diskCache as { _dirty?: boolean })._dirty) { + try { await saveCache(diskCache) } catch {} + } + + // Merge across providers by normalised project path so the same repository + // is not double-counted when it was worked on with more than one tool + // (e.g. both Claude Code and Codex). Two sub-problems: + // + // 1. Codex's sanitizeProject strips the leading '/' from cwds, so + // "Users/carlo/foo" and "/Users/carlo/foo" must compare equal. We + // normalise by stripping leading slashes before keying. + // + // 2. Codex worktrees (e.g. ~/.codex/worktrees/e55f/Repo) are not resolved + // to their main-repo path by canonicalizeProviderCallProject because that + // function only operates on call.projectPath, which Codex doesn't set. + // Resolve at the ProjectSummary level here: prepend '/' if needed to get + // an absolute path, then run the same worktree-detection logic. + const resolvedOtherProjects = await Promise.all(otherProjects.map(async p => { + const absPath = p.projectPath.startsWith('/') || p.projectPath.startsWith('\\') + ? p.projectPath + : '/' + p.projectPath + const canonical = await resolveCanonicalProjectPath(absPath) + // Skip if path is unchanged: same location, not a worktree, not a subdir + if (!canonical.isWorktree && canonical.path === absPath.replace(/[/\\]+$/, '')) return p + return { ...p, project: projectNameFromPath(canonical.path, p.project), projectPath: canonical.path } + })) + + const crossProviderKey = (p: ProjectSummary): string => { + const path = p.projectPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase() + return path.includes('/') ? path : p.project.toLowerCase() + } + const mergedMap = new Map() + for (const p of [...claudeProjects, ...resolvedOtherProjects]) { + const key = crossProviderKey(p) + const existing = mergedMap.get(key) + if (existing) { + existing.sessions.push(...p.sessions) + existing.totalCostUSD += p.totalCostUSD + existing.totalApiCalls += p.totalApiCalls + } else { + mergedMap.set(key, { ...p }) + } + } + + // Re-derive proxy attribution on the merged total: the merge above sums + // totalCostUSD across providers that share a canonical path but never + // recomputed totalProxiedCostUSD, so a merged project (e.g. the same repo + // used with Claude Code + Codex) would otherwise carry the proxied amount of + // only the first-seen provider. The merge key is the canonical path, so both + // sides share the same proxied status — keying off the surviving projectPath + // and the final cost keeps the project-level all-or-nothing rule intact. + for (const p of mergedMap.values()) { + p.totalProxiedCostUSD = isProxiedPath(p.projectPath) ? p.totalCostUSD : 0 + } + + const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD) + cachePut(key, result) + return result +} diff --git a/src/persistent-codeburn.ts b/src/persistent-codeburn.ts new file mode 100644 index 0000000..fc04323 --- /dev/null +++ b/src/persistent-codeburn.ts @@ -0,0 +1,66 @@ +import { constants } from 'fs' +import { access } from 'fs/promises' +import { delimiter, join } from 'path' + +export const PERSISTENT_CLI_REQUIRED_MESSAGE = + 'CodeBurn needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn' + +const DEFAULT_CLI_LOOKUP_PATHS = process.platform === 'win32' + ? [] + : ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'] + +export function buildPersistentCodeburnLookupPath(existingPath = process.env.PATH ?? ''): string { + const parts = existingPath.split(delimiter).filter(Boolean) + for (const fallback of DEFAULT_CLI_LOOKUP_PATHS) { + if (!parts.includes(fallback)) parts.push(fallback) + } + return parts.join(delimiter) +} + +export function isTransientNpxPath(path: string): boolean { + return path.includes('/_npx/') || path.includes('/.npm/_npx/') || path.includes('\\_npx\\') +} + +function codeburnExecutableNames(): string[] { + if (process.platform !== 'win32') return ['codeburn'] + return ['codeburn.cmd', 'codeburn.exe', 'codeburn.bat', 'codeburn'] +} + +async function executableExists(path: string): Promise { + try { + await access(path, process.platform === 'win32' ? constants.F_OK : constants.F_OK | constants.X_OK) + return true + } catch { + return false + } +} + +export async function resolvePersistentCodeburnPathFromPath( + lookupPath: string, + message: string = PERSISTENT_CLI_REQUIRED_MESSAGE, +): Promise { + const seen = new Set() + for (const dir of lookupPath.split(delimiter).filter(Boolean)) { + for (const executable of codeburnExecutableNames()) { + const candidate = join(dir, executable) + if (seen.has(candidate)) continue + seen.add(candidate) + if (isTransientNpxPath(candidate)) continue + if (await executableExists(candidate)) return candidate + } + } + throw new Error(message) +} + +export function resolvePersistentCodeburnPathFromWhichOutput( + output: string, + message: string = PERSISTENT_CLI_REQUIRED_MESSAGE, +): string { + const paths = output + .split(/\r?\n/) + .map(path => path.trim()) + .filter(Boolean) + const persistentPath = paths.find(path => path.startsWith('/') && !isTransientNpxPath(path)) + if (persistentPath) return persistentPath + throw new Error(message) +} diff --git a/src/plan-usage.ts b/src/plan-usage.ts new file mode 100644 index 0000000..648d3c9 --- /dev/null +++ b/src/plan-usage.ts @@ -0,0 +1,214 @@ +import { readPlans, type Plan, type PlanMap } from './config.js' +import { parseAllSessions } from './parser.js' +import { PLAN_PROVIDERS } from './plans.js' +import type { DateRange, ProjectSummary } from './types.js' + +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const PLAN_NEAR_THRESHOLD_PCT = 80 + +export type PlanStatus = 'under' | 'near' | 'over' + +export type PlanUsage = { + plan: Plan + periodStart: Date + periodEnd: Date + spentApiEquivalentUsd: number + budgetUsd: number + percentUsed: number + status: PlanStatus + projectedMonthUsd: number + daysUntilReset: number +} + +export function clampResetDay(resetDay: number | undefined): number { + if (!Number.isInteger(resetDay)) return 1 + return Math.min(28, Math.max(1, resetDay ?? 1)) +} + +export function computePeriodFromResetDay(resetDay: number | undefined, today: Date): { periodStart: Date; periodEnd: Date } { + const day = clampResetDay(resetDay) + const year = today.getFullYear() + const month = today.getMonth() + + if (today.getDate() >= day) { + return { + periodStart: new Date(year, month, day, 0, 0, 0, 0), + periodEnd: new Date(year, month + 1, day, 0, 0, 0, 0), + } + } + + return { + periodStart: new Date(year, month - 1, day, 0, 0, 0, 0), + periodEnd: new Date(year, month, day, 0, 0, 0, 0), + } +} + +function median(values: number[]): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2 + } + return sorted[mid]! +} + +function toLocalDateKey(d: Date): string { + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function toDayIndex(d: Date): number { + return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / MS_PER_DAY) +} + +function diffCalendarDays(from: Date, to: Date): number { + return toDayIndex(to) - toDayIndex(from) +} + +export function projectMonthEnd( + projects: ProjectSummary[], + periodStart: Date, + periodEnd: Date, + today: Date, + spent: number, +): number { + const dayCosts = 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 timestamp = call.timestamp || turn.timestamp + if (!timestamp) continue + const ts = new Date(timestamp) + if (Number.isNaN(ts.getTime())) continue + if (ts < periodStart || ts > today) continue + const dayKey = toLocalDateKey(ts) + dayCosts.set(dayKey, (dayCosts.get(dayKey) ?? 0) + call.costUSD) + } + } + } + } + + const elapsedDays = Math.max(1, diffCalendarDays(periodStart, today) + 1) + const elapsedDailyCosts: number[] = [] + for (let i = 0; i < elapsedDays; i++) { + const date = new Date(periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate() + i) + elapsedDailyCosts.push(dayCosts.get(toLocalDateKey(date)) ?? 0) + } + + const trailingWindow = elapsedDailyCosts.slice(-7) + const medianDailyCost = median(trailingWindow) + const daysRemaining = Math.max(0, diffCalendarDays(today, periodEnd) - 1) + + return spent + medianDailyCost * daysRemaining +} + +export function getPlanUsageFromProjects(plan: Plan, projects: ProjectSummary[], today = new Date()): PlanUsage { + const { periodStart, periodEnd } = computePeriodFromResetDay(plan.resetDay, today) + const spent = projects.reduce((sum, p) => sum + p.totalCostUSD, 0) + const budgetUsd = plan.monthlyUsd + const percentUsed = budgetUsd > 0 ? (spent / budgetUsd) * 100 : 0 + const status: PlanStatus = percentUsed > 100 ? 'over' : percentUsed >= PLAN_NEAR_THRESHOLD_PCT ? 'near' : 'under' + const projectedMonthUsd = projectMonthEnd(projects, periodStart, periodEnd, today, spent) + const daysUntilReset = Math.max(0, diffCalendarDays(today, periodEnd)) + + return { + plan, + periodStart, + periodEnd, + spentApiEquivalentUsd: spent, + budgetUsd, + percentUsed, + status, + projectedMonthUsd, + daysUntilReset, + } +} + +function getPlanScopedProjects(plan: Plan, projects: ProjectSummary[], today: Date): ProjectSummary[] { + const { periodStart } = computePeriodFromResetDay(plan.resetDay, today) + const provider = plan.provider + + // These scoped clones are consumed only by plan usage math; cost/call rollups + // are recomputed below, while unrelated breakdown fields remain unchanged. + return projects + .map(project => { + const sessions = project.sessions + .map(session => { + const turns = session.turns + .map(turn => { + const assistantCalls = turn.assistantCalls.filter(call => { + if (provider !== 'all' && call.provider !== provider) return false + const timestamp = call.timestamp || turn.timestamp + if (!timestamp) return false + const ts = new Date(timestamp) + return !Number.isNaN(ts.getTime()) && ts >= periodStart && ts <= today + }) + return assistantCalls.length > 0 ? { ...turn, assistantCalls } : null + }) + .filter((turn): turn is NonNullable => turn !== null) + + const totalCostUSD = turns.reduce( + (sum, turn) => sum + turn.assistantCalls.reduce((turnSum, call) => turnSum + call.costUSD, 0), + 0, + ) + const apiCalls = turns.reduce((sum, turn) => sum + turn.assistantCalls.length, 0) + return apiCalls > 0 ? { ...session, turns, totalCostUSD, apiCalls } : null + }) + .filter((session): session is NonNullable => session !== null) + + const totalCostUSD = sessions.reduce((sum, session) => sum + session.totalCostUSD, 0) + const totalApiCalls = sessions.reduce((sum, session) => sum + session.apiCalls, 0) + return totalApiCalls > 0 ? { ...project, sessions, totalCostUSD, totalApiCalls } : null + }) + .filter((project): project is NonNullable => project !== null) +} + +export async function getPlanUsage(plan: Plan, today = new Date()): Promise { + const { periodStart } = computePeriodFromResetDay(plan.resetDay, today) + const range: DateRange = { + start: periodStart, + end: today, + } + const projects = await parseAllSessions(range, plan.provider) + return getPlanUsageFromProjects(plan, projects, today) +} + +export async function getPlanUsageOrNull(today = new Date()): Promise { + return (await getPlanUsages(today))[0] ?? null +} + +export function activePlansFromMap(plans: PlanMap): Plan[] { + return PLAN_PROVIDERS + .map(provider => plans[provider]) + .filter(isActivePlan) +} + +export async function getPlanUsages(today = new Date()): Promise { + const plans = activePlansFromMap(await readPlans()) + if (plans.length === 0) return [] + + const starts = plans.map(plan => computePeriodFromResetDay(plan.resetDay, today).periodStart.getTime()) + const range: DateRange = { + start: new Date(Math.min(...starts)), + end: today, + } + + if (plans.length === 1) { + const plan = plans[0]! + const projects = await parseAllSessions(range, plan.provider) + return [getPlanUsageFromProjects(plan, projects, today)] + } + + const projects = await parseAllSessions(range, 'all') + + return plans.map(plan => getPlanUsageFromProjects(plan, getPlanScopedProjects(plan, projects, today), today)) +} + +export function isActivePlan(plan: Plan | undefined): plan is Plan { + return plan !== undefined && plan.id !== 'none' && Number.isFinite(plan.monthlyUsd) && plan.monthlyUsd > 0 +} diff --git a/src/plans.ts b/src/plans.ts new file mode 100644 index 0000000..e384e75 --- /dev/null +++ b/src/plans.ts @@ -0,0 +1,79 @@ +import type { Plan, PlanId, PlanProvider } from './config.js' + +export const PLAN_PROVIDERS: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok'] +export const PLAN_IDS: PlanId[] = ['claude-pro', 'claude-max', 'claude-max-5x', 'cursor-pro', 'supergrok', 'supergrok-heavy', 'custom', 'none'] + +export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy', Omit> = { + 'claude-pro': { + id: 'claude-pro', + monthlyUsd: 20, + provider: 'claude', + resetDay: 1, + }, + 'claude-max': { + id: 'claude-max', + monthlyUsd: 200, + provider: 'claude', + resetDay: 1, + }, + 'claude-max-5x': { + id: 'claude-max-5x', + monthlyUsd: 100, + provider: 'claude', + resetDay: 1, + }, + 'cursor-pro': { + id: 'cursor-pro', + monthlyUsd: 20, + provider: 'cursor', + resetDay: 1, + }, + 'supergrok': { + id: 'supergrok', + monthlyUsd: 30, + provider: 'grok', + resetDay: 1, + }, + 'supergrok-heavy': { + id: 'supergrok-heavy', + monthlyUsd: 300, + provider: 'grok', + resetDay: 1, + }, +} + +export function isPlanProvider(value: string): value is PlanProvider { + return PLAN_PROVIDERS.includes(value as PlanProvider) +} + +export function isPlanId(value: string): value is PlanId { + return PLAN_IDS.includes(value as PlanId) +} + +export function getPresetPlan(id: string): Omit | null { + if (id in PRESET_PLANS) { + return PRESET_PLANS[id as keyof typeof PRESET_PLANS] + } + return null +} + +export function planDisplayName(id: PlanId): string { + switch (id) { + case 'claude-pro': + return 'Claude Pro' + case 'claude-max': + return 'Claude Max 20x' + case 'claude-max-5x': + return 'Claude Max 5x' + case 'cursor-pro': + return 'Cursor Pro' + case 'supergrok': + return 'SuperGrok' + case 'supergrok-heavy': + return 'SuperGrok Heavy' + case 'custom': + return 'Custom' + case 'none': + return 'None' + } +} diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts new file mode 100644 index 0000000..710b4bf --- /dev/null +++ b/src/providers/antigravity.ts @@ -0,0 +1,1351 @@ +import { readdir, readFile, mkdir, stat, open, rename, unlink } from 'fs/promises' +import { execFile } from 'child_process' +import { randomBytes } from 'crypto' +import { basename, join } from 'path' +import { homedir } from 'os' +import { fileURLToPath } from 'url' +import https from 'https' + +import { calculateCost } from '../models.js' +import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +type AntigravityConversationRoot = { + dir: string + project: string + extensions: readonly string[] +} + +const CONVERSATION_ROOTS: readonly AntigravityConversationRoot[] = [ + { + dir: join(homedir(), '.gemini', 'antigravity', 'conversations'), + project: 'antigravity', + extensions: ['.pb', '.db'], + }, + { + dir: join(homedir(), '.gemini', 'antigravity-cli', 'conversations'), + project: 'antigravity-cli', + extensions: ['.pb', '.db'], + }, + { + dir: join(homedir(), '.gemini', 'antigravity-cli', 'implicit'), + project: 'antigravity-cli', + extensions: ['.pb'], + }, + { + dir: join(homedir(), '.gemini', 'antigravity-ide', 'conversations'), + project: 'antigravity-ide', + extensions: ['.pb', '.db'], + }, + { + dir: join(homedir(), '.gemini', 'antigravity-ide', 'implicit'), + project: 'antigravity-ide', + extensions: ['.pb'], + }, +] as const +const CACHE_VERSION = 2 + +const RPC_TIMEOUT_MS = 5000 +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 + +export type ServerInfo = { + port: number + csrfToken: string +} + +type ServerCandidate = ServerInfo & { + appDataDir?: 'antigravity' | 'antigravity-cli' | 'antigravity-ide' +} + +type ModelMap = Record + +type UsageEntry = { + model: string + inputTokens: string + outputTokens: string + thinkingOutputTokens?: string + responseOutputTokens?: string + apiProvider: string + responseId?: string +} + +export type GeneratorMetadata = { + stepIndices?: number[] + chatModel?: { + model: string + usage: UsageEntry + chatStartMetadata?: { + createdAt?: string + } + } +} + +type ModelMapResponse = { + models?: Record + response?: { + models?: Record + } +} + +type GeneratorMetadataResponse = { + generatorMetadata?: GeneratorMetadata[] + response?: { + generatorMetadata?: GeneratorMetadata[] + } +} + +type StatusLineCurrentUsage = { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +type StatusLinePayload = { + conversation_id?: string + session_id?: string + model?: string | { + id?: string + display_name?: string + } + context_window?: { + current_usage?: StatusLineCurrentUsage | null + } +} + +type StatusLineEvent = { + at: string + conversationId: string + sessionId?: string + model: string + usage: { + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + } +} + +type CachedCascade = { + mtimeMs: number + sizeBytes: number + calls: ParsedProviderCall[] +} + +type AntigravityCache = { + version: number + cascades: Record +} + +type ProtoField = { + number: number + wireType: number + value?: bigint + bytes?: Uint8Array +} + +type ProtoVarint = { + value: bigint + offset: number +} + +type AntigravityGenMetadataRow = { + idx: number + data: Uint8Array | string +} + +const cachedServers = new Map() +const cachedModelMaps = new Map() +let memCache: AntigravityCache | null = null +let cacheDirty = false +let httpsAgent: https.Agent | undefined +const protoTextDecoder = new TextDecoder('utf-8', { fatal: false }) + +const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port', 'https-server-port', 'extension-server-port'] +const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token', 'csrf-token', 'extension-server-csrf-token'] +const APP_DATA_DIR_FLAGS = ['app_data_dir', 'app-data-dir'] + +function getAgent(): https.Agent { + if (!httpsAgent) httpsAgent = new https.Agent({ rejectUnauthorized: false }) + return httpsAgent +} + +function getCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function getCachePath(): string { + return join(getCacheDir(), 'antigravity-results.json') +} + +export function getAntigravityStatusLineEventsPath(): string { + return join(getCacheDir(), 'antigravity-statusline.jsonl') +} + +function execFileText(command: string, args: string[], timeout = 3000): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { encoding: 'utf-8', timeout, maxBuffer: 1024 * 1024 }, (err, stdout) => { + if (err) reject(err) + else resolve(stdout) + }) + }) +} + +function getFlagValue(line: string, names: string[]): string | null { + for (const name of names) { + const match = line.match(new RegExp(`--${name}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|([^\\s]+))`, 'i')) + const value = match?.[1] ?? match?.[2] ?? match?.[3] + if (value && !value.startsWith('--')) return value + } + return null +} + +function isLikelyCsrfToken(value: string): boolean { + return value.length >= 16 && /^[A-Za-z0-9._~:/+=-]+$/.test(value) +} + +function normalizeAppDataDir(value: string | null): 'antigravity' | 'antigravity-cli' | 'antigravity-ide' | undefined { + if (!value) return undefined + const normalized = value.replace(/\\/g, '/').toLowerCase() + if (normalized.includes('antigravity-ide')) return 'antigravity-ide' + if (normalized.includes('antigravity-cli')) return 'antigravity-cli' + if (normalized.includes('antigravity')) return 'antigravity' + return undefined +} + +export function extractAntigravityAppDataDirFromLine(line: string): 'antigravity' | 'antigravity-cli' | 'antigravity-ide' | undefined { + return normalizeAppDataDir(getFlagValue(line, APP_DATA_DIR_FLAGS)) +} + +function parseAntigravityServerCandidateFromLine(line: string): ServerCandidate | null { + const lower = line.toLowerCase() + if (!lower.includes('language_server') || !lower.includes('antigravity')) return null + + const rawPort = getFlagValue(line, SERVER_PORT_FLAGS) + const csrfToken = getFlagValue(line, CSRF_TOKEN_FLAGS) + if (!rawPort || !csrfToken) return null + if (!isLikelyCsrfToken(csrfToken)) return null + + const port = Number(rawPort) + if (!Number.isInteger(port) || port < 0 || port > 65535) return null + + return { + port, + csrfToken, + appDataDir: extractAntigravityAppDataDirFromLine(line), + } +} + +export function parseAntigravityServerInfoFromLine(line: string): ServerInfo | { port: 0; csrfToken: string } | null { + const candidate = parseAntigravityServerCandidateFromLine(line) + return candidate ? { port: candidate.port, csrfToken: candidate.csrfToken } : null +} + +export function parseAntigravityServerInfo(lines: string[]): ServerInfo | null { + for (const line of lines) { + const server = parseAntigravityServerInfoFromLine(line) + if (server) return server + } + return null +} + +function parseAntigravityServerCandidates(lines: string[]): ServerCandidate[] { + return lines + .map(parseAntigravityServerCandidateFromLine) + .filter((server): server is ServerCandidate => server !== null) +} + +function getCanonicalModelId(key: string, displayName?: string): string { + if (displayName) { + const lower = displayName.toLowerCase() + if (lower.includes('3.5 flash')) { + if (lower.includes('high')) return 'gemini-3.5-flash-high' + if (lower.includes('medium')) return 'gemini-3.5-flash-medium' + if (lower.includes('low')) return 'gemini-3.5-flash-low' + return 'gemini-3.5-flash' + } + if (lower.includes('3.1 pro')) { + if (lower.includes('high')) return 'gemini-3.1-pro-high' + if (lower.includes('low')) return 'gemini-3.1-pro-low' + return 'gemini-3.1-pro' + } + if (lower.includes('3.1 flash')) { + if (lower.includes('image')) return 'gemini-3.1-flash-image' + if (lower.includes('lite')) return 'gemini-3.1-flash-lite' + return 'gemini-3.1-flash' + } + if (lower.includes('3 flash')) { + return 'gemini-3-flash' + } + if (lower.includes('3 pro')) { + return 'gemini-3-pro' + } + } + return key +} + +export function extractAntigravityModelMap(resp: unknown): ModelMap { + if (!resp || typeof resp !== 'object') return {} + const data = resp as ModelMapResponse + const models = data.response?.models ?? data.models + const map = new Map() + if (!models) return {} + for (const [key, info] of Object.entries(models)) { + if (info && typeof info === 'object' && typeof info.model === 'string') { + const canonicalKey = getCanonicalModelId(key, info.displayName) + map.set(info.model, canonicalKey) + } + } + return Object.fromEntries(map) +} + +export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMetadata[] { + if (!resp || typeof resp !== 'object') return [] + const data = resp as GeneratorMetadataResponse + const metadata = data.response?.generatorMetadata ?? data.generatorMetadata + return Array.isArray(metadata) ? metadata : [] +} + +async function loadCache(): Promise { + if (memCache) return memCache + try { + const raw = await readFile(getCachePath(), 'utf-8') + const cache = JSON.parse(raw) as AntigravityCache + if (cache.version === CACHE_VERSION && cache.cascades && typeof cache.cascades === 'object') { + memCache = cache + return cache + } + } catch { /* no cache or invalid */ } + memCache = { version: CACHE_VERSION, cascades: {} } + return memCache +} + +async function flushCache(liveCascadeIds?: Set): Promise { + if (!memCache) return + // If the caller supplied liveCascadeIds, we must run the eviction step + // even when no cascade was added or updated this run; otherwise deleted + // .pb files would persist in the cache forever once it stops getting + // dirty writes. Mark the cache dirty when an eviction happens so the + // file write below proceeds. + if (liveCascadeIds) { + for (const id of Object.keys(memCache.cascades)) { + if (!liveCascadeIds.has(id)) { + delete memCache.cascades[id] + cacheDirty = true + } + } + } + if (!cacheDirty) return + try { + + const dir = getCacheDir() + await mkdir(dir, { recursive: true }) + const finalPath = getCachePath() + const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` + const handle = await open(tempPath, 'w', 0o600) + try { + await handle.writeFile(JSON.stringify(memCache), { encoding: 'utf-8' }) + await handle.sync() + } finally { + await handle.close() + } + try { + await rename(tempPath, finalPath) + } catch { + try { await unlink(tempPath) } catch { /* cleanup */ } + } + cacheDirty = false + } catch { /* best-effort */ } +} + +async function readProcessCommandLines(): Promise { + if (process.platform === 'win32') { + const script = [ + "$ErrorActionPreference = 'SilentlyContinue'", + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -like '*language_server*' -and $_.CommandLine -like '*antigravity*' } | ForEach-Object { $_.CommandLine }", + ].join('; ') + const output = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], 5000) + return output.split(/\r?\n/) + } + + const output = await execFileText('ps', ['-ww', '-eo', 'args']) + return output.split('\n') +} + +async function resolveEphemeralPort(csrfToken: string, appDataDir?: 'antigravity' | 'antigravity-cli' | 'antigravity-ide'): Promise { + if (process.platform === 'win32') { + try { + const script = [ + "$ErrorActionPreference = 'SilentlyContinue'", + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -like '*language_server*' -and $_.CommandLine -like '*antigravity*' } | ForEach-Object { @{ PID = $_.ProcessId; Cmd = $_.CommandLine } | ConvertTo-Json -Compress }" + ].join('; ') + const output = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], 5000) + + let targetPid = 0 + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) continue + try { + const proc = JSON.parse(line) as { PID: number; Cmd: string } + const candidate = parseAntigravityServerCandidateFromLine(proc.Cmd) + if (candidate && candidate.csrfToken === csrfToken) { + if (!appDataDir || !candidate.appDataDir || candidate.appDataDir === appDataDir) { + targetPid = proc.PID + break + } + } + } catch { /* skip invalid parse */ } + } + + if (targetPid === 0) return null + + const portScript = `Get-NetTCPConnection -State Listen -OwningProcess ${targetPid} | Select-Object -ExpandProperty LocalPort` + const portOutput = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', portScript], 5000) + const ports = portOutput.split(/\r?\n/) + .map(p => Number(p.trim())) + .filter(p => Number.isInteger(p) && p > 0) + + for (const port of ports) { + try { + await new Promise((resolve, reject) => { + const req = https.request({ + hostname: '127.0.0.1', + port: port, + path: '/exa.language_server_pb.LanguageServerService/GetAvailableModels', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1', + 'X-Codeium-Csrf-Token': csrfToken, + 'Content-Length': 2, + }, + agent: getAgent(), + timeout: 1000, + }, (res) => { + if (res.statusCode === 200) resolve(true) + else reject(new Error()) + }) + req.on('error', reject) + req.write('{}') + req.end() + }) + return { port, csrfToken } + } catch { /* try next port */ } + } + } catch { /* best-effort */ } + return null + } + + try { + const processOutput = await execFileText('ps', ['-ww', '-eo', 'pid=,args=']) + let pid = '' + for (const line of processOutput.split('\n')) { + const match = line.trim().match(/^(\d+)\s+(.+)$/) + if (!match) continue + const candidate = parseAntigravityServerCandidateFromLine(match[2]!) + if (!candidate) continue + if (candidate.csrfToken !== csrfToken) continue + if (appDataDir && candidate.appDataDir && candidate.appDataDir !== appDataDir) continue + pid = match[1]! + break + } + if (!pid) return null + const lsofOutput = await execFileText('lsof', ['-a', '-i', '-P', '-n', '-p', pid]) + for (const line of lsofOutput.split('\n')) { + if (!line.includes('LISTEN')) continue + const match = line.match(/:(\d+)\s+\(LISTEN\)/) + if (match) { + const port = Number(match[1]) + if (port > 0) return { port, csrfToken } + } + } + } catch { /* best-effort */ } + return null +} + +export function antigravityAppDataDirFromSourcePath(path: string): 'antigravity' | 'antigravity-cli' | 'antigravity-ide' { + const lower = path.replace(/\\/g, '/').toLowerCase() + if (lower.includes('/.gemini/antigravity-ide/')) return 'antigravity-ide' + if (lower.includes('/.gemini/antigravity-cli/')) return 'antigravity-cli' + return 'antigravity' +} + +async function detectServer(appDataDir: 'antigravity' | 'antigravity-cli' | 'antigravity-ide' = 'antigravity'): Promise { + if (cachedServers.has(appDataDir)) return cachedServers.get(appDataDir)! + try { + const candidates = parseAntigravityServerCandidates(await readProcessCommandLines()) + const info = candidates.find(candidate => candidate.appDataDir === appDataDir) + ?? (appDataDir === 'antigravity' ? candidates.find(candidate => candidate.appDataDir === undefined) : undefined) + ?? null + if (info && info.port > 0 && appDataDir !== 'antigravity-ide') { + cachedServers.set(appDataDir, { port: info.port, csrfToken: info.csrfToken }) + } else if (info) { + cachedServers.set(appDataDir, await resolveEphemeralPort(info.csrfToken, appDataDir)) + } else { + cachedServers.set(appDataDir, null) + } + return cachedServers.get(appDataDir)! + } catch { /* process discovery failed or timed out */ } + cachedServers.set(appDataDir, null) + return null +} + +async function rpc(server: ServerInfo, method: string, body: Record = {}): Promise { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body) + const req = https.request({ + hostname: '127.0.0.1', + port: server.port, + path: `/exa.language_server_pb.LanguageServerService/${method}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1', + 'X-Codeium-Csrf-Token': server.csrfToken, + 'Content-Length': Buffer.byteLength(data), + }, + agent: getAgent(), + timeout: RPC_TIMEOUT_MS, + }, (res) => { + const chunks: Buffer[] = [] + let totalBytes = 0 + res.on('data', (chunk: Buffer) => { + totalBytes += chunk.length + if (totalBytes > MAX_RESPONSE_BYTES) { + res.destroy() + reject(new Error(`RPC ${method}: response too large`)) + return + } + chunks.push(chunk) + }) + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`RPC ${method}: HTTP ${res.statusCode}`)) + return + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))) + } catch { + reject(new Error(`RPC ${method}: invalid JSON`)) + } + }) + res.on('error', reject) + }) + req.on('error', reject) + req.on('timeout', () => { req.destroy(); reject(new Error(`RPC ${method}: timeout`)) }) + req.write(data) + req.end() + }) +} + +async function getModelMap(server: ServerInfo): Promise { + const cacheKey = `${server.port}:${server.csrfToken}` + const cachedModelMap = cachedModelMaps.get(cacheKey) + if (cachedModelMap) return cachedModelMap + try { + const modelMap = extractAntigravityModelMap(await rpc(server, 'GetAvailableModels')) + cachedModelMaps.set(cacheKey, modelMap) + return modelMap + } catch { /* best-effort */ } + cachedModelMaps.set(cacheKey, {}) + return {} +} + +// Strip Antigravity-specific suffixes so the pricing DB can match +const PRICING_ALIASES: Record = { + 'gemini-pro': 'gemini-3.1-pro', +} + +function normalizePricingModel(model: string): string { + const stripped = model.replace(/-(high|medium|low|agent)$/, '') + return PRICING_ALIASES[stripped] ?? stripped +} + +function readProtoVarint(data: Uint8Array, startOffset: number): ProtoVarint | null { + let value = 0n + let shift = 0n + let offset = startOffset + + while (offset < data.length) { + const byte = BigInt(data[offset]!) + offset += 1 + value |= (byte & 0x7fn) << shift + if ((byte & 0x80n) === 0n) return { value, offset } + shift += 7n + if (shift > 70n) return null + } + + return null +} + +function parseProtoFields(data: Uint8Array): ProtoField[] { + const fields: ProtoField[] = [] + let offset = 0 + + while (offset < data.length) { + const key = readProtoVarint(data, offset) + if (!key) break + offset = key.offset + + const fieldNumber = Number(key.value >> 3n) + const wireType = Number(key.value & 0x7n) + if (!Number.isSafeInteger(fieldNumber) || fieldNumber <= 0) break + + if (wireType === 0) { + const value = readProtoVarint(data, offset) + if (!value) break + fields.push({ number: fieldNumber, wireType, value: value.value }) + offset = value.offset + continue + } + + if (wireType === 1) { + if (offset + 8 > data.length) break + fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 8) }) + offset += 8 + continue + } + + if (wireType === 2) { + const length = readProtoVarint(data, offset) + if (!length) break + offset = length.offset + const byteLength = Number(length.value) + if (!Number.isSafeInteger(byteLength) || byteLength < 0 || offset + byteLength > data.length) break + fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + byteLength) }) + offset += byteLength + continue + } + + if (wireType === 5) { + if (offset + 4 > data.length) break + fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 4) }) + offset += 4 + continue + } + + break + } + + return fields +} + +function firstProtoField(fields: readonly ProtoField[], fieldNumber: number): ProtoField | undefined { + return fields.find(field => field.number === fieldNumber) +} + +function protoFieldText(field: ProtoField | undefined): string | undefined { + if (!field?.bytes || field.bytes.length === 0) return undefined + const text = protoTextDecoder.decode(field.bytes) + if (!text || /[\u0000-\u0008\u000E-\u001F\u007F\uFFFD]/.test(text)) return undefined + return text +} + +function protoFieldPositiveInteger(field: ProtoField | undefined): number { + if (field?.value === undefined) return 0 + const value = Number(field.value) + return Number.isSafeInteger(value) && value > 0 ? value : 0 +} + +function protoFieldBytes(field: ProtoField | undefined): Uint8Array | undefined { + return field?.bytes +} + +function isAntigravityResponseId(value: string): boolean { + return /^[^\s]+$/.test(value) +} + +function antigravitySqliteResponseId(usageFields: readonly ProtoField[], fallback: string): string { + const responseId = protoFieldText(firstProtoField(usageFields, 11)) + return responseId && isAntigravityResponseId(responseId) ? responseId : fallback +} + +function genMetadataDataBytes(value: Uint8Array | string): Uint8Array { + return typeof value === 'string' + ? new TextEncoder().encode(value) + : value +} + +function antigravitySqliteMetadataAttributes(chatFields: readonly ProtoField[]): Map { + const attributes = new Map() + for (const field of chatFields) { + if (field.number !== 20) continue + const pairFields = parseProtoFields(protoFieldBytes(field) ?? new Uint8Array()) + const key = protoFieldText(firstProtoField(pairFields, 1)) + const value = protoFieldText(firstProtoField(pairFields, 2)) + if (key && value) attributes.set(key, value) + } + return attributes +} + +function antigravitySqliteModel(chatFields: readonly ProtoField[]): string { + const attributes = antigravitySqliteMetadataAttributes(chatFields) + const displayName = protoFieldText(firstProtoField(chatFields, 21)) + const rawModel = protoFieldText(firstProtoField(chatFields, 19)) + ?? attributes.get('model_enum') + ?? displayName + ?? 'unknown' + + return getCanonicalModelId(rawModel, displayName) +} + +function buildCallFromSqliteGenMetadataRow(cascadeId: string, row: AntigravityGenMetadataRow): ParsedProviderCall | null { + const rootFields = parseProtoFields(genMetadataDataBytes(row.data)) + const chatFields = parseProtoFields(protoFieldBytes(firstProtoField(rootFields, 1)) ?? new Uint8Array()) + const usageFields = parseProtoFields(protoFieldBytes(firstProtoField(chatFields, 4)) ?? new Uint8Array()) + if (usageFields.length === 0) return null + + const inputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 2)) + || protoFieldPositiveInteger(firstProtoField(usageFields, 1)) + const totalOutputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 3)) + let responseTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 9)) + let thinkingTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 10)) + + if (responseTokens === 0 && thinkingTokens === 0) { + responseTokens = totalOutputTokens + } else if (totalOutputTokens > 0 && responseTokens + thinkingTokens !== totalOutputTokens) { + const adjustedResponseTokens = totalOutputTokens - thinkingTokens + if (adjustedResponseTokens >= 0) responseTokens = adjustedResponseTokens + } + + if (inputTokens === 0 && totalOutputTokens === 0) return null + + const responseId = antigravitySqliteResponseId(usageFields, String(row.idx)) + const model = antigravitySqliteModel(chatFields) + const pricingModel = normalizePricingModel(model) + const costUSD = calculateCost(pricingModel, inputTokens, responseTokens + thinkingTokens, 0, 0, 0) + + return { + provider: 'antigravity', + model, + inputTokens, + outputTokens: responseTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: thinkingTokens, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp: '', + speed: 'standard', + deduplicationKey: `antigravity:${cascadeId}:${responseId}`, + userMessage: '', + sessionId: cascadeId, + } +} + +function buildCallsFromSqliteGenMetadata(cascadeId: string, rows: AntigravityGenMetadataRow[]): ParsedProviderCall[] { + const calls: ParsedProviderCall[] = [] + const seenResponseIds = new Set() + + for (const row of rows) { + const call = buildCallFromSqliteGenMetadataRow(cascadeId, row) + if (!call) continue + if (seenResponseIds.has(call.deduplicationKey)) continue + seenResponseIds.add(call.deduplicationKey) + calls.push(call) + } + + return calls +} + +async function parseSqliteGenMetadataCalls(filePath: string, cascadeId: string): Promise { + if (!filePath.toLowerCase().endsWith('.db')) return [] + if (!isSqliteAvailable()) return [] + + let db: ReturnType | null = null + try { + db = openDatabase(filePath) + const rows = db.query('SELECT idx, data FROM gen_metadata ORDER BY idx') + return buildCallsFromSqliteGenMetadata(cascadeId, rows) + } catch (err) { + // Let a transient lock propagate so the run retries this file on the next + // refresh instead of treating it as empty (see parser.ts busy handling). + if (isSqliteBusyError(err)) throw err + return [] + } finally { + db?.close() + } +} + +function parseFiniteToken(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : 0 +} + +function usageSignature(event: StatusLineEvent): string { + const u = event.usage + return [ + event.model, + u.inputTokens, + u.outputTokens, + u.cacheCreationInputTokens, + u.cacheReadInputTokens, + ].join(':') +} + +function usageHasTokens(usage: StatusLineEvent['usage']): boolean { + return ( + usage.inputTokens > 0 || + usage.outputTokens > 0 || + usage.cacheCreationInputTokens > 0 || + usage.cacheReadInputTokens > 0 + ) +} + +function usageIsMonotonic(current: StatusLineEvent['usage'], previous: StatusLineEvent['usage']): boolean { + return ( + current.inputTokens >= previous.inputTokens && + current.outputTokens >= previous.outputTokens && + current.cacheCreationInputTokens >= previous.cacheCreationInputTokens && + current.cacheReadInputTokens >= previous.cacheReadInputTokens + ) +} + +function usageDelta(current: StatusLineEvent['usage'], previous: StatusLineEvent['usage']): StatusLineEvent['usage'] { + return { + inputTokens: current.inputTokens - previous.inputTokens, + outputTokens: current.outputTokens - previous.outputTokens, + cacheCreationInputTokens: current.cacheCreationInputTokens - previous.cacheCreationInputTokens, + cacheReadInputTokens: current.cacheReadInputTokens - previous.cacheReadInputTokens, + } +} + +export function antigravityCascadeIdFromPath(path: string): string { + return basename(path).replace(/\.(pb|db)$/i, '') +} + +function buildCallsFromGeneratorMetadata( + cascadeId: string, + metadata: GeneratorMetadata[], + modelMap: ModelMap, +): ParsedProviderCall[] { + const results: ParsedProviderCall[] = [] + + for (let i = 0; i < metadata.length; i++) { + const entry = metadata[i]! + const usage = entry.chatModel?.usage + if (!usage) continue + + const inputTokens = parseInt(usage.inputTokens ?? '0', 10) + const outputTokens = parseInt(usage.outputTokens ?? '0', 10) + const thinkingTokens = parseInt(usage.thinkingOutputTokens ?? '0', 10) + const responseTokens = parseInt(usage.responseOutputTokens ?? '0', 10) + + if (inputTokens === 0 && outputTokens === 0) continue + + const responseId = usage.responseId || String(i) + const dedupKey = `antigravity:${cascadeId}:${responseId}` + + const model = modelMap[usage.model] ?? usage.model + const pricingModel = normalizePricingModel(model) + const timestamp = entry.chatModel?.chatStartMetadata?.createdAt ?? '' + const costUSD = calculateCost(pricingModel, inputTokens, responseTokens + thinkingTokens, 0, 0, 0) + + results.push({ + provider: 'antigravity', + model, + inputTokens, + outputTokens: responseTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: thinkingTokens, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId: cascadeId, + }) + } + + return results +} + +function isConversationFile(file: string, extensions: readonly string[]): boolean { + const lowerFile = file.toLowerCase() + return extensions.some(ext => lowerFile.endsWith(ext)) +} + +export function isAntigravityStatusLineEventsPath(path: string): boolean { + return path === getAntigravityStatusLineEventsPath() +} + +export async function discoverAntigravitySessionSources( + roots: readonly AntigravityConversationRoot[] = CONVERSATION_ROOTS, +): Promise { + const includeStatusLineEvents = roots === CONVERSATION_ROOTS + const sources: SessionSource[] = [] + for (const root of roots) { + let files: string[] + try { + files = await readdir(root.dir) + } catch { + continue + } + + for (const file of files.sort()) { + if (!isConversationFile(file, root.extensions)) continue + const path = join(root.dir, file) + const s = await stat(path).catch(() => null) + if (!s?.isFile()) continue + sources.push({ + path, + project: root.project, + provider: 'antigravity', + }) + } + } + + if (includeStatusLineEvents) { + const statusLinePath = getAntigravityStatusLineEventsPath() + const statusLineStat = await stat(statusLinePath).catch(() => null) + if (statusLineStat?.isFile()) { + sources.push({ + path: statusLinePath, + project: 'antigravity-cli', + provider: 'antigravity', + }) + } + } + + return sources +} + +function parseStatusLinePayload(input: unknown): StatusLineEvent | null { + if (!input || typeof input !== 'object') return null + const payload = input as StatusLinePayload + if (typeof payload.conversation_id !== 'string' || payload.conversation_id.length === 0) return null + const usage = payload.context_window?.current_usage + if (!usage) return null + + const event: StatusLineEvent = { + at: new Date().toISOString(), + conversationId: payload.conversation_id, + sessionId: typeof payload.session_id === 'string' ? payload.session_id : undefined, + model: typeof payload.model === 'string' + ? payload.model + : payload.model?.id ?? payload.model?.display_name ?? 'unknown', + usage: { + inputTokens: parseFiniteToken(usage.input_tokens), + outputTokens: parseFiniteToken(usage.output_tokens), + cacheCreationInputTokens: parseFiniteToken(usage.cache_creation_input_tokens), + cacheReadInputTokens: parseFiniteToken(usage.cache_read_input_tokens), + }, + } + + const u = event.usage + if (u.inputTokens === 0 && u.outputTokens === 0 && u.cacheCreationInputTokens === 0 && u.cacheReadInputTokens === 0) { + return null + } + if (event.model === 'unknown') return null + return event +} + +export async function recordAntigravityStatusLinePayload(input: unknown): Promise { + const event = parseStatusLinePayload(input) + if (!event) return false + + const path = getAntigravityStatusLineEventsPath() + await mkdir(getCacheDir(), { recursive: true, mode: 0o700 }) + const fd = await open(path, 'a', 0o600) + try { + await fd.appendFile(`${JSON.stringify(event)}\n`, { encoding: 'utf-8' }) + } finally { + await fd.close() + } + return true +} + +function parseStatusLineEvent(input: unknown): StatusLineEvent | null { + if (!input || typeof input !== 'object') return null + const event = input as StatusLineEvent + if (typeof event.at !== 'string' || Number.isNaN(new Date(event.at).getTime())) return null + if (typeof event.conversationId !== 'string' || event.conversationId.length === 0) return null + if (typeof event.model !== 'string' || event.model.length === 0) return null + if (!event.usage || typeof event.usage !== 'object') return null + + const usage = { + inputTokens: parseFiniteToken(event.usage.inputTokens), + outputTokens: parseFiniteToken(event.usage.outputTokens), + cacheCreationInputTokens: parseFiniteToken(event.usage.cacheCreationInputTokens), + cacheReadInputTokens: parseFiniteToken(event.usage.cacheReadInputTokens), + } + + if ( + usage.inputTokens === 0 && + usage.outputTokens === 0 && + usage.cacheCreationInputTokens === 0 && + usage.cacheReadInputTokens === 0 + ) return null + + return { + at: event.at, + conversationId: event.conversationId, + sessionId: typeof event.sessionId === 'string' ? event.sessionId : undefined, + model: event.model, + usage, + } +} + +function hasRpcCacheForConversation(seenKeys: Set, conversationId: string): boolean { + const prefix = `antigravity:${conversationId}:` + for (const key of seenKeys) { + if (key.startsWith(prefix)) return true + } + return false +} + +async function parseStatusLineCalls(source: SessionSource, seenKeys: Set): Promise { + const raw = await readFile(source.path, 'utf-8').catch(() => '') + const runsByConversation = new Map>() + + for (const line of raw.split(/\r?\n/)) { + if (!line.trim()) continue + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + + const event = parseStatusLineEvent(parsed) + if (!event) continue + if (hasRpcCacheForConversation(seenKeys, event.conversationId)) continue + + const signature = usageSignature(event) + const runs = runsByConversation.get(event.conversationId) ?? [] + const lastRun = runs.at(-1) + if (lastRun?.signature === signature) { + lastRun.count += 1 + lastRun.event = event + } else { + runs.push({ event, signature, count: 1 }) + runsByConversation.set(event.conversationId, runs) + } + } + + const results: ParsedProviderCall[] = [] + + for (const runs of runsByConversation.values()) { + let turnIndex = 0 + let previousSnapshotUsage: StatusLineEvent['usage'] | null = null + for (let i = 0; i < runs.length; i++) { + const run = runs[i]! + const isLastRun = i === runs.length - 1 + if (run.count === 1 && !isLastRun) continue + + const event = run.event + const signature = run.signature + const billableUsage = previousSnapshotUsage && usageIsMonotonic(event.usage, previousSnapshotUsage) + ? usageDelta(event.usage, previousSnapshotUsage) + : event.usage + previousSnapshotUsage = event.usage + if (!usageHasTokens(billableUsage)) continue + + const dedupKey = `antigravity-statusline:${event.conversationId}:${turnIndex}:${signature}` + turnIndex += 1 + if (seenKeys.has(dedupKey)) continue + + const u = billableUsage + const costUSD = calculateCost( + normalizePricingModel(event.model), + u.inputTokens, + u.outputTokens, + u.cacheCreationInputTokens, + u.cacheReadInputTokens, + 0, + ) + + results.push({ + provider: 'antigravity', + model: event.model, + inputTokens: u.inputTokens, + outputTokens: u.outputTokens, + cacheCreationInputTokens: u.cacheCreationInputTokens, + cacheReadInputTokens: u.cacheReadInputTokens, + cachedInputTokens: 0, + // StatusLine current_usage exposes aggregate output tokens, not a + // separate thinking/response split. Preserve the exact total instead + // of inventing a breakdown. + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp: event.at, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId: event.conversationId, + project: source.project, + }) + } + } + + return results +} + +export function shouldReparseAntigravitySource(path: string, cachedTurnCount: number): boolean { + if (cachedTurnCount === 0) return true + return isAntigravityStatusLineEventsPath(path) +} + +async function findCascadeSource(cascadeId: string): Promise { + const sources = await discoverAntigravitySessionSources() + return sources.find(source => { + const lower = source.path.replace(/\\/g, '/').toLowerCase() + return (lower.includes('/.gemini/antigravity-cli/') || lower.includes('/.gemini/antigravity-ide/')) && + antigravityCascadeIdFromPath(source.path) === cascadeId + }) ?? null +} + +export async function snapshotAntigravityStatusLinePayload(input: unknown): Promise { + const event = parseStatusLinePayload(input) + if (!event) return false + + const cascadeId = event.conversationId + const source = await findCascadeSource(cascadeId) + if (!source) return false + + const s = await stat(source.path).catch(() => null) + if (!s) return false + + const cache = await loadCache() + const cached = cache.cascades[cascadeId] + if (cached && cached.mtimeMs === s.mtimeMs && cached.sizeBytes === s.size && cached.calls.length > 0) { + return true + } + + const server = await detectServer(antigravityAppDataDirFromSourcePath(source.path)) + if (!server) return false + + let metadata: GeneratorMetadata[] + try { + const modelMap = await getModelMap(server) + metadata = extractAntigravityGeneratorMetadata( + await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }), + ) + cache.cascades[cascadeId] = { + mtimeMs: s.mtimeMs, + sizeBytes: s.size, + calls: buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap), + } + cacheDirty = true + await flushCache() + return cache.cascades[cascadeId]!.calls.length > 0 + } catch { + return false + } +} + +async function extractWorkspacePath(filePath: string): Promise { + let text = '' + if (filePath.endsWith('.db') && isSqliteAvailable()) { + try { + const db = openDatabase(filePath) + const rows = db.query<{ data: Uint8Array }>('SELECT data FROM trajectory_metadata_blob') + db.close() + const textDecoder = new TextDecoder('utf-8', { fatal: false }) + text = rows.map(r => textDecoder.decode(r.data)).join(' ') + } catch { /* ignore and fallback */ } + } + + if (!text) { + try { + text = await readFile(filePath, 'utf-8') + } catch { + return undefined + } + } + + const match = text.match(/file:\/\/\/[^\x00-\x1F\x7F"'\s]+/i) + if (!match) return undefined + + try { + return fileURLToPath(match[0]) + } catch { + return undefined + } +} + +function sanitizeProject(path: string): string { + return basename(path.replace(/\\/g, '/')) +} + +function applyAntigravityProject(call: ParsedProviderCall, source: SessionSource, projectPath: string | undefined): void { + if (source.project === 'antigravity-cli') { + call.project = source.project + delete call.projectPath + return + } + + if (projectPath) { + call.projectPath = projectPath + call.project = sanitizeProject(projectPath) + return + } + + call.project = source.project +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + if (isAntigravityStatusLineEventsPath(source.path)) { + for (const call of await parseStatusLineCalls(source, seenKeys)) { + seenKeys.add(call.deduplicationKey) + yield call + } + return + } + + const cascadeId = antigravityCascadeIdFromPath(source.path) + const cache = await loadCache() + + const s = await stat(source.path).catch(() => null) + if (!s) return + + const projectPath = await extractWorkspacePath(source.path) + + const cached = cache.cascades[cascadeId] + if (cached && cached.mtimeMs === s.mtimeMs && cached.sizeBytes === s.size && cached.calls.length > 0) { + for (const call of cached.calls) { + applyAntigravityProject(call, source, projectPath) + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + return + } + + const sqliteResults = await parseSqliteGenMetadataCalls(source.path, cascadeId) + if (sqliteResults.length > 0) { + for (const call of sqliteResults) { + applyAntigravityProject(call, source, projectPath) + } + + cache.cascades[cascadeId] = { + mtimeMs: s.mtimeMs, + sizeBytes: s.size, + calls: sqliteResults, + } + cacheDirty = true + + for (const call of sqliteResults) { + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + return + } + + const server = await detectServer(antigravityAppDataDirFromSourcePath(source.path)) + if (!server) { + if (cached) { + for (const call of cached.calls) { + applyAntigravityProject(call, source, projectPath) + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + } + return + } + + const modelMap = await getModelMap(server) + + let metadata: GeneratorMetadata[] + try { + metadata = extractAntigravityGeneratorMetadata( + await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }), + ) + } catch { + if (cached) { + for (const call of cached.calls) { + applyAntigravityProject(call, source, projectPath) + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + } + return + } + + const results = buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap) + for (const call of results) { + applyAntigravityProject(call, source, projectPath) + } + + cache.cascades[cascadeId] = { + mtimeMs: s.mtimeMs, + sizeBytes: s.size, + calls: results, + } + cacheDirty = true + + for (const call of results) { + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + }, + } +} + +const modelDisplayNames: Record = { + 'gemini-pro-agent': 'Gemini Pro', + 'gemini-3-pro': 'Gemini 3 Pro', + 'gemini-3.1-pro-high': 'Gemini 3.1 Pro', + 'gemini-3.1-pro-low': 'Gemini 3.1 Pro (Low)', + 'gemini-3-flash': 'Gemini 3 Flash', + 'gemini-3-flash-agent': 'Gemini 3 Flash', + 'gemini-3.5-flash': 'Gemini 3.5 Flash', + 'gemini-3.5-flash-high': 'Gemini 3.5 Flash', + 'gemini-3.5-flash-medium': 'Gemini 3.5 Flash', + 'gemini-3.5-flash-low': 'Gemini 3.5 Flash', + 'Gemini 3.5 Flash (High)': 'Gemini 3.5 Flash', + 'Gemini 3.5 Flash (Medium)': 'Gemini 3.5 Flash', + 'Gemini 3.5 Flash (Low)': 'Gemini 3.5 Flash', + 'gemini-3.1-flash-image': 'Gemini 3.1 Flash', + 'gemini-3.1-flash-lite': 'Gemini 3.1 Flash Lite', + 'claude-opus-4-6-thinking': 'Opus 4.6', + 'claude-sonnet-4-6': 'Sonnet 4.6', +} + +export function createAntigravityProvider(): Provider { + return { + name: 'antigravity', + displayName: 'Antigravity', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + return discoverAntigravitySessionSources() + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export async function flushAntigravityCache(liveCascadeIds?: Set): Promise { + await flushCache(liveCascadeIds) +} + +export const antigravity = createAntigravityProvider() diff --git a/src/providers/claude.ts b/src/providers/claude.ts new file mode 100644 index 0000000..3613168 --- /dev/null +++ b/src/providers/claude.ts @@ -0,0 +1,327 @@ +import { readFile, readdir, stat } from 'fs/promises' +import { basename, delimiter as pathDelimiter, join, resolve } from 'path' +import { homedir } from 'os' +import { createHash } from 'crypto' + +import type { Provider, SessionSource, SessionParser } from './types.js' +import { getShortModelName } from '../models.js' +import { readConfig } from '../config.js' + +export type ClaudeConfigSource = { + id: string + label: string + path: string +} + +function expandHome(p: string): string { + if (p === '~') return homedir() + if (p.startsWith('~/') || p.startsWith('~\\')) return join(homedir(), p.slice(2)) + return p +} + +function dedupeResolved(paths: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const p of paths) { + if (!seen.has(p)) { + seen.add(p) + out.push(p) + } + } + return out +} + +function claudeConfigSourceId(path: string): string { + return 'claude-config:' + createHash('sha256').update(path).digest('hex').slice(0, 16) +} + +function baseClaudeConfigLabel(path: string): string { + const normalized = resolve(path) + if (normalized === resolve(join(homedir(), '.claude'))) return 'Default Claude' + const name = basename(normalized).replace(/^\./, '').trim() + return name || normalized +} + +function makeUniqueLabels(sources: ClaudeConfigSource[]): ClaudeConfigSource[] { + const counts = new Map() + for (const source of sources) counts.set(source.label, (counts.get(source.label) ?? 0) + 1) + if (![...counts.values()].some(count => count > 1)) return sources + + const seen = new Map() + return sources.map(source => { + if ((counts.get(source.label) ?? 0) <= 1) return source + const index = (seen.get(source.label) ?? 0) + 1 + seen.set(source.label, index) + return { ...source, label: `${source.label} ${index}` } + }) +} + +/// Returns every Claude config dir to scan, in priority order with duplicates +/// removed (resolved-path equality). Precedence: `CLAUDE_CONFIG_DIRS` (a +/// `path.delimiter`-separated list, ":" on POSIX, ";" on Windows), then +/// `CLAUDE_CONFIG_DIR` (single dir), then the `claudeConfigDirs` array in +/// `~/.config/codeburn/config.json` (how the macOS menubar configures +/// multi-account aggregation, since a GUI app can't inherit the shell env), +/// then `~/.claude`. Sessions from every returned dir are merged into one +/// ProjectSummary per project name in `src/parser.ts:scanProjectDirs`, so two +/// dirs holding the same sanitized project slug naturally aggregate (#208). +export async function getClaudeConfigDirs(): Promise { + const multi = process.env['CLAUDE_CONFIG_DIRS'] + if (multi !== undefined && multi !== '') { + const dirs = multi + .split(pathDelimiter) + .map(s => s.trim()) + .filter(s => s.length > 0) + .map(s => resolve(expandHome(s))) + if (dirs.length > 0) return dedupeResolved(dirs) + } + const single = process.env['CLAUDE_CONFIG_DIR'] + if (single !== undefined && single !== '') return [resolve(expandHome(single))] + + // Config-file fallback (menubar-driven). Env vars always win so a power user + // can still override per-shell. A non-array or empty value falls through to + // the ~/.claude default, matching the "unset" behavior. + const config = await readConfig() + if (Array.isArray(config.claudeConfigDirs)) { + const dirs = config.claudeConfigDirs + .filter((s): s is string => typeof s === 'string' && s.trim().length > 0) + .map(s => resolve(expandHome(s.trim()))) + if (dirs.length > 0) return dedupeResolved(dirs) + } + + return [join(homedir(), '.claude')] +} + +export async function discoverClaudeConfigSources(): Promise { + const dirs = await getClaudeConfigDirs() + return makeUniqueLabels(dirs.map(path => ({ + id: claudeConfigSourceId(path), + label: baseClaudeConfigLabel(path), + path, + }))) +} + +export function getDesktopSessionsDir(): string { + const override = process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] + if (override) return override + if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions') + if (process.platform === 'win32') return join(homedir(), 'AppData', 'Roaming', 'Claude', 'local-agent-mode-sessions') + return join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions') +} + +async function findDesktopProjectDirs(base: string): Promise { + const results: string[] = [] + async function walk(dir: string, depth: number): Promise { + if (depth > 8) return + const entries = await readdir(dir).catch(() => []) + for (const entry of entries) { + if (entry === 'node_modules' || entry === '.git') continue + const full = join(dir, entry) + const s = await stat(full).catch(() => null) + if (!s?.isDirectory()) continue + if (entry === 'projects') { + const projectDirs = await readdir(full).catch(() => []) + for (const pd of projectDirs) { + const pdFull = join(full, pd) + const pdStat = await stat(pdFull).catch(() => null) + if (pdStat?.isDirectory()) results.push(pdFull) + } + } else { + await walk(full, depth + 1) + } + } + } + await walk(base, 0) + return results +} + +// ── Cowork space resolution ──────────────────────────────────────────── +// Claude Desktop's local-agent-mode creates one directory per session under +// ///local_/ +// Inside each session directory Claude Code stores its own config at +// .claude/projects// +// which is what findDesktopProjectDirs picks up. The actual project name +// lives in the sibling /local_.json (spaceId field) +// and /spaces.json (id → name mapping). + +interface CoworkSpace { id: string; name: string } +interface CoworkSpacesFile { spaces: CoworkSpace[] } + +// Cache spaces.json per workspace directory to avoid redundant reads. +const spacesJsonCache = new Map() + +async function loadSpacesJson(workspaceDir: string): Promise { + if (spacesJsonCache.has(workspaceDir)) return spacesJsonCache.get(workspaceDir) ?? null + try { + const raw = await readFile(join(workspaceDir, 'spaces.json'), 'utf-8') + const parsed: unknown = JSON.parse(raw) + if ( + parsed !== null && + typeof parsed === 'object' && + 'spaces' in parsed && + Array.isArray((parsed as { spaces: unknown }).spaces) + ) { + const result = parsed as CoworkSpacesFile + spacesJsonCache.set(workspaceDir, result) + return result + } + } catch { + // unreadable or malformed — treat as no spaces + } + spacesJsonCache.set(workspaceDir, null) + return null +} + +async function resolveCoworkSpaceName(workspaceDir: string, sessionId: string): Promise { + const [spacesFile, sessionMetaRaw] = await Promise.all([ + loadSpacesJson(workspaceDir), + readFile(join(workspaceDir, `${sessionId}.json`), 'utf-8').catch(() => null), + ]) + if (!sessionMetaRaw) return null + let sessionMeta: unknown + try { sessionMeta = JSON.parse(sessionMetaRaw) } catch { return null } + if (sessionMeta === null || typeof sessionMeta !== 'object') return null + const meta = sessionMeta as Record + + const spaceId = meta['spaceId'] + if (typeof spaceId === 'string' && spacesFile) { + const spaceName = spacesFile.spaces.find(s => s.id === spaceId)?.name + if (spaceName) return spaceName + } + + // No spaceId (standalone session): fall back to selected folder then title. + const folders = meta['userSelectedFolders'] + if (Array.isArray(folders) && folders.length > 0 && typeof folders[0] === 'string') { + return basename(folders[0]) + } + const title = meta['title'] + if (typeof title === 'string' && title.trim().length > 0) return title.trim() + + return null +} + +export const claude: Provider = { + name: 'claude', + displayName: 'Claude', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + const sources: SessionSource[] = [] + const seenProjectDirs = new Set() + const configSources = await discoverClaudeConfigSources() + let anyDirReadable = false + + for (const configSource of configSources) { + const claudeDir = configSource.path + const projectsDir = join(claudeDir, 'projects') + let entries: string[] + try { + entries = await readdir(projectsDir) + anyDirReadable = true + } catch { + // Missing or unreadable dir is not fatal: a user can configure both + // a real and a stale path in CLAUDE_CONFIG_DIRS without breaking. + continue + } + for (const dirName of entries) { + const dirPath = join(projectsDir, dirName) + // Resolve before deduping so two CLAUDE_CONFIG_DIRS entries that + // reach the same projects/ directory (via symlinks or + // overlapping configs) emit only one SessionSource. + const resolved = resolve(dirPath) + if (seenProjectDirs.has(resolved)) continue + const dirStat = await stat(dirPath).catch(() => null) + if (!dirStat?.isDirectory()) continue + seenProjectDirs.add(resolved) + // `project: dirName` is identical across config dirs for the same + // sanitized slug, which is exactly what makes the parser merge + // their sessions into a single ProjectSummary. + sources.push({ + path: dirPath, + project: dirName, + provider: 'claude', + sourceId: configSource.id, + sourceLabel: configSource.label, + sourcePath: configSource.path, + sourceKind: 'claude-config', + }) + } + } + + // If the user explicitly set CLAUDE_CONFIG_DIRS and every entry was + // unreadable, emit a one-line stderr hint. Catches the most common + // misconfiguration: a Windows user typing `:` (POSIX delimiter) when + // the platform expects `;`, which produces a single bogus path that + // silently resolves to nothing on disk. + const explicitMulti = process.env['CLAUDE_CONFIG_DIRS'] + if (!anyDirReadable && explicitMulti !== undefined && explicitMulti !== '' && configSources.length > 0) { + process.stderr.write( + `codeburn: CLAUDE_CONFIG_DIRS was set but no listed directory could be read. ` + + `Tried: ${configSources.map(s => s.path).join(', ')}. ` + + `Use "${pathDelimiter}" as the separator on this platform.\n`, + ) + } + + const desktopBase = getDesktopSessionsDir() + const desktopDirs = await findDesktopProjectDirs(desktopBase) + const sep = desktopBase.includes('\\') ? '\\' : '/' + // Desktop / Cowork sessions belong to no CLAUDE_CONFIG_DIR. Tag them with a + // distinct source so a per-config view can account for them as their own + // "Claude Desktop" bucket instead of silently dropping them (which made + // sum-of-configs < All). + const desktopSourceId = 'claude-desktop:' + createHash('sha256').update(resolve(desktopBase)).digest('hex').slice(0, 16) + for (const dirPath of desktopDirs) { + const resolved = resolve(dirPath) + if (seenProjectDirs.has(resolved)) continue + seenProjectDirs.add(resolved) + + // For Claude Desktop local-agent-mode (Cowork) sessions, the project dir + // lives inside local_/.claude/projects/. We resolve the space + // name from the sibling .json and spaces.json so it groups correctly. + // Path structure: ///local_/.claude/projects/ + let projectName = basename(dirPath) + const resolvedBase = resolve(desktopBase) + if (resolved.startsWith(resolvedBase + sep) || resolved.startsWith(resolvedBase + '/')) { + const rel = resolved.slice(resolvedBase.length + 1) + const parts = rel.split(/[/\\]/) + // parts = [appId, workspaceId, local_sessionId, .claude, projects, slug] + if ( + parts.length >= 6 && + parts[2]?.startsWith('local_') && + parts[3] === '.claude' && + parts[4] === 'projects' + ) { + const workspaceDir = join(resolvedBase, parts[0]!, parts[1]!) + const sessionId = parts[2]! + const spaceName = await resolveCoworkSpaceName(workspaceDir, sessionId) + if (spaceName) projectName = spaceName + } + } + + sources.push({ + path: dirPath, + project: projectName, + provider: 'claude', + sourceId: desktopSourceId, + sourceLabel: 'Claude Desktop', + sourcePath: desktopBase, + sourceKind: 'claude-desktop', + }) + } + + return sources + }, + + createSessionParser(): SessionParser { + return { + async *parse() {}, + } + }, +} diff --git a/src/providers/cline.ts b/src/providers/cline.ts new file mode 100644 index 0000000..7317706 --- /dev/null +++ b/src/providers/cline.ts @@ -0,0 +1,73 @@ +import { stat } from 'fs/promises' +import { homedir } from 'os' +import { basename, join } from 'path' + +import { discoverClineTasks, createClineParser, getVSCodeGlobalStoragePath } from './vscode-cline-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const EXTENSION_ID = 'saoudrizwan.claude-dev' + +export function getClineDataPath(): string { + return join(homedir(), '.cline', 'data') +} + +function normalizeOverrideDirs(overrideDirs?: string | string[]): string[] | undefined { + if (overrideDirs === undefined) return undefined + // Cline has two default roots, so tests and future callers can override one or both. + return Array.isArray(overrideDirs) ? overrideDirs : [overrideDirs] +} + +async function dedupeTaskSources(sources: SessionSource[]): Promise { + const candidates = await Promise.all(sources.map(async source => ({ + source, + mtimeMs: (await stat(join(source.path, 'ui_messages.json')).catch(() => null))?.mtimeMs ?? 0, + }))) + + const seenTaskIds = new Set() + const deduped: SessionSource[] = [] + + for (const { source } of candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)) { + const taskId = basename(source.path) + if (seenTaskIds.has(taskId)) continue + seenTaskIds.add(taskId) + deduped.push(source) + } + + return deduped +} + +export function createClineProvider(overrideDirs?: string | string[]): Provider { + const configuredDirs = normalizeOverrideDirs(overrideDirs) + + return { + name: 'cline', + displayName: 'Cline', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + const baseDirs = configuredDirs ?? [ + getVSCodeGlobalStoragePath(EXTENSION_ID), + getClineDataPath(), + ] + + const sources = await Promise.all( + baseDirs.map(dir => discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', dir)), + ) + + return dedupeTaskSources(sources.flat()) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createClineParser(source, seenKeys, 'cline') + }, + } +} + +export const cline = createClineProvider() diff --git a/src/providers/codebuff.ts b/src/providers/codebuff.ts new file mode 100644 index 0000000..6ee746c --- /dev/null +++ b/src/providers/codebuff.ts @@ -0,0 +1,460 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { homedir } from 'os' + +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +// Codebuff (formerly Manicode) uses a credit-based billing system. The local +// chat-messages.json doesn't record per-call token counts the way Claude Code +// or Codex do -- only `credits` on completed assistant messages. We convert +// credits to USD using Codebuff's retail pay-as-you-go rate so the cost shows +// up in the dashboard even when tokens are absent. The rate intentionally +// rounds up to the public PAYG tier ($0.01 / credit) so we never understate +// spend; users on a subscription plan get a conservative upper bound. +const USD_PER_CREDIT = 0.01 + +// Codebuff's chat history lives under `~/.config/manicode/` (the legacy +// product name is still on disk). Development and staging channels use +// `manicode-dev` and `manicode-staging` -- we walk all three when present. +const CHANNELS = ['manicode', 'manicode-dev', 'manicode-staging'] as const + +const modelDisplayNames: Record = { + codebuff: 'Codebuff', + 'codebuff-base': 'Codebuff Base', + 'codebuff-base2': 'Codebuff Base 2', + 'codebuff-lite': 'Codebuff Lite', + 'codebuff-max': 'Codebuff Max', +} + +// Codebuff's native tool names map to codeburn's canonical tool set so +// classifier heuristics (edit/read/bash/etc.) behave consistently with the +// other providers. +const toolNameMap: Record = { + read_files: 'Read', + read_file: 'Read', + code_search: 'Grep', + glob: 'Glob', + find_files: 'Glob', + str_replace: 'Edit', + edit_file: 'Edit', + write_file: 'Write', + run_terminal_command: 'Bash', + terminal: 'Bash', + spawn_agents: 'Agent', + spawn_agent: 'Agent', + write_todos: 'TodoWrite', + create_plan: 'TodoWrite', + browser_logs: 'WebFetch', + web_search: 'WebSearch', + fetch_url: 'WebFetch', +} + +// Tool names we ignore for classification -- they're not useful signals for +// distinguishing "coding" vs "exploration" vs "planning" work. +const IGNORED_TOOLS = new Set(['suggest_followups', 'end_turn']) + +type CodebuffUsage = { + inputTokens?: number + input_tokens?: number + promptTokens?: number + prompt_tokens?: number + outputTokens?: number + output_tokens?: number + completionTokens?: number + completion_tokens?: number + cacheCreationInputTokens?: number + cache_creation_input_tokens?: number + cacheReadInputTokens?: number + cache_read_input_tokens?: number + promptTokensDetails?: { cachedTokens?: number } + prompt_tokens_details?: { cached_tokens?: number } +} + +type CodebuffBlock = { + type?: string + content?: string + toolName?: string + input?: Record + output?: string + agentName?: string + agentType?: string + status?: string + blocks?: CodebuffBlock[] +} + +type CodebuffHistoryMessage = { + role?: string + providerOptions?: { + codebuff?: { model?: string; usage?: CodebuffUsage } + usage?: CodebuffUsage + } +} + +type CodebuffMetadata = { + model?: string + modelId?: string + timestamp?: string | number + usage?: CodebuffUsage + codebuff?: { model?: string; usage?: CodebuffUsage } + runState?: { + cwd?: string + sessionState?: { + cwd?: string + projectContext?: { cwd?: string } + fileContext?: { cwd?: string } + mainAgentState?: { + agentType?: string + messageHistory?: CodebuffHistoryMessage[] + } + } + } +} + +type CodebuffChatMessage = { + id?: string + variant?: string + role?: string + content?: string + timestamp?: string | number + credits?: number + blocks?: CodebuffBlock[] + metadata?: CodebuffMetadata +} + +function getCodebuffBaseDir(override?: string): string { + if (override && override.trim()) return override + const envPath = process.env['CODEBUFF_DATA_DIR'] + if (envPath && envPath.trim()) return envPath + return join(homedir(), '.config', 'manicode') +} + +function pickNumber(...vals: Array): number | undefined { + for (const v of vals) { + if (typeof v === 'number' && Number.isFinite(v)) return v + } + return undefined +} + +function normalizeUsage(u: CodebuffUsage | undefined): { + input: number + output: number + cacheRead: number + cacheWrite: number +} { + if (!u) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + return { + input: pickNumber(u.inputTokens, u.input_tokens, u.promptTokens, u.prompt_tokens) ?? 0, + output: pickNumber(u.outputTokens, u.output_tokens, u.completionTokens, u.completion_tokens) ?? 0, + cacheRead: + pickNumber( + u.cacheReadInputTokens, + u.cache_read_input_tokens, + u.promptTokensDetails?.cachedTokens, + u.prompt_tokens_details?.cached_tokens, + ) ?? 0, + cacheWrite: pickNumber(u.cacheCreationInputTokens, u.cache_creation_input_tokens) ?? 0, + } +} + +function coerceTimestamp(value: string | number | undefined): string { + if (value == null) return '' + if (typeof value === 'number') { + return Number.isFinite(value) ? new Date(value).toISOString() : '' + } + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value +} + +function parseChatIdToIso(chatId: string): string { + const iso = chatId.replace(/(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})/, '$1:$2:$3') + const parsed = Date.parse(iso) + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '' +} + +function extractCwd(meta: CodebuffMetadata | undefined): string | null { + const rs = meta?.runState + if (!rs) return null + return ( + rs.sessionState?.projectContext?.cwd ?? + rs.sessionState?.fileContext?.cwd ?? + rs.sessionState?.cwd ?? + rs.cwd ?? + null + ) +} + +function extractAgentType(meta: CodebuffMetadata | undefined): string | null { + return meta?.runState?.sessionState?.mainAgentState?.agentType ?? null +} + +function collectBlockTools(blocks: CodebuffBlock[] | undefined, acc: { tools: string[]; bash: string[] }): void { + if (!Array.isArray(blocks)) return + for (const block of blocks) { + if (!block || typeof block !== 'object') continue + if (block.type === 'tool' && typeof block.toolName === 'string') { + const raw = block.toolName + if (!IGNORED_TOOLS.has(raw)) { + acc.tools.push(toolNameMap[raw] ?? raw) + } + if ((raw === 'run_terminal_command' || raw === 'terminal') && block.input) { + const cmd = block.input['command'] + if (typeof cmd === 'string') { + acc.bash.push(...extractBashCommands(cmd)) + } + } + } + if (block.type === 'agent' && Array.isArray(block.blocks)) { + collectBlockTools(block.blocks, acc) + } + } +} + +function resolveModel(meta: CodebuffMetadata | undefined, stashedModel: string | null): string { + const direct = meta?.model ?? meta?.modelId ?? meta?.codebuff?.model + if (direct) return direct + if (stashedModel) return stashedModel + const agentType = extractAgentType(meta) + if (agentType) return `codebuff-${agentType}` + return 'codebuff' +} + +function usageFromHistory(meta: CodebuffMetadata | undefined): { + model: string | null + input: number + output: number + cacheRead: number + cacheWrite: number +} { + const hist = meta?.runState?.sessionState?.mainAgentState?.messageHistory + if (!Array.isArray(hist)) return { model: null, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + for (let i = hist.length - 1; i >= 0; i--) { + const entry = hist[i] + if (!entry || entry.role !== 'assistant' || !entry.providerOptions) continue + const u = normalizeUsage(entry.providerOptions.usage ?? entry.providerOptions.codebuff?.usage) + if (u.input > 0 || u.output > 0 || u.cacheRead > 0 || u.cacheWrite > 0) { + return { model: entry.providerOptions.codebuff?.model ?? null, ...u } + } + } + return { model: null, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } +} + +async function readJson(filePath: string): Promise { + try { + const raw = await readFile(filePath, 'utf-8') + return JSON.parse(raw) as T + } catch { + return null + } +} + +async function discoverChannel(root: string): Promise { + const sources: SessionSource[] = [] + const projectsDir = join(root, 'projects') + + let projectNames: string[] + try { + projectNames = await readdir(projectsDir) + } catch { + return sources + } + + for (const projectName of projectNames) { + const chatsDir = join(projectsDir, projectName, 'chats') + let chatIds: string[] + try { + chatIds = await readdir(chatsDir) + } catch { + continue + } + + for (const chatId of chatIds) { + const chatDir = join(chatsDir, chatId) + const dirStat = await stat(chatDir).catch(() => null) + if (!dirStat?.isDirectory()) continue + + const messagesPath = join(chatDir, 'chat-messages.json') + const messagesStat = await stat(messagesPath).catch(() => null) + if (!messagesStat?.isFile()) continue + + // Resolve the real cwd from run-state.json so sessions group by the + // originating project directory instead of the sanitized chat folder + // name (which is often the same for many users). + const runState = await readJson( + join(chatDir, 'run-state.json'), + ) + const cwd = extractCwd({ runState: runState ?? undefined }) + const project = cwd ? basename(cwd) : projectName + + sources.push({ path: chatDir, project, provider: 'codebuff' }) + } + } + + return sources +} + +async function discoverSessionsInBase(baseDir: string): Promise { + const results: SessionSource[] = [] + + // Honor an explicit override: walk only the provided directory even if it + // matches one of the channel names literally. + if (process.env['CODEBUFF_DATA_DIR'] || baseDir !== join(homedir(), '.config', 'manicode')) { + const rootStat = await stat(baseDir).catch(() => null) + if (!rootStat?.isDirectory()) return results + results.push(...await discoverChannel(baseDir)) + return results + } + + const configDir = join(homedir(), '.config') + for (const channel of CHANNELS) { + const root = join(configDir, channel) + const rootStat = await stat(root).catch(() => null) + if (!rootStat?.isDirectory()) continue + results.push(...await discoverChannel(root)) + } + return results +} + +// Downstream aggregation groups sessions by `(provider, sessionId, project)` +// (see src/parser.ts). Codebuff chat folders are ISO timestamps, which means +// the same `chatId` can legitimately appear under each channel root +// (`manicode`, `manicode-dev`, `manicode-staging`) and even resolve to the +// same project cwd. To keep those sessions distinct we include the channel +// identity in the sessionId. The channel is derived from the fixed path +// structure Codebuff writes on disk: `/projects//chats/`. +// Returns null when the path doesn't match that shape so the caller can fall +// back to a plain chatId. +// +// We use '/' as the channel/chatId separator rather than ':' because +// src/parser.ts builds its session key as `${provider}:${sessionId}:${project}` +// and reconstructs the sessionId with `key.split(':')[1]` -- any colon inside +// sessionId would get truncated to just the channel name downstream. +function extractChannelFromChatDir(chatDir: string): string | null { + const chatsDir = dirname(chatDir) + if (basename(chatsDir) !== 'chats') return null + const projectDir = dirname(chatsDir) + const projectsDir = dirname(projectDir) + if (basename(projectsDir) !== 'projects') return null + const channel = basename(dirname(projectsDir)) + return channel ? channel : null +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const chatDir = source.path + const chatId = basename(chatDir) + const channel = extractChannelFromChatDir(chatDir) + const sessionId = channel ? `${channel}/${chatId}` : chatId + const fallbackTs = parseChatIdToIso(chatId) + + const messages = await readJson( + join(chatDir, 'chat-messages.json'), + ) + if (!Array.isArray(messages)) return + + let pendingUserMessage = '' + + for (const [idx, msg] of messages.entries()) { + if (!msg || typeof msg !== 'object') continue + + const variant = msg.variant ?? msg.role + if (variant === 'user') { + if (typeof msg.content === 'string' && msg.content.length > 0) { + pendingUserMessage = msg.content + } + continue + } + + if (variant !== 'ai' && variant !== 'agent' && variant !== 'assistant') continue + + const credits = typeof msg.credits === 'number' && Number.isFinite(msg.credits) ? msg.credits : 0 + const directUsage = normalizeUsage(msg.metadata?.usage ?? msg.metadata?.codebuff?.usage) + const stashedUsage = usageFromHistory(msg.metadata) + + const hasDirect = + directUsage.input > 0 || + directUsage.output > 0 || + directUsage.cacheRead > 0 || + directUsage.cacheWrite > 0 + const usage = hasDirect ? directUsage : stashedUsage + const stashedModel = stashedUsage.model + + // Skip messages with neither credits nor tokens -- they're typically + // in-progress mode dividers or empty framing blocks. + if (credits === 0 && usage.input === 0 && usage.output === 0 && usage.cacheRead === 0 && usage.cacheWrite === 0) { + continue + } + + const model = resolveModel(msg.metadata, stashedModel) + const timestamp = coerceTimestamp(msg.timestamp ?? msg.metadata?.timestamp) || fallbackTs + + const dedupId = msg.id ?? String(idx) + const dedupKey = `codebuff:${chatDir}:${dedupId}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const acc = { tools: [] as string[], bash: [] as string[] } + collectBlockTools(msg.blocks, acc) + + // Prefer calculated cost from tokens when available (multi-provider + // models routed through Codebuff still show up in LiteLLM); otherwise + // fall back to the credit-based approximation. + let costUSD = calculateCost(model, usage.input, usage.output, usage.cacheWrite, usage.cacheRead, 0) + if (costUSD === 0 && credits > 0) { + costUSD = credits * USD_PER_CREDIT + } + + yield { + provider: 'codebuff', + model, + inputTokens: usage.input, + outputTokens: usage.output, + cacheCreationInputTokens: usage.cacheWrite, + cacheReadInputTokens: usage.cacheRead, + cachedInputTokens: usage.cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: acc.tools, + bashCommands: acc.bash, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + } + + pendingUserMessage = '' + } + }, + } +} + +export function createCodebuffProvider(baseDir?: string): Provider { + const dir = getCodebuffBaseDir(baseDir) + + return { + name: 'codebuff', + displayName: 'Codebuff', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + return discoverSessionsInBase(dir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const codebuff = createCodebuffProvider() diff --git a/src/providers/codex.ts b/src/providers/codex.ts new file mode 100644 index 0000000..0917f66 --- /dev/null +++ b/src/providers/codex.ts @@ -0,0 +1,689 @@ +import { readdir, stat } from 'fs/promises' +import { createReadStream } from 'fs' +import { createInterface } from 'readline' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionLines } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import { readCachedCodexResults, writeCachedCodexResults, getCachedCodexProject, fingerprintFile } from '../codex-cache.js' +import { normalizeContentBlocks } from '../content-utils.js' +import type { ToolCall } from '../types.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const modelDisplayNames: Record = { + 'codex-auto-review': 'Codex Auto Review', + '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', + 'gpt-5': 'GPT-5', + 'gpt-4o-mini': 'GPT-4o Mini', + 'gpt-4o': 'GPT-4o', +} + +// Longest-first + version-boundary match so an unlisted future minor (gpt-5.6) +// falls through to its raw id instead of collapsing into the base "GPT-5" entry. +const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) + +const toolNameMap: Record = { + exec_command: 'Bash', + read_file: 'Read', + write_file: 'Edit', + apply_diff: 'Edit', + apply_patch: 'Edit', + spawn_agent: 'Agent', + close_agent: 'Agent', + wait_agent: 'Agent', + read_dir: 'Glob', +} + +// CLI-based MCP wrappers (e.g. philschmid/mcp-cli) let Codex call an MCP tool +// through a shell command instead of registering the server natively. Codex +// then logs a plain exec_command with no `mcp_tool_call_end` event, so the MCP +// usage would only appear as a shell command and be absent from the MCP +// breakdown (issue #478). Recognize the `mcp-cli [options] call +// ` form and return the canonical mcp____ so the call is +// also attributed to MCP. Only the `call` subcommand (an actual tool execution) +// is matched; info / grep / bare listing are lookups. The exec_command still +// counts as Bash since it genuinely is a shell exec. Scoped to the mcp-cli +// binary; other wrappers would need their own pattern. +// +// The negative lookbehind keeps `mcp-cli` a standalone binary (a leading +// quote/space/slash from a `bash -lc "..."` wrapper or absolute path is fine, +// but `foo-mcp-cli` is not). `(?:\s+(?!call\b)[^\s;|&]+)*` skips any options and +// their values between the binary and the subcommand (e.g. +// `mcp-cli -c ./mcp.json call ...`) without crossing a shell separator, and +// stops at the `call` token. This is substring matching, so a command that +// merely mentions the phrase (a comment, an echo, a commit message) can +// false-positive, an accepted tradeoff for the common case. \s+ and the token +// class don't overlap, so there is no catastrophic backtracking. +const MCP_CLI_CALL = /(? typeof x === 'string').join(' ') : '' + if (!text) return null + const m = MCP_CLI_CALL.exec(text) + if (!m) return null + const server = m[1]!.replace(/['"]/g, '') + const tool = m[2]!.replace(/['"]/g, '') + if (!server || !tool) return null + return `mcp__${server}__${tool}` +} + +type CodexEntry = { + type: string + timestamp?: string + payload?: { + type?: string + role?: string + cwd?: string + model_provider?: string + originator?: string + session_id?: string + forked_from_id?: string + model?: string + name?: string + content?: Array<{ type?: string; text?: string }> + info?: { + model?: string + model_name?: string + last_token_usage?: CodexTokenUsage + total_token_usage?: CodexTokenUsage + } + } +} + +type CodexTokenUsage = { + input_tokens?: number + cached_input_tokens?: number + output_tokens?: number + reasoning_output_tokens?: number + total_tokens?: number +} + +const CHARS_PER_TOKEN = 4 +const RAW_HEAD_BYTES = 64 * 1024 +const LARGE_TEXT_CAP = 2000 + +function getCodexDir(override?: string): string { + return override ?? process.env['CODEX_HOME'] ?? join(homedir(), '.codex') +} + +function sanitizeProject(cwd: string): string { + return cwd.replace(/^\//, '').replace(/\//g, '-') +} + +// Cap how many bytes we'll read while looking for the first newline. Real +// Codex session_meta lines are ~22-27 KB; this leaves plenty of headroom while +// keeping memory bounded if a corrupt file has no newline at all. +const FIRST_LINE_READ_CAP = 1024 * 1024 + +async function readFirstLine(filePath: string): Promise { + // Codex CLI 0.128+ writes a session_meta line that can exceed 20 KB because + // it embeds the full base_instructions / system prompt. A fixed-size buffer + // would miss the trailing newline and reject the session as invalid. + // Stream the file via readline so we can read the first line up to + // FIRST_LINE_READ_CAP, which keeps memory bounded if the file has no newline. + const stream = createReadStream(filePath, { + encoding: 'utf-8', + start: 0, + end: FIRST_LINE_READ_CAP - 1, + }) + // Silence stream errors so a late read-ahead error after we've already + // returned the first line cannot escape as an unhandled 'error' event. + // readline's async iterator re-throws underlying stream errors (ENOENT, + // EACCES, etc.) on Node 16+, which the catch below handles for the cases + // that matter for validation. + stream.on('error', () => {}) + const rl = createInterface({ input: stream, crlfDelay: Infinity }) + let firstLine: string | undefined + try { + for await (const line of rl) { + firstLine = line + break + } + } catch { + return null + } finally { + rl.close() + stream.destroy() + } + if (!firstLine || !firstLine.trim()) return null + try { + return JSON.parse(firstLine) as CodexEntry + } catch { + return null + } +} + +async function isValidCodexSession(filePath: string): Promise<{ valid: boolean; meta?: CodexEntry }> { + const entry = await readFirstLine(filePath) + if (!entry) return { valid: false } + const valid = entry.type === 'session_meta' && + typeof entry.payload?.originator === 'string' && + entry.payload.originator.toLowerCase().startsWith('codex') + return { valid, meta: valid ? entry : undefined } +} + +function getRawJsonStringField(head: string, field: string): string | undefined { + const re = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`) + const match = re.exec(head) + if (!match) return undefined + try { + return JSON.parse(`"${match[1]}"`) as string + } catch { + return match[1] + } +} + +function payloadHead(head: string): string { + const idx = head.indexOf('"payload"') + return idx === -1 ? head : head.slice(idx) +} + +function countJsonStringBytes(source: Buffer, valueStart: number): number { + let count = 0 + for (let i = valueStart; i < source.length; i++) { + const ch = source[i] + if (ch === 0x5c) { + i++ + count++ + continue + } + if (ch === 0x22) return count + count++ + } + return count +} + +function extractFirstJsonText(source: Buffer, cap = LARGE_TEXT_CAP): string { + const key = Buffer.from('"text"') + const idx = source.indexOf(key) + if (idx === -1) return '' + const colon = source.indexOf(0x3a, idx + key.length) + if (colon === -1) return '' + const qStart = source.indexOf(0x22, colon + 1) + if (qStart === -1) return '' + const chunks: number[] = [] + for (let i = qStart + 1; i < source.length && chunks.length < cap; i++) { + const ch = source[i] + if (ch === 0x5c) { + const next = source[++i] + if (next === 0x6e) chunks.push(0x0a) + else if (next === 0x72) chunks.push(0x0d) + else if (next === 0x74) chunks.push(0x09) + else if (next !== undefined) chunks.push(next) + continue + } + if (ch === 0x22) break + chunks.push(ch) + } + return Buffer.from(chunks).toString('utf-8') +} + +function countFirstJsonText(source: Buffer): number { + const key = Buffer.from('"text"') + const idx = source.indexOf(key) + if (idx === -1) return 0 + const colon = source.indexOf(0x3a, idx + key.length) + if (colon === -1) return 0 + const qStart = source.indexOf(0x22, colon + 1) + if (qStart === -1) return 0 + return countJsonStringBytes(source, qStart + 1) +} + +function parseCodexLine(line: string | Buffer): CodexEntry | null { + if (typeof line === 'string') { + const trimmed = line.trim() + if (!trimmed) return null + try { + return JSON.parse(trimmed) as CodexEntry + } catch { + return null + } + } + + if (line.length === 0) return null + const head = line.subarray(0, RAW_HEAD_BYTES).toString('utf-8') + const type = getRawJsonStringField(head, 'type') + if (!type) return null + const pHead = payloadHead(head) + const payloadType = getRawJsonStringField(pHead, 'type') + const role = getRawJsonStringField(pHead, 'role') + + const entry: CodexEntry = { + type, + timestamp: getRawJsonStringField(head, 'timestamp'), + payload: { + type: payloadType, + role, + cwd: getRawJsonStringField(pHead, 'cwd'), + model_provider: getRawJsonStringField(pHead, 'model_provider'), + originator: getRawJsonStringField(pHead, 'originator'), + session_id: getRawJsonStringField(pHead, 'session_id'), + forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'), + model: getRawJsonStringField(pHead, 'model'), + name: getRawJsonStringField(pHead, 'name'), + }, + } + + if (type === 'response_item' && payloadType === 'message' && role === 'user') { + entry.payload!.content = [{ type: 'input_text', text: extractFirstJsonText(line) }] + } else if (type === 'response_item' && payloadType === 'message' && role === 'assistant') { + entry.payload!.content = [{ type: 'output_text', text: 'x'.repeat(Math.min(countFirstJsonText(line), LARGE_TEXT_CAP)) }] + } + + return entry +} + +async function discoverSessionsInDir(codexDir: string): Promise { + const sessionsDir = join(codexDir, 'sessions') + const sources: SessionSource[] = [] + + let years: string[] + try { + years = await readdir(sessionsDir) + } catch { + return sources + } + + for (const year of years) { + if (!/^\d{4}$/.test(year)) continue + const yearDir = join(sessionsDir, year) + const months = await readdir(yearDir).catch(() => [] as string[]) + + for (const month of months) { + if (!/^\d{2}$/.test(month)) continue + const monthDir = join(yearDir, month) + const days = await readdir(monthDir).catch(() => [] as string[]) + + for (const day of days) { + if (!/^\d{2}$/.test(day)) continue + const dayDir = join(monthDir, day) + const files = await readdir(dayDir).catch(() => [] as string[]) + + for (const file of files) { + if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue + const filePath = join(dayDir, file) + const s = await stat(filePath).catch(() => null) + if (!s?.isFile()) continue + + const cachedProject = await getCachedCodexProject(filePath) + if (cachedProject) { + sources.push({ path: filePath, project: cachedProject, provider: 'codex' }) + continue + } + + const { valid, meta } = await isValidCodexSession(filePath) + if (!valid || !meta) continue + + const cwd = meta.payload?.cwd ?? 'unknown' + sources.push({ path: filePath, project: sanitizeProject(cwd), provider: 'codex' }) + } + } + } + } + + return sources +} + +function resolveModel(info: CodexEntry['payload'], sessionModel?: string): string { + return info?.model + ?? info?.info?.model + ?? info?.info?.model_name + ?? sessionModel + ?? 'gpt-5' +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const cached = await readCachedCodexResults(source.path) + if (cached) { + for (const call of cached) { + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + return + } + + const fp = await fingerprintFile(source.path) + if (!fp) return + + let sessionModel: string | undefined + let sessionId = '' + let forkedFromId = '' + let forkCutoff = '' + // Null sentinel rather than `0` so the FIRST event is never confused + // with a duplicate. A session that only emits last_token_usage (no + // total_token_usage) reports cumulativeTotal=0 on every event; with a + // 0-initialized prev, the first event would have matched and been + // dropped. Once we've observed any event, we record its cumulative + // total and dedup on equality regardless of whether it is zero. + let prevCumulativeTotal: number | null = null + let prevInput = 0 + let prevCached = 0 + let prevOutput = 0 + let prevReasoning = 0 + let pendingTools: string[] = [] + let pendingToolSequence: ToolCall[][] = [] + let pendingUserMessage = '' + let pendingOutputChars = 0 + let estCounter = 0 + let turnCounter = 0 + let currentTurnId = `${sessionId}:t0` + let sawAnyLine = false + const results: ParsedProviderCall[] = [] + + // Stream the session file line by line. Heavy Codex sessions can exceed + // 250 MB on disk; reading the entire file into a string would either hit + // the readSessionFile cap or push V8 toward its 512 MB string limit + // after split('\n'). readSessionLines streams raw buffers and hands + // huge lines to the compact parser without full string conversion. + for await (const rawLine of readSessionLines(source.path, undefined, { largeLineAsBuffer: true })) { + sawAnyLine = true + const entry = parseCodexLine(rawLine) + if (!entry) continue + + if (entry.type === 'session_meta') { + sessionId = entry.payload?.session_id ?? basename(source.path, '.jsonl') + forkedFromId = entry.payload?.forked_from_id ?? '' + if (forkedFromId && entry.timestamp) { + forkCutoff = new Date(new Date(entry.timestamp).getTime() + 5000).toISOString() + } + sessionModel = entry.payload?.model ?? sessionModel + continue + } + + if (entry.type === 'turn_context' && entry.payload?.model) { + sessionModel = entry.payload.model + continue + } + + if (entry.type === 'response_item' && entry.payload?.type === 'function_call') { + const rawName = entry.payload.name ?? '' + const mapped = toolNameMap[rawName] ?? rawName + pendingTools.push(mapped) + const call: ToolCall = { tool: mapped } + const rawArgs = (entry.payload as Record)['arguments'] + const args = typeof rawArgs === 'string' + ? (() => { try { return JSON.parse(rawArgs) as Record } catch { return null } })() + : typeof rawArgs === 'object' && rawArgs ? rawArgs as Record : null + if (args) { + const fp = args['file_path'] ?? args['path'] + if (typeof fp === 'string') call.file = fp + const cmd = args['command'] ?? args['cmd'] + if (typeof cmd === 'string') call.command = cmd + // Attribute a CLI-wrapped MCP call (e.g. `mcp-cli call server tool`) + // to the MCP breakdown too; the exec still counts as Bash above. + const mcpTool = mcpToolFromShellCommand(cmd) + if (mcpTool) { + pendingTools.push(mcpTool) + pendingToolSequence.push([{ tool: mcpTool }]) + } + } + pendingToolSequence.push([call]) + continue + } + + if (entry.type === 'event_msg' && entry.payload?.type === 'patch_apply_end') { + pendingTools.push('Edit') + const p = entry.payload as Record + const changes = p['changes'] + const filePaths = typeof changes === 'object' && changes ? Object.keys(changes as object) : [] + if (filePaths.length > 0) { + for (const fp of filePaths) { + pendingToolSequence.push([{ tool: 'Edit', file: fp }]) + } + } else { + pendingToolSequence.push([{ tool: 'Edit' }]) + } + continue + } + + // 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. + if (entry.type === 'event_msg' && entry.payload?.type === '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) { + const name = `mcp__${server}__${tool}` + pendingTools.push(name) + pendingToolSequence.push([{ tool: name }]) + } + continue + } + + if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload?.role === 'user') { + const texts = normalizeContentBlocks(entry.payload.content) + .filter(c => c.type === 'input_text') + .map(c => c.text ?? '') + .filter(Boolean) + if (texts.length > 0) { + pendingUserMessage = texts.join(' ').slice(0, 500) + currentTurnId = `${sessionId}:t${++turnCounter}` + } + continue + } + + if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload?.role === 'assistant') { + const texts = normalizeContentBlocks(entry.payload.content) + .filter(c => c.type === 'output_text' || c.type === 'text') + .map(c => c.text ?? '') + pendingOutputChars += texts.join('').length + continue + } + + if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') { + // Forked sessions replay the parent's entire event history with + // timestamps clustered at the fork creation time. Skip replayed + // events (within 5s of fork) to avoid double-counting. + if (forkCutoff && entry.timestamp && entry.timestamp < forkCutoff) continue + const info = entry.payload.info + if (!info) { + if (pendingOutputChars === 0 && pendingUserMessage.length === 0) continue + const estInput = Math.ceil(pendingUserMessage.length / CHARS_PER_TOKEN) + const estOutput = Math.ceil(pendingOutputChars / CHARS_PER_TOKEN) + if (estInput === 0 && estOutput === 0) continue + + const model = sessionModel ?? 'gpt-5' + const timestamp = entry.timestamp ?? '' + const dedupKey = `codex:${sessionId}:${timestamp}:est${estCounter++}` + + if (seenKeys.has(dedupKey)) { pendingTools = []; pendingToolSequence = []; pendingUserMessage = ''; pendingOutputChars = 0; continue } + seenKeys.add(dedupKey) + + const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0) + + results.push({ + provider: 'codex', + model, + inputTokens: estInput, + outputTokens: estOutput, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + costIsEstimated: true, + tools: pendingTools, + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + turnId: currentTurnId, + toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined, + userMessage: pendingUserMessage, + sessionId, + }) + + pendingTools = [] + pendingToolSequence = [] + pendingUserMessage = '' + pendingOutputChars = 0 + continue + } + + const cumulativeTotal = info.total_token_usage?.total_tokens ?? 0 + // Dedup guard. Two consecutive events with cumulativeTotal=0 but + // non-empty last_token_usage would have been double-counted with + // the previous `> 0` clause. The null sentinel ensures the FIRST + // event always passes (so a session that never reports cumulative + // doesn't lose its opening turn). + if (prevCumulativeTotal !== null && cumulativeTotal === prevCumulativeTotal) continue + prevCumulativeTotal = cumulativeTotal + + const last = info.last_token_usage + let inputTokens = 0 + let cachedInputTokens = 0 + let outputTokens = 0 + let reasoningTokens = 0 + + if (last) { + inputTokens = last.input_tokens ?? 0 + cachedInputTokens = last.cached_input_tokens ?? 0 + outputTokens = last.output_tokens ?? 0 + reasoningTokens = last.reasoning_output_tokens ?? 0 + } else if (cumulativeTotal > 0) { + const total = info.total_token_usage + if (!total) continue + inputTokens = (total.input_tokens ?? 0) - prevInput + cachedInputTokens = (total.cached_input_tokens ?? 0) - prevCached + outputTokens = (total.output_tokens ?? 0) - prevOutput + reasoningTokens = (total.reasoning_output_tokens ?? 0) - prevReasoning + } + + // Always advance the prev counters to track the cumulative state. + // Previously prev was only updated on the fallback branch, so a + // session with mixed last_token_usage / no-last events would + // compute the next fallback delta against a stale prev=0 baseline, + // double-counting the entire cumulative window. The prev value + // must mirror what cumulative reports regardless of whether this + // event used `last` or fell back to deltas. + const total = info.total_token_usage + if (total) { + prevInput = total.input_tokens ?? 0 + prevCached = total.cached_input_tokens ?? 0 + prevOutput = total.output_tokens ?? 0 + prevReasoning = total.reasoning_output_tokens ?? 0 + } + + const totalTokens = inputTokens + cachedInputTokens + outputTokens + reasoningTokens + if (totalTokens === 0) continue + + // OpenAI includes cached tokens inside input_tokens; Anthropic does not. + // Normalize to Anthropic semantics: inputTokens = non-cached only. + const uncachedInputTokens = Math.max(0, inputTokens - cachedInputTokens) + + const model = resolveModel(entry.payload, sessionModel) + const timestamp = entry.timestamp ?? '' + // Forked sessions copy the parent's entire token_count history + // (re-timestamped), so replays must collide with the parent's events + // and drop to avoid double-counting -- hence the parent namespace + // (forkedFromId) and the deliberate omission of the per-session id. + // But cumulativeTotal alone is too coarse a discriminator: a genuine + // post-divergence fork event whose running total coincidentally equals + // some parent total would also collide and be lost (undercount). So we + // also key on the cumulative token breakdown, which a fork replays + // verbatim from the parent -- a true replay collides exactly, while + // genuinely different work at the same total stays distinct. We use the + // CUMULATIVE figures (not the per-event deltas) on purpose: the deltas + // are computed against a running `prev` that the fork advances + // differently once the 5s cutoff skips some replays, so a delta-based + // key would spuriously diverge on a replay and double-count it. + const dedupKey = `codex:${forkedFromId || sessionId}:${cumulativeTotal}:${total?.input_tokens ?? 0}:${total?.cached_input_tokens ?? 0}:${total?.output_tokens ?? 0}:${total?.reasoning_output_tokens ?? 0}` + + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const costUSD = calculateCost( + model, + uncachedInputTokens, + outputTokens + reasoningTokens, + 0, + cachedInputTokens, + 0, + ) + + results.push({ + provider: 'codex', + model, + inputTokens: uncachedInputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: cachedInputTokens, + cachedInputTokens, + reasoningTokens, + webSearchRequests: 0, + costUSD, + tools: pendingTools, + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + turnId: currentTurnId, + toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined, + userMessage: pendingUserMessage, + sessionId, + }) + + pendingTools = [] + pendingToolSequence = [] + pendingUserMessage = '' + pendingOutputChars = 0 + } + } + + // If the stream yielded nothing the file was unreadable, oversized, or + // empty. Skip cache write so a transient failure can't pin an empty + // result set against a fingerprint that would otherwise be re-parsed. + if (!sawAnyLine) return + + await writeCachedCodexResults(source.path, source.project, results, fp) + + for (const call of results) { + yield call + } + }, + } +} + +export function createCodexProvider(codexDir?: string): Provider { + const dir = getCodexDir(codexDir) + + return { + name: 'codex', + displayName: 'Codex', + + modelDisplayName(model: string): string { + for (const [key, name] of modelDisplayEntries) { + if (model === key || model.startsWith(key + '-')) return name + } + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + return discoverSessionsInDir(dir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const codex = createCodexProvider() diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts new file mode 100644 index 0000000..e756197 --- /dev/null +++ b/src/providers/copilot.ts @@ -0,0 +1,2321 @@ +// ============================================================================= +// copilot.ts — Modified CodeBurn Copilot provider +// ============================================================================= +// +// WHAT CHANGED: +// The original provider only reads Copilot's JSONL session-state files from +// ~/.copilot/session-state/, which only log output tokens. Input tokens, +// cache-read tokens, and cache-creation tokens are never written there, so +// CodeBurn underreports Copilot costs by 60-80%. +// +// This modified version adds VS Code sources that can carry fuller token +// data: the OTel SQLite store (agent-traces.db), VS Code core chatSessions +// journals, and legacy extension transcripts. OTel and chatSessions contain +// input/output token breakdowns for Copilot Chat users; legacy JSONL remains +// a fallback when richer sources are absent. +// +// HOW TO ENABLE THE OTEL SQLITE STORE: +// TWO settings must both be enabled in VS Code settings.json: +// +// { +// "github.copilot.chat.otel.enabled": true, +// "github.copilot.chat.otel.dbSpanExporter.enabled": true +// } +// +// The first enables the OTel pipeline; the second (defaults to false) enables +// the SQLite span exporter that actually writes agent-traces.db. +// After changing these settings, restart VS Code — the extension watches for +// these changes and requires a reload to take effect. +// +// Or set the environment variable before launching VS Code: +// +// export COPILOT_OTEL_ENABLED=true +// +// The DB file is created in VS Code's global storage directory: +// ~/Library/Application Support/Code/User/globalStorage/github.copilot-chat/agent-traces.db +// +// ENVIRONMENT VARIABLES: +// CODEBURN_COPILOT_OTEL_DB — Override the agent-traces.db path +// CODEBURN_COPILOT_DISABLE_OTEL=1 — Skip OTel entirely, use only JSONL +// CODEBURN_COPILOT_WS_STORAGE_DIR — Override VS Code workspaceStorage +// CODEBURN_COPILOT_GLOBAL_STORAGE_DIR — Override VS Code globalStorage +// CODEBURN_COPILOT_JETBRAINS_DIR — Override the JetBrains github-copilot root +// +// ARCHITECTURE: +// discoverSessions() returns OTel sessions and legacy JSONL sessions. When +// OTel is present, VS Code core chatSessions are skipped because they mirror +// the same Copilot turns under different IDs. OTel sessions carry the full +// token breakdown; JSONL sessions only carry output tokens (the original +// behaviour, as a fallback). +// +// LIMITATIONS: +// - The OTel DB only contains Copilot Chat and Agent mode spans. Inline +// completions (ghost text) and Agent Host spans are NOT yet written to +// this DB (see https://github.com/microsoft/vscode/issues/315901). +// - The DB schema is inferred from the official OTel GenAI semantic +// conventions and the Copilot Budget extension's approach. If VS Code +// changes the schema, this parser will need updating. +// ============================================================================= + +import { readdir, stat } from 'fs/promises' +import { homedir, platform } from 'os' +import { join, basename, dirname, posix, win32 } from 'path' +import { existsSync } from 'fs' +import { createHash } from 'crypto' +import { readSessionFile } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import { estimateTokens } from '../context-tree.js' +import type { + Provider, + SessionSource, + SessionParser, + ParsedProviderCall, +} from './types.js' + +// --------------------------------------------------------------------------- +// Model display names (unchanged from original) +// --------------------------------------------------------------------------- +const modelDisplayNames: Record = { + 'gpt-4.1-nano': 'GPT-4.1 Nano', + 'gpt-4.1-mini': 'GPT-4.1 Mini', + 'gpt-4.1': 'GPT-4.1', + 'gpt-4o-mini': 'GPT-4o Mini', + 'gpt-4o': 'GPT-4o', + 'gpt-5-mini': 'GPT-5 Mini', + 'gpt-5': 'GPT-5', + 'claude-sonnet-4-5': 'Sonnet 4.5', + 'claude-sonnet-4': 'Sonnet 4', + 'copilot-openai-auto': 'Copilot (OpenAI auto)', + 'copilot-anthropic-auto': 'Copilot (Anthropic auto)', +} + +// --------------------------------------------------------------------------- +// Tool name normalisation (unchanged from original, plus OTel tool names) +// --------------------------------------------------------------------------- +const toolNameMap: Record = { + // JSONL session-state tool names + bash: 'Bash', + read_file: 'Read', + write_file: 'Edit', + edit_file: 'Edit', + delete_file: 'Delete', + github_repo: 'GitHub', + web_search: 'WebSearch', + run_in_terminal: 'Shell', + // JetBrains Copilot agent tool names (snake_case) + insert_edit_into_file: 'Edit', + create_file: 'Edit', + get_errors: 'Diagnostics', + file_search: 'Search', + grep_search: 'Search', + semantic_search: 'Search', + list_dir: 'Search', + fetch_webpage: 'Web', + // OTel execute_tool span names from Copilot Chat: + readFile: 'Read', + writeFile: 'Edit', + editFile: 'Edit', + runCommand: 'Shell', + runInTerminal: 'Shell', + findFiles: 'Search', + grepSearch: 'Search', + codebaseSearch: 'Search', + getErrors: 'Diagnostics', + listCodeUsages: 'Search', + createFile: 'Edit', + deleteFile: 'Delete', + renameOrMoveFile: 'Edit', + fetchWebpage: 'Web', +} + +/** + * Normalise a raw tool name to its display form. + * - Known tools are mapped via toolNameMap. + * - MCP tools (containing both '-' and '_') are formatted as + * mcp__server_name__tool_name. + * - Everything else is returned unchanged. + */ +function normalizeTool(rawTool: string): string { + const mapped = toolNameMap[rawTool] + if (mapped) return mapped + // MCP tool names follow the pattern: server-name-tool_operand + // e.g. github-mcp-server-list_issues → mcp__github_mcp_server__list_issues + const dashIdx = rawTool.lastIndexOf('-') + if (dashIdx > 0 && rawTool.includes('_')) { + const server = rawTool.slice(0, dashIdx).replace(/-/g, '_') + const tool = rawTool.slice(dashIdx + 1) + return `mcp__${server}__${tool}` + } + return rawTool +} + +const modelDisplayEntries = Object.entries(modelDisplayNames).sort( + (a, b) => b[0].length - a[0].length +) + +// Tool names that represent shell/bash execution. When the AI calls one of +// these, we extract the `arguments.command` string into bashCommands[]. +const BASH_TOOL_NAMES = new Set(['bash', 'run_in_terminal', 'runInTerminal', 'runCommand']) + +// --------------------------------------------------------------------------- +// Types for JSONL session state events (unchanged from original) +// --------------------------------------------------------------------------- +type ToolRequest = { + toolName?: string // older format + name?: string // newer format (copilot-agent) + arguments?: Record +} + +type SessionStartData = { + selectedModel?: string +} + +type ModelChangeData = { + newModel: string + previousModel?: string +} + +type UserMessageData = { + content: string + interactionId?: string +} + +type AssistantMessageData = { + messageId: string + model?: string // present in newer copilot-agent format + outputTokens: number + interactionId?: string + toolRequests?: ToolRequest[] +} + +type SubagentSelectedData = { + agentName: string + agentDisplayName?: string + tools?: string[] +} + +type CopilotEvent = + | { type: 'session.start'; data: SessionStartData; timestamp?: string } + | { type: 'session.model_change'; data: ModelChangeData; timestamp?: string } + | { type: 'user.message'; data: UserMessageData; timestamp?: string } + | { type: 'assistant.message'; data: AssistantMessageData; timestamp?: string } + | { type: 'subagent.selected'; data: SubagentSelectedData; timestamp?: string } + +type ChatJournalPathSegment = string | number +type ChatSessionRequest = Record + +// --------------------------------------------------------------------------- +// Types for OTel span rows from agent-traces.db +// --------------------------------------------------------------------------- + +// The OTel SQLite store schema uses a spans table where attributes are stored +// either as a JSON blob or as individual columns. We handle both patterns. +// The Copilot Budget extension reads from this same DB and uses per-span +// token counts, confirming this schema is stable enough to depend on. + +// Parsed attribute bag from a span +interface SpanAttributes { + 'gen_ai.operation.name'?: string + 'gen_ai.response.model'?: string + 'gen_ai.request.model'?: string + 'gen_ai.usage.input_tokens'?: number + 'gen_ai.usage.output_tokens'?: number + 'gen_ai.usage.cache_read.input_tokens'?: number + 'gen_ai.usage.cache_creation.input_tokens'?: number + 'gen_ai.conversation.id'?: string + 'gen_ai.agent.name'?: string + 'gen_ai.tool.name'?: string + 'gen_ai.tool.call.arguments'?: string + 'copilot_chat.parent_chat_session_id'?: string + 'github.copilot.chat.turn.id'?: string + [key: string]: unknown +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +function getCopilotSessionStateDir(override?: string): string { + return override ?? process.env['CODEBURN_COPILOT_SESSION_STATE_DIR'] ?? join(homedir(), '.copilot', 'session-state') +} + +/** + * Locate the agent-traces.db file. + * + * Priority: + * 1. CODEBURN_COPILOT_OTEL_DB env var + * 2. Platform-specific default VS Code global storage path + * 3. VSCodium variant paths + */ +function getAgentTracesDbPath(): string | null { + // Allow explicit override + const envOverride = process.env['CODEBURN_COPILOT_OTEL_DB'] + if (envOverride) { + return existsSync(envOverride) ? envOverride : null + } + + const home = homedir() + const candidates: string[] = [] + + const p = platform() + if (p === 'darwin') { + // macOS: VS Code, VS Code Insiders, VSCodium + candidates.push( + join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'github.copilot-chat', 'agent-traces.db'), + join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'globalStorage', 'github.copilot-chat', 'agent-traces.db'), + join(home, 'Library', 'Application Support', 'VSCodium', 'User', 'globalStorage', 'github.copilot-chat', 'agent-traces.db'), + ) + } else if (p === 'linux') { + // Linux: VS Code, VS Code Insiders, VSCodium + candidates.push( + join(home, '.config', 'Code', 'User', 'globalStorage', 'github.copilot-chat', 'agent-traces.db'), + join(home, '.config', 'Code - Insiders', 'User', 'globalStorage', 'github.copilot-chat', 'agent-traces.db'), + join(home, '.config', 'VSCodium', 'User', 'globalStorage', 'github.copilot-chat', 'agent-traces.db'), + ) + } else if (p === 'win32') { + // Windows + const appdata = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming') + candidates.push( + join(appdata, 'Code', 'User', 'globalStorage', 'github.copilot-chat', 'agent-traces.db'), + join(appdata, 'Code - Insiders', 'User', 'globalStorage', 'github.copilot-chat', 'agent-traces.db'), + join(appdata, 'VSCodium', 'User', 'globalStorage', 'github.copilot-chat', 'agent-traces.db'), + ) + } + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + return null +} + +/** + * Locate the GitHub Copilot config root used by the JetBrains IDE plugin + * (IntelliJ IDEA, PyCharm, RubyMine, …). The JetBrains Copilot agent persists + * chat/agent sessions here — a location none of the VS Code or CLI sources + * touch, so this is the only way JetBrains-driven Copilot usage becomes + * visible to CodeBurn. + * + * The path mirrors the plugin's own `getXdgConfigPath` logic (observed in the + * bundled copilot-agent language server): + * - $XDG_CONFIG_HOME/github-copilot (when set to an absolute path) + * - macOS / Linux: ~/.config/github-copilot + * - Windows: %USERPROFILE%\AppData\Local\github-copilot + * + * Under this root, each IDE has its own subdir (e.g. `iu` for IntelliJ IDEA + * Ultimate, `intellij` for the community edition) containing + * chat-agent-sessions/, chat-sessions/, and chat-edit-sessions/. + */ +function getJetBrainsCopilotRoot(override?: string): string { + const envOverride = override ?? process.env['CODEBURN_COPILOT_JETBRAINS_DIR'] + if (envOverride) return envOverride + + const xdg = process.env['XDG_CONFIG_HOME'] + if (xdg && (posix.isAbsolute(xdg) || win32.isAbsolute(xdg))) { + return join(xdg, 'github-copilot') + } + + if (platform() === 'win32') { + const local = process.env['LOCALAPPDATA'] ?? join(homedir(), 'AppData', 'Local') + return join(local, 'github-copilot') + } + + return join(homedir(), '.config', 'github-copilot') +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseCwd(yaml: string): string | null { + const match = yaml.match(/^cwd:\s*(.+)$/m) + if (!match?.[1]) return null + let raw = match[1].trim() + // Strip inline YAML comments (# preceded by optional whitespace) + raw = raw.replace(/\s*#.*$/, '') + // Strip surrounding single/double quotes + raw = raw.replace(/^['"]|['"]$/g, '').trim() + return raw || null +} + +/** + * Load span attributes from the span_attributes table (key-value pairs). + * This handles the modern VS Code Copilot Chat schema where attributes + * are stored as separate key-value rows rather than a JSON blob. + */ +function loadSpanAttributesFromTable( + db: ReturnType, + spanId: string +): SpanAttributes { + try { + const rows = db.query<{ key: string; value: string | null }>( + `SELECT key, value FROM span_attributes WHERE span_id = ?`, + [spanId] + ) + const attrs: SpanAttributes = {} + for (const row of rows) { + if (row.key && row.value) { + try { + // Try to parse numeric values + const numValue = Number(row.value) + attrs[row.key as keyof SpanAttributes] = Number.isNaN(numValue) + ? row.value + : numValue + } catch { + attrs[row.key as keyof SpanAttributes] = row.value + } + } + } + return attrs + } catch { + return {} + } +} + +/** + * Convert nanosecond or millisecond epoch to ISO timestamp. + * The OTel spec uses nanoseconds, but some implementations use milliseconds. + */ +function epochToISO(epoch: number): string { + // Guard malformed rows: new Date(NaN).toISOString() throws. Fall back to the + // epoch (1970) so a bad timestamp is excluded from period totals, not crashing. + if (!Number.isFinite(epoch) || epoch <= 0) return new Date(0).toISOString() + // If the value looks like nanoseconds (> 1e15), convert to ms + const ms = epoch > 1e15 ? Math.floor(epoch / 1e6) : epoch > 1e12 ? epoch : epoch * 1000 + return new Date(ms).toISOString() +} + +function timestampToISO(raw: unknown): string { + if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) { + return epochToISO(raw) + } + if (typeof raw !== 'string') return '' + const trimmed = raw.trim() + if (!trimmed) return '' + if (/^\d+(\.\d+)?$/.test(trimmed)) { + return epochToISO(Number(trimmed)) + } + const parsed = Date.parse(trimmed) + return Number.isNaN(parsed) ? '' : new Date(parsed).toISOString() +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isReplayContainer(value: unknown): value is object { + return typeof value === 'object' && value !== null +} + +function createReplayObject(): Record { + return Object.create(null) as Record +} + +const FORBIDDEN_CHAT_JOURNAL_KEYS = new Set(['__proto__', 'prototype', 'constructor']) + +function parseChatJournalPath(rawPath: unknown, fallback?: ChatJournalPathSegment[]): ChatJournalPathSegment[] | null { + const value = rawPath === undefined ? fallback : rawPath + if (!Array.isArray(value)) return null + + const path: ChatJournalPathSegment[] = [] + for (const segment of value) { + if (typeof segment === 'number') { + if (!Number.isInteger(segment) || segment < 0) return null + path.push(segment) + continue + } + if (typeof segment === 'string') { + if (FORBIDDEN_CHAT_JOURNAL_KEYS.has(segment)) return null + path.push(segment) + continue + } + return null + } + return path +} + +function getReplayValue(container: object, segment: ChatJournalPathSegment): unknown { + return (container as Record)[String(segment)] +} + +function setReplayValue(container: object, segment: ChatJournalPathSegment, value: unknown): void { + ;(container as Record)[String(segment)] = value +} + +function createContainerForNext(segment: ChatJournalPathSegment): unknown[] | Record { + return typeof segment === 'number' ? [] : createReplayObject() +} + +function ensureReplayParent(root: object, path: ChatJournalPathSegment[]): object | null { + let current: object = root + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]! + const nextSegment = path[i + 1]! + let child = getReplayValue(current, segment) + if (!isReplayContainer(child)) { + const created = createContainerForNext(nextSegment) + setReplayValue(current, segment, created) + current = created + continue + } + current = child + } + return current +} + +function applyChatJournalSet(root: unknown, path: ChatJournalPathSegment[], value: unknown): unknown { + if (path.length === 0) return value + + const workingRoot = isReplayContainer(root) ? root : createReplayObject() + const parent = ensureReplayParent(workingRoot, path) + if (!parent) return workingRoot + setReplayValue(parent, path[path.length - 1]!, value) + return workingRoot +} + +function applyChatJournalAppend(root: unknown, path: ChatJournalPathSegment[], items: unknown[]): unknown { + const workingRoot = isReplayContainer(root) ? root : createReplayObject() + + if (path.length === 0) { + if (Array.isArray(workingRoot)) { + for (const item of items) workingRoot.push(item) + } + return workingRoot + } + + const parent = ensureReplayParent(workingRoot, path) + if (!parent) return workingRoot + + const last = path[path.length - 1]! + let target = getReplayValue(parent, last) + const targetArray: unknown[] = Array.isArray(target) ? target : [] + if (target !== targetArray) { + setReplayValue(parent, last, targetArray) + } + for (const item of items) targetArray.push(item) + return workingRoot +} + +function replayChatSessionJournal(content: string): unknown { + let root: unknown = createReplayObject() + const lines = content.split('\n').filter((l) => l.trim()) + + for (const line of lines) { + let entry: unknown + try { + entry = JSON.parse(line) as unknown + } catch { + continue + } + if (!isRecord(entry)) continue + + const kind = entry['kind'] + if (kind === 0) { + root = entry['v'] + continue + } + + if (kind === 1) { + const path = parseChatJournalPath(entry['k']) + if (!path) continue + root = applyChatJournalSet(root, path, entry['v']) + continue + } + + if (kind === 2) { + const hasPath = Object.prototype.hasOwnProperty.call(entry, 'k') + const path = parseChatJournalPath(hasPath ? entry['k'] : undefined, ['requests']) + const items = Array.isArray(entry['v']) ? entry['v'] : [] + if (!path) continue + root = applyChatJournalAppend(root, path, items) + } + } + + return root +} + +function numberOrZero(raw: unknown): number { + return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : 0 +} + +function readString(raw: unknown): string { + return typeof raw === 'string' ? raw : '' +} + +function modelFromChatSessionRequest(req: ChatSessionRequest, metadata: Record): string { + const resolved = readString(metadata['resolvedModel']) + if (resolved) return resolved + + const modelId = readString(req['modelId']).replace(/^copilot\//, '') + return modelId || 'unknown' +} + +function extractChatSessionTools(metadata: Record): string[] { + const rounds = metadata['toolCallRounds'] + if (!Array.isArray(rounds)) return [] + + const names = new Set() + const addName = (raw: unknown): void => { + if (typeof raw === 'string' && raw.trim()) names.add(normalizeTool(raw)) + } + const addFromRecord = (record: Record): void => { + addName(record['toolName']) + addName(record['name']) + addName(record['tool']) + } + + for (const round of rounds) { + if (!isRecord(round)) continue + addFromRecord(round) + + for (const key of ['tools', 'toolCalls', 'toolRequests']) { + const entries = round[key] + if (!Array.isArray(entries)) continue + for (const entry of entries) { + if (typeof entry === 'string') { + addName(entry) + } else if (isRecord(entry)) { + addFromRecord(entry) + } + } + } + } + + return [...names] +} + +/** + * Extract a shell command string from an OTel execute_tool span's + * `gen_ai.tool.call.arguments` attribute. The attribute is a JSON-encoded + * argument object (e.g. `{"command":"ls -la"}`); we pull out the `command` + * field. Returns null when the attribute is absent or doesn't carry a command, + * so callers can skip shell-command extraction cleanly. + */ +function parseToolCommand(raw: unknown): string | null { + if (typeof raw !== 'string' || !raw.trim()) return null + try { + const parsed = JSON.parse(raw) as Record + const command = parsed['command'] + return typeof command === 'string' ? command : null + } catch { + return null + } +} + +// Shell control-flow keywords. These lead a statement but are not commands, so +// they must never be reported as bash commands. +const OTEL_SHELL_KEYWORDS = new Set([ + 'if', 'then', 'else', 'elif', 'fi', + 'for', 'while', 'until', 'do', 'done', + 'case', 'esac', 'select', 'function', 'in', 'time', 'coproc', +]) + +/** + * Normalise an OTEL shell command before command-name extraction. + * + * Unlike the Copilot CLI / VS Code JSONL logs — which record a single command + * per tool call (e.g. `cd x && python3 y`) — the OTEL store records the FULL + * multi-line script the agent ran (heredocs, for/if blocks, newline-separated + * statements). The shared extractBashCommands helper only splits on `;`/`&&`/`|` + * and has no concept of shell keywords, so those scripts leak control-flow words + * (`for`, `do`, `if`, `then`, …) and collapse newline-separated statements. + * + * Normalising here — rather than in the shared helper — keeps every other + * provider's behaviour unchanged. We (1) turn newlines into `;` so each + * statement is its own segment, then (2) drop shell control-flow keywords. + */ +function extractOtelBashCommands(command: string): string[] { + const normalized = command.replace(/\r?\n/g, '; ') + return extractBashCommands(normalized).filter(c => !OTEL_SHELL_KEYWORDS.has(c)) +} + +// --------------------------------------------------------------------------- +// Helpers for JSONL / transcript parsing +// --------------------------------------------------------------------------- + +/** + * Safely coerce a raw toolRequests value to an array of ToolRequest. + * Non-array values (string, null, undefined) are treated as empty arrays + * so that a corrupt event.data doesn't abort the whole file parse loop. + */ +function coerceToolRequests(raw: unknown): ToolRequest[] { + return Array.isArray(raw) ? (raw as ToolRequest[]) : [] +} + +/** + * Infer the model bucket for a VS Code transcript file by counting the + * toolCallId prefixes across all assistant messages: + * call_* → OpenAI + * tooluse_* / toolu_* → Anthropic + * The dominant prefix determines the model for the whole session. + * Returns '' if no toolCallIds are present. + */ +function inferTranscriptModel(lines: string[]): string { + let openaiCount = 0 + let anthropicCount = 0 + + for (const line of lines) { + try { + const event = JSON.parse(line) as CopilotEvent + if (event.type !== 'assistant.message') continue + const data = event.data as AssistantMessageData & { toolRequests?: Array<{ toolCallId?: string }> } + const reqs = coerceToolRequests(data.toolRequests) + for (const req of reqs) { + const id = (req as { toolCallId?: unknown }).toolCallId + if (typeof id !== 'string') continue + if (id.startsWith('call_')) openaiCount++ + else if (/^tooluse_|^toolu_/.test(id)) anthropicCount++ + } + } catch { + continue + } + } + + if (openaiCount === 0 && anthropicCount === 0) return '' + return openaiCount >= anthropicCount ? 'copilot-openai-auto' : 'copilot-anthropic-auto' +} + +// --------------------------------------------------------------------------- +// JSONL parser (handles both regular session-state events and VS Code +// transcript format via session.start { producer: 'copilot-agent' }) +// --------------------------------------------------------------------------- + +function createJsonlParser( + source: SessionSource, + seenKeys: Set +): SessionParser { + return { + async *parse(): AsyncGenerator { + const content = await readSessionFile(source.path) + if (!content) return + const sessionId = basename(dirname(source.path)) + const lines = content.split('\n').filter((l) => l.trim()) + + // Detect VS Code transcript format: the first session.start event has + // { producer: 'copilot-agent' } and no outputTokens in messages. + let isTranscript = false + let currentModel = '' + let pendingUserMessage = '' + // Track the active subagent for this session (from subagent.selected events). + // Resets when a new subagent is selected. + let currentSubagentType: string | undefined + + // First pass: detect format and infer transcript model if needed. + for (const line of lines) { + try { + const ev = JSON.parse(line) as CopilotEvent + if (ev.type === 'session.start') { + const data = ev.data as SessionStartData & { producer?: string } + if (data.producer === 'copilot-agent') { + isTranscript = true + } + break + } + if (ev.type === 'session.model_change') break // regular format + } catch { + continue + } + } + + if (isTranscript) { + currentModel = inferTranscriptModel(lines) + if (!currentModel) return // no toolCallIds to infer model from + } + + for (const line of lines) { + let event: CopilotEvent + try { + event = JSON.parse(line) as CopilotEvent + } catch { + continue + } + + if (event.type === 'session.start') { + if (!isTranscript) { + currentModel = (event.data as SessionStartData).selectedModel ?? currentModel + } + continue + } + + if (event.type === 'session.model_change') { + currentModel = (event.data as ModelChangeData).newModel ?? currentModel + continue + } + + if (event.type === 'subagent.selected') { + currentSubagentType = (event.data as SubagentSelectedData).agentName + continue + } + + if (event.type === 'user.message') { + pendingUserMessage = (event.data as UserMessageData).content ?? '' + continue + } + + if (event.type === 'assistant.message') { + const msgData = event.data as AssistantMessageData + const { messageId, model: msgModel, outputTokens = 0 } = msgData + const rawRequests = (msgData as { toolRequests?: unknown }).toolRequests + const toolRequests = coerceToolRequests(rawRequests) + + // model may be carried per-message in newer copilot-agent format + if (msgModel) currentModel = msgModel + // Regular JSONL: skip zero-token messages; transcripts don't have tokens + if (!isTranscript && outputTokens === 0) continue + if (!currentModel) continue + + const dedupKey = `copilot:${sessionId}:${messageId}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const tools = toolRequests + .map((t) => { + const raw = typeof t === 'object' && t !== null + ? ((t as { name?: unknown; toolName?: unknown }).name ?? (t as { name?: unknown; toolName?: unknown }).toolName) + : null + return typeof raw === 'string' ? normalizeTool(raw) : null + }) + .filter((t): t is string => t !== null) + + // Extract base command names from bash-type tool requests, routing the + // raw command through the shared extractBashCommands helper so chained + // commands are normalised the same way as every other provider + // (see bash-utils.ts, parser.ts, forge.ts, grok.ts, etc.). + const bashCommands = toolRequests.flatMap((t) => { + if (typeof t !== 'object' || t === null) return [] + const name = (t.name ?? t.toolName) ?? '' + if (!BASH_TOOL_NAMES.has(name)) return [] + const cmd = t.arguments?.['command'] + return typeof cmd === 'string' ? extractBashCommands(cmd) : [] + }) + + // Copilot JSONL only logs outputTokens; inputTokens are NOT available. + // Cost will be lower than actual API cost. This is the original + // behaviour — OTel data (below) replaces it when available. + const costUSD = calculateCost(currentModel, 0, outputTokens, 0, 0, 0) + + yield { + provider: 'copilot', + sessionId, + model: currentModel, + inputTokens: 0, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + subagentTypes: currentSubagentType ? [currentSubagentType] : undefined, + timestamp: event.timestamp ?? '', + speed: 'standard' as const, + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + } + pendingUserMessage = '' + } + } + }, + } +} + +function createChatSessionParser( + source: SessionSource, + seenKeys: Set +): SessionParser { + return { + async *parse(): AsyncGenerator { + const content = await readSessionFile(source.path) + if (!content) return + + const root = replayChatSessionJournal(content) + if (!isRecord(root)) return + + const sessionId = readString(root['sessionId']) || basename(source.path, '.jsonl') + const sessionCreatedAt = timestampToISO(root['creationDate']) + const requests = Array.isArray(root['requests']) ? root['requests'] : [] + + for (let index = 0; index < requests.length; index++) { + const rawReq = requests[index] + if (!isRecord(rawReq)) continue + + const result = rawReq['result'] + const resultRecord = isRecord(result) ? result : null + const rawMetadata = resultRecord?.['metadata'] + const metadata = isRecord(rawMetadata) ? rawMetadata : createReplayObject() + + const inputTokens = numberOrZero(metadata['promptTokens']) + const metadataOutputTokens = numberOrZero(metadata['outputTokens']) + const outputTokens = metadataOutputTokens || numberOrZero(rawReq['completionTokens']) + + if (inputTokens === 0 && outputTokens === 0) continue + + const requestId = readString(rawReq['requestId']) || `request-${index}` + const dedupKey = `copilot-chatsession:${sessionId}:${requestId}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const model = modelFromChatSessionRequest(rawReq, metadata) + const costUSD = calculateCost(model, inputTokens, outputTokens, 0, 0, 0) + const timestamp = timestampToISO(rawReq['timestamp']) || sessionCreatedAt + + yield { + provider: 'copilot', + sessionId, + project: source.project, + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: extractChatSessionTools(metadata), + bashCommands: [], + timestamp, + speed: 'standard' as const, + deduplicationKey: dedupKey, + userMessage: '', + } + } + }, + } +} + +// --------------------------------------------------------------------------- +// JetBrains parser (Nitrite .db from ~/.config/github-copilot) +// --------------------------------------------------------------------------- +// +// The JetBrains Copilot plugin stores each chat/agent session in a Nitrite +// (H2 MVStore) .db of Java-serialized documents. There is NO token accounting +// anywhere in the store, so we estimate output tokens from the assistant reply +// text (the same char-count approach CodeBurn already uses for Cursor and +// legacy Copilot JSONL). Cost is therefore marked costIsEstimated. +// +// The model (e.g. "claude-opus-4.5", "gpt-4.1") is not always tagged on each +// turn, so we recover it by scanning the raw buffer for a known model token. + +// Known JetBrains Copilot model tokens, longest-first so we match the most +// specific name (e.g. "gpt-4.1-mini" before "gpt-4.1"). +const JETBRAINS_MODEL_TOKENS = [ + 'claude-opus-4.5', + 'claude-opus-4.1', + 'claude-opus-4', + 'claude-sonnet-4.5', + 'claude-sonnet-4', + 'gpt-5.3-codex', + 'gpt-5.3', + 'gpt-5.2', + 'gpt-5.1', + 'gpt-5-mini', + 'gpt-5', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4.1', + 'gpt-4o-mini', + 'gpt-4o', + 'gemini-2.5-pro', + 'gemini-2.0-flash', + 'o3-mini', + 'o4-mini', + 'o3', +] + +/** + * Normalise a raw JetBrains model token to CodeBurn's canonical model id. + * Claude names use dots on disk (claude-opus-4.5) but dashes in the pricing + * tables (claude-opus-4-5); GPT/Gemini names are kept verbatim. + */ +function normalizeJetBrainsModelName(raw: string): string { + const t = raw.trim() + if (!t) return '' + if (t.startsWith('claude-')) return t.replace(/\./g, '-') + return t +} + +/** Match a known model token at an alnum boundary anywhere in a string. */ +function findJetBrainsModelToken(s: string): string { + for (const token of JETBRAINS_MODEL_TOKENS) { + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // "o3" etc. must not match inside words like "iso3166". + if (new RegExp(`(?() + let m: RegExpExecArray | null + while ((m = re.exec(raw))) { + // Decode %20 etc. and strip a trailing .rej/.orig suffix noise; keep the dir. + let p = m[1] + try { p = decodeURIComponent(p) } catch { /* leave as-is */ } + const dir = p.slice(0, p.lastIndexOf('/')) + if (dir.startsWith('/')) seen.add(dir) + } + if (seen.size === 0) return undefined + + for (const dir of seen) { + const repo = findGitRepoRoot(dir) + if (repo) return repo + } + return undefined +} + +/** Walk up from `dir` to the nearest ancestor containing `.git`; return its basename. */ +function findGitRepoRoot(dir: string): string | undefined { + let cur = dir + // Bound the walk to avoid pathological loops; repos are never this deep. + for (let i = 0; i < 40 && cur && cur !== '/'; i++) { + if (existsSync(join(cur, '.git'))) { + const name = basename(cur) + return name || undefined + } + const parent = dirname(cur) + if (parent === cur) break + cur = parent + } + return undefined +} + +/** + * Recover the plugin-recorded project label from a Nitrite .db. + * + * JetBrains Copilot 1.12+ serialises a `projectName` field on the session doc + * (e.g. `my-service`, `codeburn`). It is the plugin's OWN authoritative + * label — the JetBrains analogue of the OTel source's + * `github.copilot.git.repository` — so it is preferred over the file-path + * git-walk heuristic when present. + * + * The field is a Java-serialized string: the key bytes `projectName` are + * followed immediately by TC_STRING framing `0x74 + * `. We read exactly `length` bytes (so an embedded newline or + * quote can't truncate it) and accept the first occurrence whose value is a + * plausible short, printable repo name. Older plugins that don't write the + * field simply yield undefined (callers fall back to the git-walk). + * + * Note: the field lives on the session doc, which the plugin writes into the + * `chat-sessions` / `chat-edit-sessions` stores — often NOT the + * `chat-agent-sessions` store where the billable turns live. Discovery joins + * the two by store id; see resolveJetBrainsProjectNames. + */ +function extractJetBrainsProjectName(raw: string): string | undefined { + const re = /projectName\x74([\x00-\xff])([\x00-\xff])/g + let m: RegExpExecArray | null + while ((m = re.exec(raw))) { + const len = (m[1]!.charCodeAt(0) << 8) | m[2]!.charCodeAt(0) + // Repo names are short; a huge length means we matched a schema/key + // occurrence rather than a value-bearing one — skip it. + if (len < 1 || len > 128) continue + const start = m.index + m[0].length + // The .db is read as latin1, so re-interpret the length-delimited bytes as + // UTF-8 (repo names can contain non-ASCII). Reject only if the decoded value + // holds control chars — a sign we matched a non-value occurrence, not a name. + const val = Buffer.from(raw.slice(start, start + len), 'latin1').toString('utf8') + // eslint-disable-next-line no-control-regex + if (val.length > 0 && !/[\x00-\x1f]/.test(val)) return val + } + return undefined +} + +// --------------------------------------------------------------------------- +// Nitrite .db (H2 MVStore) extraction +// --------------------------------------------------------------------------- +// +// JetBrains Copilot sessions store their conversation in the Nitrite .db +// (copilot-*-nitrite.db). One .db holds many conversations. Assistant replies +// are stored as a distinct blob shape: +// +// {"__first__":{"type":"Subgraph","value":"..."}, ...} +// +// which is more deeply escaped than the user-message value-maps. The reply text +// is recovered by progressive unescaping and collecting "text":"..." fields. +// Failed turns ("Sorry, an error occurred …") carry an error status and no reply +// text — they are detected and billed as $0. + +// One assistant turn recovered from a .db. +type JBDbTurn = { + replyText: string + model: string + errored: boolean + // The owning conversation (chat tab): its internal GUID and title. One .db + // holds many conversations; turns are grouped back to their tab by this id. + conversationId: string + conversationTitle: string + // The file path this conversation referenced (home-relative common dir), or + // '' if the chat touched no files. Used as the project label. + conversationProject: string +} + +// A conversation (chat tab) recovered from a .db: internal GUID → title. +type JBConversation = { id: string; title: string } + +/** + * Recover the conversation (chat-tab) records from a raw .db buffer. Each is + * stored as `$ … name … value … source copilot`. Returns the + * GUID→title map so turns can be grouped back to the tab the user sees. + */ +function extractJetBrainsConversations(raw: string): JBConversation[] { + // A conversation's title EVOLVES as the user chats: it starts as "New Agent + // Session", may pass through an auto-generated name, and ends at the final + // title shown in the UI. The same `$<GUID> … name … value <title> … source` + // record is rewritten each time, so we collect every occurrence per GUID and + // keep the LAST meaningful (non-default) one. + const DEFAULT_TITLES = new Set(['New Agent Session', 'New Session', 'New Chat']) + const byId = new Map<string, string>() + const re = /\$([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})[\s\S]{0,8}name/g + let m: RegExpExecArray | null + while ((m = re.exec(raw))) { + const id = m[1] + const window = raw.slice(m.index, m.index + 400) + // The title is the Java-UTF string between the `value` marker and `source`. + const tm = window.match(/value.{1,6}?([\x20-\x7e]{3,80}?)t\x00\x06source/) + if (!tm) continue + const title = Buffer.from(tm[1].replace(/^[^A-Za-z0-9]*/, ''), 'latin1').toString('utf8').trim() + if (!title) continue + // Keep the latest non-default title; only fall back to a default if no + // meaningful title has been seen for this conversation yet. + const existing = byId.get(id) + if (existing && !DEFAULT_TITLES.has(existing) && DEFAULT_TITLES.has(title)) continue + byId.set(id, title) + } + return [...byId.entries()].map(([id, title]) => ({ id, title })) +} + +/** Brace-match a JSON object starting at `start`, tolerating escaped quotes. */ +function matchJsonObject(raw: string, start: number): { chunk: string; end: number } { + let depth = 0 + let inStr = false + let esc = false + let i = start + for (; i < raw.length; i++) { + const c = raw[i] + if (esc) { esc = false; continue } + if (c === '\\') { esc = true; continue } + if (c === '"') { inStr = !inStr; continue } + if (inStr) continue + if (c === '{') depth++ + else if (c === '}') { depth--; if (depth === 0) { i++; break } } + } + return { chunk: raw.slice(start, i), end: i } +} + +/** + * Recover the assistant reply text from a `__first__`/Subgraph response blob. + * + * JetBrains Copilot has two turn shapes, both handled here: + * + * - **Ask mode:** the reply is a `Markdown` record whose `data` is an escaped + * JSON document `{"text":"…","annotations":…}`. + * - **Agent mode** (e.g. PyCharm agent sessions): the reply is the `reply` + * field of an `AgentRound` record `{"roundId":N,"reply":"…","toolCalls":[…]}`. + * In agent mode the `Markdown` records hold the USER's prompts, not the + * reply, so we must NOT read them — the assistant output is the AgentRound + * reply. + * + * Both are read STRUCTURALLY rather than by fully unescaping the blob (which + * would strip the reply's own quotes and make regex extraction ambiguous): we + * locate each `data`/`reply` value, read it as a properly-delimited JSON-string + * literal (honouring escaping), unescape one level, and `JSON.parse` to reach + * the text. We unescape the blob one level at a time and extract at the first + * depth that yields text, never accumulating across depths (which would union a + * quote-truncated half-unescaped capture with the full one and garble the + * reply, inflating the token/cost estimate). + * + * Steps/error/progress-only blobs (no Markdown text and no AgentRound reply) + * yield '' and are billed as $0 upstream. + */ +function extractResponseText(blob: string): string { + let s = blob + for (let depth = 0; depth < 8; depth++) { + // Decide the mode by the PRESENCE of an AgentRound record, not by whether it + // yielded a reply. In agent mode the Markdown record holds the USER prompt, + // so an agent blob whose reply is empty (a failed turn, or a pure tool-call + // round) must NOT fall back to Markdown — that would bill the user's prompt + // as the assistant's output. Ask-mode blobs have no AgentRound record and + // use Markdown. (Verified across every observed store: the two reply shapes + // never coexist in one blob, so this mode split is unambiguous.) + const isAgentMode = /"type":"AgentRound"/.test(s) + if (isAgentMode || /"type":"Markdown"/.test(s)) { + const decoded = isAgentMode ? extractAgentRoundReplies(s) : extractMarkdownTexts(s) + // The .db is read as latin1 (byte-stable), so multibyte UTF-8 characters + // are split into separate code units. Re-interpret as UTF-8 so the char + // count (→ token estimate) reflects real content length, not byte count. + // decoded may be empty (failed/tool-only agent turn) → '' (billed $0). + return Buffer.from(decoded.join('\n').trim(), 'latin1').toString('utf8') + } + // Not yet at the depth where record markers appear bare — unescape one level + // in a single left-to-right pass so `\\` and `\"` resolve together (a + // two-pass replace would turn `\\"` into `\"` not `\\` + `"`). + const next = s.replace(/\\([\\"])/g, '$1') + if (next === s) break + s = next + } + return '' +} + +/** + * Collect the `text` of every `Markdown` record in `s`, treating each record's + * `data` value as a one-level-escaped JSON string parsed structurally (so the + * reply's own quotes never truncate it). Returns [] if `s` is not yet at the + * right unescape depth (no bare `"type":"Markdown"` with a parseable `data`). + * Scoping to Markdown skips `Error` (`message`) and `Steps` records — not + * billable output. Revisions repeat a reply, so identical texts are de-duped. + */ +function extractMarkdownTexts(s: string): string[] { + return extractRecordStrings(s, '"type":"Markdown"', '"data":"', 'text') +} + +/** + * Collect the non-empty `reply` of every `AgentRound` record (agent mode). A + * single blob can hold several rounds (a multi-turn agent session); each round's + * `reply` is the assistant's text for that step (empty on pure tool-call rounds). + * Deduped in order. + */ +function extractAgentRoundReplies(s: string): string[] { + return extractRecordStrings(s, '"type":"AgentRound"', '"data":"', 'reply') +} + +/** + * Shared structural reader: for every `<marker>` in `s`, find the following + * `<dataKey>` string literal (a one-level-escaped JSON document), parse it, and + * collect `doc[field]` when it is a non-empty string. Reading the value as a + * delimited literal — not a greedy regex — means the payload's own quotes never + * truncate it. Returns [] when `s` is not yet at the depth where the marker + * appears bare with a parseable payload. De-dupes in order (the store keeps + * byte-copies/revisions of each reply). + */ +function extractRecordStrings(s: string, marker: string, dataKey: string, field: string): string[] { + const texts: string[] = [] + const seen = new Set<string>() + const re = new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') + let m: RegExpExecArray | null + while ((m = re.exec(s))) { + const dk = s.indexOf(dataKey, m.index) + if (dk === -1 || dk - m.index > 200) continue + // The value runs from after `<dataKey>` to the first UNescaped quote (an odd + // run of preceding backslashes escapes it). + const start = dk + dataKey.length + let i = start + for (; i < s.length; i++) { + if (s[i] !== '"') continue + let bs = 0 + for (let j = i - 1; j >= start && s[j] === '\\'; j--) bs++ + if (bs % 2 === 0) break + } + const literal = s.slice(start, i) + try { + // Wrapping in quotes + parsing unescapes exactly one level → the inner + // JSON document as a string; parsing THAT reaches { <field>, … }. + const doc = JSON.parse(JSON.parse('"' + literal + '"') as string) as Record<string, unknown> + const text = typeof doc[field] === 'string' ? (doc[field] as string) : '' + if (text && !seen.has(text)) { + seen.add(text) + texts.push(text) + } + } catch { + // Not the right depth (or not a matching record) — skip. + } + } + return texts +} + +/** + * Extract assistant turns from a raw (latin1) Nitrite .db buffer. Each turn is + * one `{"__first__":{"type":"Subgraph"…}` blob; the per-turn model is recovered + * from inside the blob when present, else the whole-store default. Each turn is + * grouped back to its owning conversation (chat tab) by the nearest preceding + * conversation GUID. Duplicate byte-copies of the same reply (the store keeps + * several) are de-duplicated by content, per conversation. + */ +function extractJetBrainsDbTurns(raw: string): JBDbTurn[] { + const conversations = extractJetBrainsConversations(raw) + // Precompute the byte offset of each conversation GUID's full form so a turn + // can be attributed to the conversation whose id most recently precedes it. + const convById = new Map(conversations.map((c) => [c.id, c])) + + const turns: JBDbTurn[] = [] + const seenReplies = new Set<string>() // keyed by `${conversationId}::${reply}` + const re = /\{"__first__":\{"type":"Subgraph"/g + let m: RegExpExecArray | null + while ((m = re.exec(raw))) { + const { chunk, end } = matchJsonObject(raw, m.index) + re.lastIndex = end + + // Attribute this turn to the conversation whose GUID last appears before it. + let conversationId = '' + let conversationTitle = '' + let bestPos = -1 + for (const c of convById.values()) { + const p = raw.lastIndexOf(c.id, m.index) + if (p > bestPos) { + bestPos = p + conversationId = c.id + conversationTitle = c.title + } + } + + const replyText = extractResponseText(chunk) + // The files this turn referenced (home-relative common dir) → project label. + const conversationProject = inferJetBrainsProject(chunk) ?? '' + // A per-turn model token sometimes appears inside the blob. + const model = findJetBrainsModelToken(chunk) + // A failed turn carries an error status / phrase AND produces no reply text. + // Requiring empty text avoids misclassifying a genuine reply that merely + // *discusses* an error (e.g. explaining a stack trace) as a failed turn. + const hasErrorMarker = /error occurred|"isError":true|\\+"status\\+":\\+"(?:error|failed)\\+"/i.test(chunk) + if (hasErrorMarker && !replyText) { + turns.push({ replyText: '', model, errored: true, conversationId, conversationTitle, conversationProject }) + continue + } + if (!replyText) continue // Steps/progress-only blob — no billable output + const dedupeKey = `${conversationId}::${replyText}` + if (seenReplies.has(dedupeKey)) continue + seenReplies.add(dedupeKey) + turns.push({ replyText, model, errored: false, conversationId, conversationTitle, conversationProject }) + } + + // --------------------------------------------------------------------------- + // Fallback: old JetBrains Copilot plugin format (≤1.5.x, e.g. 1.5.59-243) + // --------------------------------------------------------------------------- + // In this format ALL session turns are stored inside ONE large outer Nitrite + // document — a binary-framed JSON object with UUID-keyed Value entries — rather + // than the per-turn {"__first__":{"type":"Subgraph",...}} blobs used by newer + // plugins (≥1.12.x). The AgentRound entries sit one escaping level deeper + // inside the outer document's string values, so `extractResponseText`'s + // depth-unescape loop handles extraction correctly once we feed it the right + // chunk. MVStore keeps two identical copies of the collection; `seenReplies` + // deduplicates them automatically. + // + // Detection heuristic: the __first__/Subgraph path produced no turns AND the + // raw file contains bare 'AgentRound' text (meaning old-format data is present). + if (turns.length === 0 && raw.includes('AgentRound')) { + // The outer Nitrite document is preceded by a single binary framing byte + // (0x81 in practice, but any non-printable/non-ASCII byte in MVStore). + // It starts with a UUID-keyed Value entry: {"<uuid>":{"type":"Value",...}}. + // Hex is matched case-insensitively — an uppercase UUID must not cause the + // whole session to fall through to $0 (the exact bug this path fixes). + const outerDocRe = /[\x00-\x1f\x7f-\xff]\{"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}":\{"type":"Value"/g + let dm: RegExpExecArray | null + while ((dm = outerDocRe.exec(raw))) { + // Skip the leading binary byte; matchJsonObject starts at the '{'. + const docStart = dm.index + 1 + const { chunk, end } = matchJsonObject(raw, docStart) + outerDocRe.lastIndex = end + + // Skip documents that contain no AgentRound data (e.g. empty sessions). + if (!chunk.includes('AgentRound')) continue + + // Attribute to the conversation whose GUID most recently precedes this doc. + let conversationId = '' + let conversationTitle = '' + let bestPos = -1 + for (const c of convById.values()) { + const p = raw.lastIndexOf(c.id, docStart) + if (p > bestPos) { + bestPos = p + conversationId = c.id + conversationTitle = c.title + } + } + + // extractResponseText handles the depth-1 unescape needed to surface the + // AgentRound records, then calls extractAgentRoundReplies for each turn. + // Because the outer document holds ALL turns in one blob we get back a + // single joined string; split it on the '\n' join to yield per-turn texts. + const allReplies = extractResponseText(chunk) + if (!allReplies) continue + + const conversationProject = inferJetBrainsProject(chunk) ?? '' + const storeModel = findJetBrainsModelToken(chunk) + + // extractResponseText joins multiple replies with '\n'. Since individual + // replies can themselves span multiple lines we cannot cleanly split here — + // instead we emit one ParsedProviderCall per outer document (one session). + const dedupeKey = `${conversationId}::${allReplies}` + if (seenReplies.has(dedupeKey)) continue + seenReplies.add(dedupeKey) + + turns.push({ + replyText: allReplies, + model: storeModel, + errored: false, + conversationId, + conversationTitle, + conversationProject, + }) + } + } + + // A project derived from ANY turn of a conversation applies to all its turns + // (the files are usually referenced in the first substantive turn only). + const projByConv = new Map<string, string>() + for (const t of turns) { + if (t.conversationProject && !projByConv.has(t.conversationId)) { + projByConv.set(t.conversationId, t.conversationProject) + } + } + for (const t of turns) { + if (!t.conversationProject) t.conversationProject = projByConv.get(t.conversationId) ?? '' + } + + return turns +} + +// --------------------------------------------------------------------------- +// JetBrains parser: one ParsedProviderCall per assistant turn in the .db +// --------------------------------------------------------------------------- + +function createJetBrainsParser( + source: JetBrainsSessionSource, + seenKeys: Set<string> +): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const sessionId = source.sessionId + + // Nitrite .db (the store's authoritative session content). Read as latin1 + // so byte offsets are stable through the binary MVStore framing. + if (source.dbPath) { + let dbRaw: string | null = null + try { + dbRaw = await readSessionFile(source.dbPath, 'latin1') + } catch { + dbRaw = null + } + if (dbRaw) { + const storeModel = inferJetBrainsModel(dbRaw) + const turns = extractJetBrainsDbTurns(dbRaw) + // Dedup keys derive from the reply CONTENT, not the scan position: + // copilot is a durable provider (cached turns are never deleted and a + // re-parse appends any key it hasn't seen), while MVStore compaction + // can rewrite the file with blobs in a different byte order. With + // positional keys, a rewrite that puts a new blob ahead of an old one + // hands the new turn the old turn's key (skipped as seen) and re-emits + // the old turn under a fresh index — double-billing it. The per-hash + // counter keeps genuinely repeated replies and errored turns (which + // share replyText '') distinct within a conversation. + const perContentIndex = new Map<string, number>() + for (const turn of turns) { + // One .db holds many chat tabs; group each turn under its own + // conversation so the user sees one session per tab, not per file. + const convId = turn.conversationId || sessionId + const contentHash = createHash('sha256').update(turn.replyText).digest('hex').slice(0, 12) + const nth = (perContentIndex.get(`${convId}:${contentHash}`) ?? 0) + 1 + perContentIndex.set(`${convId}:${contentHash}`, nth) + const dedupKey = `copilot:jb:${convId}:${contentHash}:${nth}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + // Prefer the per-turn model, else the store default, else a generic + // Copilot bucket so a real reply is never mis-priced as free. + const model = turn.model || storeModel || 'copilot-anthropic-auto' + // Errored turns (failed generation) contribute no billable output. + const outputTokens = turn.errored ? 0 : estimateTokens(turn.replyText) + const costUSD = outputTokens > 0 ? calculateCost(model, 0, outputTokens, 0, 0, 0) : 0 + // Project resolution precedence: + // 1. projectName — the plugin's own recorded label (1.12+), + // joined across kind dirs by store id. Authoritative. + // 2. the git repo root of a file:// path the chat referenced + // (older plugins / when projectName is absent). + // 3. one honest bucket when neither signal exists. + // The conversation TITLE is a chat-thread name, NOT a project, and is + // kept out of `project` (it would otherwise pollute By-Project). + const project = + source.projectName || turn.conversationProject || 'copilot-jetbrains' + + yield { + provider: 'copilot', + sessionId: convId, + project, + model, + inputTokens: 0, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + costIsEstimated: true, + tools: [], + bashCommands: [], + timestamp: source.mtime, + speed: 'standard' as const, + deduplicationKey: dedupKey, + // Surface the chat-thread name here (it is the session's label, not + // a project) so it remains visible in session-level views. + userMessage: turn.conversationTitle, + } + } + } + } + + }, + } +} + +// --------------------------------------------------------------------------- +// OTel SQLite parser — reads agent-traces.db for FULL token data +// --------------------------------------------------------------------------- + +function createOtelParser( + source: SessionSource, + seenKeys: Set<string> +): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + // Lazy-load the SQLite module (same pattern as Cursor/OpenCode providers) + const { openDatabase } = await import('../sqlite.js') + + // One DB open handles ALL conversations — avoids N opens for N conversations. + const db = openDatabase(source.path) + + try { + // --------------------------------------------------------------- + // Get all distinct conversations in the DB with their project names. + // --------------------------------------------------------------- + const conversationRows = db.query<{ + conversation_id: string + project: string | null + min_start: number + }>( + `SELECT DISTINCT + sa_conv.value AS conversation_id, + COALESCE(sa_repo.value, 'copilot-chat') AS project, + MIN(s.start_time_ms) AS min_start + FROM spans s + LEFT JOIN span_attributes sa_conv + ON s.span_id = sa_conv.span_id AND sa_conv.key = 'gen_ai.conversation.id' + LEFT JOIN span_attributes sa_repo + ON s.span_id = sa_repo.span_id AND sa_repo.key = 'github.copilot.git.repository' + WHERE sa_conv.value IS NOT NULL + GROUP BY sa_conv.value + ORDER BY min_start DESC` + ) + + for (const convRow of conversationRows) { + const conversationId = convRow.conversation_id + if (!conversationId) continue + + let project = convRow.project ?? 'copilot-chat' + if (project.includes('/')) { + project = basename(project.replace(/\.git$/, '')) + } + + // ----------------------------------------------------------- + // Query all 'chat' spans for this conversation. + // ----------------------------------------------------------- + + const spanIdRows = db.query<{ span_id: string; trace_id: string }>( + `SELECT DISTINCT s.span_id, s.trace_id + FROM spans s + INNER JOIN span_attributes sa + ON s.span_id = sa.span_id AND sa.key = 'gen_ai.conversation.id' AND sa.value = ? + ORDER BY s.start_time_ms ASC`, + [conversationId] + ) + + // Collect trace IDs and span IDs belonging to this conversation + const traceIds = new Set<string>() + for (const row of spanIdRows) { + traceIds.add(row.trace_id) + } + + if (traceIds.size === 0) { + continue + } + + // Now query all spans within those traces to find chat and tool spans. + // Pull the metadata columns in the same query so we don't re-query the + // spans table once per chat span below (avoids an N+1). + const traceIdArr = [...traceIds] + const tracePlaceholders = traceIdArr.map(() => '?').join(',') + const traceSpans = db.query<{ + span_id: string + trace_id: string + operation_name: string | null + start_time_ms: number + response_model: string | null + }>( + `SELECT span_id, trace_id, operation_name, start_time_ms, response_model FROM spans WHERE trace_id IN (${tracePlaceholders})`, + traceIdArr + ) + + // Collect tool names, shell commands and subagent names from the + // execute_tool / invoke_agent spans for each trace. These mirror the + // metadata the JSONL path captures, so the OTel source stays + // equivalent (tools + bashCommands + subagentTypes are all first-class + // call metadata per types.ts). + // + // Subagent attribution: VS Code records a subagent run as an + // invoke_agent span carrying copilot_chat.parent_chat_session_id. The + // root turn agent (gen_ai.agent.name = 'GitHub Copilot Chat') has NO + // parent session and is intentionally excluded, otherwise it would + // surface as a bogus 'GitHub Copilot Chat' entry in the agents view. + // A subagent's invoke_agent span lives in the same trace as that + // subagent's own chat spans, so attributing the agent name per-trace + // labels exactly the subagent's calls. + const toolsByTrace = new Map<string, string[]>() + const bashByTrace = new Map<string, string[]>() + const subagentsByTrace = new Map<string, string[]>() + const chatSpanIds: string[] = [] + const spanMetaById = new Map<string, { trace_id: string; start_time_ms: number; response_model: string | null }>() + + for (const span of traceSpans) { + const opName = span.operation_name || '' + spanMetaById.set(span.span_id, span) + + if (opName === 'chat') { + chatSpanIds.push(span.span_id) + continue + } + + if (opName === 'execute_tool') { + // Load tool name from attributes and normalise to display form + const attrs = loadSpanAttributesFromTable(db, span.span_id) + const rawToolName = attrs['gen_ai.tool.name'] as string | undefined + if (rawToolName) { + const existing = toolsByTrace.get(span.trace_id) ?? [] + existing.push(normalizeTool(rawToolName)) + toolsByTrace.set(span.trace_id, existing) + + // For shell tools, extract command names via the OTEL-specific + // normaliser (handles the full multi-line scripts the OTEL store + // records; see extractOtelBashCommands). + if (BASH_TOOL_NAMES.has(rawToolName)) { + const command = parseToolCommand(attrs['gen_ai.tool.call.arguments']) + if (command) { + const bash = bashByTrace.get(span.trace_id) ?? [] + bash.push(...extractOtelBashCommands(command)) + bashByTrace.set(span.trace_id, bash) + } + } + } + continue + } + + // Genuine subagent invocation: an invoke_agent span with a parent + // chat session. The root turn agent ('GitHub Copilot Chat') has no + // parent session and is skipped to avoid a bogus agents-view entry. + if (opName === 'invoke_agent') { + const attrs = loadSpanAttributesFromTable(db, span.span_id) + const parentSession = attrs['copilot_chat.parent_chat_session_id'] + const agentName = attrs['gen_ai.agent.name'] as string | undefined + if (parentSession && agentName) { + const subs = subagentsByTrace.get(span.trace_id) ?? [] + subs.push(agentName) + subagentsByTrace.set(span.trace_id, subs) + } + } + } + + // Yield one ParsedProviderCall per chat span + for (const spanId of chatSpanIds) { + const attrs = loadSpanAttributesFromTable(db, spanId) + + const spanMetadata = spanMetaById.get(spanId) + if (!spanMetadata) continue + + const model = + (attrs['gen_ai.response.model'] as string | undefined) ?? + (attrs['gen_ai.request.model'] as string | undefined) ?? + spanMetadata.response_model ?? + 'unknown' + + const inputTokens = Number(attrs['gen_ai.usage.input_tokens'] ?? 0) + const outputTokens = Number(attrs['gen_ai.usage.output_tokens'] ?? 0) + const cacheReadTokens = Number(attrs['gen_ai.usage.cache_read.input_tokens'] ?? 0) + const cacheCreationTokens = Number(attrs['gen_ai.usage.cache_creation.input_tokens'] ?? 0) + + if (inputTokens === 0 && outputTokens === 0) { + continue + } + + // Dedup key uses span_id which is globally unique + const dedupKey = `copilot-otel:${spanId}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + // Also add a JSONL-style dedupKey pattern so that if the same + // interaction appears in both OTel and JSONL, we don't double-count. + // We use the turn ID from Copilot attributes if available. + const turnId = attrs['github.copilot.chat.turn.id'] as string | undefined + if (turnId) { + const jsonlDedupKey = `copilot:${conversationId}:${turnId}` + seenKeys.add(jsonlDedupKey) + } + + const tools = toolsByTrace.get(spanMetadata.trace_id) ?? [] + const bashCommands = bashByTrace.get(spanMetadata.trace_id) ?? [] + const subagentTypes = subagentsByTrace.get(spanMetadata.trace_id) + const timestamp = epochToISO(spanMetadata.start_time_ms) + + // calculateCost with FULL token data — this is the key improvement. + const costUSD = calculateCost( + model, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + 0 // reasoningTokens — not exposed in current OTel schema + ) + + yield { + provider: 'copilot', + sessionId: conversationId, + project, + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + subagentTypes: subagentTypes && subagentTypes.length > 0 ? subagentTypes : undefined, + timestamp, + speed: 'standard' as const, + deduplicationKey: dedupKey, + userMessage: '', // Not available in OTel spans by default + } + } + } + } finally { + db.close() + } + }, + } +} + +// --------------------------------------------------------------------------- +// Extended SessionSource for OTel sessions +// --------------------------------------------------------------------------- + +interface OTelSessionSource extends SessionSource { + conversationId?: string + sourceType: 'otel' +} + +interface JsonlSessionSource extends SessionSource { + sourceType: 'jsonl' +} + +interface ChatSessionSource extends SessionSource { + sourceType: 'chatsession' +} + +interface JetBrainsSessionSource extends SessionSource { + sourceType: 'jetbrains' + // Fallback conversation id for turns whose own GUID can't be recovered (the + // on-disk store dir name). Normally each turn is grouped by its own tab GUID. + sessionId: string + // On-disk store directory name — the join key for the projectName lookup + // across sibling kind dirs (chat-sessions / chat-edit-sessions). + storeId: string + // Nitrite .db (copilot-*-nitrite.db) — the store's session content. + dbPath: string + // File mtime (ISO). The store has no reliable per-turn timestamp, so this + // places every turn on a day — without it, calls fall outside date ranges. + mtime: string + // Plugin-recorded project label (JetBrains Copilot 1.12+), resolved across + // all kind dirs by store id. The billable turns live in chat-agent-sessions, + // but the projectName field is usually written only into the sibling + // chat-sessions / chat-edit-sessions store, so discovery joins them by id. + // Undefined for older plugins that don't record it. + projectName?: string +} + +function isOtelSource(source: SessionSource): source is OTelSessionSource { + return (source as OTelSessionSource).sourceType === 'otel' +} + +function isChatSessionSource(source: SessionSource): source is ChatSessionSource { + return (source as ChatSessionSource).sourceType === 'chatsession' +} + +function isJetBrainsSource(source: SessionSource): source is JetBrainsSessionSource { + return (source as JetBrainsSessionSource).sourceType === 'jetbrains' +} + +// --------------------------------------------------------------------------- +// Session discovery: JSONL (original) +// --------------------------------------------------------------------------- + +async function discoverJsonlSessions( + sessionStateDir: string +): Promise<JsonlSessionSource[]> { + const sources: JsonlSessionSource[] = [] + + let sessionDirs: string[] + try { + sessionDirs = await readdir(sessionStateDir) + } catch { + return sources + } + + for (const sessionId of sessionDirs) { + const eventsPath = join(sessionStateDir, sessionId, 'events.jsonl') + const s = await stat(eventsPath).catch(() => null) + if (!s?.isFile()) continue + + let project = sessionId + try { + const yaml = await readSessionFile( + join(sessionStateDir, sessionId, 'workspace.yaml') + ) + const cwd = parseCwd(yaml ?? '') + if (cwd) project = basename(cwd) + } catch { + // workspace.yaml may not exist + } + + sources.push({ + path: eventsPath, + project, + provider: 'copilot', + sourceType: 'jsonl', + }) + } + + return sources +} + +// --------------------------------------------------------------------------- +// Session discovery: OTel SQLite +// --------------------------------------------------------------------------- + +async function discoverOtelSessions( + dbPath: string +): Promise<OTelSessionSource[]> { + // Verify the DB file exists. Return one source per DB file; the parser + // opens the DB once and iterates all conversations in a single DB open, + // which is far more efficient than one source (and one DB open) per conversation. + try { + await stat(dbPath) + } catch { + return [] + } + return [{ path: dbPath, project: 'copilot-chat', provider: 'copilot', sourceType: 'otel' }] +} + +// --------------------------------------------------------------------------- +// Session discovery: JetBrains (IntelliJ IDEA, PyCharm, …) +// --------------------------------------------------------------------------- + +// The three JetBrains Copilot session kinds (agent / ask / edit mode). Each +// store directory holds a Nitrite .db with that kind's session content. +const JETBRAINS_SESSION_KINDS = ['chat-agent-sessions', 'chat-sessions', 'chat-edit-sessions'] + +// Candidate Nitrite .db filenames per kind, plus a generic fallback. +const JETBRAINS_DB_NAMES: Record<string, string> = { + 'chat-agent-sessions': 'copilot-agent-sessions-nitrite.db', + 'chat-sessions': 'copilot-chat-nitrite.db', + 'chat-edit-sessions': 'copilot-edit-sessions-nitrite.db', +} + +/** Locate the Nitrite .db in a store dir (known name, else any *-nitrite.db). */ +async function findNitriteDbPath(storeDir: string, kind: string): Promise<string | null> { + const known = JETBRAINS_DB_NAMES[kind] + if (known) { + const p = join(storeDir, known) + if ((await stat(p).catch(() => null))?.isFile()) return p + } + let files: string[] + try { + files = await readdir(storeDir) + } catch { + return null + } + const db = files.find((f) => f.endsWith('-nitrite.db')) + return db ? join(storeDir, db) : null +} + +/** + * Discover JetBrains Copilot sessions under the github-copilot config root. + * + * Layout: <root>/<ide>/<kind>/<storeId>/copilot-*-nitrite.db + * <ide> — per-IDE dir (iu, intellij, PyCharm2025.2, …) + * <kind> — one of JETBRAINS_SESSION_KINDS + * + * Emits one source per store directory that has a Nitrite .db. The store + * records no token counts, so the parser estimates output tokens from the + * assistant reply text (see createJetBrainsParser). + */ +async function discoverJetBrainsSessions( + root: string +): Promise<JetBrainsSessionSource[]> { + const sources: JetBrainsSessionSource[] = [] + + let ideDirs: string[] + try { + ideDirs = await readdir(root) + } catch { + return sources + } + + for (const ide of ideDirs) { + for (const kind of JETBRAINS_SESSION_KINDS) { + const kindDir = join(root, ide, kind) + let storeDirs: string[] + try { + storeDirs = await readdir(kindDir) + } catch { + continue // this IDE doesn't have this session kind + } + + for (const storeId of storeDirs) { + const storeDir = join(kindDir, storeId) + const dbPath = await findNitriteDbPath(storeDir, kind) + if (!dbPath) continue + + const dbStat = await stat(dbPath).catch(() => null) + const mtime = (dbStat?.mtime ?? new Date(0)).toISOString() + + sources.push({ + path: dbPath, + project: 'copilot-jetbrains', + provider: 'copilot', + sourceType: 'jetbrains', + sessionId: storeId, + storeId, + dbPath, + mtime, + }) + } + } + } + + // Join projectName across kinds by store id. The plugin records the label on + // the session doc, which usually lands in the chat-sessions/chat-edit-sessions + // store — NOT the chat-agent-sessions store where the billable turns live. + // Without this join, every current agent session falls to the generic bucket + // even though its repo name is sitting one store dir over. + await resolveJetBrainsProjectNames(sources) + + return sources +} + +/** + * Populate each source's `projectName` from whichever store dir (of the same + * store id) actually recorded it. Reads each source's .db once; a store whose + * own .db lacks the field inherits it from a sibling-kind store with the same + * id. Best-effort — read/parse failures leave projectName undefined. + */ +async function resolveJetBrainsProjectNames( + sources: JetBrainsSessionSource[] +): Promise<void> { + const byStore = new Map<string, string>() + for (const src of sources) { + // Already found this store's name via a sibling-kind source — skip the read. + if (!src.dbPath || byStore.has(src.storeId)) continue + let raw: string | null = null + try { + raw = await readSessionFile(src.dbPath, 'latin1') + } catch { + raw = null + } + if (!raw) continue + const name = extractJetBrainsProjectName(raw) + if (name) byStore.set(src.storeId, name) + } + for (const src of sources) { + const name = byStore.get(src.storeId) + if (name) src.projectName = name + } +} + +// --------------------------------------------------------------------------- +// Provider factory +// --------------------------------------------------------------------------- + +/** + * Returns the VS Code workspaceStorage directories for all VS Code variants + * (Code, Code Insiders, VSCodium) on the given platform. Used to discover + * transcript sessions written by the Copilot Chat extension. + * + * Accepts explicit `home` and `os` arguments so callers (and tests) can pass + * custom values without relying on process-level globals. + */ +export function getVSCodeWorkspaceStorageDirs(home: string, os: string): string[] { + const j = os === 'win32' ? win32.join : posix.join + if (os === 'darwin') { + return [ + j(home, 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'), + j(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'workspaceStorage'), + j(home, 'Library', 'Application Support', 'VSCodium', 'User', 'workspaceStorage'), + ] + } + if (os === 'linux') { + return [ + j(home, '.config', 'Code', 'User', 'workspaceStorage'), + j(home, '.config', 'Code - Insiders', 'User', 'workspaceStorage'), + j(home, '.config', 'VSCodium', 'User', 'workspaceStorage'), + ] + } + // win32 + return [ + j(home, 'AppData', 'Roaming', 'Code', 'User', 'workspaceStorage'), + j(home, 'AppData', 'Roaming', 'Code - Insiders', 'User', 'workspaceStorage'), + j(home, 'AppData', 'Roaming', 'VSCodium', 'User', 'workspaceStorage'), + ] +} + +export function getVSCodeGlobalStorageDirs(home: string, os: string): string[] { + const j = os === 'win32' ? win32.join : posix.join + if (os === 'darwin') { + return [ + j(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage'), + j(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'globalStorage'), + j(home, 'Library', 'Application Support', 'VSCodium', 'User', 'globalStorage'), + ] + } + if (os === 'linux') { + return [ + j(home, '.config', 'Code', 'User', 'globalStorage'), + j(home, '.config', 'Code - Insiders', 'User', 'globalStorage'), + j(home, '.config', 'VSCodium', 'User', 'globalStorage'), + ] + } + return [ + j(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage'), + j(home, 'AppData', 'Roaming', 'Code - Insiders', 'User', 'globalStorage'), + j(home, 'AppData', 'Roaming', 'VSCodium', 'User', 'globalStorage'), + ] +} + +async function resolveWorkspaceProject(wsDir: string, hashDir: string): Promise<string> { + let project = hashDir + try { + const wsJson = await readSessionFile(join(wsDir, hashDir, 'workspace.json')) + if (wsJson) { + const data = JSON.parse(wsJson) as { folder?: string } + if (typeof data.folder === 'string') { + // folder is a URI like 'file:///home/user/myapp' or 'file:///C:/Users/...' + const folder = data.folder.replace(/^file:\/\//, '').replace(/\/+$/, '') + const name = basename(folder) + if (name) project = name + } + } + } catch { + // workspace.json may be absent or malformed + } + return project +} + +async function hasChatSessionFiles(chatSessionsDir: string): Promise<boolean> { + let files: string[] + try { + files = await readdir(chatSessionsDir) + } catch { + return false + } + + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + const s = await stat(join(chatSessionsDir, file)).catch(() => null) + if (s?.isFile()) return true + } + return false +} + +// --------------------------------------------------------------------------- +// Session discovery: VS Code core chatSessions +// --------------------------------------------------------------------------- + +async function discoverWorkspaceChatSessions( + workspaceStorageDirs: string[] +): Promise<ChatSessionSource[]> { + const sources: ChatSessionSource[] = [] + + for (const wsDir of workspaceStorageDirs) { + let hashDirs: string[] + try { + hashDirs = await readdir(wsDir) + } catch { + continue + } + + for (const hashDir of hashDirs) { + const chatSessionsDir = join(wsDir, hashDir, 'chatSessions') + let files: string[] + try { + files = await readdir(chatSessionsDir) + } catch { + continue + } + + const project = await resolveWorkspaceProject(wsDir, hashDir) + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + const path = join(chatSessionsDir, file) + const s = await stat(path).catch(() => null) + if (!s?.isFile()) continue + sources.push({ + path, + project, + provider: 'copilot', + sourceType: 'chatsession', + }) + } + } + } + + return sources +} + +async function discoverEmptyWindowChatSessions( + globalStorageDirs: string[] +): Promise<ChatSessionSource[]> { + const sources: ChatSessionSource[] = [] + + for (const globalDir of globalStorageDirs) { + const chatSessionsDir = join(globalDir, 'emptyWindowChatSessions') + let files: string[] + try { + files = await readdir(chatSessionsDir) + } catch { + continue + } + + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + const path = join(chatSessionsDir, file) + const s = await stat(path).catch(() => null) + if (!s?.isFile()) continue + sources.push({ + path, + project: 'copilot-chat', + provider: 'copilot', + sourceType: 'chatsession', + }) + } + } + + return sources +} + +// --------------------------------------------------------------------------- +// Session discovery: VS Code workspace transcripts +// --------------------------------------------------------------------------- + +/** + * Discover Copilot Chat transcript sessions stored in VS Code workspaceStorage. + * Structure: {wsDir}/{hash}/GitHub.copilot-chat/transcripts/{session}.jsonl + * Project is read from {wsDir}/{hash}/workspace.json (folder URI). + */ +async function discoverTranscriptSessions( + workspaceStorageDirs: string[] +): Promise<JsonlSessionSource[]> { + const sources: JsonlSessionSource[] = [] + + for (const wsDir of workspaceStorageDirs) { + let hashDirs: string[] + try { + hashDirs = await readdir(wsDir) + } catch { + continue + } + + for (const hashDir of hashDirs) { + const chatSessionsDir = join(wsDir, hashDir, 'chatSessions') + if (await hasChatSessionFiles(chatSessionsDir)) continue + + const transcriptsDir = join(wsDir, hashDir, 'GitHub.copilot-chat', 'transcripts') + const project = await resolveWorkspaceProject(wsDir, hashDir) + + let transcriptFiles: string[] + try { + transcriptFiles = await readdir(transcriptsDir) + } catch { + continue + } + + for (const file of transcriptFiles) { + if (!file.endsWith('.jsonl')) continue + const s = await stat(join(transcriptsDir, file)).catch(() => null) + if (!s?.isFile()) continue + sources.push({ + path: join(transcriptsDir, file), + project, + provider: 'copilot', + sourceType: 'jsonl', + }) + } + } + } + + return sources +} + +export function createCopilotProvider( + sessionStateDir?: string, + workspaceStorageDir?: string, + globalStorageDir?: string, + jetbrainsDir?: string +): Provider { + // jsonlDir is resolved lazily inside discoverSessions so that env-var + // overrides set after module load (e.g. in tests) are respected. + + /** + * Returns the workspaceStorage directories to scan for transcript sessions. + * When workspaceStorageDir is explicitly provided (e.g. in tests), that single + * directory is used. The CODEBURN_COPILOT_WS_STORAGE_DIR env var provides a + * single-dir override (useful for tests). Otherwise all platform-default VS + * Code variant paths are returned. + */ + function getWsDirs(): string[] { + if (workspaceStorageDir !== undefined) return [workspaceStorageDir] + const envDir = process.env['CODEBURN_COPILOT_WS_STORAGE_DIR'] + if (envDir) return [envDir] + return getVSCodeWorkspaceStorageDirs(homedir(), platform()) + } + + function getGlobalDirs(): string[] { + if (globalStorageDir !== undefined) return [globalStorageDir] + const envDir = process.env['CODEBURN_COPILOT_GLOBAL_STORAGE_DIR'] + if (envDir) return [envDir] + return getVSCodeGlobalStorageDirs(homedir(), platform()) + } + + return { + name: 'copilot', + displayName: 'Copilot', + durableSources: true, + + modelDisplayName(model: string): string { + for (const [key, display] of modelDisplayEntries) { + if (model.includes(key)) return display + } + return model + }, + + toolDisplayName(rawTool: string): string { + return normalizeTool(rawTool) + }, + + async discoverSessions(): Promise<SessionSource[]> { + const sources: SessionSource[] = [] + let discoveredOtel = false + + // 1. Discover OTel sessions (preferred — full token data) + const disableOtel = process.env['CODEBURN_COPILOT_DISABLE_OTEL'] === '1' + if (!disableOtel) { + const dbPath = getAgentTracesDbPath() + if (dbPath) { + try { + const otelSources = await discoverOtelSessions(dbPath) + discoveredOtel = otelSources.length > 0 + sources.push(...otelSources) + } catch { + // OTel discovery failed — fall through to JSONL + } + } + } + + // 2. Discover JSONL sessions (fallback — output tokens only) + try { + const jsonlDir = getCopilotSessionStateDir(sessionStateDir) + const jsonlSources = await discoverJsonlSessions(jsonlDir) + sources.push(...jsonlSources) + } catch { + // JSONL discovery failed + } + + // Prefer OTel over chatSessions: they can mirror the same turns under + // incompatible IDs, and OTel carries richer token/cache data. + if (!discoveredOtel) { + // 3. Discover VS Code core chatSessions journals + try { + const chatSessionSources = await discoverWorkspaceChatSessions(getWsDirs()) + sources.push(...chatSessionSources) + } catch { + // Workspace chatSessions discovery failed + } + + // 4. Discover VS Code empty-window chatSessions journals + try { + const emptyWindowSources = await discoverEmptyWindowChatSessions(getGlobalDirs()) + sources.push(...emptyWindowSources) + } catch { + // Empty-window chatSessions discovery failed + } + } + + // 5. Discover VS Code workspace transcript sessions + try { + const transcriptSources = await discoverTranscriptSessions(getWsDirs()) + sources.push(...transcriptSources) + } catch { + // Transcript discovery failed + } + + // 6. Discover JetBrains IDE sessions (IntelliJ, PyCharm, …). These live + // in a store none of the VS Code / CLI sources touch, so there is no + // overlap to dedupe against; the shared seenKeys set still guards it. + try { + const jetbrainsSources = await discoverJetBrainsSessions( + getJetBrainsCopilotRoot(jetbrainsDir) + ) + sources.push(...jetbrainsSources) + } catch { + // JetBrains discovery failed + } + + return sources + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set<string> + ): SessionParser { + // Route to the correct parser based on source type. + // The dedup key set (seenKeys) is shared across both parsers, + // so if OTel already yielded a span, the JSONL parser will skip + // the matching assistant.message (and vice versa). + if (isOtelSource(source)) { + return createOtelParser(source, seenKeys) + } + if (isChatSessionSource(source)) { + return createChatSessionParser(source, seenKeys) + } + if (isJetBrainsSource(source)) { + return createJetBrainsParser(source, seenKeys) + } + return createJsonlParser(source, seenKeys) + }, + } +} + +// Default export for the provider registry +export const copilot = createCopilotProvider() diff --git a/src/providers/crush.ts b/src/providers/crush.ts new file mode 100644 index 0000000..5661d82 --- /dev/null +++ b/src/providers/crush.ts @@ -0,0 +1,258 @@ +import { readFile } from 'fs/promises' +import { join, resolve } from 'path' +import { homedir, platform } 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' + +/// Crush stores per-project SQLite databases discovered through a JSON registry. +/// We only read both. Schema source: charmbracelet/crush +/// internal/db/migrations/20250424200609_initial.sql, verified against v0.66.1. +/// The schema *comments* in that file claim millisecond timestamps, but every +/// INSERT/UPDATE in internal/db/sql/{sessions,messages}.sql uses +/// strftime('%s', 'now') which returns Unix seconds. We treat values as seconds. + +type ProjectEntry = { + path: string + data_dir: string +} + +type SessionRow = { + id: string + prompt_tokens: number | null + completion_tokens: number | null + cost: number | null + created_at: number | null + updated_at: number | null + message_count: number | null +} + +function getRegistryPath(): string { + const explicit = process.env['CRUSH_GLOBAL_DATA'] + if (explicit) return join(explicit, 'projects.json') + + if (platform() === 'win32') { + const localAppData = process.env['LOCALAPPDATA'] ?? join(homedir(), 'AppData', 'Local') + return join(localAppData, 'crush', 'projects.json') + } + + const xdg = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share') + return join(xdg, 'crush', 'projects.json') +} + +async function loadRegistry(path: string): Promise<ProjectEntry[]> { + let raw: string + try { + raw = await readFile(path, 'utf-8') + } catch { + return [] + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + // Crush writes projects.json as an object keyed by project id. Older builds + // (and tokscale's sample fixtures) emit an array. Accept both shapes. + let entries: unknown[] + if (Array.isArray(parsed)) { + entries = parsed + } else if (parsed && typeof parsed === 'object') { + entries = Object.values(parsed) + } else { + return [] + } + const out: ProjectEntry[] = [] + for (const e of entries) { + if (!e || typeof e !== 'object') continue + const obj = e as Record<string, unknown> + if (typeof obj['path'] !== 'string' || typeof obj['data_dir'] !== 'string') continue + out.push({ path: obj['path'], data_dir: obj['data_dir'] }) + } + return out +} + +function resolveDbPath(entry: ProjectEntry): string { + // data_dir defaults to ".crush" relative to the project path. Absolute paths + // are honored if a user has overridden the layout. + return join(resolve(entry.path, entry.data_dir), 'crush.db') +} + +function sanitizeProject(path: string): string { + return path.replace(/^\//, '').replace(/\//g, '-') +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM sessions LIMIT 1') + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM messages LIMIT 1') + return true + } catch { + return false + } +} + +function epochSecondsToIso(epochSeconds: number | null): string { + if (epochSeconds === null || !Number.isFinite(epochSeconds)) { + return new Date(0).toISOString() + } + return new Date(epochSeconds * 1000).toISOString() +} + +function dominantModel(db: SqliteDatabase, sessionId: string): string { + try { + const rows = db.query<{ model: string | null }>( + `SELECT model FROM messages + WHERE session_id = ? AND model IS NOT NULL AND model <> '' + GROUP BY model + ORDER BY COUNT(*) DESC + LIMIT 1`, + [sessionId], + ) + if (rows.length === 0) return 'unknown' + return rows[0]!.model ?? 'unknown' + } catch { + return 'unknown' + } +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + // Source paths are encoded as `<dbPath>:<sessionId>`. Split from the + // right because dbPath may contain a colon on Windows (drive letter). + 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 Crush database: ${err instanceof Error ? err.message : err}\n`, + ) + return + } + + try { + if (!validateSchema(db)) return + + const rows = db.query<SessionRow>( + `SELECT id, prompt_tokens, completion_tokens, cost, created_at, updated_at, message_count + FROM sessions + WHERE id = ? AND parent_session_id IS NULL`, + [sessionId], + ) + if (rows.length === 0) return + const session = rows[0]! + + const inputTokens = session.prompt_tokens ?? 0 + const outputTokens = session.completion_tokens ?? 0 + const cost = session.cost ?? 0 + if (inputTokens === 0 && outputTokens === 0 && cost === 0) return + + const dedupKey = `crush:${sessionId}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + const model = dominantModel(db, sessionId) + // Crush already records cost in dollars; trust it. Fall back to + // pricing-table calculation only when the row is missing a cost. + const costUSD = cost > 0 + ? cost + : calculateCost(model, inputTokens, outputTokens, 0, 0, 0) + + yield { + provider: 'crush', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp: epochSecondsToIso(session.updated_at ?? session.created_at), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId, + } + } finally { + db.close() + } + }, + } +} + +async function discoverFromDb(dbPath: string, project: string): Promise<SessionSource[]> { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + try { + if (!validateSchema(db)) return [] + const rows = db.query<{ id: string }>( + `SELECT id FROM sessions + WHERE parent_session_id IS NULL + AND (cost > 0 OR prompt_tokens > 0 OR completion_tokens > 0) + ORDER BY created_at DESC`, + ) + return rows.map(row => ({ + path: `${dbPath}:${row.id}`, + project, + provider: 'crush', + })) + } catch { + return [] + } finally { + db.close() + } +} + +export function createCrushProvider(): Provider { + return { + name: 'crush', + displayName: 'Crush', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + if (!isSqliteAvailable()) return [] + const registry = await loadRegistry(getRegistryPath()) + const sources: SessionSource[] = [] + for (const entry of registry) { + const dbPath = resolveDbPath(entry) + const project = sanitizeProject(entry.path) + const found = await discoverFromDb(dbPath, project) + sources.push(...found) + } + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const crush = createCrushProvider() diff --git a/src/providers/cursor-agent.ts b/src/providers/cursor-agent.ts new file mode 100644 index 0000000..fbc0bfc --- /dev/null +++ b/src/providers/cursor-agent.ts @@ -0,0 +1,546 @@ +import { createHash } from 'crypto' +import { existsSync } from 'fs' +import { readdir, readFile, stat } from 'fs/promises' +import { join, basename } from 'path' +import { homedir } from 'os' + +import { calculateCost } from '../models.js' +import { openDatabase, type SqliteDatabase } from '../sqlite.js' +import { normalizeContentBlocks } from '../content-utils.js' +import type { + Provider, + SessionSource, + SessionParser, + ParsedProviderCall, +} from './types.js' + +type ConversationSummary = { + conversationId: string + model: string | null + title: string | null + updatedAt: string | null +} + +type AssistantTurn = { + body: string + reasoning: string + tools: string[] +} + +type ParsedTurn = { + userMessage: string + assistant: AssistantTurn +} + +const CURSOR_AGENT_COST_MODEL = 'claude-sonnet-4-5' +const CHARS_PER_TOKEN = 4 +const MAX_USER_TEXT_LENGTH = 500 +const DIGITS_ONLY = /^\d+$/ +const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const USER_MARKER = /^\s*user:\s*/i +const ASSISTANT_MARKER = /^\s*A:\s*/ +const THINKING_MARKER = /^\s*\[Thinking\]\s*/ +const TOOL_CALL_MARKER = /^\s*\[Tool call\]\s*(.+?)\s*$/i +const TOOL_RESULT_MARKER = /^\s*\[Tool result\]\b/i +const USER_QUERY_OPEN = '<user_query>' +const USER_QUERY_CLOSE = '</user_query>' +const warnedUnrecognizedTranscripts = new Set<string>() +const CONVERSATION_SUMMARY_QUERY = ` + SELECT conversationId, model, title, updatedAt + FROM conversation_summaries + WHERE conversationId = ? +` + +const modelDisplayNames: Record<string, string> = { + 'claude-4.5-opus-high-thinking': 'Opus 4.5 (Thinking)', + 'claude-4-opus': 'Opus 4', + 'claude-4-sonnet-thinking': 'Sonnet 4 (Thinking)', + 'claude-4.5-sonnet-thinking': 'Sonnet 4.5 (Thinking)', + 'claude-4.6-sonnet': 'Sonnet 4.6', + 'composer-1': 'Composer 1', + 'grok-code-fast-1': 'Grok Code Fast', + 'gemini-3-pro': 'Gemini 3 Pro', + 'gpt-5.1-codex-high': 'GPT-5.1 Codex', + 'gpt-5': 'GPT-5', + 'gpt-4.1': 'GPT-4.1', + default: 'Auto (Sonnet est.)', +} + +function getCursorAgentBaseDir(baseDirOverride?: string): string { + if (baseDirOverride) return baseDirOverride + // Windows paths unverified; tracked as Open Question 3 in issue #55. + return join(homedir(), '.cursor') +} + +function getProjectsDir(baseDir: string): string { + return join(baseDir, 'projects') +} + +function getAttributionDbPath(baseDir: string): string { + return join(baseDir, 'ai-tracking', 'ai-code-tracking.db') +} + +function estimateTokens(charCount: number): number { + if (charCount <= 0) return 0 + return Math.ceil(charCount / CHARS_PER_TOKEN) +} + +function parseToolName(raw: string): string { + const clean = raw.trim() + if (clean.length === 0) return 'unknown' + return clean.toLowerCase().replace(/\s+/g, '-') +} + +function normalizeTimestamp(raw: string | number | null | undefined): string | null { + if (raw === null || raw === undefined) return null + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (trimmed.length === 0) return null + if (DIGITS_ONLY.test(trimmed)) { + const num = Number(trimmed) + if (!Number.isNaN(num)) { + const ms = num < 1e12 ? num * 1000 : num + return new Date(ms).toISOString() + } + } + const parsed = new Date(trimmed) + if (!Number.isNaN(parsed.getTime())) return parsed.toISOString() + return null + } + + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +function prettifyProjectId(raw: string): string { + if (!raw) return raw + + if (DIGITS_ONLY.test(raw)) { + const num = Number(raw) + if (!Number.isNaN(num) && raw.length >= 13) { + const iso = new Date(num).toISOString() + return `cursor-agent:${iso}` + } + } + + const withoutPrefix = raw.replace(/^-Users-/, '') + const parts = withoutPrefix.split('-').filter(Boolean) + if (parts.length > 0) return parts[parts.length - 1]! + + return raw +} + +function resolveModel(raw: string | null | undefined): string { + if (!raw || raw === 'default') return 'cursor-agent-auto' + return raw +} + +function costModel(model: string): string { + return model === 'cursor-agent-auto' ? CURSOR_AGENT_COST_MODEL : model +} + +function transcriptStem(transcriptPath: string): string { + const name = basename(transcriptPath) + if (name.endsWith('.jsonl')) return name.slice(0, -'.jsonl'.length) + if (name.endsWith('.txt')) return name.slice(0, -'.txt'.length) + return name +} + +function toConversationId(transcriptPath: string): string { + const filename = transcriptStem(transcriptPath) + if (filename.length === 36 && UUID_LIKE.test(filename)) return filename + return createHash('sha1').update(transcriptPath).digest('hex').slice(0, 16) +} + +async function appendTranscriptSources( + scanDir: string, + projectId: string, + sources: SessionSource[], +): Promise<void> { + const transcriptEntries = await readdir(scanDir, { withFileTypes: true }) + for (const transcript of transcriptEntries) { + // Legacy format: .txt files directly in the scan dir + if (transcript.isFile() && transcript.name.endsWith('.txt')) { + sources.push({ + path: join(scanDir, transcript.name), + project: projectId, + provider: 'cursor-agent', + }) + continue + } + + // Composer 2 format: UUID subdirectories with .jsonl files + if (transcript.isDirectory() && UUID_LIKE.test(transcript.name)) { + const subdir = join(scanDir, transcript.name) + const subEntries = await readdir(subdir, { withFileTypes: true }).catch(() => []) + const transcriptFilesByStem = new Map<string, { jsonl?: string; txt?: string }>() + + for (const sub of subEntries) { + if (sub.isFile() && (sub.name.endsWith('.jsonl') || sub.name.endsWith('.txt'))) { + const stem = transcriptStem(sub.name) + const existing = transcriptFilesByStem.get(stem) ?? {} + if (sub.name.endsWith('.jsonl')) { + transcriptFilesByStem.set(stem, { ...existing, jsonl: sub.name }) + } else { + transcriptFilesByStem.set(stem, { ...existing, txt: sub.name }) + } + continue + } + + // Subagent transcripts inside a subagents/ directory + if (sub.isDirectory() && sub.name === 'subagents') { + const subagentEntries = await readdir(join(subdir, sub.name), { withFileTypes: true }).catch(() => []) + for (const sa of subagentEntries) { + if (!sa.isFile()) continue + if (!sa.name.endsWith('.jsonl') && !sa.name.endsWith('.txt')) continue + sources.push({ + path: join(subdir, sub.name, sa.name), + project: projectId, + provider: 'cursor-agent', + }) + } + } + } + + for (const files of transcriptFilesByStem.values()) { + const selectedName = files.jsonl ?? files.txt + if (selectedName) { + sources.push({ + path: join(subdir, selectedName), + project: projectId, + provider: 'cursor-agent', + }) + } + } + } + } +} + +function extractUserQuery(userBlock: string): string { + const chunks: string[] = [] + let cursor = 0 + + while (cursor < userBlock.length) { + const openIndex = userBlock.indexOf(USER_QUERY_OPEN, cursor) + if (openIndex === -1) break + const start = openIndex + USER_QUERY_OPEN.length + const closeIndex = userBlock.indexOf(USER_QUERY_CLOSE, start) + if (closeIndex === -1) { + chunks.push(userBlock.slice(start).trim()) + break + } + chunks.push(userBlock.slice(start, closeIndex).trim()) + cursor = closeIndex + USER_QUERY_CLOSE.length + } + + const combined = chunks.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() + return combined.slice(0, MAX_USER_TEXT_LENGTH) +} + +function parseJsonlTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolean } { + const lines = raw.split(/\r?\n/).filter(l => l.trim()) + if (lines.length === 0) return { turns: [], recognized: false } + + const turns: ParsedTurn[] = [] + let currentUserMessage = '' + + for (const line of lines) { + let entry: { role?: string; message?: { content?: Array<{ type?: string; text?: string; name?: string }> } } + try { + entry = JSON.parse(line) + } catch { + continue + } + + if (entry.role === 'user') { + const texts = normalizeContentBlocks(entry.message?.content) + .filter(c => c.type === 'text') + .map(c => c.text ?? '') + const combined = texts.join(' ') + currentUserMessage = extractUserQuery(combined) || combined.slice(0, MAX_USER_TEXT_LENGTH) + continue + } + + if (entry.role === 'assistant' && currentUserMessage) { + const content = normalizeContentBlocks(entry.message?.content) + const bodyParts: string[] = [] + const tools: string[] = [] + + for (const block of content) { + if (block.type === 'text' && block.text) { + bodyParts.push(block.text) + } else if (block.type === 'tool_use' && block.name) { + tools.push(`cursor:${block.name.toLowerCase()}`) + } + } + + turns.push({ + userMessage: currentUserMessage, + assistant: { + body: bodyParts.join('\n').trim(), + reasoning: '', + tools, + }, + }) + currentUserMessage = '' + } + } + + return { turns, recognized: turns.length > 0 } +} + +function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolean } { + const lines = raw.split(/\r?\n/) + let recognized = false + + const pendingUsers: string[] = [] + const turns: ParsedTurn[] = [] + + let active: 'none' | 'user' | 'assistant' = 'none' + let userLines: string[] = [] + let assistantLines: string[] = [] + + const flushUser = () => { + if (userLines.length === 0) return + const userQuery = extractUserQuery(userLines.join('\n')) + if (userQuery.length > 0) pendingUsers.push(userQuery) + userLines = [] + } + + const flushAssistant = () => { + if (assistantLines.length === 0) return + + let output = '' + let reasoning = '' + const toolsByTurn = new Map<string, true>() + + for (const line of assistantLines) { + if (TOOL_RESULT_MARKER.test(line)) continue + + const thinkingMatch = line.match(THINKING_MARKER) + if (thinkingMatch) { + const body = line.replace(THINKING_MARKER, '').trim() + if (body.length > 0) reasoning += `${body}\n` + continue + } + + const toolMatch = line.match(TOOL_CALL_MARKER) + if (toolMatch) { + const parsedTool = parseToolName(toolMatch[1] ?? '') + const toolKey = `cursor:${parsedTool}` + toolsByTurn.set(toolKey, true) + continue + } + + output += `${line}\n` + } + + if (pendingUsers.length > 0) { + const userMessage = pendingUsers.shift()! + const tools = Array.from(toolsByTurn.keys()) + turns.push({ + userMessage, + assistant: { + body: output.trim(), + reasoning: reasoning.trim(), + tools, + }, + }) + } + + assistantLines = [] + } + + for (const line of lines) { + if (USER_MARKER.test(line)) { + recognized = true + if (active === 'user') flushUser() + if (active === 'assistant') flushAssistant() + active = 'user' + userLines = [line.replace(USER_MARKER, '')] + continue + } + + if (ASSISTANT_MARKER.test(line)) { + recognized = true + if (active === 'user') flushUser() + if (active === 'assistant') flushAssistant() + active = 'assistant' + assistantLines = [line.replace(ASSISTANT_MARKER, '')] + continue + } + + if (active === 'user') { + userLines.push(line) + continue + } + + if (active === 'assistant') { + assistantLines.push(line) + } + } + + if (active === 'user') flushUser() + if (active === 'assistant') flushAssistant() + + return { turns, recognized } +} + +function createParser( + source: SessionSource, + seenKeys: Set<string>, + dbPath: string, + summariesByConversationId: Map<string, ConversationSummary>, +): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const conversationId = toConversationId(source.path) + + let summary = summariesByConversationId.get(conversationId) + let db: SqliteDatabase | null = null + + try { + if (!summary) { + if (existsSync(dbPath)) { + try { + db = openDatabase(dbPath) + const rows = db.query<{ + conversationId: string + model: string | null + title: string | null + updatedAt: string | number | null + }>(CONVERSATION_SUMMARY_QUERY, [conversationId]) + + if (rows.length > 0) { + const row = rows[0]! + summary = { + conversationId: row.conversationId, + model: row.model, + title: row.title, + updatedAt: normalizeTimestamp(row.updatedAt), + } + summariesByConversationId.set(conversationId, summary) + } + } catch { + summary = undefined + } + } + } + + const transcript = await readFile(source.path, 'utf-8') + const isJsonl = source.path.endsWith('.jsonl') + const parsed = isJsonl ? parseJsonlTranscript(transcript) : parseTranscript(transcript) + + if (!parsed.recognized) { + if (!warnedUnrecognizedTranscripts.has(source.path)) { + warnedUnrecognizedTranscripts.add(source.path) + process.stderr.write(`codeburn: skipped ${basename(source.path)}: unrecognized cursor-agent transcript format\n`) + } + return + } + + let timestamp = summary?.updatedAt ?? null + if (!timestamp) { + const fileStat = await stat(source.path) + timestamp = fileStat.mtime.toISOString() + } + + const model = resolveModel(summary?.model ?? null) + + for (let turnIndex = 0; turnIndex < parsed.turns.length; turnIndex++) { + const turn = parsed.turns[turnIndex]! + const inputTokens = estimateTokens(turn.userMessage.length) + const outputTokens = estimateTokens(turn.assistant.body.length) + const reasoningTokens = estimateTokens(turn.assistant.reasoning.length) + const deduplicationKey = `cursor-agent:${conversationId}:${turnIndex}` + + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + + const costUSD = calculateCost( + costModel(model), + inputTokens, + outputTokens + reasoningTokens, + 0, + 0, + 0, + ) + + yield { + provider: 'cursor-agent', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens, + webSearchRequests: 0, + costUSD, + tools: turn.assistant.tools, + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey, + userMessage: turn.userMessage, + sessionId: conversationId, + } + } + } finally { + db?.close() + } + }, + } +} + +export function createCursorAgentProvider(baseDirOverride?: string): Provider { + const baseDir = getCursorAgentBaseDir(baseDirOverride) + const projectsDir = getProjectsDir(baseDir) + const dbPath = getAttributionDbPath(baseDir) + const summariesByConversationId = new Map<string, ConversationSummary>() + + return { + name: 'cursor-agent', + displayName: 'Cursor Agent', + + modelDisplayName(model: string): string { + if (model === 'cursor-agent-auto') return 'Cursor (auto)' + const label = modelDisplayNames[model] ?? model + return `${label} (est.)` + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + if (!existsSync(projectsDir)) return [] + + const projectEntries = await readdir(projectsDir, { withFileTypes: true }) + const sources: SessionSource[] = [] + + for (const entry of projectEntries) { + if (!entry.isDirectory()) continue + + const projectId = prettifyProjectId(entry.name) + const projectDir = join(projectsDir, entry.name) + if (entry.name === 'agent-transcripts') { + await appendTranscriptSources(projectDir, projectId, sources) + continue + } + + const transcriptDir = join(projectDir, 'agent-transcripts') + if (!existsSync(transcriptDir)) continue + await appendTranscriptSources(transcriptDir, projectId, sources) + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys, dbPath, summariesByConversationId) + }, + } +} + +export const cursor_agent = createCursorAgentProvider() diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts new file mode 100644 index 0000000..63ec7f2 --- /dev/null +++ b/src/providers/cursor.ts @@ -0,0 +1,1084 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' + +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import { readCachedResults, writeCachedResults } from '../cursor-cache.js' +import { isSqliteAvailable, isSqliteBusyError, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' +import type { DateRange } from '../types.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +/** Matches cli-date.ts "all" period cap (6 months). */ +const CURSOR_MAX_LOOKBACK_MONTHS = 6 + +export function getCursorTimeFloor(dateRange?: DateRange): string { + const now = new Date() + const maxStart = new Date( + now.getFullYear(), + now.getMonth() - CURSOR_MAX_LOOKBACK_MONTHS, + now.getDate(), + ) + const start = dateRange?.start ?? maxStart + const effective = start < maxStart ? maxStart : start + return effective.toISOString() +} + +const CURSOR_COST_MODEL = 'claude-sonnet-4-5' + +const modelDisplayNames: Record<string, string> = { + 'claude-4.5-opus-high-thinking': 'Opus 4.5 (Thinking)', + 'claude-4-opus': 'Opus 4', + 'claude-4-sonnet-thinking': 'Sonnet 4 (Thinking)', + 'claude-4.5-sonnet-thinking': 'Sonnet 4.5 (Thinking)', + 'claude-4.6-sonnet': 'Sonnet 4.6', + 'composer-1': 'Composer 1', + 'grok-code-fast-1': 'Grok Code Fast', + 'gemini-3-pro': 'Gemini 3 Pro', + 'gpt-5.2-low': 'GPT-5.2 Low', + 'gpt-5.2': 'GPT-5.2', + 'gpt-5.1-codex-high': 'GPT-5.1 Codex', + 'gpt-5': 'GPT-5', + 'gpt-4.1': 'GPT-4.1', + 'cursor-auto': 'Cursor (auto)', +} + +type BubbleRow = { + bubble_key: string + input_tokens: number | null + output_tokens: number | null + model: string | null + created_at: string | null + request_id: string | null + user_text: Uint8Array | string | null + 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 = { + role: string | null + content: Uint8Array | string | null + request_id: string | null + model: string | null +} + +// SQLITE_BUSY must reach parser.ts, whose busy path skips the source without +// caching; swallowing it here would stamp a silently degraded parse into the +// results cache under an unchanged DB fingerprint (Cursor writes via WAL, so +// contention does not change the main file's stat). +function rethrowBusy(err: unknown): void { + if (isSqliteBusyError(err)) throw err +} + +const CHARS_PER_TOKEN = 4 + +function getCursorDbPath(): string { + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb') + } + if (process.platform === 'win32') { + return join(homedir(), 'AppData', 'Roaming', 'Cursor', 'User', 'globalStorage', 'state.vscdb') + } + return join(homedir(), '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb') +} + +function getCursorWorkspaceStorageDir(globalDbPath: string): string { + // Sibling of globalStorage. Cursor lays out User/{globalStorage,workspaceStorage}/. + // We derive the workspaceStorage path from the global DB path so a test or + // override can supply both consistently from one root. + // globalDbPath = .../User/globalStorage/state.vscdb + // workspaceStorage = .../User/workspaceStorage + const userDir = join(globalDbPath, '..', '..') + return join(userDir, 'workspaceStorage') +} + +/// Per-conversation workspace lookup table. Cursor stores each chat as +/// `bubbleId:<composerId>:<bubbleUuid>` rows in the GLOBAL state.vscdb but +/// does NOT carry a workspace path on the bubble itself. The mapping lives +/// in per-workspace dirs at `workspaceStorage/<hash>/`: +/// - `workspace.json` carries the folder URI (`file:///Users/me/proj`) +/// - `state.vscdb`'s `ItemTable['composer.composerData']` lists every +/// composerId opened in that workspace +/// We walk every workspace dir, pull both, and build composerId -> folder. +type WorkspaceMapping = { + composerToWorkspace: Map<string, string> // composerId -> folder URI + workspaceProjectName: Map<string, string> // folder URI -> sanitized project name +} + +const ORPHAN_TAG = '__orphan__' +// Catch-all project label for composers that did not register against any +// workspace. When the user has no workspaces at all this is the only label +// shown, matching the pre-PR `cursor` project so legacy installs are not +// renamed by the breakdown change. +const ORPHAN_PROJECT = 'cursor' + +function sanitizeWorkspaceUri(uri: string): string { + // Mirrors Claude's slug convention so two providers reporting the same + // project path produce identical project keys for cross-provider rollup. + // file:///Users/me/myproject → -Users-me-myproject + // vscode-remote://wsl+Ubuntu/home/me/proj → -wsl-Ubuntu-home-me-proj + let path: string + if (uri.startsWith('file://')) { + path = uri.slice('file://'.length) + } else { + // Other URI schemes (vscode-remote://, ssh+remote://, etc.): swap "://" + // for a leading "/" so the slugifier produces a predictable shape. + path = uri.replace(/^[^:]+:\/\//, '/').replace(/\+/g, '-') + } + try { + path = decodeURIComponent(path) + } catch { + // Malformed percent encoding — keep as-is rather than throw. + } + return path.replace(/\/+/g, '-') +} + +let workspaceMapCache: WorkspaceMapping | null = null +let workspaceMapCacheRoot: string | null = null + +/// Visible for tests so a fixture can rebuild the map after writing fresh +/// workspace directories. +export function clearCursorWorkspaceMapCache(): void { + workspaceMapCache = null + workspaceMapCacheRoot = null +} + +function loadWorkspaceMap(workspaceStorageDir: string): WorkspaceMapping { + if (workspaceMapCache && workspaceMapCacheRoot === workspaceStorageDir) { + return workspaceMapCache + } + const result: WorkspaceMapping = { + composerToWorkspace: new Map(), + workspaceProjectName: new Map(), + } + + let entries: string[] + try { + entries = readdirSync(workspaceStorageDir) + } catch { + workspaceMapCache = result + workspaceMapCacheRoot = workspaceStorageDir + return result + } + + for (const hashDir of entries) { + const wsJsonPath = join(workspaceStorageDir, hashDir, 'workspace.json') + const wsDbPath = join(workspaceStorageDir, hashDir, 'state.vscdb') + + let wsJsonRaw: string + try { + wsJsonRaw = readFileSync(wsJsonPath, 'utf-8') + } catch { + continue + } + + let folder: string | undefined + try { + const parsed = JSON.parse(wsJsonRaw) as { folder?: string } + folder = parsed.folder + } catch { + continue + } + if (!folder) continue + if (!existsSync(wsDbPath)) continue + + let db: SqliteDatabase + try { + db = openDatabase(wsDbPath) + } catch { + continue + } + try { + // Cursor renamed the per-workspace composer list from + // 'composer.composerData' to 'composer.composerHeaders' in newer builds + // (identical { allComposers: [{ composerId }] } shape). Read both keys + // and merge so the composer -> workspace mapping keeps working across + // Cursor versions. Without this, on builds that only write + // 'composer.composerHeaders' every composer falls through to the + // 'cursor' orphan bucket and per-project attribution is lost. + const rows = db.query<{ value: string }>( + "SELECT value FROM ItemTable WHERE key IN ('composer.composerData', 'composer.composerHeaders')", + ) + if (rows.length === 0) continue + const project = sanitizeWorkspaceUri(folder) + let added = 0 + for (const row of rows) { + let parsed: { allComposers?: Array<{ composerId?: string }> } + try { + parsed = JSON.parse(row.value) + } catch { + continue + } + for (const c of parsed.allComposers ?? []) { + if (typeof c.composerId === 'string') { + result.composerToWorkspace.set(c.composerId, folder) + added += 1 + } + } + } + if (added > 0) { + result.workspaceProjectName.set(folder, project) + } + } catch { + // best-effort + } finally { + db.close() + } + } + + workspaceMapCache = result + workspaceMapCacheRoot = workspaceStorageDir + return result +} + +/// Pulls the composer id out of a `bubbleId:<composerId>:<bubbleUuid>` key. +/// Returns null when the composer segment contains a CR/LF, which is the +/// signature Cursor uses for tool-call sub-composer rows in real data — +/// e.g. `bubbleId:task-call_xxxx\nfc_yyyy:<bubbleUuid>` is one key with a +/// literal newline between the `task-call_` and `fc_` halves. Those rows +/// are not standalone composers and would otherwise inflate the orphan +/// project's session count. +function parseComposerIdFromKey(key: string | undefined): string | null { + if (!key) return null + const firstColon = key.indexOf(':') + if (firstColon < 0) return null + const secondColon = key.indexOf(':', firstColon + 1) + if (secondColon < 0) return null + const candidate = key.slice(firstColon + 1, secondColon) + if (!candidate) return null + // Reject any multi-line / control-char composer id. Real composer ids + // (UUIDs) and synthetic fixture ids are both single-line. + if (/[\r\n\x00]/.test(candidate)) return null + return candidate +} + +// Encodes the active workspace into source.path so the parser knows which +// composers to filter for. `#cursor-ws=` is a private separator: `state.vscdb` +// does not contain `#` (we construct the path ourselves), and the literal +// token only appears in source paths emitted from this provider, so there +// is no realistic collision. +const WORKSPACE_SEP = '#cursor-ws=' + +function encodeSourcePath(dbPath: string, workspaceTag: string): string { + return `${dbPath}${WORKSPACE_SEP}${workspaceTag}` +} + +function decodeSourcePath(sourcePath: string): { dbPath: string; workspaceTag: string } { + const idx = sourcePath.indexOf(WORKSPACE_SEP) + // Backwards-compat: a bare DB path with no workspace tag means "give me + // every call from this DB". Older cached SessionSource entries and any + // hand-constructed source from a test land here. + if (idx < 0) return { dbPath: sourcePath, workspaceTag: '__all__' } + return { + dbPath: sourcePath.slice(0, idx), + workspaceTag: sourcePath.slice(idx + WORKSPACE_SEP.length), + } +} + +type CodeBlock = { languageId?: string } + +function extractLanguages(codeBlocksJson: string | null): string[] { + if (!codeBlocksJson) return [] + try { + const blocks = JSON.parse(codeBlocksJson) as CodeBlock[] + if (!Array.isArray(blocks)) return [] + const langs = new Set<string>() + for (const block of blocks) { + if (block.languageId && block.languageId !== 'plaintext') { + langs.add(block.languageId) + } + } + return [...langs] + } catch { + return [] + } +} + +function resolveModel(raw: string | null): string { + if (!raw || raw === 'default') return CURSOR_COST_MODEL + return raw +} + +function modelForDisplay(raw: string | null): string { + if (!raw || raw === 'default') return 'cursor-auto' + return raw +} + +const BUBBLE_QUERY_BASE = ` + SELECT + key as bubble_key, + 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, '$.requestId') as request_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:%' +` + +const AGENTKV_QUERY = ` + SELECT + json_extract(value, '$.role') as role, + CAST(json_extract(value, '$.content') AS BLOB) as content, + json_extract(value, '$.providerOptions.cursor.requestId') as request_id, + json_extract(value, '$.providerOptions.cursor.modelName') as model + FROM cursorDiskKV + WHERE key LIKE 'agentKv:blob:%' + AND hex(substr(value, 1, 1)) = '7B' + ORDER BY ROWID ASC +` + +const USER_MESSAGES_QUERY = ` + SELECT + key as bubble_key, + json_extract(value, '$.createdAt') as created_at, + CAST(substr(json_extract(value, '$.text'), 1, 500) AS BLOB) as text + FROM cursorDiskKV + WHERE key LIKE 'bubbleId:%' + AND json_extract(value, '$.type') = 1 + AND (json_extract(value, '$.createdAt') > ? OR json_extract(value, '$.createdAt') IS NULL) + ORDER BY ROWID ASC +` + +// Split into HEAD (predicates we always emit) and TAIL (ORDER BY) so the +// caller can splice in an optional `ROWID >= ?` cutoff without rewriting +// the whole template. The original combined string is preserved as +// BUBBLE_QUERY_SINCE for any caller that doesn't want the cap. +const BUBBLE_QUERY_SINCE_HEAD = BUBBLE_QUERY_BASE + ` + AND json_extract(value, '$.createdAt') IS NOT NULL + AND json_extract(value, '$.createdAt') > ?` +const BUBBLE_QUERY_SINCE_TAIL = ` + ORDER BY ROWID ASC +` +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, '$.requestId') as request_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 }>( + "SELECT COUNT(*) as cnt FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' LIMIT 1" + ) + return rows.length > 0 + } catch (err) { + rethrowBusy(err) + return false + } +} + +type UserMsgRow = { bubble_key: string; created_at: string; text: Uint8Array | string } + +/// Per-conversation user-message buffer. We pop messages in arrival order via +/// the `pos` cursor — a previous implementation called Array.shift() which is +/// O(n) per call on large conversations and pinned multi-GB Cursor DBs at +/// minutes-of-parse for power users. The cursor walk is O(1). +type UserMessageQueue = { + messages: string[] + pos: number +} + +function buildUserMessageMap(db: SqliteDatabase, timeFloor: string): Map<string, UserMessageQueue> { + const map = new Map<string, UserMessageQueue>() + try { + const rows = db.query<UserMsgRow>(USER_MESSAGES_QUERY, [timeFloor]) + for (const row of rows) { + // Extract the composerId from the bubble key, matching parseBubbles(). + // The JSON `conversationId` field is empty in current Cursor builds. + const composerId = parseComposerIdFromKey(row.bubble_key) + if (!composerId || !row.text) continue + const text = blobToText(row.text) + const existing = map.get(composerId) + if (existing) { + existing.messages.push(text) + } else { + map.set(composerId, { messages: [text], pos: 0 }) + } + } + } catch (err) { + rethrowBusy(err) + } + return map +} + +function takeUserMessage(queues: Map<string, UserMessageQueue>, conversationId: string): string { + const queue = queues.get(conversationId) + if (!queue || queue.pos >= queue.messages.length) return '' + const msg = queue.messages[queue.pos] + queue.pos += 1 + return msg +} + +/// 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<BubbleRow>(BUBBLE_QUERY_PAGE, [beforeRowId, BATCH]) + } catch (err) { + rethrowBusy(err) + 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 } +} + +// Cursor leaves the per-bubble tokenCount at {0,0} on current builds. The only +// real input figure on disk is the latest context-window snapshot, which Cursor +// records in composerData.promptTokenBreakdown.totalUsedTokens or +// contextTokensUsed (the in-app context meter). This is not cumulative per-turn, +// so local SQLite undercounts admin-console usage; parity requires the opt-in +// Cursor Admin API: POST api.cursor.com/teams/filtered-usage-events. +// The key-range predicate seeks the primary key instead of scanning the 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;' +` + +type ComposerMeta = { tokens: number; createdAt: number | null } + +function loadComposerMeta(db: SqliteDatabase): Map<string, ComposerMeta> { + const map = new Map<string, ComposerMeta>() + 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) { + // `||` rather than `??`: a recorded-but-zero breakdown must fall through + // to the context meter instead of shadowing it. + 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 (err) { + rethrowBusy(err) + /* best-effort: callers fall back to the per-bubble text estimate */ + } + return map +} + +type AgentStream = { + tools: string[] + bash: string[] + userChars: number + contextChars: number + assistantChars: number + model: string | null +} + +function newAgentStream(): AgentStream { + return { tools: [], bash: [], userChars: 0, contextChars: 0, assistantChars: 0, model: null } +} + +// agentKv rows store content as a plain string or a block array; count only +// the text inside blocks so the JSON envelope and non-text parts are not +// billed as prompt characters. +function contentTextLength(raw: string): number { + const trimmed = raw.trimStart() + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as unknown + const blocks = Array.isArray(parsed) ? parsed : [parsed] + let len = 0 + for (const block of blocks) { + if (block == null || typeof block !== 'object') continue + const b = block as { text?: unknown; content?: unknown } + if (typeof b.text === 'string') len += b.text.length + else if (typeof b.content === 'string') len += b.content.length + } + return len + } catch { + return raw.length + } + } + return raw.length +} + +// Cursor logs the agent's stream (prompt, injected context, tool calls, reply +// deltas) in agentKv blobs keyed by requestId. Bubbles carry the same +// requestId, so the map built from the scanned bubbles joins each request to +// its conversation. Requests with no matching bubble are kept separately: +// they are real sessions (background runs, older builds) that would otherwise +// vanish from totals. +function loadAgentStreams( + db: SqliteDatabase, + requestToComposer: Map<string, string>, +): { byComposer: Map<string, AgentStream>; unjoined: Map<string, AgentStream> } { + const byComposer = new Map<string, AgentStream>() + const unjoined = new Map<string, AgentStream>() + + let rows: AgentKvRow[] + try { + rows = db.query<AgentKvRow>(AGENTKV_QUERY) + } catch (err) { + rethrowBusy(err) + return { byComposer, unjoined } + } + + const bucketFor = (requestId: string): AgentStream => { + const composer = requestToComposer.get(requestId) + const map = composer ? byComposer : unjoined + const key = composer ?? requestId + const existing = map.get(key) + if (existing) return existing + const fresh = newAgentStream() + map.set(key, fresh) + return fresh + } + + // Only the turn-opening (user) agentKv row carries the requestId; rows that + // follow inherit it. Rows written BEFORE their request's id appears (the + // system prompt and opening user prompt at a conversation start) buffer + // until the next id, and a system row closes the previous request so + // interleaved sessions cannot inherit across a conversation boundary. + let currentRequestId: string | null = null + let pendingUserChars = 0 + let pendingContextChars = 0 + for (const row of rows) { + if (row.request_id) { + currentRequestId = row.request_id + if (pendingUserChars > 0 || pendingContextChars > 0) { + const bucket = bucketFor(currentRequestId) + bucket.userChars += pendingUserChars + bucket.contextChars += pendingContextChars + pendingUserChars = 0 + pendingContextChars = 0 + } + } + if (row.model && currentRequestId) { + const bucket = bucketFor(currentRequestId) + if (!bucket.model) bucket.model = row.model + } + if (!row.content) continue + + if (row.role === 'system') { + pendingContextChars += contentTextLength(blobToText(row.content)) + currentRequestId = null + continue + } + if (row.role === 'user') { + const len = contentTextLength(blobToText(row.content)) + if (currentRequestId) bucketFor(currentRequestId).userChars += len + else pendingUserChars += len + continue + } + if (row.role === 'tool') { + if (currentRequestId) bucketFor(currentRequestId).contextChars += contentTextLength(blobToText(row.content)) + continue + } + if (row.role !== 'assistant' || !currentRequestId) continue + + let content: unknown + try { + content = JSON.parse(blobToText(row.content)) + } catch { + continue + } + if (!Array.isArray(content)) continue + const bucket = bucketFor(currentRequestId) + for (const block of content as Array<{ type?: string; text?: unknown; toolName?: unknown; args?: { command?: unknown } }>) { + if (block == null || typeof block !== 'object') continue + if (typeof block.text === 'string') bucket.assistantChars += block.text.length + if (block.type !== 'tool-call' || typeof block.toolName !== 'string' || !block.toolName) continue + // Cursor's terminal tool is 'Shell'; emit the canonical 'Bash' so the + // cross-provider tool and command breakdowns merge. + bucket.tools.push(block.toolName === 'Shell' ? 'Bash' : block.toolName) + if (block.toolName === 'Shell' && typeof block.args?.command === 'string') { + bucket.bash.push(...extractBashCommands(block.args.command)) + } + } + } + return { byComposer, unjoined } +} + +// What drives a conversation's input figure, decided once per conversation so +// the sources can never stack on each other: +// bubbleTokens - some bubble carries a real tokenCount (older builds), so +// per-turn counts are authoritative and nothing is estimated. +// meter - the composerData context meter exists; one conversation +// record carries it. +// stream - no meter, but the agent stream holds the prompt/context; one +// conversation record carries the estimate. +// text - only visible bubble text exists; estimated per bubble. +type InputSource = 'bubbleTokens' | 'meter' | 'stream' | 'text' + +type ComposerScan = { + hasRealTokens: boolean + firstBubbleTs: string | null + assistantTextChars: number + model: string | null +} + +function parseBubbles( + db: SqliteDatabase, + seenKeys: Set<string>, + timeFloor: string, + agentKvTimestamp: string, +): { calls: ParsedProviderCall[] } { + const results: ParsedProviderCall[] = [] + let skipped = 0 + + const composerMeta = loadComposerMeta(db) + + // 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:%'" + ) + total = countRows[0]?.cnt ?? 0 + } catch (err) { + rethrowBusy(err) + } + + let rows: BubbleRow[] + try { + 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<BubbleRow>(BUBBLE_QUERY_SINCE, [timeFloor]) + } + } catch (err) { + rethrowBusy(err) + return { calls: results } + } + + // Pre-pass: per-conversation facts the crediting decisions need, plus the + // requestId join for the agent stream — all from the rows already fetched, + // so no extra unbudgeted table scans. + const scans = new Map<string, ComposerScan>() + const requestToComposer = new Map<string, string>() + for (const row of rows) { + const cid = parseComposerIdFromKey(row.bubble_key) + if (!cid) continue + if (row.request_id) requestToComposer.set(row.request_id, cid) + let scan = scans.get(cid) + if (!scan) { + scan = { hasRealTokens: false, firstBubbleTs: null, assistantTextChars: 0, model: null } + scans.set(cid, scan) + } + if ((row.input_tokens ?? 0) > 0 || (row.output_tokens ?? 0) > 0) scan.hasRealTokens = true + if (!scan.firstBubbleTs && row.created_at) scan.firstBubbleTs = row.created_at + if (row.bubble_type !== 1) scan.assistantTextChars += row.text_length ?? 0 + if (!scan.model && row.model) scan.model = row.model + } + + const { byComposer: agentStreams, unjoined } = loadAgentStreams(db, requestToComposer) + const userMessages = buildUserMessageMap(db, timeFloor) + const lastUserMsg = new Map<string, string>() + + const inputSource = (cid: string): InputSource => { + if (scans.get(cid)?.hasRealTokens) return 'bubbleTokens' + if (composerMeta.has(cid)) return 'meter' + const stream = agentStreams.get(cid) + if ((stream?.userChars ?? 0) + (stream?.contextChars ?? 0) > 0) return 'stream' + return 'text' + } + + const emit = (call: Omit<ParsedProviderCall, 'provider' | 'speed' | 'cacheCreationInputTokens' | 'cacheReadInputTokens' | 'cachedInputTokens' | 'reasoningTokens' | 'webSearchRequests' | 'costIsEstimated'>): void => { + results.push({ + provider: 'cursor', + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + speed: 'standard', + // Output is a reply-text estimate and the input meter is the latest + // context snapshot, not a per-turn sum, so no cursor figure is exact. + costIsEstimated: true, + ...call, + }) + } + + const toolsAttached = new Set<string>() + for (const row of rows) { + try { + // The real composerId lives in the row key `bubbleId:<composerId>:<uuid>` + // (the JSON conversationId field is empty in current builds). + // parseComposerIdFromKey returns null for non-UUID composer segments + // (tool-call output rows and similar shapes), which are NOT sessions. + const conversationId = parseComposerIdFromKey(row.bubble_key) + if (!conversationId) { + skipped++ + continue + } + const createdAt = row.created_at + if (!createdAt) continue + + // Pair each user turn with its own prompt (even when the turn itself + // emits nothing) so the assistant reply that follows classifies against + // the right question. + if (row.bubble_type === 1) { + lastUserMsg.set(conversationId, takeUserMessage(userMessages, conversationId)) + } + + let inputTokens = row.input_tokens ?? 0 + let outputTokens = row.output_tokens ?? 0 + if (inputTokens === 0 && outputTokens === 0) { + const textLen = row.text_length ?? 0 + if (row.bubble_type === 1) { + // Conversation-level input (meter or stream) is emitted once after + // this loop; per-bubble text only counts when it is the + // conversation's best available signal. + if (inputSource(conversationId) === 'text' && textLen > 0) { + inputTokens = Math.ceil(textLen / CHARS_PER_TOKEN) + } + } else { + outputTokens = Math.ceil(textLen / CHARS_PER_TOKEN) + } + if (inputTokens === 0 && outputTokens === 0) continue + } + + // Use the SQLite row key (bubbleId:<unique>) as the dedup key. + // Cursor mutates token counts on the row in place when streaming + // completes — including tokens in the dedup key (the previous + // implementation) caused the same bubble to be counted twice once + // its tokens stabilized. + const dedupKey = `cursor:bubble:${row.bubble_key}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + // User bubbles (type=1) carry no modelInfo, so fall back to the + // conversation's model seen on its assistant bubbles or agent stream. + const effectiveModel = row.model ?? scans.get(conversationId)?.model ?? agentStreams.get(conversationId)?.model ?? null + const pricingModel = resolveModel(effectiveModel) + const costUSD = calculateCost(pricingModel, inputTokens, outputTokens, 0, 0, 0) + + const userQuestion = lastUserMsg.get(conversationId) ?? '' + const assistantText = blobToText(row.user_text) + const userText = (userQuestion + ' ' + assistantText).trim() + + const languages = extractLanguages(blobToText(row.code_blocks)) + const hasCode = languages.length > 0 + + // Meter/stream conversations carry their agent tools on the synthetic + // conversation record below; the rest attach them to their first + // emitted call so they are counted exactly once. + let agentTurn: AgentStream | undefined + const source = inputSource(conversationId) + if ((source === 'text' || source === 'bubbleTokens') && !toolsAttached.has(conversationId)) { + agentTurn = agentStreams.get(conversationId) + if (agentTurn) toolsAttached.add(conversationId) + } + + emit({ + model: modelForDisplay(effectiveModel), + inputTokens, + outputTokens, + costUSD, + tools: [ + ...(hasCode ? ['cursor:edit', ...languages.map(l => `lang:${l}`)] : []), + ...(agentTurn?.tools ?? []), + ], + bashCommands: agentTurn?.bash ?? [], + timestamp: createdAt, + deduplicationKey: dedupKey, + userMessage: userText, + sessionId: conversationId, + }) + } catch { + skipped++ + } + } + + // One conversation-level input record per metered/stream conversation, + // anchored to the conversation's own start (composerData.createdAt) so the + // credited day never depends on the parse window or cache state, and keyed + // by composerId so re-parses and daily-cache gap fills dedupe instead of + // multiplying. The meter is the LATEST context size, not a per-turn sum; + // growth after the anchor day is finalized stays uncounted, which keeps the + // documented undercount-vs-admin-console tradeoff but never double counts. + for (const [cid, scan] of scans) { + const source = inputSource(cid) + if (source !== 'meter' && source !== 'stream') continue + const stream = agentStreams.get(cid) + const meta = composerMeta.get(cid) + const inputTokens = source === 'meter' + ? meta?.tokens ?? 0 + : Math.ceil(((stream?.userChars ?? 0) + (stream?.contextChars ?? 0)) / CHARS_PER_TOKEN) + // Reply text normally lives on assistant bubbles; count the stream's + // reply deltas only when the bubbles carried none. + const outputTokens = scan.assistantTextChars > 0 ? 0 : Math.ceil((stream?.assistantChars ?? 0) / CHARS_PER_TOKEN) + if (inputTokens === 0 && outputTokens === 0) continue + + const dedupKey = `cursor:composer-input:${cid}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const createdAtMs = meta?.createdAt + const timestamp = typeof createdAtMs === 'number' && createdAtMs > 0 ? new Date(createdAtMs).toISOString() : scan.firstBubbleTs + if (!timestamp) continue + + const effectiveModel = scan.model ?? stream?.model ?? null + emit({ + model: modelForDisplay(effectiveModel), + inputTokens, + outputTokens, + costUSD: calculateCost(resolveModel(effectiveModel), inputTokens, outputTokens, 0, 0, 0), + tools: stream?.tools ?? [], + bashCommands: stream?.bash ?? [], + timestamp, + deduplicationKey: dedupKey, + userMessage: '', + sessionId: cid, + }) + } + + // Sessions recorded only in the agent stream (no bubble carries their + // requestId). agentKv stores no timestamps, so these reuse the DB file's + // mtime as a bounded "last write" time, like the pre-composer parser did. + for (const [requestId, stream] of unjoined) { + const inputTokens = Math.ceil((stream.userChars + stream.contextChars) / CHARS_PER_TOKEN) + const outputTokens = Math.ceil(stream.assistantChars / CHARS_PER_TOKEN) + if (inputTokens === 0 && outputTokens === 0) continue + + const dedupKey = `cursor:agentKv:${requestId}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + emit({ + model: modelForDisplay(stream.model), + inputTokens, + outputTokens, + costUSD: calculateCost(resolveModel(stream.model), inputTokens, outputTokens, 0, 0, 0), + tools: stream.tools, + bashCommands: stream.bash, + timestamp: agentKvTimestamp, + deduplicationKey: dedupKey, + userMessage: '', + sessionId: requestId, + }) + } + + if (skipped > 0) { + process.stderr.write(`codeburn: skipped ${skipped} unreadable Cursor entries\n`) + } + + return { calls: results } +} + +function createParser( + source: SessionSource, + seenKeys: Set<string>, + dateRange?: DateRange, +): SessionParser { + const timeFloor = getCursorTimeFloor(dateRange) + + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + const { dbPath, workspaceTag } = decodeSourcePath(source.path) + + // Decide which composers belong to this source. The workspace map is + // built once per process from `workspaceStorage/*` and reused across + // every workspace-scoped source, so we pay the directory walk cost + // only once per CLI run regardless of how many projects the user has. + // `composerFilter` holds the set of composers EITHER allowed (workspace + // source) or denied (orphan source); `filterMode` says which. + let composerFilter: Set<string> | null = null + let filterMode: 'include' | 'exclude' = 'include' + if (workspaceTag !== '__all__') { + const wsMap = loadWorkspaceMap(getCursorWorkspaceStorageDir(dbPath)) + if (workspaceTag === ORPHAN_TAG) { + // Orphan source: every composer that is mapped to SOME workspace + // is excluded here, so unmapped composers (and any non-UUID + // sub-composer ids that slip through) land in this bucket. + composerFilter = new Set(wsMap.composerToWorkspace.keys()) + filterMode = 'exclude' + } else { + composerFilter = new Set() + for (const [composerId, folder] of wsMap.composerToWorkspace) { + if (folder === workspaceTag) composerFilter.add(composerId) + } + filterMode = 'include' + } + } + + // Cache is keyed on the bare DB path so multiple workspace-scoped + // sources reuse one parsed bubble set per CLI run. Filtering happens + // post-cache so each source emits only its own composers. + let allCalls: ParsedProviderCall[] | null = null + const cached = await readCachedResults(dbPath, timeFloor) + if (cached) { + allCalls = cached + } else { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + rethrowBusy(err) + process.stderr.write(`codeburn: cannot open Cursor database: ${err instanceof Error ? err.message : err}\n`) + return + } + try { + if (!validateSchema(db)) { + process.stderr.write('codeburn: Cursor storage format not recognized. You may need to update CodeBurn.\n') + return + } + // Use a fresh local Set for intra-parse dedup so the global + // seenKeys is not mutated by calls that the workspace filter is + // about to drop. Cross-source dedup happens at yield time. + const localSeen = new Set<string>() + // agentKv rows carry no timestamps; sessions found only there get + // the DB's last-write time. + let agentKvTimestamp: string + try { + agentKvTimestamp = new Date(statSync(dbPath).mtimeMs).toISOString() + } catch { + agentKvTimestamp = new Date().toISOString() + } + const { calls: bubbleCalls } = parseBubbles(db, localSeen, timeFloor, agentKvTimestamp) + allCalls = bubbleCalls + await writeCachedResults(dbPath, allCalls, timeFloor) + } finally { + db.close() + } + } + + for (const call of allCalls) { + if (composerFilter !== null) { + const inSet = composerFilter.has(call.sessionId) + if (filterMode === 'include' && !inSet) continue + if (filterMode === 'exclude' && inSet) continue + } + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + yield call + } + }, + } +} + +export function createCursorProvider(dbPathOverride?: string): Provider { + return { + name: 'cursor', + displayName: 'Cursor', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + if (!isSqliteAvailable()) return [] + + const dbPath = dbPathOverride ?? getCursorDbPath() + if (!existsSync(dbPath)) return [] + + const wsMap = loadWorkspaceMap(getCursorWorkspaceStorageDir(dbPath)) + const sources: SessionSource[] = [] + for (const [folder, project] of wsMap.workspaceProjectName) { + sources.push({ + path: encodeSourcePath(dbPath, folder), + project, + provider: 'cursor', + }) + } + // Always emit a catch-all source for composers with no workspace + // mapping. About a third of composers in real-world Cursor installs + // are unmapped (multi-root workspaces, "no folder open" sessions, + // deleted workspaces with surviving global rows). When the user has + // no workspaces at all this source captures everything and the + // dashboard looks identical to the pre-PR `cursor` project. + sources.push({ + path: encodeSourcePath(dbPath, ORPHAN_TAG), + project: ORPHAN_PROJECT, + provider: 'cursor', + }) + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>, dateRange?: DateRange): SessionParser { + return createParser(source, seenKeys, dateRange) + }, + } +} + +export const cursor = createCursorProvider() diff --git a/src/providers/devin.ts b/src/providers/devin.ts new file mode 100644 index 0000000..b41e7bb --- /dev/null +++ b/src/providers/devin.ts @@ -0,0 +1,594 @@ +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"; +import { isPositiveNumber, safeNumber } from "../parser.js"; + +type AgentTrajectory<StepType extends Step = Step, AgentExtra = unknown> = { + schema_version: string; + session_id?: string; + agent: Agent<AgentExtra>; + 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<Extra = unknown> = { + 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<StepExtra = unknown, MetricsExtra = unknown> = { + step_id: number; + timestamp?: string; + source: string; + model_name?: string; + message: string | Array<ContentPart>; + tool_calls?: Array<ToolCall>; + extra?: StepExtra; + observation?: Observation; + metrics?: Metrics<MetricsExtra>; +}; + +type DevinTelemetry = { + source?: string; + operation?: string; +}; + +type DevinStepExtra = { + committed_acu_cost?: number; + generation_model?: string; + telemetry?: DevinTelemetry; +}; + +type Observation = { + results: Array<ObservationResult>; +}; + +type ObservationResult = { + source_call_id?: string; + content?: string | Array<ContentPart>; +}; + +type Metrics<Extra = unknown> = { + prompt_tokens?: number; + completion_tokens?: number; + cached_tokens?: number; + extra?: Extra; +}; + +type DevinMetricsExtra = { + cache_creation_input_tokens?: number; +}; + +type DevinStep = Step<DevinStepExtra, DevinMetricsExtra> & { + metadata?: DevinMetadata; +}; + +type DevinAgentTrajectory = AgentTrajectory<DevinStep, DevinAgentExtra>; + +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"; +const DEVIN_EFFORT_TIERS = new Set(["xhigh", "high", "medium", "low"]); + +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<DevinMetricsExtra> | 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<DevinMetricsExtra> | 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<DevinMetricsExtra> { + 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 firstPresentString(...values: Array<string | undefined>): string | undefined { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +function getFriendlyGptName(model: string): string { + const shortName = getShortModelName(model); + const match = model.match(/^gpt-(\d+(?:\.\d+)*)(?:-(.+))?$/); + if (!match) return shortName; + + const suffixParts = match[2]?.split("-").filter(Boolean) ?? []; + // A purely numeric suffix token means this is a dated snapshot id such as + // gpt-4-1106-preview, not a clean version+word id. Fabricating a friendly + // name here would mislabel the date as text (e.g. "GPT-4 1106 Preview"), so + // defer to getShortModelName, which passes unknown snapshots through raw. + if (suffixParts.some((part) => /^\d+$/.test(part))) return shortName; + + const suffix = suffixParts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + + const reconstructed = `GPT-${match[1]}${suffix ? ` ${suffix}` : ""}`; + if (shortName !== model && (!suffix || shortName !== `GPT-${match[1]}`)) { + return shortName; + } + + return reconstructed; +} + +function getDevinDisplayModelName( + generationModel: string | undefined, + modelName: string, +): string { + if (!generationModel || /^MODEL_/.test(generationModel)) { + return getShortModelName(modelName); + } + + if (generationModel.startsWith("gpt-")) { + // Devin minor versions are always a single digit (gpt-5-3-codex). Restrict + // the dash-to-dot rewrite to a single-digit minor at a token boundary so a + // dated snapshot like gpt-4-1106-preview is not misread as version 4.1106. + const normalized = generationModel.replace(/^gpt-(\d+)-(\d)(?=-|$)/, "gpt-$1.$2"); + const effortMatch = normalized.match(/-([^-]+)$/); + const effort = effortMatch && DEVIN_EFFORT_TIERS.has(effortMatch[1]!) + ? effortMatch[1] + : undefined; + const base = effort ? normalized.slice(0, -(effort.length + 1)) : normalized; + const friendlyBase = getFriendlyGptName(base); + return effort ? `${friendlyBase} (${effort})` : friendlyBase; + } + + return getShortModelName(generationModel); +} + +function getModelName( + transcript: DevinAgentTrajectory, + step: DevinStep, + session: DevinSessionMetadata | null, +): string { + const generationModel = firstPresentString( + step.metadata?.generation_model, + step.extra?.generation_model, + ); + const modelName = firstPresentString( + step.model_name, + transcript.agent?.model_name, + session?.model, + ) ?? DEFAULT_MODEL_NAME; + + return getDevinDisplayModelName(generationModel, modelName); +} + +function getToolNames(step: DevinStep): string[] { + return (step.tool_calls ?? []).map((call) => call.function_name); +} + +function normalizeContentPartMessage(contentPart: ContentPart) { + if (isTextContentPart(contentPart)) { + return contentPart.text; + } else { + return contentPart.source.path; + } +} + +function normalizeStepMessage(message: string | Array<ContentPart>): 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<string, DevinSessionMetadata> { + const sessions = new Map<string, DevinSessionMetadata>(); + let db: ReturnType<typeof openDatabase> | 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<number | null> { + const configRate = (await readConfig()).devin?.acuUsdRate; + return isPositiveNumber(configRate) ? configRate : null; +} + +class DevinSessionParser implements SessionParser { + constructor( + private source: SessionSource, + private seenKeys: Set<string>, + private sessionMetadata: Map<string, DevinSessionMetadata>, + ) {} + + async *parse(): AsyncGenerator<ParsedProviderCall> { + 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<string, DevinSessionMetadata> | 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 model; + }, + + toolDisplayName(rawTool: string): string { + return rawTool; + }, + + async discoverSessions(): Promise<SessionSource[]> { + 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<string>, + ): SessionParser { + return new DevinSessionParser(source, seenKeys, getSessionMetadata()); + }, + }; +} + +export const devin = createDevinProvider(DEFAULT_DEVIN_CLI_DIR); diff --git a/src/providers/droid.ts b/src/providers/droid.ts new file mode 100644 index 0000000..4ec8cf6 --- /dev/null +++ b/src/providers/droid.ts @@ -0,0 +1,407 @@ +import { readdir, stat, readFile } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' + +import { readSessionFile, readSessionLines } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import { normalizeContentBlocks } from '../content-utils.js' +import type { + Provider, + SessionSource, + SessionParser, + ParsedProviderCall, +} from './types.js' + +const toolNameMap: Record<string, string> = { + Read: 'Read', + Create: 'Create', + Edit: 'Edit', + MultiEdit: 'MultiEdit', + LS: 'LS', + Glob: 'Glob', + Grep: 'Grep', + Execute: 'Bash', + AskUser: 'AskUser', + TodoWrite: 'TodoWrite', + Skill: 'Skill', + Task: 'Agent', + WebSearch: 'WebSearch', + FetchUrl: 'FetchUrl', + GenerateDroid: 'GenerateDroid', + ExitSpecMode: 'ExitSpecMode', +} + +type DroidSettings = { + model?: string + tokenUsage?: { + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + } +} + +type DroidContent = { + type: string + text?: string + name?: string + input?: Record<string, unknown> +} + +type DroidMessage = { + role: string + content?: DroidContent[] +} + +type DroidJsonlEntry = { + type: string + id?: string + timestamp?: string + message?: DroidMessage + title?: string + cwd?: string +} + +function getFactoryDir(): string { + return process.env['FACTORY_DIR'] ?? join(homedir(), '.factory') +} + + +// Strip Droid-specific wrapper to get the model's display name. +// e.g. "custom:GLM-5.1-[Proxy]-0" -> "GLM-5.1" +// Cost lookup is handled by codeburn's existing calculateCost/getCanonicalName +// which normalizes case and strips date suffixes automatically. +function stripModelPrefix(raw: string): string { + return raw + .replace(/^custom:/, '') + .replace(/\[.*?\]/g, '') + .replace(/-\d+$/, '') + .replace(/-+$/, '') + .replace(/^-/, '') +} + +function parseModelForDisplay(raw: string): string { + const stripped = stripModelPrefix(raw) + const lower = stripped.toLowerCase() + + if (lower.includes('opus')) return getShortModelName(stripped) + if (lower.includes('sonnet')) return getShortModelName(stripped) + if (lower.includes('haiku')) return getShortModelName(stripped) + if (lower.startsWith('gpt-')) return getShortModelName(stripped) + if (lower.startsWith('o3') || lower.startsWith('o4')) return getShortModelName(stripped) + if (lower.startsWith('gemini')) return getShortModelName(stripped) + + return stripped +} + +/** + * Extract meaningful shell command names from a Droid Execute call. + * Droid frequently passes multi-line scripts (python -c "...", heredocs, etc.) + * where splitting on ;/&&/| produces noise tokens like '}', 'await', 'import'. + * Instead, extract only the primary command from each logical line. + */ +function extractDroidBashCommands(command: string): string[] { + if (!command || !command.trim()) return [] + + const firstLine = command.split('\n')[0]!.trim() + return extractBashCommands(firstLine) +} + +function createParser( + source: SessionSource, + seenKeys: Set<string>, +): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const content = await readSessionFile(source.path) + if (content === null) return + + // Read the companion settings file for token usage + const settingsPath = source.path.replace(/\.jsonl$/, '.settings.json') + let settings: DroidSettings = {} + try { + const raw = await readFile(settingsPath, 'utf-8') + settings = JSON.parse(raw) as DroidSettings + } catch { + // No settings file or parse error + } + + const lines = content.split('\n').filter(l => l.trim()) + let sessionId = '' + let sessionModelDisplay = settings.model ? stripModelPrefix(settings.model) : 'unknown' + let currentUserMessage = '' + + // Collect all assistant messages with their tools + const assistantCalls: Array<{ + id: string + timestamp: string + tools: string[] + bashCommands: string[] + }> = [] + + let pendingTools: string[] = [] + let pendingBashCommands: string[] = [] + + for (const line of lines) { + let entry: DroidJsonlEntry + try { + entry = JSON.parse(line) as DroidJsonlEntry + } catch { + continue + } + + if (entry.type === 'session_start') { + sessionId = entry.id ?? '' + continue + } + + if (entry.type !== 'message' || !entry.message) continue + + const msg = entry.message + + if (msg.role === 'user') { + // Extract user text from content + const texts = normalizeContentBlocks(msg.content) + .filter(c => c.type === 'text' && c.text) + .map(c => c.text!) + .filter(Boolean) + // Skip system-reminder-only messages + const nonSystemTexts = texts.filter(t => !t.startsWith('<system-reminder>')) + if (nonSystemTexts.length > 0) { + currentUserMessage = nonSystemTexts.join(' ').slice(0, 500) + } + continue + } + + if (msg.role === 'assistant') { + const toolUses = normalizeContentBlocks(msg.content).filter(c => c.type === 'tool_use') + + for (const tu of toolUses) { + const toolName = tu.name ?? '' + pendingTools.push(toolNameMap[toolName] ?? toolName) + + if (toolName === 'Execute' && tu.input && typeof tu.input['command'] === 'string') { + pendingBashCommands.push(...extractDroidBashCommands(tu.input['command'] as string)) + } + } + + // Check if this assistant message has any text content (non-thinking) + const hasText = normalizeContentBlocks(msg.content).some(c => c.type === 'text' && c.text) + + // Only emit a call entry if there are tools or substantial text + if (pendingTools.length > 0 || hasText) { + assistantCalls.push({ + id: entry.id ?? `msg-${assistantCalls.length}`, + timestamp: entry.timestamp ?? '', + tools: [...pendingTools], + bashCommands: [...pendingBashCommands], + }) + pendingTools = [] + pendingBashCommands = [] + } + continue + } + } + + if (assistantCalls.length === 0) return + + // KNOWN LIMITATION: Droid records token usage only at session level + // (settings.tokenUsage), not per-message. We split evenly across the + // emitted assistant calls and price all of them at settings.model + // (the latest model the session used). For sessions where the user + // switched models mid-stream, costs are approximate — we have no + // ground-truth breakdown to attribute tokens per model. + const totalTokens = settings.tokenUsage + if (!totalTokens) return + + const totalInput = totalTokens.inputTokens ?? 0 + const totalOutput = totalTokens.outputTokens ?? 0 + const totalCacheCreation = totalTokens.cacheCreationTokens ?? 0 + const totalCacheRead = totalTokens.cacheReadTokens ?? 0 + const totalThinking = totalTokens.thinkingTokens ?? 0 + const numCalls = assistantCalls.length + + // Distribute evenly across calls + const inputPerCall = Math.floor(totalInput / numCalls) + const outputPerCall = Math.floor(totalOutput / numCalls) + const cacheCreationPerCall = Math.floor(totalCacheCreation / numCalls) + const cacheReadPerCall = Math.floor(totalCacheRead / numCalls) + const thinkingPerCall = Math.floor(totalThinking / numCalls) + + for (let i = 0; i < assistantCalls.length; i++) { + const call = assistantCalls[i] + + // Assign remainder to the last call + const isLast = i === assistantCalls.length - 1 + const inputTokens = isLast + ? totalInput - inputPerCall * (numCalls - 1) + : inputPerCall + const outputTokens = isLast + ? totalOutput - outputPerCall * (numCalls - 1) + : outputPerCall + const cacheCreationTokens = isLast + ? totalCacheCreation - cacheCreationPerCall * (numCalls - 1) + : cacheCreationPerCall + const cacheReadTokens = isLast + ? totalCacheRead - cacheReadPerCall * (numCalls - 1) + : cacheReadPerCall + const thinkingTokens = isLast + ? totalThinking - thinkingPerCall * (numCalls - 1) + : thinkingPerCall + + const dedupKey = `droid:${sessionId}:${call.id}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const costUSD = calculateCost( + sessionModelDisplay.toLowerCase(), + inputTokens, + outputTokens + thinkingTokens, + cacheCreationTokens, + cacheReadTokens, + 0, + ) + + // Use the call's timestamp, or session_start timestamp + const timestamp = call.timestamp || '' + + yield { + provider: 'droid', + model: sessionModelDisplay, + inputTokens, + outputTokens, + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens, + cachedInputTokens: cacheReadTokens, + reasoningTokens: thinkingTokens, + webSearchRequests: 0, + costUSD, + tools: call.tools, + bashCommands: call.bashCommands, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: i === 0 ? currentUserMessage : '', + sessionId, + } + } + }, + } +} + +function isInternalSession(cwd: string, factoryDir: string): boolean { + // Skip sessions whose cwd is the .factory directory itself (internal housekeeping) + const normalized = cwd.replace(/\/+$/, '') + return normalized === factoryDir +} + +function deriveProjectName(cwd: string): string { + const normalized = cwd.replace(/\/+$/, '') + const home = homedir() + + // Strip home directory prefix + let relative = normalized.startsWith(home) + ? normalized.slice(home.length).replace(/^\/+/, '') + : normalized.replace(/^\/+/, '') + + if (!relative) relative = '~' + + // Walk from the right: use the "projects/<name>" segment if present, + // otherwise the last meaningful path component. + const parts = relative.split('/') + const projectsIdx = parts.lastIndexOf('projects') + if (projectsIdx !== -1 && projectsIdx + 1 < parts.length) { + return parts.slice(projectsIdx + 1).join('/') + } + + return parts.join('/') +} + +async function readFirstJsonlLine(filePath: string): Promise<string | null> { + for await (const line of readSessionLines(filePath)) { + return line + } + return null +} + +async function discoverSessionsInDir( + sessionsDir: string, + factoryDir: string, +): Promise<SessionSource[]> { + const sources: SessionSource[] = [] + + let entries: string[] + try { + entries = await readdir(sessionsDir) + } catch { + return sources + } + + for (const entry of entries) { + const subDir = join(sessionsDir, entry) + const s = await stat(subDir).catch(() => null) + if (!s?.isDirectory()) continue + + const files = await readdir(subDir).catch(() => [] as string[]) + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + const filePath = join(subDir, file) + + const firstLine = await readFirstJsonlLine(filePath) + if (!firstLine?.trim()) continue + + let startEntry: DroidJsonlEntry + try { + startEntry = JSON.parse(firstLine) as DroidJsonlEntry + } catch { + continue + } + + if (startEntry.type !== 'session_start') continue + + const cwd = startEntry.cwd ?? entry + if (isInternalSession(cwd, factoryDir)) continue + + sources.push({ + path: filePath, + project: deriveProjectName(cwd), + provider: 'droid', + }) + } + } + + return sources +} + +export function createDroidProvider(factoryDir?: string): Provider { + const base = factoryDir ?? getFactoryDir() + const sessionsDir = join(base, 'sessions') + + return { + name: 'droid', + displayName: 'Droid', + + modelDisplayName(model: string): string { + return parseModelForDisplay(model) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + return discoverSessionsInDir(sessionsDir, base) + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set<string>, + ): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const droid = createDroidProvider() diff --git a/src/providers/forge.ts b/src/providers/forge.ts new file mode 100644 index 0000000..27931e9 --- /dev/null +++ b/src/providers/forge.ts @@ -0,0 +1,284 @@ +import { existsSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' + +import { extractBashCommands } from '../bash-utils.js' +import { calculateCost } from '../models.js' +import { getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' + +type ConversationRow = { + conversation_id: string + title: string | null + workspace_id: number | string + context: string | null + created_at: string | null + updated_at: string | null +} + +type DiscoveryRow = { + conversation_id: string + title: string | null + workspace_id: string +} + +type ContextMessage = { + message?: { + text?: { + role?: unknown + content?: unknown + model?: unknown + tool_calls?: unknown + } + } + usage?: unknown +} + +const DEFAULT_DB_PATH = join(homedir(), '.forge', '.forge.db') + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query('SELECT conversation_id, title, CAST(workspace_id AS TEXT) AS workspace_id, context, created_at, updated_at FROM conversations LIMIT 1') + return true + } catch { + return false + } +} + +function sqliteTimestampToIso(value: string | null | undefined): string { + if (!value) return new Date(0).toISOString() + + const match = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/) + if (match) { + const ms = (match[3] ?? '').padEnd(3, '0').slice(0, 3) + const parsed = new Date(`${match[1]}T${match[2]}.${ms}Z`) + if (!Number.isNaN(parsed.getTime())) return parsed.toISOString() + } + + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? new Date(0).toISOString() : parsed.toISOString() +} + +function actual(value: unknown): number { + if (!value || typeof value !== 'object') return 0 + const raw = (value as Record<string, unknown>)['actual'] + return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0 +} + +function usageActual(usage: unknown, key: string): number { + if (!usage || typeof usage !== 'object') return 0 + return actual((usage as Record<string, unknown>)[key]) +} + +function mapToolName(name: string): string { + switch (name) { + case 'shell': + case 'bash': + return 'Bash' + case 'read': + case 'Read': + return 'Read' + case 'write': + case 'Write': + return 'Write' + case 'patch': + case 'Edit': + case 'edit': + return 'Edit' + case 'fs_search': + case 'grep': + return 'Grep' + case 'task': + case 'dispatch_agent': + return 'Agent' + default: + return name + } +} + +function pushUnique(values: string[], value: string): void { + if (!values.includes(value)) values.push(value) +} + +function toolCalls(value: unknown): Record<string, unknown>[] { + return Array.isArray(value) ? value.filter(v => v && typeof v === 'object') as Record<string, unknown>[] : [] +} + +function extractToolsAndCommands(calls: Record<string, unknown>[]): { tools: string[]; bashCommands: string[]; firstCallId?: string } { + const tools: string[] = [] + const bashCommands: string[] = [] + let firstCallId: string | undefined + + for (const call of calls) { + const rawName = call['name'] + if (typeof rawName !== 'string') continue + if (!firstCallId && typeof call['call_id'] === 'string') firstCallId = call['call_id'] + + const tool = mapToolName(rawName) + pushUnique(tools, tool) + + if (tool === 'Bash') { + const args = call['arguments'] + if (args && typeof args === 'object') { + const command = (args as Record<string, unknown>)['command'] + if (typeof command === 'string') { + for (const cmd of extractBashCommands(command)) pushUnique(bashCommands, cmd) + } + } + } + } + + return { tools, bashCommands, firstCallId } +} + +function splitSourcePath(path: string): { dbPath: string; conversationId: string } | null { + const idx = path.lastIndexOf(':') + if (idx < 0) return null + return { dbPath: path.slice(0, idx), conversationId: path.slice(idx + 1) } +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + const split = splitSourcePath(source.path) + if (!split) return + + let db: SqliteDatabase + try { + db = openDatabase(split.dbPath) + } catch { + return + } + + try { + if (!validateSchema(db)) return + const rows = db.query<ConversationRow>( + `SELECT conversation_id, title, CAST(workspace_id AS TEXT) AS workspace_id, context, created_at, updated_at + FROM conversations + WHERE conversation_id = ?`, + [split.conversationId], + ) + const row = rows[0] + if (!row?.context) return + + let parsed: unknown + try { + parsed = JSON.parse(row.context) + } catch { + return + } + const messages = Array.isArray((parsed as { messages?: unknown }).messages) + ? (parsed as { messages: ContextMessage[] }).messages + : [] + + let userMessage = '' + for (let i = 0; i < messages.length; i++) { + const text = messages[i]?.message?.text + const role = typeof text?.role === 'string' ? text.role.toLowerCase() : '' + const content = typeof text?.content === 'string' ? text.content : '' + + if (role === 'user') { + userMessage = content.length > 500 ? content.slice(0, 500) : content + continue + } + if (role !== 'assistant') continue + + const promptTokens = usageActual(messages[i]?.usage, 'prompt_tokens') + const outputTokens = usageActual(messages[i]?.usage, 'completion_tokens') + const cachedInputTokens = usageActual(messages[i]?.usage, 'cached_tokens') + const inputTokens = Math.max(0, promptTokens - cachedInputTokens) + if (inputTokens === 0 && outputTokens === 0) continue + + const model = typeof text?.model === 'string' ? text.model : 'unknown' + const calls = toolCalls(text?.tool_calls) + const { tools, bashCommands, firstCallId } = extractToolsAndCommands(calls) + const stableId = firstCallId ?? `${model}:${promptTokens}:${outputTokens}:${i}` + const deduplicationKey = `forge:${row.conversation_id}:${stableId}` + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + yield { + provider: 'forge', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: cachedInputTokens, + cachedInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: calculateCost(model, inputTokens, outputTokens, 0, cachedInputTokens, 0), + tools, + bashCommands, + timestamp: sqliteTimestampToIso(row.updated_at ?? row.created_at), + speed: 'standard', + deduplicationKey, + userMessage, + sessionId: row.conversation_id, + } + } + } finally { + db.close() + } + }, + } +} + +async function discoverFromDb(dbPath: string): Promise<SessionSource[]> { + if (!existsSync(dbPath)) return [] + + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + + try { + if (!validateSchema(db)) return [] + const rows = db.query<DiscoveryRow>( + `SELECT conversation_id, title, CAST(workspace_id AS TEXT) AS workspace_id + FROM conversations + WHERE context IS NOT NULL`, + ) + return rows.map(row => ({ + path: `${dbPath}:${row.conversation_id}`, + project: row.title ?? String(row.workspace_id), + provider: 'forge', + })) + } catch { + return [] + } finally { + db.close() + } +} + +export function createForgeProvider(dbPath = DEFAULT_DB_PATH): Provider { + return { + name: 'forge', + displayName: 'Forge', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + if (!isSqliteAvailable()) return [] + return discoverFromDb(dbPath) + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const forge = createForgeProvider() diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts new file mode 100644 index 0000000..e0333f0 --- /dev/null +++ b/src/providers/gemini.ts @@ -0,0 +1,285 @@ +import { readdir, stat } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const toolNameMap: Record<string, string> = { + read_file: 'Read', + write_file: 'Write', + edit_file: 'Edit', + create_file: 'Write', + delete_file: 'Delete', + list_dir: 'LS', + grep_search: 'Grep', + search_files: 'Grep', + find_files: 'Glob', + run_command: 'Bash', + web_search: 'WebSearch', + ReadFile: 'Read', + WriteFile: 'Write', + EditFile: 'Edit', + ListDir: 'LS', + SearchText: 'Grep', + Shell: 'Bash', +} + +type GeminiTokens = { + input?: number + output?: number + cached?: number + thoughts?: number + tool?: number + total?: number +} + +type GeminiToolCall = { + id: string + name: string + args: Record<string, unknown> + status?: string + displayName?: string +} + +type GeminiMessage = { + id: string + timestamp: string + type: 'user' | 'gemini' | 'info' + content: string | Array<{ text: string }> + tokens?: GeminiTokens + model?: string + toolCalls?: GeminiToolCall[] + thoughts?: unknown[] +} + +type GeminiSession = { + sessionId: string + projectHash?: string + startTime: string + lastUpdated?: string + messages: GeminiMessage[] + kind?: string +} + +function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProviderCall[] { + const results: ParsedProviderCall[] = [] + + let lastUserMessage = '' + let turnOrdinal = 0 + let currentTurnId = `${data.sessionId}:prelude` + let geminiOrdinal = 0 + + for (const msg of data.messages) { + if (msg.type === 'user') { + if (Array.isArray(msg.content)) { + lastUserMessage = msg.content.map(c => c.text).join(' ').slice(0, 500) + } else if (typeof msg.content === 'string') { + lastUserMessage = msg.content.slice(0, 500) + } + currentTurnId = `${data.sessionId}:turn-${turnOrdinal++}` + continue + } + + if (msg.type !== 'gemini' || !msg.tokens || !msg.model) continue + + const t = msg.tokens + const totalInput = t.input ?? 0 + const totalOutput = t.output ?? 0 + const totalCached = t.cached ?? 0 + const totalThoughts = t.thoughts ?? 0 + if (totalInput === 0 && totalOutput === 0 && totalCached === 0 && totalThoughts === 0) continue + + const messageKey = msg.id || `idx-${geminiOrdinal}` + geminiOrdinal++ + const dedupKey = `gemini:${data.sessionId}:${messageKey}` + if (seenKeys.has(dedupKey)) continue + + const tools: string[] = [] + const bashCommands: string[] = [] + + if (msg.toolCalls) { + for (const tc of msg.toolCalls) { + const mapped = toolNameMap[tc.displayName ?? ''] ?? toolNameMap[tc.name] ?? tc.displayName ?? tc.name + tools.push(mapped) + if (mapped === 'Bash' && tc.args && typeof tc.args.command === 'string') { + bashCommands.push(...extractBashCommands(tc.args.command)) + } + } + } + + // Gemini's `input` count includes `cached` tokens as a subset, so fresh + // input must subtract cached to avoid double-charging at both rates. + const freshInput = Math.max(0, totalInput - totalCached) + + const tsDate = new Date(msg.timestamp || data.startTime) + if (isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) continue + + seenKeys.add(dedupKey) + + // Gemini bills thoughts at the output token rate; calculateCost does not + // accept a reasoning parameter, so fold thoughts into the output count for + // pricing while keeping outputTokens / reasoningTokens reported separately. + const costUSD = calculateCost(msg.model, freshInput, totalOutput + totalThoughts, 0, totalCached, 0) + + results.push({ + provider: 'gemini', + model: msg.model, + inputTokens: freshInput, + outputTokens: totalOutput, + cacheCreationInputTokens: 0, + cacheReadInputTokens: totalCached, + cachedInputTokens: totalCached, + reasoningTokens: totalThoughts, + webSearchRequests: 0, + costUSD, + tools: [...new Set(tools)], + bashCommands: [...new Set(bashCommands)], + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + turnId: currentTurnId, + userMessage: lastUserMessage, + sessionId: data.sessionId, + }) + } + + return results +} + +function parseJsonl(raw: string): GeminiSession | null { + const lines = raw.split('\n').filter(l => l.trim()) + if (lines.length === 0) return null + + let sessionId = '' + let startTime = '' + let projectHash: string | undefined + let lastUpdated: string | undefined + let kind: string | undefined + const messages: GeminiMessage[] = [] + + for (const line of lines) { + let obj: Record<string, unknown> + try { + obj = JSON.parse(line) + } catch { + continue + } + if (obj['$set'] !== undefined) continue + if (obj['sessionId'] && obj['startTime'] && !sessionId) { + sessionId = obj['sessionId'] as string + startTime = obj['startTime'] as string + projectHash = obj['projectHash'] as string | undefined + lastUpdated = obj['lastUpdated'] as string | undefined + kind = obj['kind'] as string | undefined + } else if (obj['id'] && obj['type']) { + messages.push(obj as unknown as GeminiMessage) + } + } + + if (!sessionId) return null + return { sessionId, projectHash, startTime, lastUpdated, kind, messages } +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const raw = await readSessionFile(source.path) + if (raw === null) return + + let data: GeminiSession | null = null + + // Try single JSON first (Gemini CLI <=0.38), then JSONL (>=0.39) + try { + const parsed = JSON.parse(raw) + if (parsed.messages && parsed.sessionId) { + data = parsed + } + } catch { /* not single JSON */ } + + if (!data) { + data = parseJsonl(raw) + } + + if (!data?.messages || !data.sessionId) return + + const calls = parseSession(data, seenKeys) + for (const call of calls) { + yield call + } + }, + } +} + +function getGeminiTmpDir(): string { + return join(homedir(), '.gemini', 'tmp') +} + +async function discoverSessions(): Promise<SessionSource[]> { + const sources: SessionSource[] = [] + const tmpDir = getGeminiTmpDir() + + let projectDirs: string[] + try { + const entries = await readdir(tmpDir, { withFileTypes: true }) + projectDirs = entries.filter(e => e.isDirectory()).map(e => e.name) + } catch { + return sources + } + + for (const project of projectDirs) { + const chatsDir = join(tmpDir, project, 'chats') + let files: string[] + try { + const entries = await readdir(chatsDir) + files = entries.filter(f => f.startsWith('session-') && (f.endsWith('.json') || f.endsWith('.jsonl'))) + } catch { + continue + } + + for (const file of files) { + const filePath = join(chatsDir, file) + const s = await stat(filePath).catch(() => null) + if (!s?.isFile()) continue + sources.push({ path: filePath, project, provider: 'gemini' }) + } + } + + return sources +} + +export function createGeminiProvider(): Provider { + return { + name: 'gemini', + displayName: 'Gemini', + + modelDisplayName(model: string): string { + if (model === 'gemini-auto') return 'Gemini (auto)' + const display: Record<string, string> = { + 'gemini-3-flash-preview': 'Gemini 3 Flash', + 'gemini-3.5-flash': 'Gemini 3.5 Flash', + 'gemini-3.1-pro-preview': 'Gemini 3.1 Pro', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'gemini-2.0-flash': 'Gemini 2.0 Flash', + } + return display[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + return discoverSessions() + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const gemini = createGeminiProvider() diff --git a/src/providers/goose.ts b/src/providers/goose.ts new file mode 100644 index 0000000..7109709 --- /dev/null +++ b/src/providers/goose.ts @@ -0,0 +1,290 @@ +import { join } from 'path' +import { homedir, platform } from 'os' + +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' +import type { ToolCall } from '../types.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +type SessionRow = { + id: string + name: string + working_dir: string | null + created_at: string | null + updated_at: string | null + accumulated_input_tokens: number | null + accumulated_output_tokens: number | null + provider_name: string | null + model_config_json: Uint8Array | string | null +} + +type ModelConfig = { + model_name?: string + reasoning?: boolean +} + +type MessageRow = { + message_id: string + role: string + content_json: Uint8Array | string + created_timestamp: number +} + +type ContentItem = { + type: string + toolCall?: { value?: { name?: string; arguments?: Record<string, unknown> } } +} + +const toolNameMap: Record<string, string> = { + developer__shell: 'Bash', + developer__text_editor: 'Edit', + developer__read_file: 'Read', + developer__write_file: 'Write', + developer__list_directory: 'LS', + developer__search_files: 'Grep', + computercontroller__shell: 'Bash', +} + +function sanitize(dir: string): string { + return dir.replace(/^\//, '').replace(/\//g, '-') +} + +function getDbPath(): string { + const root = process.env['GOOSE_PATH_ROOT'] + if (root) return join(root, 'data', 'sessions', 'sessions.db') + + const p = platform() + if (p === 'darwin' || p === 'linux') { + const base = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share') + return join(base, 'goose', 'sessions', 'sessions.db') + } + return join(homedir(), 'AppData', 'Roaming', 'Block', 'goose', 'sessions', 'sessions.db') +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions LIMIT 1") + db.query<{ cnt: number }>("SELECT COUNT(*) as cnt FROM messages LIMIT 1") + return true + } catch { + return false + } +} + +function parseModelConfig(raw: string | null): ModelConfig { + if (!raw) return {} + try { + return JSON.parse(raw) as ModelConfig + } catch { + return {} + } +} + +function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[]; toolSequence: ToolCall[][] } { + const tools: string[] = [] + const bashCommands: string[] = [] + const seen = new Set<string>() + const toolSequence: ToolCall[][] = [] + + try { + const rows = db.query<{ content_json: Uint8Array | string }>( + "SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'assistant' AND content_json LIKE '%toolRequest%' ORDER BY created_timestamp ASC", + [sessionId], + ) + + for (const row of rows) { + let items: ContentItem[] + try { + items = JSON.parse(blobToText(row.content_json)) as ContentItem[] + } catch { + continue + } + const msgCalls: ToolCall[] = [] + for (const item of items) { + if (item.type !== 'toolRequest') continue + const rawName = item.toolCall?.value?.name ?? '' + if (!rawName) continue + const mapped = toolNameMap[rawName] ?? rawName.split('__').pop() ?? rawName + if (!seen.has(mapped)) { + seen.add(mapped) + tools.push(mapped) + } + const call: ToolCall = { tool: mapped } + const args = item.toolCall?.value?.arguments + if (args && typeof args === 'object') { + const fp = (args as Record<string, unknown>)['file_path'] + if (typeof fp === 'string') call.file = fp + const cmd = (args as Record<string, unknown>)['command'] + if (typeof cmd === 'string') call.command = cmd + } + msgCalls.push(call) + if (mapped === 'Bash') { + const cmd = item.toolCall?.value?.arguments?.command + if (typeof cmd === 'string') { + for (const c of extractBashCommands(cmd)) { + if (!bashCommands.includes(c)) bashCommands.push(c) + } + } + } + } + if (msgCalls.length > 0) toolSequence.push(msgCalls) + } + } catch { /* best-effort */ } + + return { tools, bashCommands, toolSequence } +} + +function getFirstUserMessage(db: SqliteDatabase, sessionId: string): string { + try { + const rows = db.query<{ content_json: Uint8Array | string }>( + "SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'user' ORDER BY created_timestamp ASC LIMIT 1", + [sessionId], + ) + if (rows.length === 0) return '' + const items = JSON.parse(blobToText(rows[0]!.content_json)) as ContentItem[] + const text = items.find(i => i.type === 'text') as { text?: string } | undefined + return (text?.text ?? '').slice(0, 500) + } catch { + return '' + } +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + 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 Goose database: ${err instanceof Error ? err.message : err}\n`) + return + } + + try { + if (!validateSchema(db)) return + + const rows = db.query<SessionRow>( + 'SELECT id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, CAST(model_config_json AS BLOB) AS model_config_json FROM sessions WHERE id = ?', + [sessionId], + ) + if (rows.length === 0) return + + const session = rows[0]! + const inputTokens = session.accumulated_input_tokens ?? 0 + const outputTokens = session.accumulated_output_tokens ?? 0 + if (inputTokens === 0 && outputTokens === 0) return + + const dedupKey = `goose:${sessionId}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + const config = parseModelConfig(blobToText(session.model_config_json)) + const model = config.model_name ?? 'unknown' + const costUSD = calculateCost(model, inputTokens, outputTokens, 0, 0, 0) + + const { tools, bashCommands, toolSequence } = extractToolsFromMessages(db, sessionId) + const userMessage = getFirstUserMessage(db, sessionId) + + const raw = session.updated_at || session.created_at || '' + let ts = new Date(raw) + if (isNaN(ts.getTime())) ts = new Date(raw + 'Z') + if (isNaN(ts.getTime())) ts = new Date() + + yield { + provider: 'goose', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + toolSequence: toolSequence.length > 1 ? toolSequence : undefined, + timestamp: ts.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage, + sessionId, + } + } finally { + db.close() + } + }, + } +} + +async function discoverFromDb(dbPath: string): Promise<SessionSource[]> { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + + try { + const rows = db.query<SessionRow>( + 'SELECT id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, CAST(model_config_json AS BLOB) AS model_config_json FROM sessions ORDER BY updated_at DESC', + ) + + return rows + .filter(r => (r.accumulated_input_tokens ?? 0) > 0 || (r.accumulated_output_tokens ?? 0) > 0) + .map(row => ({ + path: `${dbPath}:${row.id}`, + project: row.working_dir ? sanitize(row.working_dir) : 'goose', + provider: 'goose', + })) + } catch { + return [] + } finally { + db.close() + } +} + +const modelDisplayNames: Record<string, string> = { + 'gpt-5.5': 'GPT-5.5', + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-4o': 'GPT-4o', + 'gpt-4o-mini': 'GPT-4o Mini', +} + +export function createGooseProvider(): Provider { + return { + name: 'goose', + displayName: 'Goose', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + if (!isSqliteAvailable()) return [] + const dbPath = getDbPath() + return discoverFromDb(dbPath) + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const goose = createGooseProvider() diff --git a/src/providers/grok.ts b/src/providers/grok.ts new file mode 100644 index 0000000..7e347f9 --- /dev/null +++ b/src/providers/grok.ts @@ -0,0 +1,279 @@ +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 +// <grok-home>/sessions/<url-encoded-cwd>/<uuid>/, 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<string, string> = { + 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<T>(path: string): Promise<T | null> { + 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[] + subagentTypes: string[] +} { + const turns = new Map<string, { first: number; last: number }>() + const tools: string[] = [] + const bashCommands: string[] = [] + const subagentTypes: 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)) + } + if (update.title === 'spawn_subagent' && typeof update.rawInput?.subagent_type === 'string') { + subagentTypes.push(update.rawInput.subagent_type) + } + } + } + + 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, subagentTypes } +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const dir = dirname(source.path) + const summary = await readJson<GrokSummary>(join(dir, 'summary.json')) + const updates = await readSessionFile(source.path) + if (!summary || updates === null) return + + const { input, cacheRead, output, tools, bashCommands, subagentTypes } = parseUpdates(updates) + if (input === 0 && output === 0) return + + const signals = await readJson<GrokSignals>(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, + subagentTypes, + 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<SessionSource[]> { + 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<GrokSummary>(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<SessionSource[]> { + return discoverSessions(dir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): 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..309b29e --- /dev/null +++ b/src/providers/hermes.ts @@ -0,0 +1,481 @@ +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, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { ToolCall } 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<string, string> = { + 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<ProfileDb[]> { + 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 (err) { + if (isSqliteBusyError(err)) throw err + return false + } +} + +function getSessionColumns(db: SqliteDatabase): Set<string> { + return new Set(db.query<TableInfoRow>('PRAGMA table_info(sessions)').map(row => row.name)) +} + +function numberColumn(columns: Set<string>, name: TableColumn): string { + return columns.has(name) ? `coalesce(${name}, 0) AS ${name}` : `0 AS ${name}` +} + +function nullableColumn(columns: Set<string>, name: TableColumn): string { + return columns.has(name) ? name : `NULL AS ${name}` +} + +function getMessageColumns(db: SqliteDatabase): Set<string> { + return new Set(db.query<TableInfoRow>('PRAGMA table_info(messages)').map(row => row.name)) +} + +function usageExpression(columns: Set<string>): string { + const usageColumns: Array<keyof HermesSessionRow> = [ + '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: ToolCall[][]; bashCommands: string[] } { + const tools: string[] = [] + const toolSequence: ToolCall[][] = [] + const bashCommands: string[] = [] + + for (const msg of messages) { + if (msg.role === 'assistant') { + const currentTurnTools: ToolCall[] = [] + for (const call of parseToolCalls(msg.tool_calls)) { + const rawName = call.function?.name ?? '' + if (!rawName) continue + const mapped = mapToolName(rawName) + tools.push(mapped) + const toolCall: ToolCall = { tool: mapped } + const rawArgs = call.function?.arguments + if (rawArgs) { + try { + const args = JSON.parse(rawArgs) as Record<string, unknown> + const file = args['path'] ?? args['file_path'] + if (typeof file === 'string') toolCall.file = file + const command = args['command'] + if (typeof command === 'string') { + toolCall.command = command + bashCommands.push(command) + } + } catch { + // Ignore malformed arguments from historical sessions. + } + } + currentTurnTools.push(toolCall) + } + 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: toolSequence.length > 0 ? 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<SessionSource[]> { + 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<HermesSessionRow>( + `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) { + if (isSqliteBusyError(err)) throw 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<string>, hermesHome: string): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + 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<HermesSessionRow>( + `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<HermesMessageRow>( + `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) { + // A transient lock on the live state.db must propagate so the caller + // retries, not get swallowed into an empty (negatively cached) result. + if (isSqliteBusyError(err)) throw 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<SessionSource[]> { + 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<string>): SessionParser { + return createParser(source, seenKeys, hermesHome) + }, + } +} + +export const hermes = createHermesProvider() diff --git a/src/providers/ibm-bob.ts b/src/providers/ibm-bob.ts new file mode 100644 index 0000000..5aec0f6 --- /dev/null +++ b/src/providers/ibm-bob.ts @@ -0,0 +1,59 @@ +import { join } from 'path' +import { homedir } from 'os' + +import { getShortModelName } from '../models.js' +import { discoverClineTasksInBaseDirs, createClineParser } from './vscode-cline-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const PROVIDER_NAME = 'ibm-bob' +const DISPLAY_NAME = 'IBM Bob' +const EXTENSION_ID = 'ibm.bob-code' +const FALLBACK_MODEL = 'ibm-bob-auto' + +export function getIBMBobGlobalStorageDirs(): string[] { + const home = homedir() + if (process.platform === 'darwin') { + return [ + join(home, 'Library', 'Application Support', 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID), + join(home, 'Library', 'Application Support', 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID), + ] + } + if (process.platform === 'win32') { + const appData = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming') + return [ + join(appData, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID), + join(appData, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID), + ] + } + const configHome = process.env['XDG_CONFIG_HOME'] ?? join(home, '.config') + return [ + join(configHome, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID), + join(configHome, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID), + ] +} + +export function createIBMBobProvider(overrideDir?: string): Provider { + return { + name: PROVIDER_NAME, + displayName: DISPLAY_NAME, + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + const dirs = overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs() + return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME) + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createClineParser(source, seenKeys, PROVIDER_NAME, FALLBACK_MODEL) + }, + } +} + +export const ibmBob = createIBMBobProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..b61121a --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,314 @@ +import { claude } from './claude.js' +import { cline } from './cline.js' +import { codebuff } from './codebuff.js' +import { codex } from './codex.js' +import { copilot } from './copilot.js' +import { droid } from './droid.js' +import { devin } from './devin.js' +import { gemini } from './gemini.js' +import { hermes } from './hermes.js' +import { ibmBob } from './ibm-bob.js' +import { kiloCode } from './kilo-code.js' +import { kiro } from './kiro.js' +import { kimi } from './kimi.js' +import { lingtaiTui } from './lingtai-tui.js' +import { mistralVibe } from './mistral-vibe.js' +import { mux } from './mux.js' +import { openclaw } from './openclaw.js' +import { openDesign } from './open-design.js' +import { pi, omp } from './pi.js' +import { qwen } from './qwen.js' +import { rooCode } from './roo-code.js' +import { zerostack } from './zerostack.js' +import { grok } from './grok.js' +import type { Provider, SessionSource } from './types.js' + +let antigravityProvider: Provider | null = null +let antigravityLoadAttempted = false +let warpProvider: Provider | null = null +let warpLoadAttempted = false + +async function loadAntigravity(): Promise<Provider | null> { + if (antigravityLoadAttempted) return antigravityProvider + antigravityLoadAttempted = true + try { + const { antigravity } = await import('./antigravity.js') + antigravityProvider = antigravity + return antigravity + } catch { + return null + } +} + +async function loadWarp(): Promise<Provider | null> { + if (warpLoadAttempted) return warpProvider + warpLoadAttempted = true + try { + const { warp } = await import('./warp.js') + warpProvider = warp + return warp + } catch { + return null + } +} + +let forgeProvider: Provider | null = null +let forgeLoadAttempted = false + +async function loadForge(): Promise<Provider | null> { + if (forgeLoadAttempted) return forgeProvider + forgeLoadAttempted = true + try { + const { forge } = await import('./forge.js') + forgeProvider = forge + return forge + } catch { + return null + } +} + +let gooseProvider: Provider | null = null +let gooseLoadAttempted = false + +async function loadGoose(): Promise<Provider | null> { + if (gooseLoadAttempted) return gooseProvider + gooseLoadAttempted = true + try { + const { goose } = await import('./goose.js') + gooseProvider = goose + return goose + } catch { + return null + } +} + +let cursorProvider: Provider | null = null +let cursorLoadAttempted = false + +async function loadCursor(): Promise<Provider | null> { + if (cursorLoadAttempted) return cursorProvider + cursorLoadAttempted = true + try { + const { cursor } = await import('./cursor.js') + cursorProvider = cursor + return cursor + } catch { + return null + } +} + +let opencodeProvider: Provider | null = null +let opencodeLoadAttempted = false + +let cursorAgentProvider: Provider | null = null +let cursorAgentLoadAttempted = false + +let crushProvider: Provider | null = null +let crushLoadAttempted = false + +let vercelGatewayProvider: Provider | null = null +let vercelGatewayLoadAttempted = false + +async function loadVercelGateway(): Promise<Provider | null> { + if (vercelGatewayLoadAttempted) return vercelGatewayProvider + vercelGatewayLoadAttempted = true + try { + const { vercelGateway } = await import('./vercel-gateway.js') + vercelGatewayProvider = vercelGateway + return vercelGateway + } catch { + return null + } +} + +async function loadOpenCode(): Promise<Provider | null> { + if (opencodeLoadAttempted) return opencodeProvider + opencodeLoadAttempted = true + try { + const { opencode } = await import('./opencode.js') + opencodeProvider = opencode + return opencode + } catch { + return null + } +} + +async function loadCursorAgent(): Promise<Provider | null> { + if (cursorAgentLoadAttempted) return cursorAgentProvider + cursorAgentLoadAttempted = true + try { + const { cursor_agent } = await import('./cursor-agent.js') + cursorAgentProvider = cursor_agent + return cursor_agent + } catch { + return null + } +} + +async function loadCrush(): Promise<Provider | null> { + if (crushLoadAttempted) return crushProvider + crushLoadAttempted = true + try { + const { crush } = await import('./crush.js') + crushProvider = crush + return crush + } catch { + return null + } +} + +let zcodeProvider: Provider | null = null +let zcodeLoadAttempted = false + +async function loadZcode(): Promise<Provider | null> { + if (zcodeLoadAttempted) return zcodeProvider + zcodeLoadAttempted = true + try { + const { zcode } = await import('./zcode.js') + zcodeProvider = zcode + return zcode + } catch { + return null + } +} + +let zedProvider: Provider | null = null +let zedLoadAttempted = false + +async function loadZed(): Promise<Provider | null> { + if (zedLoadAttempted) return zedProvider + zedLoadAttempted = true + try { + const { zed } = await import('./zed.js') + zedProvider = zed + return zed + } catch { + return null + } +} + +const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, rooCode, zerostack, grok] + +// Lazily loaded providers, listed by name so --provider validation works even +// when an optional module fails to load. Must stay in sync with getAllProviders. +const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode', 'zed'] + +// 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 (e.g. under test mocks). +let allProviderNamesCache: string[] | undefined +export function allProviderNames(): readonly string[] { + allProviderNamesCache ??= [ + ...coreProviders.map(p => p.name), + ...lazyProviderNames, + ].sort() + return allProviderNamesCache +} + +export async function getAllProviders(): Promise<Provider[]> { + const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw, zc, zd] = await Promise.all([ + loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), loadZcode(), loadZed(), + ]) + const all = [...coreProviders] + if (ag) all.push(ag) + if (forge) all.push(forge) + if (gs) all.push(gs) + if (cursor) all.push(cursor) + if (opencode) all.push(opencode) + if (cursorAgent) all.push(cursorAgent) + if (crush) all.push(crush) + if (warp) all.push(warp) + if (vercelGw) all.push(vercelGw) + if (zc) all.push(zc) + if (zd) all.push(zd) + return all +} + +export const providers = coreProviders + +// 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. +const warnedDiscoveryFailures = new Set<string>() +export async function safeDiscoverSessions(provider: Provider): Promise<SessionSource[]> { + 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<SessionSource[]> { + 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 safeDiscoverSessions(provider) + all.push(...sessions) + } + return all +} + +export async function getProvider(name: string): Promise<Provider | undefined> { + if (name === 'antigravity') { + const ag = await loadAntigravity() + return ag ?? undefined + } + if (name === 'forge') { + const forge = await loadForge() + return forge ?? undefined + } + if (name === 'goose') { + const gs = await loadGoose() + return gs ?? undefined + } + if (name === 'cursor') { + const cursor = await loadCursor() + return cursor ?? undefined + } + if (name === 'opencode') { + const oc = await loadOpenCode() + return oc ?? undefined + } + if (name === 'cursor-agent') { + const ca = await loadCursorAgent() + return ca ?? undefined + } + if (name === 'crush') { + const c = await loadCrush() + return c ?? undefined + } + if (name === 'warp') { + const w = await loadWarp() + return w ?? undefined + } + if (name === 'vercel-gateway') { + const vg = await loadVercelGateway() + return vg ?? undefined + } + if (name === 'zcode') { + const z = await loadZcode() + return z ?? undefined + } + if (name === 'zed') { + const z = await loadZed() + return z ?? undefined + } + return coreProviders.find(p => p.name === name) +} diff --git a/src/providers/kilo-code.ts b/src/providers/kilo-code.ts new file mode 100644 index 0000000..249fb5d --- /dev/null +++ b/src/providers/kilo-code.ts @@ -0,0 +1,53 @@ +import { join } from 'path' +import { homedir } from 'os' + +import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js' +import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const EXTENSION_ID = 'kilocode.kilo-code' +const PROVIDER_NAME = 'kilo-code' + +function getSqliteConfig(): SqliteProviderConfig { + const base = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share') + return { + providerName: PROVIDER_NAME, + displayName: 'KiloCode', + dbDir: join(base, 'kilo'), + dbFilePrefix: 'kilo', + } +} + +export function createKiloCodeProvider(overrideDir?: string | string[]): Provider { + const sqliteConfig = getSqliteConfig() + + return { + name: PROVIDER_NAME, + displayName: 'KiloCode', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + const [oldSessions, dbSessions] = await Promise.all([ + discoverClineTasks(EXTENSION_ID, PROVIDER_NAME, 'KiloCode', overrideDir), + discoverSqliteSessions(sqliteConfig), + ]) + return [...oldSessions, ...dbSessions] + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + if (source.path.includes('.db:')) { + return createSqliteSessionParser(source, seenKeys, sqliteConfig) + } + return createClineParser(source, seenKeys, PROVIDER_NAME) + }, + } +} + +export const kiloCode = createKiloCodeProvider() diff --git a/src/providers/kimi.ts b/src/providers/kimi.ts new file mode 100644 index 0000000..75242cc --- /dev/null +++ b/src/providers/kimi.ts @@ -0,0 +1,394 @@ +import { createHash } from 'crypto' +import { readdir, readFile, stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { homedir } from 'os' + +import { extractBashCommands } from '../bash-utils.js' +import { readSessionLines } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' + +type JsonObject = Record<string, unknown> + +const toolNameMap: Record<string, string> = { + Shell: 'Bash', + Bash: 'Bash', + bash: 'Bash', + ReadFile: 'Read', + ReadMediaFile: 'Read', + WriteFile: 'Write', + StrReplaceFile: 'Edit', + Grep: 'Grep', + Glob: 'Glob', + SearchWeb: 'WebSearch', + FetchURL: 'WebFetch', + Agent: 'Agent', + AgentTool: 'Agent', + TaskList: 'Agent', + TaskOutput: 'Agent', + TaskStop: 'Agent', + AskUserQuestion: 'AskUser', + SetTodoList: 'TodoWrite', + Think: 'Think', + EnterPlanMode: 'EnterPlanMode', + ExitPlanMode: 'ExitPlanMode', + SendDMail: 'DMail', +} + +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 : undefined +} + +function numericField(obj: JsonObject, ...keys: string[]): number { + for (const key of keys) { + const raw = obj[key] + const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN + if (Number.isFinite(n) && n > 0) return Math.trunc(n) + } + return 0 +} + +function getShareDir(overrideDir?: string): string { + return overrideDir ?? process.env['KIMI_SHARE_DIR'] ?? join(homedir(), '.kimi') +} + +function md5(text: string): string { + return createHash('md5').update(text, 'utf-8').digest('hex') +} + +function projectNameFromPath(pathValue: string): string { + const cleaned = pathValue.replace(/\/+$/, '') + return basename(cleaned) || cleaned || 'kimi' +} + +async function loadProjectNames(shareDir: string): Promise<Map<string, string>> { + const projects = new Map<string, string>() + const raw = await readFile(join(shareDir, 'kimi.json'), 'utf-8').catch(() => null) + if (!raw) return projects + + let data: unknown + try { + data = JSON.parse(raw) + } catch { + return projects + } + + const workDirs = asObject(data)?.['work_dirs'] + if (!Array.isArray(workDirs)) return projects + + for (const entry of workDirs) { + const obj = asObject(entry) + const pathValue = stringField(obj, 'path') + if (!pathValue) continue + const hash = md5(pathValue) + const project = projectNameFromPath(pathValue) + projects.set(hash, project) + + const kaos = stringField(obj, 'kaos') + if (kaos && kaos !== 'local') projects.set(`${kaos}_${hash}`, project) + } + + return projects +} + +function parseTomlString(raw: string): string | null { + const value = raw.trim() + if (!value) return null + if (value.startsWith('"')) { + const match = value.match(/^"((?:[^"\\]|\\.)*)"/) + if (!match) return null + try { + return JSON.parse(`"${match[1]}"`) as string + } catch { + return match[1] ?? null + } + } + if (value.startsWith("'")) { + const match = value.match(/^'([^']*)'/) + return match?.[1] ?? null + } + const match = value.match(/^([^#\s]+)/) + return match?.[1] ?? null +} + +function parseDefaultModelKey(configToml: string): string | null { + for (const line of configToml.split('\n')) { + const match = line.match(/^\s*default_model\s*=\s*(.+)$/) + if (!match) continue + return parseTomlString(match[1]!) + } + return null +} + +function parseModelSectionName(line: string): string | null { + const match = line.trim().match(/^\[models\.(?:"([^"]+)"|'([^']+)'|([^\]]+))\]$/) + if (!match) return null + return (match[1] ?? match[2] ?? match[3] ?? '').trim() || null +} + +function parseModelIdForKey(configToml: string, modelKey: string): string | null { + let inSection = false + for (const line of configToml.split('\n')) { + const section = parseModelSectionName(line) + if (section !== null) { + inSection = section === modelKey + continue + } + if (!inSection) continue + if (/^\s*\[/.test(line)) { + inSection = false + continue + } + const match = line.match(/^\s*model\s*=\s*(.+)$/) + if (!match) continue + return parseTomlString(match[1]!) + } + return null +} + +async function getConfiguredModel(shareDir: string): Promise<string> { + const envModel = process.env['KIMI_MODEL_NAME']?.trim() + if (envModel) return envModel + + const raw = await readFile(join(shareDir, 'config.toml'), 'utf-8').catch(() => null) + if (!raw) return 'kimi-auto' + + const defaultModel = parseDefaultModelKey(raw) + if (!defaultModel) return 'kimi-auto' + + return parseModelIdForKey(raw, defaultModel) ?? defaultModel +} + +function parseJsonObject(text: string | undefined): JsonObject | null { + if (!text) return null + try { + return asObject(JSON.parse(text)) + } catch { + return null + } +} + +function extractUserText(value: unknown): string { + if (typeof value === 'string') return value.slice(0, 500) + if (!Array.isArray(value)) return '' + + return value + .map(part => stringField(asObject(part), 'text') ?? '') + .filter(Boolean) + .join(' ') + .slice(0, 500) +} + +function timestampToIso(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value !== 'number' || !Number.isFinite(value)) return '' + + const millis = value > 1_000_000_000_000 ? value : value * 1000 + const date = new Date(millis) + return Number.isFinite(date.getTime()) ? date.toISOString() : '' +} + +function extractEnvelope(record: JsonObject): { type: string; payload: JsonObject; timestamp: string } | null { + const message = asObject(record['message']) + const envelope = message ?? record + const type = stringField(envelope, 'type') + const payload = asObject(envelope['payload']) + if (!type || !payload) return null + return { type, payload, timestamp: timestampToIso(record['timestamp']) } +} + +function extractUsage(payload: JsonObject): { + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number +} | null { + const usage = asObject(payload['token_usage']) ?? asObject(payload['usage']) + if (!usage) return null + + const cacheReadInputTokens = numericField(usage, 'input_cache_read', 'cache_read_input_tokens', 'cached_input_tokens') + const cacheCreationInputTokens = numericField(usage, 'input_cache_creation', 'cache_creation_input_tokens') + let inputTokens = numericField(usage, 'input_other', 'input_tokens') + if (inputTokens === 0) { + const totalInput = numericField(usage, 'input') + inputTokens = Math.max(0, totalInput - cacheReadInputTokens - cacheCreationInputTokens) + } + const outputTokens = numericField(usage, 'output', 'output_tokens') + + if (inputTokens === 0 && outputTokens === 0 && cacheReadInputTokens === 0 && cacheCreationInputTokens === 0) { + return null + } + + return { inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens } +} + +function extractTool(payload: JsonObject): { tool: string; bashCommands: string[] } | null { + const fn = asObject(payload['function']) + const rawName = stringField(fn, 'name') ?? stringField(payload, 'name') + if (!rawName) return null + + const tool = toolNameMap[rawName] ?? rawName + const argsText = stringField(fn, 'arguments') ?? stringField(payload, 'arguments') + const args = parseJsonObject(argsText) + const command = stringField(args, 'command') + const bashCommands = tool === 'Bash' && command ? extractBashCommands(command) : [] + + return { tool, bashCommands } +} + +function createParser(source: SessionSource, shareDir: string, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const configuredModel = await getConfiguredModel(shareDir) + const tools = new Set<string>() + const bashCommands = new Set<string>() + let currentUserMessage = '' + const sessionId = basename(dirname(source.path)) + let index = 0 + + for await (const line of readSessionLines(source.path)) { + if (!line.trim()) continue + + let record: JsonObject | null = null + try { + record = asObject(JSON.parse(line)) + } catch { + continue + } + if (!record) continue + + const envelope = extractEnvelope(record) + if (!envelope || envelope.type === 'metadata') continue + + if (envelope.type === 'TurnBegin' || envelope.type === 'SteerInput') { + currentUserMessage = extractUserText(envelope.payload['user_input']) + continue + } + + if (envelope.type === 'TurnEnd') { + currentUserMessage = '' + tools.clear() + bashCommands.clear() + continue + } + + if (envelope.type === 'ToolCall' || envelope.type === 'ToolCallRequest') { + const extracted = extractTool(envelope.payload) + if (!extracted) continue + tools.add(extracted.tool) + for (const command of extracted.bashCommands) bashCommands.add(command) + continue + } + + if (envelope.type !== 'StatusUpdate') continue + + const usage = extractUsage(envelope.payload) + if (!usage) continue + + const rawMessageId = stringField(envelope.payload, 'message_id') + const dedupKey = `kimi:${sessionId}:${rawMessageId ?? index}` + index++ + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const model = stringField(envelope.payload, 'model') ?? stringField(envelope.payload, 'model_name') ?? configuredModel + const costUSD = calculateCost( + model, + usage.inputTokens, + usage.outputTokens, + usage.cacheCreationInputTokens, + usage.cacheReadInputTokens, + 0, + ) + + yield { + provider: 'kimi', + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + cacheReadInputTokens: usage.cacheReadInputTokens, + cachedInputTokens: usage.cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [...tools], + bashCommands: [...bashCommands], + timestamp: envelope.timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: currentUserMessage, + sessionId, + } + + tools.clear() + bashCommands.clear() + } + }, + } +} + +async function addWireSource(sources: SessionSource[], filePath: string, project: string): Promise<void> { + const s = await stat(filePath).catch(() => null) + if (!s?.isFile()) return + sources.push({ path: filePath, project, provider: 'kimi' }) +} + +export function createKimiProvider(overrideDir?: string): Provider { + const shareDir = getShareDir(overrideDir) + + return { + name: 'kimi', + displayName: 'Kimi', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + const sources: SessionSource[] = [] + const sessionsRoot = join(shareDir, 'sessions') + const projectNames = await loadProjectNames(shareDir) + const workDirs = await readdir(sessionsRoot, { withFileTypes: true }).catch(() => []) + + for (const workDir of workDirs) { + if (!workDir.isDirectory()) continue + + const project = projectNames.get(workDir.name) ?? workDir.name + const workDirPath = join(sessionsRoot, workDir.name) + const sessionDirs = await readdir(workDirPath, { withFileTypes: true }).catch(() => []) + + for (const sessionDir of sessionDirs) { + if (!sessionDir.isDirectory()) continue + + const sessionPath = join(workDirPath, sessionDir.name) + await addWireSource(sources, join(sessionPath, 'wire.jsonl'), project) + + const subagentsPath = join(sessionPath, 'subagents') + const subagents = await readdir(subagentsPath, { withFileTypes: true }).catch(() => []) + for (const subagent of subagents) { + if (!subagent.isDirectory()) continue + await addWireSource(sources, join(subagentsPath, subagent.name, 'wire.jsonl'), project) + } + } + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, shareDir, seenKeys) + }, + } +} + +export const kimi = createKimiProvider() diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts new file mode 100644 index 0000000..df63ca2 --- /dev/null +++ b/src/providers/kiro.ts @@ -0,0 +1,885 @@ +import type { Dirent } from 'fs' +import { existsSync } from 'fs' +import { readdir, readFile, stat } from 'fs/promises' +import { basename, dirname, extname, join } from 'path' +import { homedir } from 'os' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import type { ToolCall } from '../types.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const CHARS_PER_TOKEN = 4 +const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 +const MODERN_CONVERSATION_KEYS = ['messages', 'conversation', 'chat', 'transcript', 'entries', 'events'] + +const modelDisplayNames: Record<string, string> = { + 'claude-sonnet-4-6': 'Sonnet 4.6', + 'claude-sonnet-4-5': 'Sonnet 4.5', + 'claude-sonnet-4': 'Sonnet 4', + 'claude-haiku-4-5': 'Haiku 4.5', + 'claude-3-7-sonnet': 'Sonnet 3.7', + 'claude-3-5-sonnet': 'Sonnet 3.5', + 'claude-3-5-haiku': 'Haiku 3.5', +} + +const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) + +const toolNameMap: Record<string, string> = { + readFile: 'Read', + read_file: 'Read', + read: 'Read', + writeFile: 'Edit', + write_file: 'Edit', + write: 'Edit', + editFile: 'Edit', + edit_file: 'Edit', + createFile: 'Write', + create_file: 'Write', + deleteFile: 'Delete', + listDir: 'LS', + list_dir: 'LS', + openFolders: 'LS', + runCommand: 'Bash', + run_command: 'Bash', + shell: 'Bash', + executeBash: 'Bash', + searchFiles: 'Grep', + search_files: 'Grep', + grep: 'Grep', + grepSearch: 'Grep', + findFiles: 'Glob', + find_files: 'Glob', + glob: 'Glob', + fileSearch: 'Glob', + webSearch: 'WebSearch', + web_search: 'WebSearch', + web_fetch: 'WebFetch', + fsWrite: 'Edit', + strReplace: 'Edit', + listDirectory: 'LS', + code: 'Read', + subagent: 'Agent', +} + +type KiroChatMessage = { + role: 'human' | 'bot' | 'tool' + content: string +} + +type KiroChatFile = { + executionId: string + actionId: string + chat: KiroChatMessage[] + metadata: { + modelId: string + modelProvider: string + workflow: string + workflowId: string + startTime: number + endTime: number + } +} + +type KiroModernExecution = Record<string, unknown> + +function normalizeModelId(raw: string): string { + return raw.replace(/(\d+)\.(\d+)/g, '$1-$2') +} + +function extractToolNames(content: string): string[] { + const tools: string[] = [] + const regex = /<tool_use>\s*<name>([^<]+)<\/name>/g + let match + while ((match = regex.exec(content)) !== null) { + const name = match[1]!.trim() + tools.push(toolNameMap[name] ?? name) + } + return tools +} + +function asRecord(value: unknown): Record<string, unknown> | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null +} + +function stringField(record: Record<string, unknown> | null, names: string[]): string { + if (!record) return '' + for (const name of names) { + const value = record[name] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return '' +} + +function timeField(record: Record<string, unknown> | null, names: string[]): number | string | undefined { + if (!record) return undefined + for (const name of names) { + const value = record[name] + if (typeof value === 'number' || typeof value === 'string') return value + } + return undefined +} + +function parseKiroTimestamp(value: number | string | undefined): Date | null { + if (value === undefined) return null + + let parsed: number | string = value + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + parsed = /^-?\d+(\.\d+)?$/.test(trimmed) ? Number(trimmed) : trimmed + } + + if (typeof parsed === 'number') { + if (!Number.isFinite(parsed)) return null + const ms = parsed < MIN_REASONABLE_TIMESTAMP_MS ? parsed * 1000 : parsed + const date = new Date(ms) + return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date + } + + const date = new Date(parsed) + return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date +} + +function textField(record: Record<string, unknown> | null, names: string[]): string { + if (!record) return '' + for (const name of names) { + const text = extractText(record[name]) + if (text) return text + } + return '' +} + +function extractText(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(extractText).filter(Boolean).join('\n') + const record = asRecord(value) + if (!record) return '' + for (const key of ['content', 'text', 'message', 'value', 'parts', 'entries']) { + const text = extractText(record[key]) + if (text) return text + } + return '' +} + +function messageRole(value: unknown): string { + const record = asRecord(value) + if (!record) return '' + return stringField(record, ['role', 'type', 'author']).toLowerCase() +} + +function extractStructuredToolNames(value: unknown, text: string, options: { includeDirectName?: boolean } = {}): string[] { + const tools = extractToolNames(text) + const record = asRecord(value) + if (!record) return tools + + if (options.includeDirectName ?? true) { + const directName = stringField(record, ['toolName', 'name']) + if (directName) tools.push(toolNameMap[directName] ?? directName) + } + + for (const key of ['toolCalls', 'tool_calls', 'tools']) { + const entries = record[key] + if (!Array.isArray(entries)) continue + for (const entry of entries) { + const name = stringField(asRecord(entry), ['name', 'toolName', 'tool_name']) + if (name) tools.push(toolNameMap[name] ?? name) + } + } + + return tools +} + +function parseChatFile(data: KiroChatFile, sessionId: string, project: string, seenKeys: Set<string>): ParsedProviderCall[] { + const results: ParsedProviderCall[] = [] + const { chat, metadata } = data + + let modelId = normalizeModelId(metadata.modelId ?? '') + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + let pendingUserMessage = '' + const allTools: string[] = [] + const toolSequence: ToolCall[][] = [] + + for (const msg of chat) { + if (msg.role === 'human') { + if (msg.content.startsWith('<identity>')) continue + pendingUserMessage = msg.content.slice(0, 500) + } + if (msg.role === 'bot') { + const msgTools = extractToolNames(msg.content) + allTools.push(...msgTools) + if (msgTools.length > 0) toolSequence.push(msgTools.map(t => ({ tool: t }))) + } + } + + const botMessages = chat.filter(m => m.role === 'bot' && m.content.length > 0) + const totalOutputChars = botMessages.reduce((sum, m) => sum + m.content.length, 0) + if (totalOutputChars === 0) return results + + const dedupKey = `kiro:${sessionId}:${data.executionId}` + if (seenKeys.has(dedupKey)) return results + + const outputTokens = Math.ceil(totalOutputChars / CHARS_PER_TOKEN) + const inputTokens = Math.ceil(pendingUserMessage.length / CHARS_PER_TOKEN) + const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) + const tsDate = parseKiroTimestamp(metadata.startTime) + if (!tsDate) return results + const timestamp = tsDate.toISOString() + seenKeys.add(dedupKey) + + results.push({ + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [...new Set(allTools)], + bashCommands: [], + toolSequence: toolSequence.length > 1 ? toolSequence : undefined, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + return results +} + +function parseModernExecution(data: KiroModernExecution, sourcePath: string, seenKeys: Set<string>): ParsedProviderCall[] { + const results: ParsedProviderCall[] = [] + if (Array.isArray(data['executions'])) return results + + const metadata = asRecord(data['metadata']) + const modelObj = asRecord(data['model']) + let modelId = normalizeModelId( + stringField(data, ['modelId', 'modelID', 'modelName', 'model']) || + stringField(modelObj, ['id', 'name']) || + stringField(metadata, ['modelId', 'modelID', 'modelName']), + ) + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + const executionId = stringField(data, ['executionId', 'id']) || basename(sourcePath) + const sessionId = stringField(data, ['sessionId', 'chatSessionId', 'conversationId', 'workflowId']) || + stringField(metadata, ['workflowId', 'sessionId']) || + basename(dirname(sourcePath)) || + executionId + + let inputChars = 0 + let outputChars = 0 + let pendingUserMessage = '' + const allTools: string[] = [] + let hasOutputActivity = false + const directInput = textField(data, ['prompt', 'input', 'userMessage', 'user_message', 'request']) + const directOutput = textField(data, ['response', 'output', 'assistantMessage', 'assistant_message', 'result']) + const directTools = extractStructuredToolNames(data, directOutput, { includeDirectName: false }) + + if (directInput) { + inputChars += directInput.length + pendingUserMessage = directInput.slice(0, 500) + } + + if (directOutput) { + outputChars += directOutput.length + hasOutputActivity = true + } + + if (directTools.length > 0) { + hasOutputActivity = true + allTools.push(...directTools) + } + + // Check both data.context[key] and data[key] for conversation arrays. + // Kiro IDE stores messages at data.context.messages in current builds. + const context = asRecord(data['context']) + const conversationSources = context ? [context, data] : [data] + + for (const source of conversationSources) { + let found = false + for (const key of MODERN_CONVERSATION_KEYS) { + const messages = (source as Record<string, unknown>)[key] + if (!Array.isArray(messages)) continue + + for (const message of messages) { + const text = extractText(message) + const role = messageRole(message) + const tools = extractStructuredToolNames(message, text) + + if (role === 'human' || role === 'user') { + if (!text) continue + inputChars += text.length + pendingUserMessage = text.slice(0, 500) + } else if (role === 'bot' || role === 'assistant' || role === 'ai' || role === 'model') { + if (text) outputChars += text.length + if (text || tools.length > 0) hasOutputActivity = true + allTools.push(...tools) + } else if (role === 'tool' || role === 'system') { + if (text) inputChars += text.length + allTools.push(...tools) + } + } + found = true + break + } + if (found) break + } + + // Extract tools from usageSummary (reliable structured tool list in current Kiro builds). + // usageSummary is an array of per-turn entries with optional usedTools field. + const usageSummary = data['usageSummary'] + if (Array.isArray(usageSummary)) { + for (const entry of usageSummary) { + const rec = asRecord(entry) + if (!rec) continue + const usedTools = rec['usedTools'] + if (Array.isArray(usedTools)) { + for (const tool of usedTools) { + if (typeof tool === 'string' && tool) { + // Strip mcp_ prefix for cleaner display (e.g. mcp_aws_sentral_mcp_search_accounts -> aws_sentral_mcp_search_accounts) + const cleaned = tool.startsWith('mcp_') ? tool.slice(4) : tool + allTools.push(toolNameMap[cleaned] ?? cleaned) + hasOutputActivity = true + } + } + } + } + } + + if (!hasOutputActivity) return results + + const dedupKey = `kiro:${sessionId}:${executionId}` + if (seenKeys.has(dedupKey)) return results + + const rawStartTime = timeField(data, ['startTime', 'createdAt', 'timestamp']) ?? + timeField(metadata, ['startTime', 'createdAt', 'timestamp']) + const tsDate = parseKiroTimestamp(rawStartTime) + if (!tsDate) return results + + const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) + const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) + seenKeys.add(dedupKey) + + results.push({ + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + return results +} + +// --- Kiro CLI session types & parser --- + +type KiroCliEntry = { + version: string + kind: 'Prompt' | 'AssistantMessage' | 'ToolResults' | 'Clear' + data: Record<string, unknown> +} + +type KiroCliSessionMeta = { + session_id: string + cwd: string + created_at: string + updated_at: string + title?: string + session_state?: { + rts_model_state?: { model_info?: { model_id?: string } } + conversation_metadata?: { + user_turn_metadatas?: Array<{ + end_timestamp?: string + builtin_tool_uses?: number + metering_usage?: Array<{ value: number; unit: string }> + total_request_count?: number + }> + } + } +} + +function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seenKeys: Set<string>): ParsedProviderCall[] { + const results: ParsedProviderCall[] = [] + const sessionId = meta.session_id + const project = basename(meta.cwd || '') + + let modelId = meta.session_state?.rts_model_state?.model_info?.model_id ?? 'auto' + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + else modelId = normalizeModelId(modelId) + + const turns = meta.session_state?.conversation_metadata?.user_turn_metadatas ?? [] + + // Walk through JSONL entries grouping by prompt turns + let turnIndex = 0 + let pendingUserMessage = '' + let outputChars = 0 + let inputChars = 0 + const allTools: string[] = [] + let turnStartTimestamp: string | undefined + + function flushTurn() { + if (outputChars === 0) return + const turnMeta = turns[turnIndex] + const dedupKey = `kiro-cli:${sessionId}:${turnIndex}` + if (seenKeys.has(dedupKey)) { turnIndex++; return } + + const timestamp = turnMeta?.end_timestamp ?? turnStartTimestamp ?? meta.created_at + const tsDate = parseKiroTimestamp(timestamp) + if (!tsDate) { turnIndex++; return } + + const costUSD = turnMeta?.metering_usage + ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) + : 0 + + const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) + const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + seenKeys.add(dedupKey) + + results.push({ + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + costIsEstimated: !turnMeta?.metering_usage, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + project, + }) + turnIndex++ + } + + let isFirstPrompt = true + for (const entry of entries) { + if (entry.kind === 'Prompt') { + if (!isFirstPrompt) { + flushTurn() + pendingUserMessage = '' + outputChars = 0 + inputChars = 0 + allTools.length = 0 + } + isFirstPrompt = false + const content = entry.data['content'] + if (Array.isArray(content)) { + for (const item of content) { + const rec = asRecord(item) + if (rec && rec['kind'] === 'text' && typeof rec['data'] === 'string') { + pendingUserMessage = (rec['data'] as string).slice(0, 500) + inputChars += (rec['data'] as string).length + } + } + } + const meta2 = asRecord(entry.data['meta']) + if (meta2) { + const ts = meta2['timestamp'] + if (typeof ts === 'number') turnStartTimestamp = new Date(ts * 1000).toISOString() + } + } else if (entry.kind === 'AssistantMessage') { + const content = entry.data['content'] + if (Array.isArray(content)) { + for (const item of content) { + const rec = asRecord(item) + if (!rec) continue + if (rec['kind'] === 'text' && typeof rec['data'] === 'string') { + outputChars += (rec['data'] as string).length + } else if (rec['kind'] === 'toolUse') { + const toolData = asRecord(rec['data']) + if (toolData) { + const name = typeof toolData['name'] === 'string' ? toolData['name'] : '' + if (name) allTools.push(toolNameMap[name] ?? name) + } + } + } + } + } else if (entry.kind === 'ToolResults') { + // Tool results count as input context + const content = entry.data['content'] + if (Array.isArray(content)) { + for (const item of content) { + const text = extractText(item) + if (text) inputChars += text.length + } + } + } + } + // Flush last turn + flushTurn() + + return results +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + // CLI session: path points to a .jsonl file + if (source.path.endsWith('.jsonl')) { + const jsonlContent = await readSessionFile(source.path) + if (jsonlContent === null) return + + const entries: KiroCliEntry[] = [] + for (const line of jsonlContent.split('\n')) { + if (!line.trim()) continue + try { entries.push(JSON.parse(line) as KiroCliEntry) } catch { /* skip malformed lines */ } + } + if (entries.length === 0) return + + // Load companion .json for metadata + const metaPath = source.path.replace(/\.jsonl$/, '.json') + let meta: KiroCliSessionMeta + try { + const raw = await readFile(metaPath, 'utf-8') + meta = JSON.parse(raw) as KiroCliSessionMeta + } catch { + // Minimal fallback + meta = { session_id: basename(source.path, '.jsonl'), cwd: '', created_at: '', updated_at: '' } + } + + for (const call of parseCliSession(meta, entries, seenKeys)) { + yield call + } + return + } + + // IDE session: original path + const content = await readSessionFile(source.path) + if (content === null) return + + let data: unknown + try { + data = JSON.parse(content) + } catch { + return + } + + const record = asRecord(data) + if (!record) return + + // Workspace-session files (newer Kiro builds): have history[] with message.role/content + // and a top-level sessionId/selectedModel/workspaceDirectory. + const historyArr = record['history'] + if (Array.isArray(historyArr) && typeof record['sessionId'] === 'string') { + const sessionId = record['sessionId'] as string + const modelRaw = stringField(record, ['selectedModel']) + let modelId = normalizeModelId(modelRaw) + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + let inputChars = 0 + let outputChars = 0 + let pendingUserMessage = '' + const allTools: string[] = [] + let hasExecutionRefs = false + let hasRealAssistantContent = false + + for (const item of historyArr) { + const rec = asRecord(item) + if (!rec) continue + + // Track if this session references execution files (which are parsed separately) + const execBacked = typeof rec['executionId'] === 'string' + if (execBacked) hasExecutionRefs = true + + const msg = asRecord(rec['message']) + if (!msg) continue + const role = stringField(msg, ['role']) + const text = extractText(msg['content']) + if (role === 'user' && text) { + inputChars += text.length + pendingUserMessage = text.slice(0, 500) + } else if (role === 'assistant' && !execBacked && text && text !== 'On it.') { + // An item carrying an executionId is execution-backed: its content is + // counted from the execution file, so counting it here would double-count. + // 'On it.' is the observed placeholder text Kiro writes for such stubs + // when the executionId rides a separate history item. + outputChars += text.length + hasRealAssistantContent = true + } + } + + // Skip workspace-session entries that are pure execution stubs: + // they reference executionIds (parsed separately as execution files) + // and have no real assistant content beyond "On it." placeholders. + // This avoids double-counting input tokens from both paths. + if (hasExecutionRefs && !hasRealAssistantContent) return + + // Skip sessions with no meaningful content + if (inputChars === 0 && outputChars === 0) return + + // Use file mtime as timestamp (workspace-session files don't carry startTime). + // No stat means no usable timestamp: drop the call like the other parse paths. + let timestamp: string + try { + const s = await stat(source.path) + timestamp = new Date(s.mtimeMs).toISOString() + } catch { + return + } + + const dedupKey = `kiro:ws-session:${sessionId}` + if (seenKeys.has(dedupKey)) return + seenKeys.add(dedupKey) + + const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) + const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) + + yield { + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + costIsEstimated: true, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + } + return + } + + const metadata = asRecord(record['metadata']) + const calls = Array.isArray(record['chat']) && metadata + ? parseChatFile(record as unknown as KiroChatFile, stringField(metadata, ['workflowId']) || basename(source.path, '.chat'), source.project, seenKeys) + : parseModernExecution(record, source.path, seenKeys) + for (const call of calls) { + yield call + } + }, + } +} + +// --- Discovery --- + +function getKiroAgentDir(override?: string): string[] { + if (override) return [override] + if (process.platform === 'darwin') { + return [join(homedir(), 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')] + } + if (process.platform === 'win32') { + return [join(homedir(), 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')] + } + // On Linux, scan both ~/.kiro-server/data/... (remote dev boxes) and + // ~/.config/Kiro/... (local installs). Both can have data simultaneously + // if the user switches between local and remote, or if .kiro-server exists + // but is stale while .config/Kiro has current sessions. + const paths: string[] = [] + const kiroServer = join(homedir(), '.kiro-server', 'data', 'User', 'globalStorage', 'kiro.kiroagent') + const kiroConfig = join(homedir(), '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + if (existsSync(kiroServer)) paths.push(kiroServer) + if (existsSync(kiroConfig)) paths.push(kiroConfig) + // Fallback to config path if neither exists (will just find nothing) + return paths.length > 0 ? paths : [kiroConfig] +} + +function getKiroWorkspaceStorageDir(override?: string): string { + if (override) return override + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Kiro', 'User', 'workspaceStorage') + } + if (process.platform === 'win32') { + return join(homedir(), 'AppData', 'Roaming', 'Kiro', 'User', 'workspaceStorage') + } + return join(homedir(), '.config', 'Kiro', 'User', 'workspaceStorage') +} + +async function readWorkspaceProject(workspaceDir: string): Promise<string> { + try { + const raw = await readFile(join(workspaceDir, 'workspace.json'), 'utf-8') + const data = JSON.parse(raw) as { folder?: string } + if (data.folder) { + const url = data.folder.replace(/^file:\/\//, '') + return basename(decodeURIComponent(url)) + } + } catch {} + return basename(workspaceDir) +} + +async function resolveWorkspaceProject(agentDir: string, workspaceStorageDir: string, workspaceHash: string): Promise<string> { + const wsDir = join(workspaceStorageDir, workspaceHash) + const project = await readWorkspaceProject(wsDir) + if (project !== workspaceHash) return project + + try { + const sessionsPath = join(agentDir, 'workspace-sessions') + const dirs = await readdir(sessionsPath) + for (const dir of dirs) { + const decoded = Buffer.from(dir.replace(/_$/, ''), 'base64').toString('utf-8') + if (decoded) return basename(decoded) + } + } catch {} + + return workspaceHash +} + +async function discoverSessions(agentDir: string, workspaceStorageDir: string, cliSessionsDir: string): Promise<SessionSource[]> { + const sources: SessionSource[] = [] + + // --- Kiro CLI sessions (~/.kiro/sessions/cli/) --- + try { + const cliEntries = await readdir(cliSessionsDir, { withFileTypes: true }) + for (const entry of cliEntries) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue + const jsonlPath = join(cliSessionsDir, entry.name) + // Derive project from companion .json + const metaPath = jsonlPath.replace(/\.jsonl$/, '.json') + let project = 'kiro-cli' + try { + const raw = await readFile(metaPath, 'utf-8') + const meta = JSON.parse(raw) as { cwd?: string } + if (meta.cwd) project = basename(meta.cwd) + } catch {} + sources.push({ path: jsonlPath, project, provider: 'kiro' }) + } + } catch {} + + // --- Kiro IDE sessions --- + let workspaceDirs: string[] + try { + const entries = await readdir(agentDir, { withFileTypes: true }) + workspaceDirs = entries.filter(e => e.isDirectory() && e.name.length === 32).map(e => e.name) + } catch { + return sources + } + + for (const wsHash of workspaceDirs) { + const wsPath = join(agentDir, wsHash) + const project = await resolveWorkspaceProject(agentDir, workspaceStorageDir, wsHash) + + let entries: Dirent[] + try { + entries = await readdir(wsPath, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + const entryPath = join(wsPath, entry.name) + if (entry.isFile() && (entry.name.endsWith('.chat') || extname(entry.name) === '')) { + sources.push({ path: entryPath, project, provider: 'kiro' }) + continue + } + + if (!entry.isDirectory()) continue + + const childEntries = await readdir(entryPath, { withFileTypes: true }).catch(() => []) + for (const child of childEntries) { + if (child.name.startsWith('.')) continue + if (!child.isFile()) continue + if (extname(child.name) !== '') continue + sources.push({ path: join(entryPath, child.name), project, provider: 'kiro' }) + } + } + } + + // --- Kiro IDE workspace-sessions (newer builds store session state here) --- + // These files contain history[].message with user prompts and assistant stubs + // plus executionId references. They capture sessions not written as per-execution files. + try { + const wsSessionsDir = join(agentDir, 'workspace-sessions') + const wsSessionDirs = await readdir(wsSessionsDir, { withFileTypes: true }) + for (const dir of wsSessionDirs) { + if (!dir.isDirectory()) continue + // Directory name is base64-encoded workspace path + let project = 'kiro-ide' + try { + const decoded = Buffer.from(dir.name.replace(/_/g, '='), 'base64').toString('utf-8') + if (decoded) project = basename(decoded) + } catch {} + // Skip bare homedir as project name + if (project === basename(homedir())) project = 'kiro-ide' + + const sessionFiles = await readdir(join(wsSessionsDir, dir.name), { withFileTypes: true }).catch(() => []) + for (const sf of sessionFiles) { + if (!sf.isFile() || !sf.name.endsWith('.json') || sf.name === 'sessions.json') continue + sources.push({ path: join(wsSessionsDir, dir.name, sf.name), project, provider: 'kiro' }) + } + } + } catch {} + + return sources +} + +export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string, cliSessionsDirOverride?: string): Provider { + const agentDirs = getKiroAgentDir(agentDirOverride) + const wsDir = getKiroWorkspaceStorageDir(workspaceStorageDirOverride) + // When overrides are provided (tests), don't scan real CLI sessions unless explicitly given + const cliDir = cliSessionsDirOverride ?? (agentDirOverride ? join(agentDirOverride, '..', 'cli-sessions') : join(process.env['KIRO_HOME'] || join(homedir(), '.kiro'), 'sessions', 'cli')) + + return { + name: 'kiro', + displayName: 'Kiro', + + modelDisplayName(model: string): string { + if (model === 'kiro-auto') return 'Kiro (auto)' + for (const [key, name] of modelDisplayEntries) { + if (model === key || model.startsWith(key + '-')) return name + } + return model + }, + + toolDisplayName(rawTool: string): string { + if (rawTool.startsWith('mcp__')) return rawTool + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + const allSources: SessionSource[] = [] + for (const agentDir of agentDirs) { + const sources = await discoverSessions(agentDir, wsDir, cliDir) + allSources.push(...sources) + } + // CLI sessions are only scanned once (first agentDir pass includes them); + // deduplicate by path in case multiple agentDirs share the same CLI dir. + const seen = new Set<string>() + return allSources.filter(s => { + if (seen.has(s.path)) return false + seen.add(s.path) + return true + }) + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const kiro = createKiroProvider() diff --git a/src/providers/lingtai-tui.ts b/src/providers/lingtai-tui.ts new file mode 100644 index 0000000..bec5d93 --- /dev/null +++ b/src/providers/lingtai-tui.ts @@ -0,0 +1,431 @@ +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<string, unknown> + +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<string | null> { + 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<string[]> { + 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<JsonObject>(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<LingTaiHome[]> { + 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<string>() + 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<T>(path: string): Promise<T | null> { + 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<LingTaiAgentManifest | null> { + const obj = asObject(await readJson<unknown>(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[]; subagentTypes: string[] } { + const normalized = sourceLabel.trim().toLowerCase() + + if (normalized === 'tc_wake' || normalized.startsWith('tc_') || normalized.includes('wake')) { + return { + userMessage: 'LingTai task coordinator wake', + tools: ['Agent'], + subagentTypes: ['lingtai-task-coordinator'], + } + } + + if (normalized === 'daemon') { + return { + userMessage: 'LingTai daemon task', + tools: ['Agent'], + subagentTypes: ['lingtai-daemon'], + } + } + + if (normalized === 'summarize_apriori' || normalized.includes('summar')) { + return { + userMessage: 'LingTai planning summary', + tools: ['EnterPlanMode'], + subagentTypes: [], + } + } + + return { + userMessage: normalized === 'main' + ? 'LingTai main conversation' + : `LingTai ${sourceLabel || 'main'} conversation`, + tools: [], + subagentTypes: [], + } +} + +async function discoverLedgersInHome(home: LingTaiHome): Promise<SessionSource[]> { + 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<SessionSource[]> { + const sources: SessionSource[] = [] + const seen = new Set<string>() + + 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<ParsedProviderCall> { + 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: [], + subagentTypes: activity.subagentTypes, + 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<SessionSource[]> { + return discoverLedgers(await getLingTaiHomes(providerOptions)) + }, + + createSessionParser(source: SessionSource): SessionParser { + return createParser(source) + }, + } +} + +export const lingtaiTui = createLingTaiTuiProvider() diff --git a/src/providers/mistral-vibe.ts b/src/providers/mistral-vibe.ts new file mode 100644 index 0000000..158a53a --- /dev/null +++ b/src/providers/mistral-vibe.ts @@ -0,0 +1,436 @@ +import { readdir, stat } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionFile, readSessionLines } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import { safeNumber } from '../parser.js' + +const METADATA_FILENAME = 'meta.json' +const MESSAGES_FILENAME = 'messages.jsonl' +const DEFAULT_MODEL = 'mistral-medium-3.5' + +const modelDisplayNames: Record<string, string> = { + 'mistral-medium-3.5': 'Mistral Medium 3.5', + 'mistral-vibe-cli-latest': 'Mistral Vibe CLI', + 'devstral-small': 'Devstral Small', + 'devstral-small-latest': 'Devstral Small', + devstral: 'Devstral', + local: 'Local', +} + +const toolNameMap: Record<string, string> = { + bash: 'Bash', + read_file: 'Read', + write_file: 'Write', + search_replace: 'Edit', + grep: 'Grep', + task: 'Agent', + todo: 'TodoWrite', + skill: 'Skill', + web_fetch: 'WebFetch', + web_search: 'WebSearch', + ask_user_question: 'AskUser', + exit_plan_mode: 'ExitPlanMode', +} + +type VibeStats = { + session_prompt_tokens?: number + session_completion_tokens?: number + session_cost?: number + input_price_per_million?: number + output_price_per_million?: number + tokens_per_second?: number +} + +type VibeModelConfig = { + name?: string + alias?: string + input_price?: number + output_price?: number +} + +type VibeMetadata = { + session_id?: string + start_time?: string + end_time?: string | null + environment?: { + working_directory?: string | null + } + stats?: VibeStats + config?: { + active_model?: string + models?: VibeModelConfig[] + } + title?: string | null +} + +type VibeToolCall = { + function?: { + name?: string + arguments?: string | Record<string, unknown> | null + } +} + +type VibeMessage = { + role?: string + content?: unknown + message_id?: string + timestamp?: string + tool_calls?: VibeToolCall[] | null +} + +function getMistralVibeSessionsDir(override?: string): string { + if (override) return override + const configuredHome = process.env['VIBE_HOME'] + const vibeHome = configuredHome ? expandHome(configuredHome) : join(homedir(), '.vibe') + return join(vibeHome, 'logs', 'session') +} + +function expandHome(path: string): string { + if (path === '~') return homedir() + if (path.startsWith('~/')) return join(homedir(), path.slice(2)) + return path +} + +async function isFile(path: string): Promise<boolean> { + const s = await stat(path).catch(() => null) + return Boolean(s?.isFile()) +} + +async function isDirectory(path: string): Promise<boolean> { + const s = await stat(path).catch(() => null) + return Boolean(s?.isDirectory()) +} + +async function hasSessionFiles(dir: string): Promise<boolean> { + const [hasMetadata, hasMessages] = await Promise.all([ + isFile(join(dir, METADATA_FILENAME)), + isFile(join(dir, MESSAGES_FILENAME)), + ]) + return hasMetadata && hasMessages +} + +async function readJsonFile<T>(path: string): Promise<T | null> { + const raw = await readSessionFile(path) + if (raw === null) return null + try { + const parsed = JSON.parse(raw) as unknown + return typeof parsed === 'object' && parsed !== null ? parsed as T : null + } catch { + return null + } +} + +async function discoverSessionDirs(root: string): Promise<string[]> { + const sessionDirs: string[] = [] + + let entries: string[] + try { + entries = (await readdir(root)).sort() + } catch { + return sessionDirs + } + + for (const entry of entries) { + const dir = join(root, entry) + if (!await isDirectory(dir)) continue + + if (await hasSessionFiles(dir)) { + sessionDirs.push(dir) + } + + const agentsDir = join(dir, 'agents') + if (!await isDirectory(agentsDir)) continue + + let agentEntries: string[] + try { + agentEntries = (await readdir(agentsDir)).sort() + } catch { + continue + } + + for (const agentEntry of agentEntries) { + const agentDir = join(agentsDir, agentEntry) + if (await isDirectory(agentDir) && await hasSessionFiles(agentDir)) { + sessionDirs.push(agentDir) + } + } + } + + return sessionDirs +} + +function activeModelConfig(metadata: VibeMetadata): VibeModelConfig | null { + const activeModel = metadata.config?.active_model + const models = metadata.config?.models + if (!activeModel || !Array.isArray(models)) return null + return models.find(m => m.alias === activeModel || m.name === activeModel) ?? null +} + +function resolveModel(metadata: VibeMetadata): string { + const activeModel = metadata.config?.active_model + if (activeModel) return activeModel + const configured = activeModelConfig(metadata) + return configured?.alias ?? configured?.name ?? DEFAULT_MODEL +} + +function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens: number, outputTokens: number): number { + const stats = metadata.stats ?? {} + const sessionCost = safeNumber(stats.session_cost) + if (sessionCost > 0) return sessionCost + + const configured = activeModelConfig(metadata) + const inputPrice = safeNumber(stats.input_price_per_million) || safeNumber(configured?.input_price) + const outputPrice = safeNumber(stats.output_price_per_million) || safeNumber(configured?.output_price) + + if (inputPrice > 0 || outputPrice > 0) { + return (inputTokens / 1_000_000) * inputPrice + (outputTokens / 1_000_000) * outputPrice + } + + return calculateCost(model, inputTokens, outputTokens, 0, 0, 0) +} + +function normalizeContent(content: unknown): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map(part => { + if (typeof part === 'string') return part + if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') return part.text + return '' + }) + .filter(Boolean) + .join(' ') + } + return '' +} + +function parseToolArguments(raw: string | Record<string, unknown> | null | undefined): Record<string, unknown> { + if (!raw) return {} + if (typeof raw === 'object') return raw + try { + const parsed = JSON.parse(raw) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {} + } catch { + return {} + } +} + +function extractMessageTools(message: VibeMessage): { tools: string[]; bashCommands: string[] } { + const tools: string[] = [] + const bashCommands: string[] = [] + + if (message.role !== 'assistant') return { tools, bashCommands } + + for (const toolCall of message.tool_calls ?? []) { + const rawName = toolCall.function?.name + if (!rawName) continue + + const mappedName = toolNameMap[rawName] ?? rawName + tools.push(mappedName) + + if (mappedName !== 'Bash') continue + const args = parseToolArguments(toolCall.function?.arguments) + const command = args['command'] + if (typeof command === 'string') { + bashCommands.push(...extractBashCommands(command)) + } + } + + return { + tools: [...new Set(tools)], + bashCommands: [...new Set(bashCommands)], + } +} + +function extractTools(messages: VibeMessage[]): { tools: string[]; bashCommands: string[] } { + const tools: string[] = [] + const bashCommands: string[] = [] + + for (const message of messages) { + const extracted = extractMessageTools(message) + tools.push(...extracted.tools) + bashCommands.push(...extracted.bashCommands) + } + + return { + tools: [...new Set(tools)], + bashCommands: [...new Set(bashCommands)], + } +} + +async function readMessages(path: string): Promise<VibeMessage[]> { + const messages: VibeMessage[] = [] + for await (const line of readSessionLines(path)) { + if (!line.trim()) continue + try { + const parsed = JSON.parse(line) as unknown + if (parsed && typeof parsed === 'object') messages.push(parsed as VibeMessage) + } catch { + continue + } + } + return messages +} + +function firstUserMessage(messages: VibeMessage[], fallback?: string | null): string { + for (const message of messages) { + if (message.role !== 'user') continue + const text = normalizeContent(message.content).trim() + if (text) return text.slice(0, 500) + } + return (fallback ?? '').slice(0, 500) +} + +function allocateInteger(total: number, index: number, count: number): number { + if (count <= 1) return total + const base = Math.floor(total / count) + const remainder = total % count + return base + (index < remainder ? 1 : 0) +} + +function allocateCost(total: number, count: number): number { + return count <= 1 ? total : total / count +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const metadataPath = join(source.path, METADATA_FILENAME) + const messagesPath = join(source.path, MESSAGES_FILENAME) + const metadata = await readJsonFile<VibeMetadata>(metadataPath) + if (!metadata) return + + const stats = metadata.stats ?? {} + const inputTokens = safeNumber(stats.session_prompt_tokens) + const outputTokens = safeNumber(stats.session_completion_tokens) + if (inputTokens === 0 && outputTokens === 0) return + + const sessionId = metadata.session_id || basename(source.path) + const messages = await readMessages(messagesPath) + const model = resolveModel(metadata) + const costUSD = calculateSessionCost(metadata, model, inputTokens, outputTokens) + const assistantMessages = messages.filter(m => m.role === 'assistant') + const fallbackTimestamp = metadata.end_time ?? metadata.start_time ?? '' + + if (assistantMessages.length === 0) { + const deduplicationKey = `mistral-vibe:${sessionId}` + if (seenKeys.has(deduplicationKey)) return + seenKeys.add(deduplicationKey) + const { tools, bashCommands } = extractTools(messages) + + yield { + provider: 'mistral-vibe', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp: fallbackTimestamp, + speed: 'standard', + deduplicationKey, + userMessage: firstUserMessage(messages, metadata.title), + sessionId, + } + return + } + + let currentUserMessage = (metadata.title ?? '').slice(0, 500) + let turnOrdinal = 0 + let currentTurnId = `${sessionId}:prelude` + let assistantOrdinal = 0 + + for (const message of messages) { + if (message.role === 'user') { + const text = normalizeContent(message.content).trim() + if (text) currentUserMessage = text.slice(0, 500) + currentTurnId = `${sessionId}:turn-${turnOrdinal++}` + continue + } + + if (message.role !== 'assistant') continue + + const messageKey = message.message_id || `idx-${assistantOrdinal}` + const deduplicationKey = `mistral-vibe:${sessionId}:${messageKey}` + const allocationIndex = assistantOrdinal + assistantOrdinal++ + + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + + const { tools, bashCommands } = extractMessageTools(message) + + yield { + provider: 'mistral-vibe', + model, + inputTokens: allocateInteger(inputTokens, allocationIndex, assistantMessages.length), + outputTokens: allocateInteger(outputTokens, allocationIndex, assistantMessages.length), + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: allocateCost(costUSD, assistantMessages.length), + tools, + bashCommands, + timestamp: message.timestamp ?? fallbackTimestamp, + speed: 'standard', + deduplicationKey, + turnId: currentTurnId, + userMessage: currentUserMessage, + sessionId, + } + } + }, + } +} + +export function createMistralVibeProvider(sessionsDir?: string): Provider { + const dir = getMistralVibeSessionsDir(sessionsDir) + + return { + name: 'mistral-vibe', + displayName: 'Mistral Vibe', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + const dirs = await discoverSessionDirs(dir) + const sources: SessionSource[] = [] + + for (const sessionDir of dirs) { + const metadata = await readJsonFile<VibeMetadata>(join(sessionDir, METADATA_FILENAME)) + if (!metadata) continue + const cwd = metadata.environment?.working_directory + sources.push({ + path: sessionDir, + project: cwd ? basename(cwd) : basename(sessionDir), + provider: 'mistral-vibe', + }) + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const mistralVibe = createMistralVibeProvider() diff --git a/src/providers/mux.ts b/src/providers/mux.ts new file mode 100644 index 0000000..4ebd919 --- /dev/null +++ b/src/providers/mux.ts @@ -0,0 +1,282 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { basename, dirname, join, resolve } from 'path' +import { homedir } from 'os' + +import { readSessionLines } 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' +import { safeNumber } from '../parser.js' + +const toolNameMap: Record<string, string> = { + bash: 'Bash', + file_read: 'Read', + file_edit_replace_string: 'Edit', + file_edit_replace_lines: 'Edit', + file_edit_insert: 'Edit', + file_edit_operation: 'Edit', + web_fetch: 'WebFetch', + web_search: 'WebSearch', + task: 'Agent', + todo: 'TodoWrite', +} + +type MuxPart = { + type?: string + text?: string + toolName?: string + input?: unknown +} + +type MuxMessage = { + id?: string + role?: string + parts?: MuxPart[] + createdAt?: string + metadata?: { + model?: string + timestamp?: number + historySequence?: number + usage?: unknown + providerMetadata?: Record<string, unknown> + } +} + +function expandHome(p: string): string { + if (p === '~') return homedir() + if (p.startsWith('~/') || p.startsWith('~\\')) return join(homedir(), p.slice(2)) + return p +} + +function getMuxRoot(override?: string): string { + if (override) return resolve(expandHome(override)) + const codeburnOverride = process.env['CODEBURN_MUX_DIR'] + if (codeburnOverride) return resolve(expandHome(codeburnOverride)) + const muxRoot = process.env['MUX_ROOT'] + if (muxRoot) return resolve(expandHome(muxRoot)) + return join(homedir(), '.mux') +} + +// Splits on the first colon only, leaving any colon inside the id intact. +function stripProvider(model: string): string { + const i = model.indexOf(':') + return i >= 0 ? model.slice(i + 1) : model +} + +function asRecord(v: unknown): Record<string, unknown> | undefined { + return v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : undefined +} + +// Guard against non-finite / out-of-range ms, which make toISOString() throw. +function toIsoTimestamp(ts: unknown, createdAt: unknown): string { + if (typeof ts === 'number' && Number.isFinite(ts)) { + const d = new Date(ts) + if (!Number.isNaN(d.getTime())) return d.toISOString() + } + return typeof createdAt === 'string' ? createdAt : '' +} + +// config.json shape: { projects: [[projectPath, { workspaces: [{ id }] }], ...] } +async function loadProjectMap(root: string): Promise<Map<string, string>> { + const map = new Map<string, string>() + let data: unknown + try { + data = JSON.parse(await readFile(join(root, 'config.json'), 'utf-8')) + } catch { + return map + } + const projects = asRecord(data)?.['projects'] + if (!Array.isArray(projects)) return map + for (const pair of projects) { + if (!Array.isArray(pair) || pair.length < 2) continue + const projectPath = pair[0] + if (typeof projectPath !== 'string') continue + const label = basename(projectPath) || projectPath + const workspaces = asRecord(pair[1])?.['workspaces'] + if (!Array.isArray(workspaces)) continue + for (const ws of workspaces) { + const id = asRecord(ws)?.['id'] + if (typeof id === 'string' && id) map.set(id, label) + } + } + return map +} + +async function pushChatSource(sources: SessionSource[], chatPath: string, project: string): Promise<void> { + const s = await stat(chatPath).catch(() => null) + if (s?.isFile()) sources.push({ path: chatPath, project, provider: 'mux' }) +} + +async function discoverSessions(root: string): Promise<SessionSource[]> { + const sessionsDir = join(root, 'sessions') + + let workspaceIds: string[] + try { + workspaceIds = await readdir(sessionsDir) + } catch { + return [] + } + + const projectMap = await loadProjectMap(root) + const sources: SessionSource[] = [] + for (const workspaceId of workspaceIds) { + const workspaceDir = join(sessionsDir, workspaceId) + const project = projectMap.get(workspaceId) ?? workspaceId + + // The workspace's own turns. + await pushChatSource(sources, join(workspaceDir, 'chat.jsonl'), project) + + // Sub-agent turns. Each spawned sub-agent is a separate LLM-client session + // recorded at subagent-transcripts/<childTaskId>/chat.jsonl — mux does NOT + // mirror these into a top-level sessions/<id> dir, so they are only + // reachable here. They carry real token usage (often the bulk of a + // session's spend) and are attributed to the parent workspace's project. + // Dedup stays correct: the parser keys off the child-task dir name, which + // is distinct from every workspace id, so each call is still counted once. + const subagentDir = join(workspaceDir, 'subagent-transcripts') + let childTaskIds: string[] + try { + childTaskIds = await readdir(subagentDir) + } catch { + continue + } + for (const childTaskId of childTaskIds) { + await pushChatSource(sources, join(subagentDir, childTaskId, 'chat.jsonl'), project) + } + } + return sources +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const workspaceId = basename(dirname(source.path)) + let pendingUserMessage = '' + let lineIdx = 0 + + for await (const line of readSessionLines(source.path)) { + lineIdx++ + let msg: MuxMessage + try { + msg = JSON.parse(line) as MuxMessage + } catch { + continue + } + if (!msg || typeof msg !== 'object') continue + + if (msg.role === 'user') { + const texts = (Array.isArray(msg.parts) ? msg.parts : []) + .filter(p => p?.type === 'text' && typeof p.text === 'string') + .map(p => p.text as string) + .filter(Boolean) + if (texts.length > 0) pendingUserMessage = texts.join(' ').slice(0, 500) + continue + } + + if (msg.role !== 'assistant') continue + const meta = msg.metadata + const usage = asRecord(meta?.usage) + if (!meta || !usage) continue + + const pm = meta.providerMetadata ?? {} + const anthropic = asRecord(pm['anthropic']) + + // mux reports inputTokens inclusive of cache read+creation and + // outputTokens inclusive of reasoning; decompose to codeburn's + // cache/reasoning-exclusive convention. Cache creation is Anthropic-only. + // The AI SDK v6 normalizes reasoning into usage.reasoningTokens across + // every provider family, so that field is the single source of truth. + const cacheRead = safeNumber(usage['cachedInputTokens']) + const cacheCreate = safeNumber(anthropic?.['cacheCreationInputTokens']) + const reasoning = safeNumber(usage['reasoningTokens']) + const inputTokens = Math.max(0, safeNumber(usage['inputTokens']) - cacheRead - cacheCreate) + const outputTokens = Math.max(0, safeNumber(usage['outputTokens']) - reasoning) + + if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && cacheCreate === 0 && reasoning === 0) { + continue + } + + // Strip the "provider:" prefix — codeburn's getCanonicalName only strips + // slash prefixes, so a colon-prefixed model would price at $0. + const rawModel = typeof meta.model === 'string' && meta.model ? meta.model : 'unknown' + const model = stripProvider(rawModel) + const id = typeof msg.id === 'string' && msg.id ? msg.id : `L${lineIdx}` + const dedupKey = `mux:${workspaceId}:${id}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const toolParts = (Array.isArray(msg.parts) ? msg.parts : []).filter( + p => p?.type === 'dynamic-tool' && typeof p.toolName === 'string', + ) + const tools = toolParts.map(p => toolNameMap[p.toolName!] ?? p.toolName!) + const bashCommands = toolParts + .filter(p => p.toolName === 'bash') + .flatMap(p => { + const input = asRecord(p.input) + const script = input?.['script'] ?? input?.['command'] + return typeof script === 'string' ? extractBashCommands(script) : [] + }) + + const costUSD = calculateCost( + model, + inputTokens, + outputTokens + reasoning, + cacheCreate, + cacheRead, + 0, + ) + + const timestamp = toIsoTimestamp(meta.timestamp, msg.createdAt) + + yield { + provider: 'mux', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: cacheCreate, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: reasoning, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId: workspaceId, + } + + pendingUserMessage = '' + } + }, + } +} + +export function createMuxProvider(muxRoot?: string): Provider { + const root = getMuxRoot(muxRoot) + + return { + name: 'mux', + displayName: 'Mux', + + modelDisplayName(model: string): string { + return getShortModelName(stripProvider(model)) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + return discoverSessions(root) + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const mux = createMuxProvider() 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<string, string>([ + ['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<string, unknown> { + 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<SessionSource[]> { + 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<SessionSource[]> { + 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<string>() + 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<SessionSource[]> { + 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<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + 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<SessionSource[]> { + return discoverOpenDesignSessions(overrideDir ?? getOpenDesignDir()) + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const openDesign = createOpenDesignProvider() diff --git a/src/providers/openclaw.ts b/src/providers/openclaw.ts new file mode 100644 index 0000000..bc6da53 --- /dev/null +++ b/src/providers/openclaw.ts @@ -0,0 +1,282 @@ +import { readdir, readFile } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const toolNameMap: Record<string, string> = { + bash: 'Bash', + exec: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + dispatch_agent: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + patch: 'Patch', +} + +type OpenClawUsage = { + input: number + output: number + cacheRead: number + cacheWrite: number + totalTokens?: number + cost?: { + total?: number + } +} + +type OpenClawEntry = { + type: string + customType?: string + id?: string + timestamp?: string + provider?: string + modelId?: string + data?: { + provider?: string + modelId?: string + } + message?: { + role?: string + content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record<string, unknown> }> + model?: string + provider?: string + usage?: OpenClawUsage + } +} + +type SessionIndex = Record<string, { + sessionId: string + sessionFile?: string +}> + +function getOpenClawDirs(): string[] { + const home = homedir() + return [ + join(home, '.openclaw', 'agents'), + join(home, '.clawdbot', 'agents'), + join(home, '.moltbot', 'agents'), + join(home, '.moldbot', 'agents'), + ] +} + +function extractTools(content: Array<{ type?: string; name?: string; arguments?: Record<string, unknown> }> | undefined): { tools: string[]; bashCommands: string[] } { + const tools: string[] = [] + const bashCommands: string[] = [] + if (!content) return { tools, bashCommands } + + for (const block of content) { + if ((block.type === 'tool_use' || block.type === 'toolCall') && block.name) { + const mapped = toolNameMap[block.name] ?? block.name + tools.push(mapped) + if (mapped === 'Bash' && block.arguments && typeof block.arguments.command === 'string') { + bashCommands.push(...extractBashCommands(block.arguments.command)) + } + } + } + return { tools, bashCommands } +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const raw = await readSessionFile(source.path) + if (raw === null) return + + const lines = raw.split('\n').filter(l => l.trim()) + let sessionId = '' + let sessionTimestamp = '' + let currentModel = '' + + const calls: { + model: string + usage: OpenClawUsage + tools: string[] + bashCommands: string[] + timestamp: string + userMessage: string + dedupId: string + }[] = [] + + let pendingUserMessage = '' + + for (const line of lines) { + let entry: OpenClawEntry + try { + entry = JSON.parse(line) + } catch { + continue + } + + if (entry.type === 'session') { + sessionId = entry.id ?? basename(source.path, '.jsonl') + sessionTimestamp = entry.timestamp ?? '' + continue + } + + if (entry.type === 'model_change') { + currentModel = entry.modelId ?? currentModel + continue + } + + if (entry.type === 'custom' && entry.customType === 'model-snapshot') { + currentModel = entry.data?.modelId ?? currentModel + continue + } + + if (entry.type !== 'message' || !entry.message) continue + + const msg = entry.message + if (msg.role === 'user') { + if (!pendingUserMessage && Array.isArray(msg.content)) { + const textBlock = msg.content.find(c => c.type === 'text' && c.text) + pendingUserMessage = (textBlock?.text ?? '').slice(0, 500) + } + continue + } + + if (msg.role !== 'assistant') continue + + const model = msg.model ?? currentModel + if (msg.usage) { + const { tools, bashCommands } = extractTools(msg.content) + calls.push({ + model, + usage: msg.usage, + tools, + bashCommands, + timestamp: entry.timestamp ?? sessionTimestamp, + userMessage: pendingUserMessage, + dedupId: entry.id ?? '', + }) + pendingUserMessage = '' + } + } + + if (!sessionId) sessionId = basename(source.path, '.jsonl') + + for (let i = 0; i < calls.length; i++) { + const call = calls[i] + const dedupKey = `openclaw:${sessionId}:${call.dedupId || i}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const u = call.usage + const costFromProvider = u.cost?.total ?? 0 + const costUSD = costFromProvider > 0 + ? costFromProvider + : calculateCost(call.model, u.input, u.output, u.cacheWrite, u.cacheRead, 0) + + const ts = new Date(call.timestamp) + if (isNaN(ts.getTime()) || ts.getTime() < 1_000_000_000_000) continue + + yield { + provider: 'openclaw', + model: call.model || 'openclaw-auto', + inputTokens: u.input, + outputTokens: u.output, + cacheCreationInputTokens: u.cacheWrite, + cacheReadInputTokens: u.cacheRead, + cachedInputTokens: u.cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [...new Set(call.tools)], + bashCommands: [...new Set(call.bashCommands)], + timestamp: ts.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: call.userMessage, + sessionId, + } + } + }, + } +} + +async function discoverInDir(agentsDir: string): Promise<SessionSource[]> { + const sources: SessionSource[] = [] + + let agentDirs: string[] + try { + const entries = await readdir(agentsDir, { withFileTypes: true }) + agentDirs = entries.filter(e => e.isDirectory()).map(e => e.name) + } catch { + return sources + } + + for (const agent of agentDirs) { + const sessionsDir = join(agentsDir, agent, 'sessions') + + let indexData: SessionIndex = {} + try { + const indexRaw = await readFile(join(sessionsDir, 'sessions.json'), 'utf-8') + indexData = JSON.parse(indexRaw) + } catch { /* no index, fall back to directory scan */ } + + const seenFiles = new Set<string>() + + for (const entry of Object.values(indexData)) { + if (entry.sessionFile) { + seenFiles.add(entry.sessionFile) + sources.push({ path: entry.sessionFile, project: agent, provider: 'openclaw' }) + } else if (entry.sessionId) { + const filePath = join(sessionsDir, `${entry.sessionId}.jsonl`) + seenFiles.add(filePath) + sources.push({ path: filePath, project: agent, provider: 'openclaw' }) + } + } + + try { + const files = await readdir(sessionsDir) + for (const f of files) { + if (!f.endsWith('.jsonl')) continue + const filePath = join(sessionsDir, f) + if (seenFiles.has(filePath)) continue + sources.push({ path: filePath, project: agent, provider: 'openclaw' }) + } + } catch { /* directory may not exist */ } + } + + return sources +} + +export function createOpenClawProvider(overrideDir?: string): Provider { + return { + name: 'openclaw', + displayName: 'OpenClaw', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + if (overrideDir) return discoverInDir(overrideDir) + const all: SessionSource[] = [] + for (const dir of getOpenClawDirs()) { + const sessions = await discoverInDir(dir) + all.push(...sessions) + } + return all + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const openclaw = createOpenClawProvider() diff --git a/src/providers/opencode-file-parser.ts b/src/providers/opencode-file-parser.ts new file mode 100644 index 0000000..60bfb6a --- /dev/null +++ b/src/providers/opencode-file-parser.ts @@ -0,0 +1,154 @@ +import { readdir, readFile } from 'fs/promises' +import { join } from '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/<projectID>/<sessionID>.json session metadata +// storage/message/<sessionID>/<messageID>.json one file per message +// storage/part/<messageID>/<partID>.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<T>(path: string): Promise<T | null> { + try { + return JSON.parse(await readFile(path, 'utf8')) as T + } catch { + return null + } +} + +async function readParts(dataDir: string, messageId: string): Promise<PartData[]> { + 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<PartData>(join(dir, f)) + if (part) parts.push(part) + } + return parts +} + +export async function discoverOpenCodeFileSessions( + dataDir: string, + providerName: string, +): Promise<SessionSource[]> { + 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<SessionMeta>(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<string>, + dataDir: string, + providerName: string, +): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const meta = await readJson<SessionMeta>(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<FileMessageData>(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 new file mode 100644 index 0000000..651f3cf --- /dev/null +++ b/src/providers/opencode.ts @@ -0,0 +1,79 @@ +import { join } from 'path' +import { homedir } from 'os' + +import { getShortModelName } from '../models.js' +import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js' +import { discoverOpenCodeFileSessions, createOpenCodeFileSessionParser } from './opencode-file-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const toolNameMap: Record<string, string> = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + skill: 'Skill', + patch: 'Patch', +} + +function getDataDir(dataDir?: string): string { + const base = + dataDir ?? + process.env['XDG_DATA_HOME'] ?? + join(homedir(), '.local', 'share') + return join(base, 'opencode') +} + +function getSqliteConfig(dataDir?: string): SqliteProviderConfig { + return { + providerName: 'opencode', + displayName: 'OpenCode', + dbDir: getDataDir(dataDir), + dbFilePrefix: 'opencode', + } +} + +export function createOpenCodeProvider(dataDir?: string): Provider { + const sqliteConfig = getSqliteConfig(dataDir) + const resolvedDataDir = getDataDir(dataDir) + + return { + name: 'opencode', + displayName: 'OpenCode', + + modelDisplayName(model: string): string { + const stripped = model.replace(/^[^/]+\//, '') + return getShortModelName(stripped) + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + // OpenCode migrated from file-based JSON (storage/session/*.json) to a + // SQLite DB (opencode.db). After an in-place upgrade, legacy JSON files + // remain on disk while all new data flows into the SQLite DB. Merge both + // sources so migrated installs keep reporting legacy sessions AND pick up + // current SQLite data. Dedup is handled per-message in createSessionParser + // via seenKeys (keyed by `${provider}:${sessionId}:${messageId}`). + async discoverSessions(): Promise<SessionSource[]> { + const fileSessions = await discoverOpenCodeFileSessions(resolvedDataDir, 'opencode') + const sqliteSessions = await discoverSqliteSessions(sqliteConfig) + return [...fileSessions, ...sqliteSessions] + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + if (source.path.endsWith('.json')) { + return createOpenCodeFileSessionParser(source, seenKeys, resolvedDataDir, 'opencode') + } + return createSqliteSessionParser(source, seenKeys, sqliteConfig) + }, + } +} + +export const opencode = createOpenCodeProvider() diff --git a/src/providers/pi.ts b/src/providers/pi.ts new file mode 100644 index 0000000..85d5f25 --- /dev/null +++ b/src/providers/pi.ts @@ -0,0 +1,314 @@ +import { readdir, stat } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import { normalizeContentBlocks } from '../content-utils.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const modelDisplayNames: Record<string, string> = { + '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', +} + +const toolNameMap: Record<string, string> = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + dispatch_agent: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + patch: 'Patch', +} + +// Pre-sorted by key length descending so longer/more-specific keys match first +const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) + +// Pi/OMP have no dedicated skill tool the way Claude Code does. A native skill +// load is emitted as an ordinary `read` tool call whose path points at the +// skill's `SKILL.md` (Pi resolves skills from many roots: ~/.pi/agent/skills, +// project .pi/skills, .agents/skills, package skills/, --skill <path>), or, in +// newer OMP builds, at a `skill://<name>` URI. Left untouched these inflate the +// Read tool count and leave the Skills dimension empty (issue #588). Return the +// skill name when a read is really a skill load, else null so it stays a Read. +function skillLoadName(name: string | undefined, args: Record<string, unknown> | undefined): string | null { + if (name !== 'read') return null + const raw = args?.['path'] ?? args?.['file_path'] + if (typeof raw !== 'string') return null + const path = raw.trim() + if (path.length === 0) return null + + if (path.startsWith('skill://')) { + const rest = path.slice('skill://'.length).replace(/^\/+/, '') + const first = rest.split(/[/?#]/)[0]?.trim() ?? '' + return first.length > 0 ? first : null + } + + // Match on the SKILL.md basename, not a directory prefix, because skill roots + // live in many locations. Split on both separators so Windows paths work. + const segments = path.split(/[\\/]/).filter(Boolean) + if (segments[segments.length - 1] !== 'SKILL.md') return null + const parent = segments[segments.length - 2]?.trim() + return parent && parent.length > 0 ? parent : null +} + +type PiEntry = { + type: string + id?: string + timestamp?: string + cwd?: string + message?: { + role?: string + content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record<string, unknown> }> | string + model?: string + responseId?: string + usage?: { + input: number + output: number + cacheRead: number + cacheWrite: number + } + } +} + +function getPiSessionsDir(override?: string): string { + return override ?? join(homedir(), '.pi', 'agent', 'sessions') +} + +function getOmpSessionsDir(override?: string): string { + return override ?? join(homedir(), '.omp', 'agent', 'sessions') +} + +async function readFirstEntry(filePath: string): Promise<PiEntry | null> { + const content = await readSessionFile(filePath) + if (content === null) return null + const line = content.split('\n')[0] + if (!line?.trim()) return null + try { + return JSON.parse(line) as PiEntry + } catch { + return null + } +} + +async function discoverSessionsInDir(sessionsDir: string, providerName: string): Promise<SessionSource[]> { + const sources: SessionSource[] = [] + + let projectDirs: string[] + try { + projectDirs = await readdir(sessionsDir) + } catch { + return sources + } + + for (const dirName of projectDirs) { + const dirPath = join(sessionsDir, dirName) + const dirStat = await stat(dirPath).catch(() => null) + if (!dirStat?.isDirectory()) continue + + let files: string[] + try { + files = await readdir(dirPath) + } catch { + continue + } + + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + const filePath = join(dirPath, file) + const fileStat = await stat(filePath).catch(() => null) + if (!fileStat?.isFile()) continue + + const first = await readFirstEntry(filePath) + if (!first || first.type !== 'session') continue + + const cwd = first.cwd ?? dirName + sources.push({ path: filePath, project: basename(cwd), provider: providerName }) + } + } + + return sources +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const content = await readSessionFile(source.path) + if (content === null) return + const lines = content.split('\n').filter(l => l.trim()) + let sessionId = basename(source.path, '.jsonl') + let pendingUserMessage = '' + + for (const [lineIdx, line] of lines.entries()) { + let entry: PiEntry + try { + entry = JSON.parse(line) as PiEntry + } catch { + continue + } + + if (entry.type === 'session') { + sessionId = entry.id ?? sessionId + continue + } + + if (entry.type !== 'message') continue + + const msg = entry.message + if (!msg) continue + + if (msg.role === 'user') { + const texts = normalizeContentBlocks(msg.content) + .filter(c => c.type === 'text') + .map(c => c.text ?? '') + .filter(Boolean) + if (texts.length > 0) pendingUserMessage = texts.join(' ') + continue + } + + if (msg.role !== 'assistant' || !msg.usage) continue + + // Coerce undefined/null token fields to 0. Pi/OMP session files + // sometimes omit individual usage fields; the destructure used to + // pass undefined into calculateCost which then returned NaN, and + // that NaN propagated into every aggregate cost total. + const input = msg.usage.input ?? 0 + const output = msg.usage.output ?? 0 + const cacheRead = msg.usage.cacheRead ?? 0 + const cacheWrite = msg.usage.cacheWrite ?? 0 + if (input === 0 && output === 0) continue + + const model = msg.model ?? 'gpt-5' + const responseId = msg.responseId ?? '' + const dedupKey = `${source.provider}:${source.path}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` + + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const toolCalls = normalizeContentBlocks(msg.content).filter(c => c.type === 'toolCall' && c.name) + + // A SKILL.md-loading read is surfaced as the `Skill` tool (not `Read`) + // and its name is recorded in `skills`. This mirrors how the Claude + // parser represents a skill invocation, so the shared classifier tags + // the turn `general` and the "Skills & Agents" breakdown picks it up, + // instead of over-counting a Read and leaving Skills empty (#588). + // Every other call stays a normal tool. + const tools: string[] = [] + const skills: string[] = [] + for (const c of toolCalls) { + const skill = skillLoadName(c.name, c.arguments) + if (skill !== null) { + skills.push(skill) + tools.push('Skill') + continue + } + tools.push(toolNameMap[c.name!] ?? c.name!) + } + + const bashCommands = toolCalls + .filter(c => c.name === 'bash') + .flatMap(c => { + const cmd = c.arguments?.['command'] + return typeof cmd === 'string' ? extractBashCommands(cmd) : [] + }) + + const costUSD = calculateCost(model, input, output, cacheWrite, cacheRead, 0) + const timestamp = entry.timestamp ?? '' + + yield { + provider: source.provider, + model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + skills, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + } + + pendingUserMessage = '' + } + }, + } +} + +export function createPiProvider(sessionsDir?: string): Provider { + const dir = getPiSessionsDir(sessionsDir) + + return { + name: 'pi', + displayName: 'Pi', + + modelDisplayName(model: string): string { + for (const [key, name] of modelDisplayEntries) { + if (model.startsWith(key)) return name + } + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + return discoverSessionsInDir(dir, 'pi') + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const pi = createPiProvider() + +export function createOmpProvider(sessionsDir?: string): Provider { + const dir = getOmpSessionsDir(sessionsDir) + + return { + name: 'omp', + displayName: 'OMP', + + modelDisplayName(model: string): string { + for (const [key, name] of modelDisplayEntries) { + if (model.startsWith(key)) return name + } + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + return discoverSessionsInDir(dir, 'omp') + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const omp = createOmpProvider() diff --git a/src/providers/qwen.ts b/src/providers/qwen.ts new file mode 100644 index 0000000..427b5fd --- /dev/null +++ b/src/providers/qwen.ts @@ -0,0 +1,204 @@ +import { readdir, stat } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const toolNameMap: Record<string, string> = { + read_file: 'Read', + write_to_file: 'Write', + edit_file: 'Edit', + execute_command: 'Bash', + search_files: 'Grep', + list_files: 'LS', + list_directory: 'LS', + browser_action: 'WebFetch', + web_search: 'WebSearch', + ask_followup_question: 'AskUser', + attempt_completion: 'Complete', +} + +type QwenPart = { + text?: string + thought?: boolean + functionCall?: { name?: string; args?: Record<string, unknown> } + functionResponse?: unknown +} + +type QwenEntry = { + uuid: string + sessionId: string + timestamp: string + type: string + subtype?: string + cwd?: string + model?: string + message?: { + role: string + parts: QwenPart[] + } + usageMetadata?: { + promptTokenCount: number + candidatesTokenCount: number + thoughtsTokenCount: number + totalTokenCount: number + cachedContentTokenCount: number + } +} + +function getQwenProjectsDir(): string { + return process.env['QWEN_DATA_DIR'] ?? join(homedir(), '.qwen', 'projects') +} + +function projectNameFromDirName(dirName: string): string { + const parts = dirName.replace(/^-/, '').split('-') + return parts[parts.length - 1] || dirName +} + +function extractTools(parts: QwenPart[]): { tools: string[]; bashCommands: string[] } { + const tools: string[] = [] + const bashCommands: string[] = [] + + for (const part of parts) { + if (part.functionCall?.name) { + const mapped = toolNameMap[part.functionCall.name] ?? part.functionCall.name + tools.push(mapped) + if (mapped === 'Bash' && part.functionCall.args && typeof part.functionCall.args['command'] === 'string') { + bashCommands.push(...extractBashCommands(part.functionCall.args['command'] as string)) + } + } + } + + return { tools, bashCommands } +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const raw = await readSessionFile(source.path) + if (raw === null) return + + const lines = raw.split('\n').filter(l => l.trim()) + let pendingUserMessage = '' + + for (const line of lines) { + let entry: QwenEntry + try { + entry = JSON.parse(line) + } catch { + continue + } + + if (entry.type === 'user' && entry.message) { + const texts = (entry.message.parts ?? []) + .filter(p => p.text && !p.thought) + .map(p => p.text!) + if (texts.length > 0) { + pendingUserMessage = texts.join(' ').slice(0, 500) + } + continue + } + + if (entry.type !== 'assistant' || !entry.usageMetadata) continue + + const usage = entry.usageMetadata + if (usage.promptTokenCount === 0 && usage.candidatesTokenCount === 0) continue + + const dedupKey = `qwen:${entry.sessionId}:${entry.uuid}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const model = entry.model || 'qwen-auto' + const { tools, bashCommands } = extractTools(entry.message?.parts ?? []) + + const inputTokens = usage.promptTokenCount + const outputTokens = usage.candidatesTokenCount + const reasoningTokens = usage.thoughtsTokenCount ?? 0 + const cachedTokens = usage.cachedContentTokenCount ?? 0 + + const costUSD = calculateCost(model, inputTokens, outputTokens + reasoningTokens, 0, cachedTokens, 0) + + yield { + provider: 'qwen', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: cachedTokens, + cachedInputTokens: cachedTokens, + reasoningTokens, + webSearchRequests: 0, + costUSD, + tools: [...new Set(tools)], + bashCommands: [...new Set(bashCommands)], + timestamp: entry.timestamp || '', + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId: entry.sessionId, + } + + pendingUserMessage = '' + } + }, + } +} + +export function createQwenProvider(overrideDir?: string): Provider { + const projectsDir = overrideDir ?? getQwenProjectsDir() + + return { + name: 'qwen', + displayName: 'Qwen', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + const sources: SessionSource[] = [] + + let projectDirs: string[] + try { + projectDirs = await readdir(projectsDir) + } catch { + return sources + } + + for (const projDir of projectDirs) { + const chatsDir = join(projectsDir, projDir, 'chats') + const project = projectNameFromDirName(projDir) + + let chatFiles: string[] + try { + chatFiles = await readdir(chatsDir) + } catch { + continue + } + + for (const file of chatFiles) { + if (!file.endsWith('.jsonl')) continue + const filePath = join(chatsDir, file) + const s = await stat(filePath).catch(() => null) + if (!s?.isFile()) continue + sources.push({ path: filePath, project, provider: 'qwen' }) + } + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const qwen = createQwenProvider() diff --git a/src/providers/roo-code.ts b/src/providers/roo-code.ts new file mode 100644 index 0000000..4059d96 --- /dev/null +++ b/src/providers/roo-code.ts @@ -0,0 +1,29 @@ +import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js' +import type { Provider, SessionSource, SessionParser } from './types.js' + +const EXTENSION_ID = 'rooveterinaryinc.roo-cline' + +export function createRooCodeProvider(overrideDir?: string | string[]): Provider { + return { + name: 'roo-code', + displayName: 'Roo Code', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + return discoverClineTasks(EXTENSION_ID, 'roo-code', 'Roo Code', overrideDir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createClineParser(source, seenKeys, 'roo-code') + }, + } +} + +export const rooCode = createRooCodeProvider() diff --git a/src/providers/session-message.ts b/src/providers/session-message.ts new file mode 100644 index 0000000..f4e4eea --- /dev/null +++ b/src/providers/session-message.ts @@ -0,0 +1,167 @@ +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 (OpenCode SQLite, the +// OpenCode file-based JSON layout, and Kilo Code). 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; name?: string; subagent_type?: string } } +} + +const toolNameMap: Record<string, string> = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + skill: 'Skill', + patch: 'Patch', +} + +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 by the SQLite and file-based +// OpenCode parsers so both 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 + 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!)) + + // The skill/subagent name lives in the tool-call input, not the tool name, so + // the Skills & Agents breakdown needs these extracted alongside the tool list. + const skills = toolParts + .filter((p) => p.tool === 'skill' && typeof p.state?.input?.name === 'string') + .map((p) => p.state!.input!.name!) + .filter(Boolean) + + const subagentTypes = toolParts + .filter((p) => p.tool === 'task' && typeof p.state?.input?.subagent_type === 'string') + .map((p) => p.state!.input!.subagent_type!) + .filter(Boolean) + + 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, + skills, + subagentTypes, + timestamp: parseTimestamp(opts.timeCreatedMs), + speed: 'standard', + deduplicationKey: opts.dedupKey, + userMessage: opts.userMessage, + sessionId: opts.sessionId, + } +} diff --git a/src/providers/sqlite-session-parser.ts b/src/providers/sqlite-session-parser.ts new file mode 100644 index 0000000..7d81e9d --- /dev/null +++ b/src/providers/sqlite-session-parser.ts @@ -0,0 +1,327 @@ +import { readdir } from 'fs/promises' +import { join } from 'path' + +import { calculateCost } from '../models.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' +import { buildAssistantCall, parseTimestamp, sanitize, type MessageData, type PartData } from './session-message.js' +import type { + SessionSource, + SessionParser, + ParsedProviderCall, +} from './types.js' + +type MessageRow = { + session_id: string + id: string + time_created: number + data: Uint8Array | string +} + +type PartRow = { + message_id: string + data: Uint8Array | string +} + +type SessionRow = { + id: string + directory: Uint8Array | string + title: Uint8Array | string + time_created: number +} + +type SessionTokenRow = { + cost?: number + tokens_input?: number + tokens_output?: number + tokens_reasoning?: number + tokens_cache_read?: number + tokens_cache_write?: number + model_id?: string +} + +function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): { + cost: number; input: number; output: number; reasoning: number + cacheRead: number; cacheWrite: number; model: string | undefined +} | null { + try { + const rows = db.query<SessionTokenRow>( + `SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, model_id FROM session WHERE id = ?`, + [sessionId], + ) + if (rows.length === 0) return null + const r = rows[0]! + return { + cost: r.cost ?? 0, + input: r.tokens_input ?? 0, + output: r.tokens_output ?? 0, + reasoning: r.tokens_reasoning ?? 0, + cacheRead: r.tokens_cache_read ?? 0, + cacheWrite: r.tokens_cache_write ?? 0, + model: r.model_id ?? undefined, + } + } catch { + return null + } +} + +type SchemaCheckResult = { ok: true } | { ok: false; missing: string[] } + +function validateSchemaDetailed(db: SqliteDatabase): SchemaCheckResult { + const required = ['session', 'message', 'part'] + const missing: string[] = [] + for (const table of required) { + try { + db.query<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table} LIMIT 1`) + } catch (err) { + if (isSqliteBusyError(err)) throw err + missing.push(table) + } + } + return missing.length === 0 ? { ok: true } : { ok: false, missing } +} + +const warnedSchemas = new Map<string, Set<string>>() + +function warnUnrecognizedSchemaOnce(providerLabel: string, missing: string[]): void { + const providerSet = warnedSchemas.get(providerLabel) ?? new Set() + const key = missing.slice().sort().join(',') + if (providerSet.has(key)) return + providerSet.add(key) + warnedSchemas.set(providerLabel, providerSet) + process.stderr.write( + `codeburn: ${providerLabel} database is missing expected tables (${missing.join(', ')}). ` + + `Run ${providerLabel} once to apply migrations, or report at https://github.com/getagentseal/codeburn/issues if this persists.\n` + ) +} + +export type SqliteProviderConfig = { + providerName: string + displayName: string + dbDir: string + dbFilePrefix: string +} + +export function createSqliteSessionParser( + source: SessionSource, + seenKeys: Set<string>, + config: SqliteProviderConfig, +): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + 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 ${config.displayName} database: ${err instanceof Error ? err.message : err}\n`) + return + } + + try { + const schema = validateSchemaDetailed(db) + if (!schema.ok) { + warnUnrecognizedSchemaOnce(config.displayName, schema.missing) + return + } + + const messages = db.query<MessageRow>( + `WITH RECURSIVE session_tree(id) AS ( + SELECT id FROM session WHERE id = ? + UNION + SELECT child.id + FROM session child + JOIN session_tree parent ON child.parent_id = parent.id + WHERE child.time_archived IS NULL + ) + SELECT session_id, id, time_created, CAST(data AS BLOB) AS data + FROM message + WHERE session_id IN (SELECT id FROM session_tree) + ORDER BY time_created ASC, id ASC`, + [sessionId], + ) + + const parts = db.query<PartRow>( + `WITH RECURSIVE session_tree(id) AS ( + SELECT id FROM session WHERE id = ? + UNION + SELECT child.id + FROM session child + JOIN session_tree parent ON child.parent_id = parent.id + WHERE child.time_archived IS NULL + ) + SELECT message_id, CAST(data AS BLOB) AS data + FROM part + WHERE session_id IN (SELECT id FROM session_tree) + ORDER BY message_id, id`, + [sessionId], + ) + + const partsByMsg = new Map<string, PartData[]>() + for (const part of parts) { + try { + const parsed = JSON.parse(blobToText(part.data)) as PartData + const list = partsByMsg.get(part.message_id) ?? [] + list.push(parsed) + partsByMsg.set(part.message_id, list) + } catch { + // skip corrupt part data + } + } + + const currentUserMessageBySession = new Map<string, string>() + let yieldCount = 0 + let parseFailCount = 0 + let roleSkipCount = 0 + + for (const msg of messages) { + let data: MessageData + try { + data = JSON.parse(blobToText(msg.data)) as MessageData + } catch { + parseFailCount++ + continue + } + + if (data.role === 'user') { + const textParts = (partsByMsg.get(msg.id) ?? []) + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .filter(Boolean) + if (textParts.length > 0) { + currentUserMessageBySession.set(msg.session_id, textParts.join(' ')) + } + continue + } + + if (data.role !== 'assistant' && data.role !== 'model') { + if (data.role !== 'user') roleSkipCount++ + continue + } + + const dedupKey = `${config.providerName}:${msg.session_id}:${msg.id}` + if (seenKeys.has(dedupKey)) continue + + const call = buildAssistantCall({ + providerName: config.providerName, + dedupKey, + sessionId, + data, + parts: partsByMsg.get(msg.id) ?? [], + timeCreatedMs: msg.time_created, + userMessage: currentUserMessageBySession.get(msg.session_id) ?? '', + }) + if (!call) continue + + seenKeys.add(dedupKey) + yieldCount++ + yield call + } + + if (yieldCount === 0 && messages.length > 0) { + const sessionTokens = tryQuerySessionTokens(db, sessionId) + if (sessionTokens && (sessionTokens.cost > 0 || sessionTokens.input > 0 || sessionTokens.output > 0)) { + const dedupKey = `${config.providerName}:${sessionId}:session-level` + if (!seenKeys.has(dedupKey)) { + seenKeys.add(dedupKey) + const model = sessionTokens.model ?? 'unknown' + let costUSD = calculateCost(model, sessionTokens.input, sessionTokens.output, sessionTokens.cacheWrite, sessionTokens.cacheRead, 0) + if (costUSD === 0 && sessionTokens.cost > 0) costUSD = sessionTokens.cost + yield { + provider: config.providerName, + model, + inputTokens: sessionTokens.input, + outputTokens: sessionTokens.output, + cacheCreationInputTokens: sessionTokens.cacheWrite, + cacheReadInputTokens: sessionTokens.cacheRead, + cachedInputTokens: sessionTokens.cacheRead, + reasoningTokens: sessionTokens.reasoning, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp: parseTimestamp(messages[0]!.time_created), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId, + } + yieldCount++ + } + } + + if (yieldCount === 0 && process.env['CODEBURN_VERBOSE'] === '1') { + process.stderr.write( + `codeburn: ${config.displayName} session ${sessionId} has ${messages.length} messages ` + + `(${parseFailCount} unparseable, ${roleSkipCount} non-user/assistant roles) ` + + `but yielded 0 calls. Parts: ${parts.length}.\n` + ) + } + } + } finally { + db.close() + } + }, + } +} + +export async function discoverSqliteSessions( + config: SqliteProviderConfig, +): Promise<SessionSource[]> { + if (!isSqliteAvailable()) return [] + + let dbPaths: string[] + try { + const entries = await readdir(config.dbDir) + dbPaths = entries + .filter((f) => f.startsWith(config.dbFilePrefix) && f.endsWith('.db')) + .map((f) => join(config.dbDir, f)) + } catch { + return [] + } + + if (dbPaths.length === 0) return [] + + const sessions: SessionSource[] = [] + for (const dbPath of dbPaths) { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + continue + } + + try { + const schema = validateSchemaDetailed(db) + if (!schema.ok) continue + + const rows = db.query<SessionRow>( + 'SELECT id, CAST(directory AS BLOB) AS directory, CAST(title AS BLOB) AS title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC', + ) + + for (const row of rows) { + const dir = blobToText(row.directory) + const title = blobToText(row.title) + sessions.push({ + path: `${dbPath}:${row.id}`, + project: dir ? sanitize(dir) : sanitize(title), + provider: config.providerName, + }) + } + } catch { + // skip this DB + } finally { + db.close() + } + } + + return sessions +} + diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..113f279 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,63 @@ +import type { DateRange, ToolCall } from '../types.js' + +export type SessionSource = { + path: string + project: string + provider: string + sourceId?: string + sourceLabel?: string + sourcePath?: string + sourceKind?: 'claude-config' | 'claude-desktop' +} + +export type SessionParser = { + parse(): AsyncGenerator<ParsedProviderCall> +} + +export type ParsedProviderCall = { + provider: string + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + costUSD: number + costIsEstimated?: boolean + tools: string[] + bashCommands: string[] + // Subagent types spawned in this call (e.g. 'general-purpose'). Feeds the + // Skills & Agents breakdown; optional since most providers don't expose it. + subagentTypes?: string[] + // Skill names invoked in this call (e.g. 'commit'). Feeds the Skills & Agents + // breakdown; optional since most providers don't expose it. + skills?: string[] + timestamp: string + speed: 'standard' | 'fast' + deduplicationKey: string + turnId?: string + toolSequence?: ToolCall[][] + userMessage: string + sessionId: string + project?: string + projectPath?: string +} + +export type Provider = { + name: string + displayName: string + // Data comes from a live API fetch (no on-disk file). Such sources can't be + // fingerprinted or incrementally cached, so the parser re-fetches every run. + network?: boolean + // Source data is managed by an external process that may prune old records + // (e.g. VS Code's OTel agent-traces.db). Cached entries for discovered paths + // are never evicted, and orphaned entries (paths no longer discovered) are + // kept and included in query-time aggregation so the monthly total never drops. + durableSources?: boolean + modelDisplayName(model: string): string + toolDisplayName(rawTool: string): string + discoverSessions(): Promise<SessionSource[]> + createSessionParser(source: SessionSource, seenKeys: Set<string>, dateRange?: DateRange): SessionParser +} diff --git a/src/providers/vercel-gateway.ts b/src/providers/vercel-gateway.ts new file mode 100644 index 0000000..9968d6d --- /dev/null +++ b/src/providers/vercel-gateway.ts @@ -0,0 +1,151 @@ +import type { DateRange } from '../types.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import { fetchWithTimeout } from '../fetch-utils.js' + +const REPORT_URL = 'https://ai-gateway.vercel.sh/v1/report' + +type ReportRow = { + day?: string + model?: string + total_cost?: number + input_tokens?: number + output_tokens?: number + cached_input_tokens?: number + cache_creation_input_tokens?: number + reasoning_tokens?: number + request_count?: number +} + +export function getVercelGatewayApiKey(): string | null { + const key = process.env['AI_GATEWAY_API_KEY'] ?? process.env['VERCEL_OIDC_TOKEN'] + return key?.trim() ? key.trim() : null +} + +function formatUtcDate(d: Date): string { + const y = d.getUTCFullYear() + const m = String(d.getUTCMonth() + 1).padStart(2, '0') + const day = String(d.getUTCDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +export async function fetchVercelGatewayReport( + dateRange: DateRange, +): Promise<ReportRow[]> { + const key = getVercelGatewayApiKey() + if (!key) return [] + + const params = new URLSearchParams({ + start_date: formatUtcDate(dateRange.start), + end_date: formatUtcDate(dateRange.end), + date_part: 'day', + group_by: 'model', + }) + + try { + const res = await fetchWithTimeout(`${REPORT_URL}?${params}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${key}`, + Accept: 'application/json', + }, + }) + + if (!res.ok) { + const detail = await res.text().catch(() => '') + process.stderr.write( + `codeburn: Vercel AI Gateway report failed (HTTP ${res.status}). ` + + 'Requires AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN (Pro/Enterprise for /v1/report). ' + + `${detail.slice(0, 200)}\n`, + ) + return [] + } + + const body = (await res.json()) as { results?: ReportRow[] } + return body.results ?? [] + } catch (err) { + process.stderr.write( + `codeburn: Vercel AI Gateway report unreachable (${err instanceof Error ? err.message : String(err)}).\n`, + ) + return [] + } +} + +function createParser( + source: SessionSource, + seenKeys: Set<string>, + dateRange?: DateRange, +): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + if (!dateRange) return + + const rows = await fetchVercelGatewayReport(dateRange) + for (const row of rows) { + const day = row.day ?? '' + const model = row.model ?? 'unknown' + const costUSD = row.total_cost ?? 0 + const inputTokens = row.input_tokens ?? 0 + const outputTokens = row.output_tokens ?? 0 + if (costUSD === 0 && inputTokens === 0 && outputTokens === 0) continue + + const deduplicationKey = `vercel-gateway:${day}:${model}` + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + + yield { + provider: 'vercel-gateway', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: row.cache_creation_input_tokens ?? 0, + cacheReadInputTokens: row.cached_input_tokens ?? 0, + cachedInputTokens: 0, + reasoningTokens: row.reasoning_tokens ?? 0, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp: day ? `${day}T12:00:00.000Z` : '', + speed: 'standard', + deduplicationKey, + userMessage: '', + sessionId: `${day}:${model}`, + project: source.project, + } + } + }, + } +} + +export const vercelGateway: Provider = { + name: 'vercel-gateway', + displayName: 'Vercel AI Gateway', + network: true, + + modelDisplayName(model: string): string { + const slash = model.indexOf('/') + return slash >= 0 ? model.slice(slash + 1) : model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + if (!getVercelGatewayApiKey()) return [] + + return [{ + path: 'vercel-ai-gateway:report', + project: 'Vercel AI Gateway', + provider: 'vercel-gateway', + }] + }, + + createSessionParser( + source: SessionSource, + seenKeys: Set<string>, + dateRange?: DateRange, + ): SessionParser { + return createParser(source, seenKeys, dateRange) + }, +} diff --git a/src/providers/vscode-cline-parser.ts b/src/providers/vscode-cline-parser.ts new file mode 100644 index 0000000..2535a22 --- /dev/null +++ b/src/providers/vscode-cline-parser.ts @@ -0,0 +1,222 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { basename, join, posix, win32 } from 'path' +import { homedir } from 'os' + +import { calculateCost } from '../models.js' +import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +type UiMessage = { + type?: string + say?: string + text?: string + ts?: number +} + +export function getVSCodeGlobalStoragePaths(extensionId: string, homeDir = homedir(), platform = process.platform): string[] { + const pathJoin = platform === 'win32' ? win32.join : posix.join + + if (platform === 'darwin') { + return [ + pathJoin(homeDir, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, 'Library', 'Application Support', 'Code - Insiders', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, 'Library', 'Application Support', 'VSCodium', 'User', 'globalStorage', extensionId), + ] + } + + if (platform === 'win32') { + return [ + pathJoin(homeDir, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, 'AppData', 'Roaming', 'Code - Insiders', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, 'AppData', 'Roaming', 'VSCodium', 'User', 'globalStorage', extensionId), + ] + } + + return [ + pathJoin(homeDir, '.config', 'Code', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, '.config', 'Code - Insiders', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, '.config', 'VSCodium', 'User', 'globalStorage', extensionId), + ] +} + +export function getVSCodeGlobalStoragePath(extensionId: string): string { + return getVSCodeGlobalStoragePaths(extensionId)[0]! +} + +export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string | string[]): Promise<SessionSource[]> { + const baseDirs = overrideDir + ? (Array.isArray(overrideDir) ? overrideDir : [overrideDir]) + : getVSCodeGlobalStoragePaths(extensionId) + return discoverClineTasksInBaseDirs(baseDirs, providerName, displayName) +} + +export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise<SessionSource[]> { + const sources: SessionSource[] = [] + const seen = new Set<string>() + for (const baseDir of baseDirs) { + for (const source of await discoverClineTasksInBaseDir(baseDir, providerName, displayName)) { + if (seen.has(source.path)) continue + seen.add(source.path) + sources.push(source) + } + } + return sources +} + +async function discoverClineTasksInBaseDir(baseDir: string, providerName: string, displayName: string): Promise<SessionSource[]> { + const tasksDir = join(baseDir, 'tasks') + const sources: SessionSource[] = [] + + let taskDirs: string[] + try { + taskDirs = await readdir(tasksDir) + } catch { + return sources + } + + for (const taskId of taskDirs) { + const taskDir = join(tasksDir, taskId) + const dirStat = await stat(taskDir).catch(() => null) + if (!dirStat?.isDirectory()) continue + + const uiPath = join(taskDir, 'ui_messages.json') + const uiStat = await stat(uiPath).catch(() => null) + if (!uiStat?.isFile()) continue + + sources.push({ path: taskDir, project: displayName, provider: providerName }) + } + + return sources +} + +const MODEL_TAG_RE = /<model>([^<]+)<\/model>/ +const WORKSPACE_DIR_RE = /Current Workspace Directory \(([^)]+)\)/ + +type HistoryMeta = { model: string; workspace: string | null } + +function extractHistoryMeta(taskDir: string, fallbackModel: string): Promise<HistoryMeta> { + return readFile(join(taskDir, 'api_conversation_history.json'), 'utf-8') + .then(raw => { + const msgs = JSON.parse(raw) as Array<{ role?: string; content?: Array<{ text?: string }> }> + if (!Array.isArray(msgs)) return { model: fallbackModel, workspace: null } + let model: string | null = null + let workspace: string | null = null + for (const msg of msgs) { + if (msg.role !== 'user' || !Array.isArray(msg.content)) continue + for (const block of msg.content) { + if (typeof block.text !== 'string') continue + if (!model) { + const mm = MODEL_TAG_RE.exec(block.text) + if (mm) model = mm[1].includes('/') ? mm[1].split('/').pop()! : mm[1] + } + if (!workspace) { + const wm = WORKSPACE_DIR_RE.exec(block.text) + if (wm) workspace = wm[1] + } + if (model && workspace) break + } + if (model && workspace) break + } + return { model: model ?? fallbackModel, workspace } + }) + .catch(() => ({ model: fallbackModel, workspace: null })) +} + +function workspaceToProject(workspace: string): string { + return basename(workspace) || workspace +} + +export function createClineParser(source: SessionSource, seenKeys: Set<string>, providerName: string, fallbackModel = 'cline-auto'): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const taskDir = source.path + const taskId = basename(taskDir) + + let uiRaw: string + try { + uiRaw = await readFile(join(taskDir, 'ui_messages.json'), 'utf-8') + } catch { + return + } + + let uiMessages: UiMessage[] + try { + uiMessages = JSON.parse(uiRaw) + } catch { + return + } + + if (!Array.isArray(uiMessages)) return + + const meta = await extractHistoryMeta(taskDir, fallbackModel) + const model = meta.model + const project = meta.workspace ? workspaceToProject(meta.workspace) : undefined + const projectPath = meta.workspace ?? undefined + + let userMessage = '' + for (const msg of uiMessages) { + if (msg.type === 'say' && (msg.say === 'user_feedback' || msg.say === 'text')) { + userMessage = (msg.text ?? '').slice(0, 500) + break + } + } + + const apiReqEntries = uiMessages.filter(m => m.type === 'say' && m.say === 'api_req_started') + + for (const [index, entry] of apiReqEntries.entries()) { + const dedupKey = `${providerName}:${taskId}:${index}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + let tokensIn = 0 + let tokensOut = 0 + let cacheReads = 0 + let cacheWrites = 0 + let cost: number | undefined + + if (entry.text) { + try { + const parsed = JSON.parse(entry.text) as { + tokensIn?: number + tokensOut?: number + cacheReads?: number + cacheWrites?: number + cost?: number + } + tokensIn = parsed.tokensIn ?? 0 + tokensOut = parsed.tokensOut ?? 0 + cacheReads = parsed.cacheReads ?? 0 + cacheWrites = parsed.cacheWrites ?? 0 + cost = parsed.cost + } catch {} + } + + if (tokensIn === 0 && tokensOut === 0) continue + + const timestamp = entry.ts ? new Date(entry.ts).toISOString() : '' + const costUSD = cost ?? calculateCost(model, tokensIn, tokensOut, cacheWrites, cacheReads, 0) + + yield { + provider: providerName, + model, + inputTokens: tokensIn, + outputTokens: tokensOut, + cacheCreationInputTokens: cacheWrites, + cacheReadInputTokens: cacheReads, + cachedInputTokens: cacheReads, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: index === 0 ? userMessage : '', + sessionId: taskId, + project, + projectPath, + } + } + }, + } +} diff --git a/src/providers/warp.ts b/src/providers/warp.ts new file mode 100644 index 0000000..9dec7ac --- /dev/null +++ b/src/providers/warp.ts @@ -0,0 +1,500 @@ +import { join } from 'path' +import { homedir } from 'os' + +import { extractBashCommands } from '../bash-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import { blobToText, getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' +import { safeNumber } from '../parser.js' + +const WARP_GROUP_CONTAINER = '2BBY89MBSN.dev.warp' +const WARP_STABLE_BUNDLE_ID = 'dev.warp.Warp-Stable' +const WARP_PREVIEW_BUNDLE_ID = 'dev.warp.Warp-Preview' +const PRIMARY_AGENT_CATEGORY = 'primary_agent' +const CHARS_PER_TOKEN = 4 + +const modelAliases: Record<string, string> = { + 'Claude Sonnet 4.6': 'claude-sonnet-4-6', + 'Claude Sonnet 4.5': 'claude-sonnet-4-5', + 'Claude Haiku 4.5': 'claude-haiku-4-5', + 'Claude Opus 4.6': 'claude-opus-4-6', + 'GPT-5.3 Codex (low reasoning)': 'gpt-5.3-codex', + 'GPT-5.3 Codex (medium reasoning)': 'gpt-5.3-codex', + 'GPT-5.3 Codex (high reasoning)': 'gpt-5.3-codex', + 'GPT-5.3 Codex (extra high reasoning)': 'gpt-5.3-codex', + 'auto-efficient': 'warp-auto-efficient', + 'auto-powerful': 'warp-auto-powerful', +} + +type WarpConversationRow = { + conversation_id: string + conversation_data: string + last_modified_at: string | null +} + +type WarpQueryRow = { + exchange_id: string + conversation_id: string + start_ts: string + input: string + working_directory: string | null + output_status: string + model_id: string + planning_model_id: string + coding_model_id: string +} + +type WarpBlockRow = { + block_id: string + start_ts: string | null + stylized_command: Uint8Array | string | null +} + +type WarpTokenUsageEntry = { + model_id?: string + warp_tokens?: number + byok_tokens?: number + warp_token_usage_by_category?: Record<string, unknown> + byok_token_usage_by_category?: Record<string, unknown> +} + +type WarpConversationData = { + conversation_usage_metadata?: { + token_usage?: WarpTokenUsageEntry[] + } +} + +type ParsedExchange = WarpQueryRow & { + startMs: number +} + +type ExchangeToolInfo = { + tools: string[] + bashCommands: string[] +} + +function sanitizeProject(path: string): string { + return path.replace(/^\/+/, '').replace(/\//g, '-') +} + +function warpDbPath(bundleId: string): string { + return join( + homedir(), + 'Library', + 'Group Containers', + WARP_GROUP_CONTAINER, + 'Library', + 'Application Support', + bundleId, + 'warp.sqlite', + ) +} + +function getDbCandidates(dbPathOverride?: string): string[] { + if (dbPathOverride) return [dbPathOverride] + if (process.env['WARP_DB_PATH']) return [process.env['WARP_DB_PATH']] + return [warpDbPath(WARP_STABLE_BUNDLE_ID), warpDbPath(WARP_PREVIEW_BUNDLE_ID)] +} + +function normalizeModel(rawModel: string): string { + const model = rawModel.trim() + if (!model) return model + return modelAliases[model] ?? model +} + +function modelDisplayName(model: string): string { + if (model === 'warp-auto-efficient') return 'Warp Auto (efficient)' + if (model === 'warp-auto-powerful') return 'Warp Auto (powerful)' + return getShortModelName(model) +} + +function parseTimestamp(raw: string | null | undefined): number | null { + if (!raw) return null + const trimmed = raw.trim() + if (!trimmed) return null + const withT = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T') + const lastPlus = withT.lastIndexOf('+') + const lastMinus = withT.lastIndexOf('-') + const hasOffset = lastPlus > 9 || lastMinus > 9 + const hasTimezone = withT.endsWith('Z') || hasOffset + const normalized = hasTimezone ? withT : `${withT}Z` + const ms = Date.parse(normalized) + return Number.isNaN(ms) ? null : ms +} + +function parseJsonString(raw: string): string { + try { + const parsed = JSON.parse(raw) as unknown + return typeof parsed === 'string' ? parsed : raw + } catch { + return raw + } +} + +function isFinalStatus(rawStatus: string): boolean { + const status = parseJsonString(rawStatus) + return status === 'Completed' || status === 'Cancelled' || status === 'Failed' +} + +function extractCategoryTokens(categories: Record<string, unknown> | undefined, key: string): number { + if (!categories) return 0 + return safeNumber(categories[key]) +} + +function extractTokenBudget(rawConversationData: string): { tokenBudget: number; dominantModel: string } { + let conversationData: WarpConversationData + try { + conversationData = JSON.parse(rawConversationData) as WarpConversationData + } catch { + return { tokenBudget: 0, dominantModel: '' } + } + + const entries = conversationData.conversation_usage_metadata?.token_usage ?? [] + let primaryTotal = 0 + let fallbackTotal = 0 + let dominantPrimaryTokens = 0 + let dominantFallbackTokens = 0 + let dominantModel = '' + + for (const entry of entries) { + const primaryTokens = + extractCategoryTokens(entry.warp_token_usage_by_category, PRIMARY_AGENT_CATEGORY) + + extractCategoryTokens(entry.byok_token_usage_by_category, PRIMARY_AGENT_CATEGORY) + const entryTotal = safeNumber(entry.warp_tokens) + safeNumber(entry.byok_tokens) + + primaryTotal += primaryTokens + fallbackTotal += entryTotal + + if (primaryTokens > dominantPrimaryTokens) { + dominantPrimaryTokens = primaryTokens + dominantModel = typeof entry.model_id === 'string' ? entry.model_id : dominantModel + } + + if (dominantPrimaryTokens === 0 && entryTotal > dominantFallbackTokens) { + dominantFallbackTokens = entryTotal + dominantModel = typeof entry.model_id === 'string' ? entry.model_id : dominantModel + } + } + + const tokenBudget = primaryTotal > 0 ? primaryTotal : fallbackTotal + return { tokenBudget: Math.max(0, Math.round(tokenBudget)), dominantModel: normalizeModel(dominantModel) } +} + +function extractUserMessage(rawInput: string): string { + try { + const parsed = JSON.parse(rawInput) as unknown + if (!Array.isArray(parsed)) return '' + for (const item of parsed) { + if (!item || typeof item !== 'object') continue + const query = (item as { Query?: { text?: unknown } }).Query + if (!query || typeof query !== 'object') continue + if (typeof query.text === 'string' && query.text.trim()) return query.text + } + return '' + } catch { + return '' + } +} + +function estimateWeight(rawInput: string): number { + const userMessage = extractUserMessage(rawInput) + const source = userMessage || rawInput + const tokens = Math.ceil(source.length / CHARS_PER_TOKEN) + return Math.max(1, tokens) +} + +function allocateTokens(weights: number[], tokenBudget: number): number[] { + if (weights.length === 0) return [] + const normalizedWeights = weights.map(w => Math.max(0, Math.round(w))) + const totalWeight = normalizedWeights.reduce((sum, weight) => sum + weight, 0) + const budget = Math.max(0, Math.round(tokenBudget)) + + if (budget === 0) return normalizedWeights.map(() => 0) + if (totalWeight === 0) { + const even = Math.floor(budget / normalizedWeights.length) + const allocated = normalizedWeights.map(() => even) + let remainder = budget - even * normalizedWeights.length + let index = 0 + while (remainder > 0) { + allocated[index] = (allocated[index] ?? 0) + 1 + remainder-- + index = (index + 1) % normalizedWeights.length + } + return allocated + } + + const rawAllocation = normalizedWeights.map(weight => (budget * weight) / totalWeight) + const allocated = rawAllocation.map(value => Math.floor(value)) + let remainder = budget - allocated.reduce((sum, value) => sum + value, 0) + + const byLargestFraction = rawAllocation + .map((value, index) => ({ index, fraction: value - Math.floor(value) })) + .sort((a, b) => b.fraction - a.fraction) + + let pointer = 0 + while (remainder > 0 && byLargestFraction.length > 0) { + const index = byLargestFraction[pointer]!.index + allocated[index] = (allocated[index] ?? 0) + 1 + remainder-- + pointer = (pointer + 1) % byLargestFraction.length + } + + return allocated +} + +function resolveModelForExchange(exchange: WarpQueryRow, dominantModel: string): string { + const candidate = + exchange.model_id.trim() || + exchange.coding_model_id.trim() || + exchange.planning_model_id.trim() || + dominantModel || + 'warp-auto-efficient' + const normalized = normalizeModel(candidate) + if ((normalized === 'warp-auto-efficient' || normalized === 'warp-auto-powerful') && dominantModel) { + return dominantModel + } + return normalized +} + +function assignCommandBlocksToExchanges( + blocks: WarpBlockRow[], + exchanges: ParsedExchange[], +): Map<string, ExchangeToolInfo> { + const toolsByExchange = new Map<string, ExchangeToolInfo>() + + function getOrCreate(exchangeId: string): ExchangeToolInfo { + const existing = toolsByExchange.get(exchangeId) + if (existing) return existing + const created: ExchangeToolInfo = { tools: [], bashCommands: [] } + toolsByExchange.set(exchangeId, created) + return created + } + + for (const block of blocks) { + const blockStartMs = parseTimestamp(block.start_ts) + if (blockStartMs === null) continue + + let targetExchange: ParsedExchange | null = null + for (const exchange of exchanges) { + if (exchange.startMs > blockStartMs) break + targetExchange = exchange + } + if (!targetExchange) continue + + const info = getOrCreate(targetExchange.exchange_id) + if (!info.tools.includes('Bash')) info.tools.push('Bash') + + const commandText = blobToText(block.stylized_command) + for (const command of extractBashCommands(commandText)) { + if (!info.bashCommands.includes(command)) info.bashCommands.push(command) + } + } + + return toolsByExchange +} + +function decodeSourcePath(path: string): { dbPath: string; conversationId: string } { + const splitIndex = path.lastIndexOf(':') + if (splitIndex <= 0) return { dbPath: path, conversationId: '' } + return { + dbPath: path.slice(0, splitIndex), + conversationId: path.slice(splitIndex + 1), + } +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM agent_conversations LIMIT 1') + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM ai_queries LIMIT 1') + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM blocks LIMIT 1') + return true + } catch { + return false + } +} + +function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + const { dbPath, conversationId } = decodeSourcePath(source.path) + if (!conversationId) return + + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open Warp database: ${err instanceof Error ? err.message : err}\n`) + return + } + + try { + if (!validateSchema(db)) return + + const conversations = db.query<WarpConversationRow>( + `SELECT conversation_id, conversation_data, last_modified_at + FROM agent_conversations + WHERE conversation_id = ? + LIMIT 1`, + [conversationId], + ) + if (conversations.length === 0) return + + const exchanges = db.query<WarpQueryRow>( + `SELECT exchange_id, conversation_id, start_ts, input, working_directory, output_status, model_id, planning_model_id, coding_model_id + FROM ai_queries + WHERE conversation_id = ? + ORDER BY start_ts ASC`, + [conversationId], + ) + + const parsedExchanges: ParsedExchange[] = [] + for (const exchange of exchanges) { + if (!isFinalStatus(exchange.output_status)) continue + const startMs = parseTimestamp(exchange.start_ts) + if (startMs === null) continue + parsedExchanges.push({ ...exchange, startMs }) + } + if (parsedExchanges.length === 0) return + + const blocks = db.query<WarpBlockRow>( + `SELECT block_id, start_ts, CAST(stylized_command AS BLOB) AS stylized_command + FROM blocks + WHERE ai_metadata IS NOT NULL + AND ai_metadata <> '' + AND json_extract(ai_metadata, '$.conversation_id') = ? + ORDER BY start_ts ASC`, + [conversationId], + ) + + const { tokenBudget, dominantModel } = extractTokenBudget(conversations[0]!.conversation_data) + const weights = parsedExchanges.map(exchange => estimateWeight(exchange.input)) + const fallbackBudget = weights.reduce((sum, weight) => sum + weight, 0) + const allocatedTokens = allocateTokens(weights, tokenBudget > 0 ? tokenBudget : fallbackBudget) + const toolsByExchange = assignCommandBlocksToExchanges(blocks, parsedExchanges) + + for (let index = 0; index < parsedExchanges.length; index++) { + const exchange = parsedExchanges[index]! + const deduplicationKey = `warp:${conversationId}:${exchange.exchange_id}` + if (seenKeys.has(deduplicationKey)) continue + + const timestamp = new Date(exchange.startMs).toISOString() + const model = resolveModelForExchange(exchange, dominantModel) + const inputTokens = allocatedTokens[index] ?? 0 + const exchangeTools = toolsByExchange.get(exchange.exchange_id) ?? { tools: [], bashCommands: [] } + const userMessage = extractUserMessage(exchange.input).slice(0, 500) + const projectPath = exchange.working_directory?.trim() || undefined + const project = projectPath ? sanitizeProject(projectPath) : source.project + + seenKeys.add(deduplicationKey) + yield { + provider: 'warp', + model, + inputTokens, + // Warp exposes only conversation-level usage totals in these tables, + // so we cannot reliably split per-exchange input vs output tokens. + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: calculateCost(model, inputTokens, 0, 0, 0, 0), + costIsEstimated: true, + tools: exchangeTools.tools, + bashCommands: exchangeTools.bashCommands, + timestamp, + speed: 'standard', + deduplicationKey, + userMessage, + sessionId: conversationId, + project, + projectPath, + } + } + } finally { + db.close() + } + }, + } +} + +async function discoverFromDb(dbPath: string): Promise<SessionSource[]> { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + + try { + if (!validateSchema(db)) return [] + const rows = db.query<{ conversation_id: string; working_directory: string | null }>( + `SELECT c.conversation_id AS conversation_id, + ( + SELECT q.working_directory + FROM ai_queries q + WHERE q.conversation_id = c.conversation_id + AND q.working_directory IS NOT NULL + AND q.working_directory <> '' + ORDER BY q.start_ts DESC + LIMIT 1 + ) AS working_directory + FROM agent_conversations c + WHERE EXISTS ( + SELECT 1 FROM ai_queries q + WHERE q.conversation_id = c.conversation_id + ) + ORDER BY c.last_modified_at DESC`, + ) + + return rows.map(row => { + const projectPath = row.working_directory?.trim() ?? '' + return { + path: `${dbPath}:${row.conversation_id}`, + project: projectPath ? sanitizeProject(projectPath) : 'warp', + provider: 'warp', + } + }) + } catch { + return [] + } finally { + db.close() + } +} + +export function createWarpProvider(dbPathOverride?: string): Provider { + return { + name: 'warp', + displayName: 'Warp', + + modelDisplayName(model: string): string { + return modelDisplayName(model) + }, + + toolDisplayName(rawTool: string): string { + return rawTool === 'run_command' ? 'Bash' : rawTool + }, + + async discoverSessions(): Promise<SessionSource[]> { + if (!isSqliteAvailable()) return [] + + const sessions: SessionSource[] = [] + for (const candidate of getDbCandidates(dbPathOverride)) { + const found = await discoverFromDb(candidate) + sessions.push(...found) + } + return sessions + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const warp = createWarpProvider() 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<SessionRow>( + `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<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + // Source paths are `<dbPath>:<sessionId>`. 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<ToolRow>( + `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<string, string[]>() + 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<UsageRow>( + `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<string>() + + 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<SessionSource[]> { + if (!isSqliteAvailable()) return [] + return discover(dbPath) + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): 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..7164149 --- /dev/null +++ b/src/providers/zed.ts @@ -0,0 +1,229 @@ +import { existsSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import zlib from 'zlib' + +import { calculateCost } from '../models.js' +import { 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, summary, updated_at, data_type, data + FROM threads + ORDER BY updated_at ASC +` + +type ThreadRow = { + id: string + summary: 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<string, TokenUsage> + 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<string>): ParsedProviderCall[] { + const calls: ParsedProviderCall[] = [] + let skipped = 0 + + let rows: ThreadRow[] + try { + rows = db.query<ThreadRow>(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 = 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<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + 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<SessionSource[]> { + if (!isSqliteAvailable()) return [] + const dbPath = dbPathOverride ?? getZedThreadsDbPath() + if (!existsSync(dbPath)) return [] + return [{ path: dbPath, project: 'zed', provider: 'zed' }] + }, + + createSessionParser(source: SessionSource, seenKeys: Set<string>): 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..959e1b1 --- /dev/null +++ b/src/providers/zerostack.ts @@ -0,0 +1,162 @@ +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 <dataDir>/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<string, string> = { + 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 + 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<ZerostackSession | null> { + 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<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + const session = await readSession(source.path) + if (!session) return + + const input = session.total_input_tokens ?? 0 + const output = session.total_output_tokens ?? 0 + if (input === 0 && output === 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: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: calculateCost(model, input, output, 0, 0, 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<SessionSource[]> { + 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<string>): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const zerostack = createZerostackProvider() diff --git a/src/session-cache.ts b/src/session-cache.ts new file mode 100644 index 0000000..7d02e9c --- /dev/null +++ b/src/session-cache.ts @@ -0,0 +1,405 @@ +import { readFile, stat, open, rename, unlink, readdir, mkdir } from 'fs/promises' +import { existsSync } from 'fs' +import { createHash, randomBytes } from 'crypto' +import { join } from 'path' +import { homedir } from 'os' + +import type { ToolCall } from './types.js' + +// ── Types ────────────────────────────────────────────────────────────── + +export type CachedUsage = { + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + cacheCreationOneHourTokens: number +} + +export type CachedCall = { + provider: string + model: string + usage: CachedUsage + costUSD?: number + speed: 'standard' | 'fast' + timestamp: string + tools: string[] + bashCommands: string[] + skills: string[] + subagentTypes: string[] + deduplicationKey: string + project?: string + projectPath?: string + toolSequence?: ToolCall[][] +} + +export type CachedTurn = { + timestamp: string + sessionId: string + userMessage: string + calls: CachedCall[] +} + +export type FileFingerprint = { + dev: number + ino: number + mtimeMs: number + sizeBytes: number +} + +export type CachedFile = { + fingerprint: FileFingerprint + lastCompleteLineOffset?: number + canonicalCwd?: string + canonicalProjectName?: string + mcpInventory: string[] + turns: CachedTurn[] + // Claude Code only: for a subagent transcript (`subagents/.../agent-*.jsonl`), + // the `agentType` from its sibling `.meta.json` (e.g. `workflow-subagent`, + // `Explore`, `general-purpose`). Drives the Claude-scoped agent-type breakdown. + agentType?: string + // Negative-result marker: this file threw while parsing at the recorded + // fingerprint. Cached so we don't re-read + re-throw it on every refresh; it + // is re-parsed only when the file changes (fingerprint differs). Carries no + // turns, so it contributes no usage. (issue #441 follow-up) + failed?: boolean +} + +export type ProviderSection = { + envFingerprint: string + files: Record<string, CachedFile> + /** True when the provider's cache entries survive source-file eviction. */ + durable?: boolean +} + +export type SessionCache = { + version: number + providers: Record<string, ProviderSection> +} + +// ── Constants ────────────────────────────────────────────────────────── + +export const CACHE_VERSION = 4 + +const CACHE_FILE = 'session-cache.json' +const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 + +const PROVIDER_ENV_VARS: Record<string, string[]> = { + claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'], + codex: ['CODEX_HOME'], + hermes: ['HERMES_HOME'], + 'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'], + droid: ['FACTORY_DIR'], + cursor: ['XDG_DATA_HOME'], + 'cursor-agent': ['XDG_DATA_HOME'], + opencode: ['XDG_DATA_HOME'], + goose: ['XDG_DATA_HOME'], + crush: ['XDG_DATA_HOME'], + warp: ['WARP_DB_PATH'], + antigravity: ['CODEBURN_CACHE_DIR'], + qwen: ['QWEN_DATA_DIR'], + 'ibm-bob': ['XDG_CONFIG_HOME'], +} + +// Names of providers whose cache entries are never evicted when source files +// disappear — they are preserved so month-to-date totals never drop. +export const DURABLE_PROVIDER_NAMES: ReadonlySet<string> = new Set(['copilot']) + +const PROVIDER_PARSE_VERSIONS: Record<string, string> = { + claude: 'cowork-space-grouping-v1', + cline: 'worktree-project-grouping-v1', + // Bump when the Codex parser changes attribution so unchanged, already-cached + // session files re-parse (session-cache.json serves them without invoking the + // provider parser otherwise). Covers native mcp_tool_call_end (#513) and + // CLI-wrapped `mcp-cli call` (#478) MCP attribution. + codex: 'mcp-attribution-v2', + cursor: 'composer-anchored-crediting-v1', + 'cursor-agent': 'workspaceless-transcript-v1', + copilot: 'otel-durable-v1', + hermes: 'reasoning-output-accounting-v1', + 'lingtai-tui': 'token-ledger-registry-activity-v3', + 'ibm-bob': 'worktree-project-grouping-v1', + kiro: 'ide-parsing-v1', + 'kilo-code': 'worktree-project-grouping-v1', + 'roo-code': 'worktree-project-grouping-v1', + warp: 'worktree-project-grouping-v1', + antigravity: 'worktree-project-grouping-v3', +} + +// ── Cache Dir ────────────────────────────────────────────────────────── + +function getCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function getCachePath(): string { + return join(getCacheDir(), CACHE_FILE) +} + +// ── Env Fingerprint ──────────────────────────────────────────────────── + +export function computeEnvFingerprint(provider: string): string { + const vars = PROVIDER_ENV_VARS[provider] ?? [] + const parts = vars.map(v => `${v}=${process.env[v] ?? ''}`) + const parseVersion = PROVIDER_PARSE_VERSIONS[provider] + if (parseVersion) parts.push(`parser=${parseVersion}`) + return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16) +} + +// ── Load / Save ──────────────────────────────────────────────────────── + +export function emptyCache(): SessionCache { + return { version: CACHE_VERSION, providers: {} } +} + +function isNum(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v) +} + +function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every(e => typeof e === 'string') +} + +function isOptionalString(v: unknown): boolean { + return v === undefined || typeof v === 'string' +} + +function isOptionalNum(v: unknown): boolean { + return v === undefined || isNum(v) +} + +function isToolCall(v: unknown): boolean { + if (!v || typeof v !== 'object') return false + const o = v as Record<string, unknown> + return typeof o['tool'] === 'string' + && isOptionalString(o['file']) + && isOptionalString(o['command']) +} + +function isToolCallArray(v: unknown): boolean { + return Array.isArray(v) && (v as unknown[]).every(isToolCall) +} + +function validateFingerprint(fp: unknown): fp is FileFingerprint { + if (!fp || typeof fp !== 'object') return false + const f = fp as Record<string, unknown> + return isNum(f['dev']) && isNum(f['ino']) && isNum(f['mtimeMs']) && isNum(f['sizeBytes']) +} + +function validateUsage(u: unknown): u is CachedUsage { + if (!u || typeof u !== 'object') return false + const o = u as Record<string, unknown> + return isNum(o['inputTokens']) && isNum(o['outputTokens']) + && isNum(o['cacheCreationInputTokens']) && isNum(o['cacheReadInputTokens']) + && isNum(o['cachedInputTokens']) && isNum(o['reasoningTokens']) + && isNum(o['webSearchRequests']) && isNum(o['cacheCreationOneHourTokens']) +} + +function validateCall(c: unknown): c is CachedCall { + if (!c || typeof c !== 'object') return false + const o = c as Record<string, unknown> + return typeof o['provider'] === 'string' + && typeof o['model'] === 'string' + && typeof o['deduplicationKey'] === 'string' + && typeof o['timestamp'] === 'string' + && (o['speed'] === 'standard' || o['speed'] === 'fast') + && isOptionalNum(o['costUSD']) + && isStringArray(o['tools']) + && isStringArray(o['bashCommands']) + && isStringArray(o['skills']) + && (o['subagentTypes'] === undefined || isStringArray(o['subagentTypes'])) + && isOptionalString(o['project']) + && isOptionalString(o['projectPath']) + && (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s)))) + && validateUsage(o['usage']) +} + +function validateTurn(t: unknown): t is CachedTurn { + if (!t || typeof t !== 'object') return false + const o = t as Record<string, unknown> + return typeof o['timestamp'] === 'string' + && typeof o['sessionId'] === 'string' + && typeof o['userMessage'] === 'string' + && Array.isArray(o['calls']) + && (o['calls'] as unknown[]).every(validateCall) +} + +function validateCachedFile(f: unknown): f is CachedFile { + if (!f || typeof f !== 'object') return false + const o = f as Record<string, unknown> + return validateFingerprint(o['fingerprint']) + && isOptionalNum(o['lastCompleteLineOffset']) + && isOptionalString(o['canonicalCwd']) + && isOptionalString(o['canonicalProjectName']) + && isStringArray(o['mcpInventory']) + && Array.isArray(o['turns']) + && (o['turns'] as unknown[]).every(validateTurn) +} + +function validateProviderSection(s: unknown): s is ProviderSection { + if (!s || typeof s !== 'object') return false + const o = s as Record<string, unknown> + if (typeof o['envFingerprint'] !== 'string') return false + if (!o['files'] || typeof o['files'] !== 'object' || Array.isArray(o['files'])) return false + return Object.values(o['files'] as Record<string, unknown>).every(validateCachedFile) +} + +function validateCache(raw: unknown): raw is SessionCache { + if (!raw || typeof raw !== 'object') return false + const o = raw as Record<string, unknown> + if (o['version'] !== CACHE_VERSION) return false + if (!o['providers'] || typeof o['providers'] !== 'object' || Array.isArray(o['providers'])) return false + return Object.values(o['providers'] as Record<string, unknown>).every(validateProviderSection) +} + +export async function loadCache(): Promise<SessionCache> { + try { + const raw = await readFile(getCachePath(), 'utf-8') + const parsed = JSON.parse(raw) + if (!validateCache(parsed)) return emptyCache() + return parsed + } catch { + return emptyCache() + } +} + +export async function saveCache(cache: SessionCache): Promise<void> { + const dir = getCacheDir() + if (!existsSync(dir)) await mkdir(dir, { recursive: true }) + + const finalPath = getCachePath() + const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` + delete (cache as { _dirty?: boolean })._dirty + const payload = JSON.stringify(cache) + + const handle = await open(tempPath, 'w', 0o600) + try { + await handle.writeFile(payload, { encoding: 'utf-8' }) + await handle.sync() + } finally { + await handle.close() + } + + try { + await rename(tempPath, finalPath) + } catch (err) { + try { await unlink(tempPath) } catch {} + throw err + } +} + +// ── File Fingerprinting ──────────────────────────────────────────────── + +export async function fingerprintFile(filePath: string): Promise<FileFingerprint | null> { + try { + const s = await stat(filePath) + return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size } + } catch { + // Providers encode extra context into source paths using virtual suffixes: + // - Cursor: `<dbPath>#cursor-ws=<workspace>` (workspace-aware routing) + // - OpenCode: `<dbPath>:<sessionId>` (session scoping) + // These compound paths don't exist on disk; strip the suffix to stat the + // underlying file. Try `#` first (rare in real paths), then `:` (must use + // lastIndexOf to tolerate Windows drive letters like C:\...). + const hashIdx = filePath.indexOf('#') + if (hashIdx > 0) { + try { + const s = await stat(filePath.slice(0, hashIdx)) + return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size } + } catch { + // fall through to colon check + } + } + const colonIdx = filePath.lastIndexOf(':') + if (colonIdx > 0) { + try { + const s = await stat(filePath.slice(0, colonIdx)) + return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size } + } catch { + return null + } + } + return null + } +} + +// ── Reconciliation ───────────────────────────────────────────────────── + +export type ReconcileAction = + | { action: 'unchanged' } + | { action: 'appended'; readFromOffset: number } + | { action: 'modified' } + | { action: 'new' } + +export function reconcileFile( + current: FileFingerprint, + cached: CachedFile | undefined, +): ReconcileAction { + if (!cached) return { action: 'new' } + + const fp = cached.fingerprint + + if ( + fp.dev === current.dev && + fp.ino === current.ino && + fp.mtimeMs === current.mtimeMs && + fp.sizeBytes === current.sizeBytes + ) { + return { action: 'unchanged' } + } + + if ( + cached.lastCompleteLineOffset !== undefined && + fp.dev === current.dev && + fp.ino === current.ino && + current.sizeBytes > fp.sizeBytes + ) { + return { action: 'appended', readFromOffset: cached.lastCompleteLineOffset } + } + + return { action: 'modified' } +} + +// ── Dedup Merge ──────────────────────────────────────────────────────── +// When appending incremental data, streaming Claude messages can re-emit +// the same dedup key with updated usage. Merge by key: keep the earliest +// timestamp, take incoming usage/tools/bashCommands/skills (latest wins). + +export function mergeCallByDedupKey( + existing: CachedCall, + incoming: CachedCall, +): CachedCall { + return { + ...incoming, + timestamp: existing.timestamp < incoming.timestamp + ? existing.timestamp + : incoming.timestamp, + } +} + +// ── Temp Cleanup ─────────────────────────────────────────────────────── + +export async function cleanupOrphanedTempFiles(): Promise<void> { + const dir = getCacheDir() + if (!existsSync(dir)) return + + try { + const entries = await readdir(dir) + const now = Date.now() + + const prefix = 'session-cache.json.' + for (const entry of entries) { + if (!entry.startsWith(prefix) || !entry.endsWith('.tmp')) continue + try { + const fullPath = join(dir, entry) + const s = await stat(fullPath) + if (now - s.mtimeMs > TEMP_FILE_MAX_AGE_MS) { + await unlink(fullPath) + } + } catch {} + } + } catch {} +} 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<string, string> = {}, + body?: string, + timeoutMs = 15000, +): Promise<Response> { + 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<Response> { + return call(ep, 'GET', '/api/peer/hello') +} + +export function pair(ep: PeerEndpoint, pin: string, name: string): Promise<Response> { + 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<Response> { + // 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<Response> { + 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..9e34874 --- /dev/null +++ b/src/sharing/discovery.ts @@ -0,0 +1,54 @@ +import { Bonjour, type Service } from 'bonjour-service' + +const SERVICE_TYPE = 'codeburn' + +export type DiscoveredDevice = { name: string; host: string; port: number; fingerprint: string } + +export type Advertiser = { stop: () => Promise<void> } + +// 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<void>((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<DiscoveredDevice[]> { + return new Promise((resolve) => { + const bonjour = new Bonjour() + const found = new Map<string, DiscoveredDevice>() + const browser = bonjour.find({ type: SERVICE_TYPE }, (service) => { + const txt = (service.txt ?? {}) as Record<string, string> + 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 }) + }) + const timer = setTimeout(() => { + browser.stop() + bonjour.destroy(() => resolve([...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<RemoteDevice> { + 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<RemoteDevice> { + 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<DevicePayload>, + query: UsageQuery, + localName: string, + opts: { dir?: string } = {}, +): Promise<DeviceUsage[]> { + 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<DeviceUsage> => { + 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<Identity> { + 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<Identity> { + 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..7aab37c --- /dev/null +++ b/src/sharing/pairing.ts @@ -0,0 +1,114 @@ +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. +export function pairingCode(fingerprintA: string, fingerprintB: string): string { + const [lo, hi] = [fingerprintA, fingerprintB].sort() + const digest = createHash('sha256').update(`${lo}|${hi}`).digest() + return (digest.readUInt16BE(0) % 1000).toString().padStart(3, '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<string, PairedPeer>() + + 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<boolean> { + 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<number> { + 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..a09f511 --- /dev/null +++ b/src/sharing/sanitize.ts @@ -0,0 +1,18 @@ +import type { MenubarPayload } from '../menubar-json.js' + +// Strip identifying detail before usage leaves the device. We never share +// project names, file paths, or per-session detail (the strongest signal of +// "what you are working on"). We DO share aggregate numbers plus model, tool, +// task, subagent, skill, and MCP-server usage, since the dashboard surfaces +// those per device. If a user names a subagent/skill after a client, that name +// would travel; revisit if that becomes a concern. +export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload { + return { + ...payload, + current: { + ...payload.current, + topProjects: [], + topSessions: [], + }, + } +} 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<typeof advertise> | null = null + private peers: PeerStore | null = null + private identity: Identity | null = null + private always = false + private idleTimer: ReturnType<typeof setInterval> | 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<typeof setTimeout> } + >() + + constructor( + private readonly getUsage: (q: UsageQuery) => Promise<unknown>, + private readonly port = 7777, + ) {} + + private async getIdentity(): Promise<Identity> { + if (!this.identity) this.identity = await loadOrCreateIdentity(this.dir) + return this.identity + } + + isSharing(): boolean { + return !!this.server + } + + async start(always: boolean): Promise<void> { + 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<void> { + 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<boolean> { + // 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<ShareStatus> { + 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..7073412 --- /dev/null +++ b/src/sharing/share-run.ts @@ -0,0 +1,92 @@ +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' +import { buildMenubarPayloadForRange } from '../usage-aggregator.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<void> { + await loadPricing() + const dir = getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const peers = new PeerStore(await loadPeers(dir)) + + const getUsage = async (q: UsageQuery): Promise<unknown> => { + 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<void> => { + 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<never>(() => { + /* 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<unknown> + // 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<boolean> +} + +// 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<number> { + return new Promise((resolve, reject) => { + this.server.once('error', reject) + this.server.listen(port, host, () => resolve((this.server.address() as AddressInfo).port)) + }) + } + + close(): Promise<void> { + 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<void> { + 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<void> { + + // 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<string> { + 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<T>(path: string, fallback: T): Promise<T> { + 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<void> { + 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<PairedPeer[]> { + return readJson(join(dir, 'paired-peers.json'), [] as PairedPeer[]) +} +export function savePeers(peers: PairedPeer[], dir: string = getSharingDir()): Promise<void> { + 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<RemoteDevice[]> { + return readJson(join(dir, 'remote-devices.json'), [] as RemoteDevice[]) +} +export function saveRemotes(remotes: RemoteDevice[], dir: string = getSharingDir()): Promise<void> { + 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<boolean> { + 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<void> { + return writeJson(join(dir, 'web-share.json'), { always }) +} diff --git a/src/sqlite.ts b/src/sqlite.ts new file mode 100644 index 0000000..3fb3c6a --- /dev/null +++ b/src/sqlite.ts @@ -0,0 +1,139 @@ +import { createRequire } from 'node:module' + +/// Thin SQLite read-only wrapper over Node's built-in `node:sqlite` module (stable in +/// Node 24, experimental in Node 22 / 23). Replaces the earlier `better-sqlite3` binding +/// so the dependency graph no longer pulls in the deprecated `prebuild-install` package +/// (issue #75). Works across Cursor and OpenCode session DBs, both of which we only read. + +const requireForSqlite = createRequire(import.meta.url) + +type Row = Record<string, unknown> + +export type SqliteDatabase = { + query<T extends Row = Row>(sql: string, params?: unknown[]): T[] + close(): void +} + +type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => { + prepare(sql: string): { all(...params: unknown[]): Row[] } + exec?(sql: string): void + close(): void +} + +let DatabaseSync: DatabaseSyncCtor | null = null +let loadAttempted = false +let loadError: string | null = null + +const textDecoder = new TextDecoder('utf-8', { fatal: false }) + +/// Safely decode a BLOB column (Uint8Array) to a UTF-8 string. Node's +/// node:sqlite crashes with a V8 CHECK abort when a TEXT column contains +/// invalid UTF-8 (common in Cursor chat blobs with truncated multi-byte +/// chars). By selecting those columns as `CAST(... AS BLOB)` in SQL, we +/// get a Uint8Array here and decode it in JS where bad bytes become the +/// U+FFFD replacement character instead of aborting the process. +export function blobToText(value: Uint8Array | string | null | undefined): string { + if (value == null) return '' + if (typeof value === 'string') return value + return textDecoder.decode(value) +} + +/// Lazily imports `node:sqlite`. On Node 22/23 it emits an ExperimentalWarning the first +/// time the module is loaded; we silence that specific warning once so dashboards aren't +/// preceded by a scary stderr line every run. Any other warnings (including future +/// non-SQLite ones) are left untouched. +function loadDriver(): boolean { + if (loadAttempted) return DatabaseSync !== null + loadAttempted = true + + const origEmit = process.emit.bind(process) + let restored = false + const restore = () => { + if (restored) return + restored = true + process.emit = origEmit + } + + // Node's `process.emit` signature is overloaded; we intercept the 'warning' channel + // only and proxy everything else through unchanged. The `any` cast avoids chasing the + // overload union which isn't worth its verbosity for a single-purpose shim. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.emit = function patchedEmit(this: NodeJS.Process, event: string, ...args: any[]): boolean { + if (event === 'warning') { + const warning = args[0] as { name?: string; message?: string } | undefined + if ( + warning?.name === 'ExperimentalWarning' && + typeof warning.message === 'string' && + /SQLite/i.test(warning.message) + ) { + return false + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (origEmit as any).call(this, event, ...args) + } as typeof process.emit + + try { + const mod = requireForSqlite('node:sqlite') as { DatabaseSync: DatabaseSyncCtor } + DatabaseSync = mod.DatabaseSync + return true + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + loadError = + 'SQLite-based providers (Cursor, OpenCode) need Node 22+ with the node:sqlite module.\n' + + `Current Node: ${process.version}.\n` + + 'Upgrade Node (https://nodejs.org) and run codeburn again.\n' + + `(underlying error: ${message})` + return false + } finally { + process.nextTick(restore) + } +} + +export function isSqliteAvailable(): boolean { + return loadDriver() +} + +export function getSqliteLoadError(): string { + return loadError ?? 'SQLite driver not available' +} + +export function isSqliteBusyError(err: unknown): boolean { + const e = err as { code?: unknown; errcode?: unknown; errstr?: unknown; message?: unknown } | null + const code = typeof e?.code === 'string' ? e.code : '' + const errcode = typeof e?.errcode === 'number' ? e.errcode : null + const message = [ + typeof e?.message === 'string' ? e.message : '', + typeof e?.errstr === 'string' ? e.errstr : '', + ].join(' ') + + return ( + errcode === 5 || + errcode === 6 || + code === 'SQLITE_BUSY' || + code === 'SQLITE_LOCKED' || + /\bSQLITE_(BUSY|LOCKED)\b|database (?:is |table is )?locked/i.test(message) + ) +} + +export function openDatabase(path: string): SqliteDatabase { + if (!loadDriver() || DatabaseSync === null) { + throw new Error(getSqliteLoadError()) + } + + const db = new DatabaseSync(path, { readOnly: true }) + try { + db.exec?.('PRAGMA busy_timeout = 1000') + } catch { + // Best effort. Some Node sqlite builds may not expose exec on DatabaseSync. + } + + return { + query<T extends Row = Row>(sql: string, params: unknown[] = []): T[] { + return db.prepare(sql).all(...params) as T[] + }, + close() { + db.close() + }, + } +} diff --git a/src/text-table.ts b/src/text-table.ts new file mode 100644 index 0000000..2a82d7c --- /dev/null +++ b/src/text-table.ts @@ -0,0 +1,41 @@ +import { Chalk } from 'chalk' + +export type TableColumn = { 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 +} + +// Box-drawing table with roomy 2-space cell padding, right-aligned numeric +// columns, and optional bold rows (e.g. a Combined/total row). Color is +// auto-detected; pass color:false to force plain text. +export function renderTable( + columns: TableColumn[], + rows: string[][], + opts: { color?: boolean; boldRows?: ReadonlySet<number> } = {}, +): string { + const c = new Chalk(opts.color === false ? { level: 0 } : {}) + const bold = opts.boldRows ?? new Set<number>() + const widths = columns.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 gap = ' ' + const sep = gap + '│' + gap + const bar = (l: string, mid: string, r: string): string => l + widths.map((w) => '─'.repeat(w + 4)).join(mid) + r + const line = (cells: string[], makeBold: boolean): string => + '│' + gap + columns.map((col, i) => { + const padded = pad(cells[i] ?? '', widths[i]!, col.right) + return makeBold ? c.bold(padded) : padded + }).join(sep) + gap + '│' + return [ + bar('┌', '┬', '┐'), + line(columns.map((col) => col.header), false), + bar('├', '┼', '┤'), + ...rows.map((r, i) => line(r, bold.has(i))), + bar('└', '┴', '┘'), + ].join('\n') +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c3cc822 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,201 @@ +export type TokenUsage = { + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number +} + +export type ToolUseBlock = { + type: 'tool_use' + id: string + name: string + input: Record<string, unknown> +} + +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'thinking'; thinking: string } + | ToolUseBlock + | { type: string; [key: string]: unknown } + +export type ApiUsage = { + input_tokens: number + output_tokens: number + cache_creation_input_tokens?: number + cache_creation?: { + ephemeral_5m_input_tokens?: number + ephemeral_1h_input_tokens?: number + } + cache_read_input_tokens?: number + server_tool_use?: { + web_search_requests?: number + web_fetch_requests?: number + } + speed?: 'standard' | 'fast' +} + +export type AssistantMessageContent = { + model: string + id?: string + type: 'message' + role: 'assistant' + content: ContentBlock[] + usage: ApiUsage + stop_reason?: string +} + +export type JournalEntry = { + type: string + uuid?: string + parentUuid?: string | null + timestamp?: string + sessionId?: string + cwd?: string + version?: string + gitBranch?: string + promptId?: string + message?: AssistantMessageContent | { role: 'user'; content: string | ContentBlock[] } + isSidechain?: boolean + [key: string]: unknown +} + +export type ParsedTurn = { + userMessage: string + assistantCalls: ParsedApiCall[] + timestamp: string + sessionId: string +} + +export type ParsedApiCall = { + provider: string + model: string + usage: TokenUsage + costUSD: number + tools: string[] + mcpTools: string[] + skills: string[] + subagentTypes: string[] + hasAgentSpawn: boolean + hasPlanMode: boolean + speed: 'standard' | 'fast' + timestamp: string + bashCommands: string[] + deduplicationKey: string + cacheCreationOneHourTokens?: number + toolSequence?: ToolCall[][] + /// When set, `costUSD` is the actual local call (forced to 0) and + /// `savingsUSD` is the counterfactual cost the same tokens would have + /// incurred against `savingsBaselineModel`. Set by the savings + /// normalization step in `src/parser.ts`. + savingsUSD?: number + savingsBaselineModel?: string + isLocalSavings?: boolean +} + +export type ToolCall = { + tool: string + file?: string + command?: string +} + +export type TaskCategory = + | 'coding' + | 'debugging' + | 'feature' + | 'refactoring' + | 'testing' + | 'exploration' + | 'planning' + | 'delegation' + | 'git' + | 'build/deploy' + | 'conversation' + | 'brainstorming' + | 'general' + +export type ClassifiedTurn = ParsedTurn & { + category: TaskCategory + subCategory?: string + retries: number + hasEdits: boolean +} + +export type SessionSourceMetadata = { + id: string + label: string + path: string + kind: 'claude-config' | 'claude-desktop' +} + +export type SessionSummary = { + sessionId: string + project: string + source?: SessionSourceMetadata + // Claude Code only: agent type of a subagent transcript session + // (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for + // ordinary sessions. Drives the Claude-scoped agent-type breakdown. + agentType?: string + firstTimestamp: string + lastTimestamp: string + totalCostUSD: number + totalSavingsUSD: number + totalInputTokens: number + totalOutputTokens: number + totalReasoningTokens: number + totalCacheReadTokens: number + totalCacheWriteTokens: number + apiCalls: number + turns: ClassifiedTurn[] + modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number }> + toolBreakdown: Record<string, { calls: number }> + mcpBreakdown: Record<string, { calls: number }> + bashBreakdown: Record<string, { calls: number }> + categoryBreakdown: Record<TaskCategory, { turns: number; costUSD: number; savingsUSD: number; retries: number; editTurns: number; oneShotTurns: number }> + skillBreakdown: Record<string, { turns: number; costUSD: number; savingsUSD: number; editTurns: number; oneShotTurns: number }> + subagentBreakdown: Record<string, { calls: number; costUSD: number; savingsUSD: number }> + // Observed MCP tools available in this session, captured from + // `attachment.deferred_tools_delta.addedNames` entries. Union across all + // turns. Each name is a fully-qualified `mcp__<server>__<tool>` identifier. + // Built-in tools (Bash, Edit, etc.) are filtered out. Provider-agnostic field; + // currently populated only by the Claude parser. + mcpInventory?: string[] +} + +export type ProjectSummary = { + project: string + projectPath: string + sessions: SessionSummary[] + totalCostUSD: number + totalSavingsUSD: number + totalApiCalls: number + // Portion of `totalCostUSD` served through a subscription-backed proxy + // (config `proxyPaths`). `totalCostUSD` is left at the full API rate (the + // billable / would-be figure); this is the subscription-covered amount, so + // net out-of-pocket for the project is `totalCostUSD - totalProxiedCostUSD`. + // 0 when the project is not under a configured proxy path. + totalProxiedCostUSD: number +} + +export type DateRange = { + start: Date + end: Date +} + +export const CATEGORY_LABELS: Record<TaskCategory, string> = { + coding: 'Coding', + debugging: 'Debugging', + feature: 'Feature Dev', + refactoring: 'Refactoring', + testing: 'Testing', + exploration: 'Exploration', + planning: 'Planning', + delegation: 'Delegation', + git: 'Git Ops', + 'build/deploy': 'Build/Deploy', + conversation: 'Conversation', + brainstorming: 'Brainstorming', + general: 'General', +} diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts new file mode 100644 index 0000000..b70b401 --- /dev/null +++ b/src/usage-aggregator.ts @@ -0,0 +1,522 @@ +import { homedir } from 'node:os' +import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory, type DateRange } from './types.js' +import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, type ClaudeConfigSelector, buildMenubarPayload } from './menubar-json.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource } from './parser.js' +import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName } from './models.js' +import { getAllProviders, safeDiscoverSessions } from './providers/index.js' +import { claude, getClaudeConfigDirs, getDesktopSessionsDir } from './providers/claude.js' +import { stat } from 'node:fs/promises' +import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' +import { aggregateModelEfficiency } from './model-efficiency.js' +import { aggregateModels } from './models-report.js' +import { scanAndDetect } from './optimize.js' +import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' + +export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { + const sessions = projects.flatMap(p => p.sessions) + const catTotals: Record<string, { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number }> = {} + const modelTotals: Record<string, { calls: number; cost: number; savingsUSD: number; tokens: number }> = {} + 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, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } + catTotals[cat].turns += d.turns + catTotals[cat].cost += d.costUSD + catTotals[cat].savingsUSD += d.savingsUSD + 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, savingsUSD: 0, tokens: 0 } + modelTotals[model].calls += d.calls + modelTotals[model].cost += d.costUSD + modelTotals[model].savingsUSD += d.savingsUSD + modelTotals[model].tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens + } + } + + return { + label, + cost: projects.reduce((s, p) => s + p.totalCostUSD, 0), + savingsUSD: projects.reduce((s, p) => s + p.totalSavingsUSD, 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, calls: d.calls, cost: d.cost, savingsUSD: d.savingsUSD })), + unpricedModels: findUnpricedModels(Object.entries(modelTotals) + .map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens }))), + } +} + +export function getDailyCacheConfigHash(): string { + const savingsHash = getLocalModelSavingsConfigHash() + const overridesHash = getPriceOverridesConfigHash() + if (!overridesHash) return savingsHash + return `localModelSavings=${savingsHash}\u0002priceOverrides=${overridesHash}` +} + +async function hydrateCache(): Promise<DailyCache> { + try { + return await ensureCacheHydrated( + (range) => parseAllSessions(range, 'all'), + aggregateProjectsIntoDays, + getDailyCacheConfigHash(), + ) + } catch (err) { + // Previously swallowed silently, which turned any backfill failure into an + // empty trend/history with no signal (issue #441). Per-file parse errors no + // longer reach here (they're isolated in parseProviderSources), so anything + // that does is exceptional and worth surfacing. + process.stderr.write( + `codeburn: daily history backfill failed; the trend chart may be incomplete. ` + + `${err instanceof Error ? err.message : String(err)}\n` + ) + return emptyCache() + } +} + +export type PeriodInfo = { range: DateRange; label: string } +export type AggregateOpts = { + provider?: string + project?: string[] + exclude?: string[] + daysSelection?: { range: DateRange; label: string; days: Set<string> } | null + optimize?: boolean + claudeConfigSourceId?: string | null +} + +type ConfigOption = { id: string; label: string; path: string } + +function buildSelector(byId: Map<string, ConfigOption>, selectedId?: string | null): ClaudeConfigSelector | undefined { + const options = [...byId.values()].sort((a, b) => a.label.localeCompare(b.label)) + if (options.length <= 1) return undefined + const validSelectedId = selectedId && options.some(option => option.id === selectedId) ? selectedId : null + return { selectedId: validSelectedId, options } +} + +// Complete option list including configs with NO data in the period (so the +// user can still switch to one to confirm it is $0). Only worth the extra +// Claude discovery walk when the user actually has multiple config dirs; a +// single-config user can never have a >1 selector, so skip it and let the +// project-derived path (which also surfaces a Claude Desktop bucket with data) +// stand. +async function claudeConfigSelector(projects: ProjectSummary[], selectedId?: string | null): Promise<ClaudeConfigSelector | undefined> { + const byId = new Map<string, ConfigOption>() + for (const session of projects.flatMap(project => project.sessions)) { + const source = session.source + if (source?.kind !== 'claude-config' && source?.kind !== 'claude-desktop') continue + if (!byId.has(source.id)) byId.set(source.id, { id: source.id, label: source.label, path: source.path }) + } + // The discovery walk lists sources that have no data in the period (so an + // idle config or Claude Desktop is still selectable). Only worth it when a + // second source is possible: more than one config dir, or a Claude Desktop + // sessions dir exists. A plain single-config user skips it entirely. + const desktopExists = await stat(getDesktopSessionsDir()).then(s => s.isDirectory()).catch(() => false) + if ((await getClaudeConfigDirs()).length > 1 || desktopExists) { + for (const source of await claude.discoverSessions()) { + if ((source.sourceKind !== 'claude-config' && source.sourceKind !== 'claude-desktop') || !source.sourceId || !source.sourceLabel || !source.sourcePath) continue + if (!byId.has(source.sourceId)) byId.set(source.sourceId, { id: source.sourceId, label: source.sourceLabel, path: source.sourcePath }) + } + } + return buildSelector(byId, selectedId) +} + +function dailyEntriesToHistory(days: ReturnType<typeof aggregateProjectsIntoDays>): MenubarPayload['history']['daily'] { + return days.map(d => { + const topModels = Object.entries(d.models) + .filter(([name]) => name !== '<synthetic>') + .sort(([, a], [, b]) => b.cost - a.cost) + .slice(0, 5) + .map(([name, m]) => ({ + name, + cost: m.cost, + savingsUSD: m.savingsUSD, + calls: m.calls, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + })) + return { + date: d.date, + cost: d.cost, + savingsUSD: d.savingsUSD, + calls: d.calls, + inputTokens: d.inputTokens, + outputTokens: d.outputTokens, + cacheReadTokens: d.cacheReadTokens, + cacheWriteTokens: d.cacheWriteTokens, + topModels, + } + }) +} + +/** + * Resolved-range aggregation shared by `status --format menubar-json` and the MCP server. + * Pricing must already be loaded (callers run loadPricing first). When opts.optimize is + * false, the expensive scanAndDetect pass is skipped (retryTax/routingWaste still computed). + */ +export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: AggregateOpts = {}): Promise<MenubarPayload> { + const pf = opts.provider ?? 'all' + const daysSelection = opts.daysSelection ?? null + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? []) + + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const todayRange: DateRange = { start: todayStart, end: now } + const todayStr = toDateString(todayStart) + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + const rangeStartStr = toDateString(periodInfo.range.start) + const rangeEndStr = toDateString(periodInfo.range.end) + const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr + const isAllProviders = pf === 'all' + + let todayAllProjects: ProjectSummary[] | null = null + let todayAllDays: ReturnType<typeof aggregateProjectsIntoDays> | null = null + + const getTodayAllProjects = async (): Promise<ProjectSummary[]> => { + if (!todayAllProjects) { + todayAllProjects = fp(await parseAllSessions(todayRange, 'all')) + } + return todayAllProjects + } + + const getTodayAllDays = async (): Promise<ReturnType<typeof aggregateProjectsIntoDays>> => { + if (!todayAllDays) { + todayAllDays = aggregateProjectsIntoDays(await getTodayAllProjects()) + } + return todayAllDays + } + + // Assigned in every branch below (scoped-valid, or the !effectivelyScoped + // fallthrough); the `!` tells the compiler what the flag guarantees. + let currentData!: PeriodData + let scanProjects!: ProjectSummary[] + let scanRange!: DateRange + let cache: DailyCache = emptyCache() + let todayProviderData: PeriodData | null = null + let claudeConfigs: ClaudeConfigSelector | undefined + const requestedClaudeConfigSourceId = opts.claudeConfigSourceId?.trim() || null + const isClaudeConfigScoped = requestedClaudeConfigSourceId !== null + + let effectivelyScoped = false + if (isClaudeConfigScoped) { + // A config source scopes Claude usage only, so scan just Claude (main.ts + // rejects a contradictory non-Claude --provider). This also avoids parsing + // every other provider's corpus on each scoped refresh. + const rawProjects = fp(await parseAllSessions(periodInfo.range, 'claude')) + const fullProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects + claudeConfigs = await claudeConfigSelector(fullProjects, requestedClaudeConfigSourceId) + const selectedSourceId = claudeConfigs?.selectedId ?? null + if (selectedSourceId) { + effectivelyScoped = true + scanProjects = filterProjectsByClaudeConfigSource(fullProjects, selectedSourceId) + scanRange = periodInfo.range + currentData = buildPeriodData(periodInfo.label, scanProjects) + } + // A stale/invalid id does NOT validate: fall through to the normal path so + // an --provider all query returns real all-provider totals instead of the + // Claude-only scan. claudeConfigs (selectedId null) is kept so the selector + // still renders. + } + if (!effectivelyScoped) { + if (isAllProviders) { + cache = await hydrateCache() + const todayProjects = await getTodayAllProjects() + const todayDays = await getTodayAllDays() + const historicalDays = rangeStartStr <= historicalRangeEndStr + ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) + : [] + const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) + const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) + const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays + currentData = buildPeriodDataFromDays(allDays, periodInfo.label) + const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr + if (isTodayOnly) { + scanProjects = todayProjects + scanRange = todayRange + } else { + const rawProjects = fp(await parseAllSessions(periodInfo.range, 'all')) + scanProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects + scanRange = periodInfo.range + } + } else { + cache = await loadDailyCache() + const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf)) + const fullProjects = daysSelection ? filterProjectsByDays(rawProviderProjects, daysSelection.days) : rawProviderProjects + todayProviderData = buildPeriodData(periodInfo.label, fullProjects) + currentData = todayProviderData + scanProjects = fullProjects + scanRange = periodInfo.range + } + } + if (isAllProviders) { + currentData = buildPeriodData(periodInfo.label, scanProjects) + } + claudeConfigs = claudeConfigs ?? await claudeConfigSelector(scanProjects, null) + + // Codex credits for the period. Reuses the models aggregation (folds reasoning + // into output, keeps non-cached input + cached-read separate) so the figure + // matches the official credit rates. + const modelRows = await aggregateModels(scanProjects) + currentData.codexCredits = modelRows.reduce( + (sum, r) => sum + (r.provider === 'codex' && r.credits != null ? r.credits : 0), + 0, + ) + + // PROVIDERS + // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero. + // For specific: just this single provider with its scoped cost. + const allProviders = await getAllProviders() + const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName])) + const providers: ProviderCost[] = [] + if (isClaudeConfigScoped) { + const providerTotals: Record<string, number> = {} + for (const d of aggregateProjectsIntoDays(scanProjects)) { + for (const [name, p] of Object.entries(d.providers)) { + providerTotals[name] = (providerTotals[name] ?? 0) + p.cost + } + } + for (const [name, cost] of Object.entries(providerTotals)) { + providers.push({ name: displayNameByName.get(name) ?? name, cost }) + } + if (providers.length === 0 && claudeConfigs?.selectedId) { + providers.push({ name: displayNameByName.get('claude') ?? 'Claude', cost: 0 }) + } + } else if (isAllProviders) { + const unfilteredProviderDays = [ + ...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []), + ...(await getTodayAllDays()).filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr), + ] + const allDaysForProviders = daysSelection ? unfilteredProviderDays.filter(d => daysSelection.days.has(d.date)) : unfilteredProviderDays + const providerTotals: Record<string, number> = {} + for (const d of allDaysForProviders) { + for (const [name, p] of Object.entries(d.providers)) { + providerTotals[name] = (providerTotals[name] ?? 0) + p.cost + } + } + for (const [name, cost] of Object.entries(providerTotals)) { + providers.push({ name: displayNameByName.get(name) ?? name, cost }) + } + for (const p of allProviders) { + if (providers.some(pc => pc.name === p.displayName)) continue + const sources = await safeDiscoverSessions(p) + if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 }) + } + } else { + const display = displayNameByName.get(pf) ?? pf + providers.push({ name: display, cost: currentData.cost }) + } + + // DAILY HISTORY (last 365 days) + // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive + // a provider-filtered history without re-parsing. Tokens aren't broken down per provider + // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). + const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)) + const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) + + let dailyHistory + if (isClaudeConfigScoped && claudeConfigs?.selectedId) { + const historyRange: DateRange = { + start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS), + end: now, + } + const historyProjects = filterProjectsByClaudeConfigSource( + fp(await parseAllSessions(historyRange, 'claude')), + claudeConfigs.selectedId, + ) + dailyHistory = dailyEntriesToHistory(aggregateProjectsIntoDays(historyProjects)) + } else if (isAllProviders) { + const todayDays = (await getTodayAllDays()).filter(d => d.date === todayStr) + const fullHistory = [...allCacheDays, ...todayDays] + dailyHistory = dailyEntriesToHistory(fullHistory) + } else { + const emptyModels = [] as { name: string; cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number }[] + const historyFromCache = allCacheDays.map(d => { + const prov = d.providers[pf] ?? { calls: 0, cost: 0, savingsUSD: 0 } + return { + date: d.date, + cost: prov.cost, + savingsUSD: prov.savingsUSD, + calls: prov.calls, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: emptyModels, + } + }) + const todayFromParse = aggregateProjectsIntoDays(scanProjects) + .filter(d => d.date === todayStr) + .map(d => { + const prov = d.providers[pf] ?? { calls: 0, cost: 0, savingsUSD: 0 } + return { + date: d.date, + cost: prov.cost, + savingsUSD: prov.savingsUSD, + calls: prov.calls, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: emptyModels, + } + }) + dailyHistory = [...historyFromCache, ...todayFromParse] + } + + const home = homedir() + const friendlyProject = (p: ProjectSummary) => { + const resolved = p.projectPath || p.project + if (resolved === home || resolved === home + '/') return 'Home' + return resolved.split('/').filter(Boolean).pop() || p.project + } + + currentData.projects = scanProjects.map(p => ({ + name: friendlyProject(p), + cost: p.totalCostUSD, + savingsUSD: p.totalSavingsUSD, + sessions: p.sessions.length, + sessionDetails: [...p.sessions] + .sort((a, b) => b.totalCostUSD - a.totalCostUSD) + .slice(0, 10) + .map(s => ({ + cost: s.totalCostUSD, + savingsUSD: s.totalSavingsUSD, + calls: s.apiCalls, + inputTokens: s.totalInputTokens, + outputTokens: s.totalOutputTokens, + date: s.firstTimestamp?.split('T')[0] ?? '', + models: Object.entries(s.modelBreakdown) + .map(([name, m]) => ({ name, cost: m.costUSD, savingsUSD: m.savingsUSD })) + .sort((a, b) => b.cost - a.cost) + .slice(0, 3), + })), + })) + + const effMap = aggregateModelEfficiency(scanProjects) + currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({ + name, + costPerEdit: eff.costPerEditUSD, + oneShotRate: eff.oneShotRate, + })) + + const retryTaxByModel = [...effMap.values()] + .filter(m => m.retries > 0 && m.editTurns > 0) + .map(m => ({ + name: m.model, + taxUSD: m.retries * (m.editCostUSD / m.editTurns), + retries: m.retries, + retriesPerEdit: m.retriesPerEdit, + })) + .sort((a, b) => b.taxUSD - a.taxUSD) + const retryTax = { + totalUSD: retryTaxByModel.reduce((s, m) => s + m.taxUSD, 0), + retries: retryTaxByModel.reduce((s, m) => s + m.retries, 0), + editTurns: [...effMap.values()].filter(m => m.retries > 0).reduce((s, m) => s + m.editTurns, 0), + byModel: retryTaxByModel.slice(0, 5), + } + + currentData.topSessions = scanProjects.flatMap(p => + p.sessions.map(s => ({ + project: friendlyProject(p), + cost: s.totalCostUSD, + savingsUSD: s.totalSavingsUSD, + calls: s.apiCalls, + date: s.firstTimestamp?.split('T')[0] ?? '', + })) + ).sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD)).slice(0, 5) + + // Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits), + // then compute how much each pricier model overpaid. + const reliableModels = [...effMap.values()] + .filter(m => m.oneShotRate !== null && m.oneShotRate >= 90 && m.editTurns >= 5 + && (m.costPerEditUSD ?? 0) >= 0.01) + .sort((a, b) => (a.costPerEditUSD ?? Infinity) - (b.costPerEditUSD ?? Infinity)) + const baseline = reliableModels[0] + const routingWasteByModel = baseline + ? [...effMap.values()] + .filter(m => m.model !== baseline.model && m.editTurns > 0 && (m.costPerEditUSD ?? 0) > (baseline.costPerEditUSD ?? 0)) + .map(m => { + const counterfactual = m.editTurns * (baseline.costPerEditUSD ?? 0) + return { + name: m.model, + costPerEdit: m.costPerEditUSD ?? 0, + editTurns: m.editTurns, + actualUSD: m.editCostUSD, + counterfactualUSD: counterfactual, + savingsUSD: m.editCostUSD - counterfactual, + } + }) + .filter(m => m.savingsUSD > 0) + .sort((a, b) => b.savingsUSD - a.savingsUSD) + : [] + const routingWaste = { + totalSavingsUSD: routingWasteByModel.reduce((s, m) => s + m.savingsUSD, 0), + baselineModel: baseline?.model ?? '', + baselineCostPerEdit: baseline?.costPerEditUSD ?? 0, + byModel: routingWasteByModel.slice(0, 5), + } + + const breakdowns: BreakdownArrays = (() => { + const toolMap: Record<string, number> = {} + const skillMap: Record<string, { turns: number; cost: number }> = {} + const subagentMap: Record<string, { calls: number; cost: number }> = {} + const mcpMap: Record<string, number> = {} + // Local-model savings rollup: avoided spend (cost forced to $0, baseline + // recorded) grouped by model and provider. Mirrors the per-call savingsUSD + // that applyLocalModelSavings stamps in the parser. + const savingsByModel = new Map<string, { calls: number; actualUSD: number; savingsUSD: number; baselineModel: string; inputTokens: number; outputTokens: number }>() + const savingsByProvider = new Map<string, { calls: number; savingsUSD: number }>() + let totalSavings = 0 + let totalSavingsCalls = 0 + for (const p of scanProjects) for (const s of p.sessions) { + for (const [t, d] of Object.entries(s.toolBreakdown)) { if (!t.startsWith('lang:')) toolMap[t] = (toolMap[t] ?? 0) + d.calls } + for (const [sk, d] of Object.entries(s.skillBreakdown)) { const e = skillMap[sk] ?? { turns: 0, cost: 0 }; e.turns += d.turns; e.cost += d.costUSD; skillMap[sk] = e } + for (const [sa, d] of Object.entries(s.subagentBreakdown)) { const e = subagentMap[sa] ?? { calls: 0, cost: 0 }; e.calls += d.calls; e.cost += d.costUSD; subagentMap[sa] = e } + for (const [m, d] of Object.entries(s.mcpBreakdown)) { mcpMap[m] = (mcpMap[m] ?? 0) + d.calls } + for (const turn of s.turns) for (const call of turn.assistantCalls) { + if (!call.savingsUSD || call.savingsUSD <= 0) continue + totalSavings += call.savingsUSD + totalSavingsCalls += 1 + const modelKey = getShortModelName(call.model) + const acc = savingsByModel.get(modelKey) ?? { calls: 0, actualUSD: 0, savingsUSD: 0, baselineModel: call.savingsBaselineModel ?? '', inputTokens: 0, outputTokens: 0 } + acc.calls += 1 + acc.actualUSD += call.costUSD + acc.savingsUSD += call.savingsUSD + acc.baselineModel = acc.baselineModel || (call.savingsBaselineModel ?? '') + acc.inputTokens += call.usage.inputTokens + acc.outputTokens += call.usage.outputTokens + savingsByModel.set(modelKey, acc) + const provAcc = savingsByProvider.get(call.provider) ?? { calls: 0, savingsUSD: 0 } + provAcc.calls += 1 + provAcc.savingsUSD += call.savingsUSD + savingsByProvider.set(call.provider, provAcc) + } + } + const localModelSavings = { + totalUSD: totalSavings, + calls: totalSavingsCalls, + byModel: Array.from(savingsByModel.entries()).sort(([, a], [, b]) => b.savingsUSD - a.savingsUSD).slice(0, 5).map(([name, d]) => ({ name, ...d })), + byProvider: Array.from(savingsByProvider.entries()).sort(([, a], [, b]) => b.savingsUSD - a.savingsUSD).slice(0, 5).map(([name, d]) => ({ name, ...d })), + } + return { + tools: Object.entries(toolMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), + skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), + subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })), + mcpServers: Object.entries(mcpMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })), + localModelSavings, + } + })() + + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) + return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs) +} diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts new file mode 100644 index 0000000..83d4290 --- /dev/null +++ b/src/web-dashboard.ts @@ -0,0 +1,414 @@ +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 { hostname } from 'os' + +import { loadPricing } from './models.js' +import { buildMenubarPayloadForRange } from './usage-aggregator.js' +import type { MenubarPayload } from './menubar-json.js' +import { periodInfoFromQuery, UsageQueryError } 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<string> { + 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<string, string> = { + '.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 = + '<!doctype html><meta charset="utf-8">' + + '<body style="font-family:system-ui;background:#0a0a0b;color:#e7e7ea;padding:48px;line-height:1.6">' + + '<h2>Dashboard not built yet</h2>' + + '<p>Build the web UI once, then reload:</p>' + + '<pre style="background:#141417;padding:12px 16px;border-radius:8px;color:#ff8c42">cd dash && npm install && npm run build</pre>' + + '<p>The CLI keeps serving the live data API in the meantime.</p></body>' + +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<Server> { + 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 }) => { + const periodInfo = periodInfoFromQuery(q, opts.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<string, { at: number; payload: Promise<MenubarPayload> }>() + const getLocalPayload = (period: string, provider: string, from?: string, to?: string): Promise<MenubarPayload> => { + 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) + const payload = buildMenubarPayloadForRange(periodInfo, { provider, project: opts.project, exclude: opts.exclude, optimize: false }) + 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<string, Promise<ContextTreeResult>>() + const getContextTree = (provider: 'claude' | 'codex', ref: SessionRef): Promise<ContextTreeResult> => { + 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<void> => { + const html = await readFile(filePath, 'utf8') + const payload = await getLocalPayload(opts.period, opts.provider, opts.from, opts.to) + const devices = [{ id: 'local', name: hostname(), local: true, payload }] + // Escape every '<' so a device/model/project name can't close the <script>. + const json = JSON.stringify({ devices }).replace(/</g, String.fromCharCode(92) + 'u003c') + const injected = html.replace('<script type="module"', `<script>window.__CODEBURN_BOOTSTRAP__=${json}</script>\n <script type="module"`) + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }) + res.end(injected) + } + + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://localhost') + + // Loopback-only server. Reject any request not addressed to localhost + // (defeats DNS rebinding, which would otherwise let a website you visit + // read your local usage) and any cross-origin request (CSRF). The local + // payload is unsanitized, so this guard is what keeps it on your machine. + const reqHost = (req.headers.host ?? '').replace(/:\d+$/, '') + const loopback = reqHost === '127.0.0.1' || reqHost === 'localhost' || reqHost === '::1' || reqHost === '[::1]' + const origin = req.headers.origin + const originOk = !origin || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(origin) + if (!loopback || !originOk) { + res.writeHead(403, { 'content-type': 'text/plain' }) + res.end('Forbidden') + return + } + + if (url.pathname === '/api/usage') { + const period = url.searchParams.get('period') ?? opts.period + const provider = url.searchParams.get('provider') ?? opts.provider + const from = url.searchParams.get('from') ?? opts.from + const to = url.searchParams.get('to') ?? opts.to + let payload + try { + payload = await getLocalPayload(period, provider, from, to) + } catch (err) { + if (!(err instanceof UsageQueryError)) throw err + writeJsonError(res, 400, err.message) + return + } + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify(payload)) + return + } + + // This machine plus every paired device, each kept separate. Remote + // payloads arrive already sanitized (aggregate numbers only). + if (url.pathname === '/api/devices') { + const period = url.searchParams.get('period') ?? opts.period + const provider = url.searchParams.get('provider') ?? opts.provider + const from = url.searchParams.get('from') ?? opts.from + const to = url.searchParams.get('to') ?? opts.to + let results + try { + results = await pullDevices(() => getLocalPayload(period, provider, from, to), { period, from, to }, hostname(), {}) + } catch (err) { + if (!(err instanceof UsageQueryError)) throw err + writeJsonError(res, 400, err.message) + return + } + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify({ devices: results })) + return + } + + // This device's own identity (name + fingerprint) for the pairing UI. + if (url.pathname === '/api/identity') { + const id = await loadOrCreateIdentity(getSharingDir()) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify({ name: id.name, fingerprint: id.fingerprint })) + return + } + + // Discover devices currently sharing on the local network (mDNS). Each + // carries the confirm code to match, and whether it is already paired. + if (url.pathname === '/api/devices/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) + const list = found + .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), + })) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify({ found: list })) + return + } + + // Pair with a chosen discovered device. Blocks until the other device + // approves (or declines / times out), then stores the link. + if (url.pathname === '/api/devices/pair' && req.method === 'POST') { + if (!(req.headers['content-type'] ?? '').includes('application/json')) { + res.writeHead(415, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: false, error: 'content-type must be application/json' })) + return + } + const body = JSON.parse((await readBody(req)) || '{}') as { name: string; host: string; port: number; fingerprint: string } + try { + const device = await linkRemote(body) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: true, name: device.name })) + } catch (err) { + res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) })) + } + return + } + + // Share-this-device controls. Status carries the pending pairing requests + // so the SPA can poll one endpoint and surface approvals in the browser. + if (url.pathname === '/api/share/status') { + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify(await share.status())) + return + } + if (url.pathname === '/api/share/start' && req.method === 'POST') { + const body = JSON.parse((await readBody(req)) || '{}') as { always?: boolean } + let startError: string | undefined + try { + await share.start(!!body.always) + await saveShareAlways(!!body.always) + } catch (err) { + // e.g. EADDRINUSE when a CLI `codeburn share` already holds the port. + startError = err instanceof Error ? err.message : String(err) + } + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ...(await share.status()), error: startError })) + return + } + if (url.pathname === '/api/share/stop' && req.method === 'POST') { + await share.stop() + await saveShareAlways(false) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify(await share.status())) + return + } + // Session ids are resolved against the discovered session files only, + // never joined into a path from user input, and responses never carry + // local file paths. + if (url.pathname === '/api/context/sessions') { + const provider = url.searchParams.get('provider') ?? 'claude' + if (provider !== 'claude' && provider !== 'codex') { + writeJsonError(res, 400, 'provider must be claude or codex') + return + } + const refs = provider === 'claude' ? await listRecentTitledSessions(15) : await listRecentCodexSessions(15) + const sessions = refs.map((r) => ({ provider, sessionId: r.sessionId, project: r.project, title: r.title, mtimeMs: r.mtimeMs, sizeBytes: r.sizeBytes })) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify({ sessions })) + return + } + if (url.pathname === '/api/context/tree') { + const provider = url.searchParams.get('provider') ?? 'claude' + const id = url.searchParams.get('id') ?? '' + if ((provider !== 'claude' && provider !== 'codex') || !id) { + writeJsonError(res, 400, 'provider (claude|codex) and id are required') + return + } + const ref = provider === 'claude' ? await findClaudeSession(id) : await findCodexSession(id) + if (!ref || ref.sessionId !== id) { + writeJsonError(res, 404, `no ${provider} session ${id}`) + return + } + const tree = await getContextTree(provider, ref) + const payload = { + ...tree, + session: { sessionId: tree.session.sessionId, project: tree.session.project, mtimeMs: tree.session.mtimeMs, sizeBytes: tree.session.sizeBytes }, + effectiveRows: snapshotRows(tree.effective), + fullRows: snapshotRows(tree.full), + } + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) + res.end(JSON.stringify(payload)) + return + } + + if (url.pathname === '/api/share/approve' && req.method === 'POST') { + const body = JSON.parse((await readBody(req)) || '{}') as { id?: string; approve?: boolean } + const ok = typeof body.id === 'string' && share.resolvePending(body.id, !!body.approve) + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok })) + return + } + + if (!dashDir) { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(NOT_BUILT_PAGE) + return + } + + let pathname = decodeURIComponent(url.pathname) + if (pathname === '/' || pathname === '') pathname = '/index.html' + const filePath = normalize(join(dashDir, pathname)) + if (filePath !== dashDir && !filePath.startsWith(dashDir + sep)) { + res.writeHead(403) + res.end('Forbidden') + return + } + try { + if (extname(filePath) === '.html') { + await serveIndexHtml(res, filePath) + } else { + const buf = await readFile(filePath) + res.writeHead(200, { 'content-type': CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream' }) + res.end(buf) + } + } catch { + // Unknown path: serve index.html so the SPA can route it. + await serveIndexHtml(res, join(dashDir, 'index.html')) + } + } catch (err) { + res.writeHead(500, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })) + } + }) + + const port = await new Promise<number>((resolve, reject) => { + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)) + } else { + reject(err) + } + }) + server.listen(opts.port, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)) + }) + // Durable handler so a post-bind socket error never crashes the process. + server.on('error', () => {}) + + void Promise.resolve().then(() => getLocalPayload(opts.period, opts.provider, opts.from, opts.to)).catch(() => {}) + + const url = `http://127.0.0.1:${port}` + if (!dashDir) { + process.stdout.write(`\n Dashboard UI is not built. Run: cd dash && npm install && npm run build\n`) + } + process.stdout.write(`\n CodeBurn dashboard at ${url}\n Press Ctrl+C to stop.\n\n`) + if (opts.open) openBrowser(url) + + // Withdraw the mDNS advertisement and close the share server cleanly on exit. + process.on('SIGINT', () => { + void share.stop().finally(() => process.exit(0)) + }) + + return server + + await new Promise<never>(() => { + /* run until interrupted */ + }) +} diff --git a/src/yield.ts b/src/yield.ts new file mode 100644 index 0000000..aefd928 --- /dev/null +++ b/src/yield.ts @@ -0,0 +1,289 @@ +import { execFileSync } from 'child_process' +import { parseAllSessions } from './parser.js' +import type { DateRange, SessionSummary } from './types.js' + +export type YieldCategory = 'productive' | 'reverted' | 'abandoned' + +export type SessionYield = { + sessionId: string + project: string + cost: number + category: YieldCategory + commitCount: number +} + +export type YieldSummary = { + productive: { cost: number; sessions: number } + reverted: { cost: number; sessions: number } + abandoned: { cost: number; sessions: number } + total: { cost: number; sessions: number } + details: SessionYield[] +} + +export type YieldJsonReport = { + period: { + label: string + start: string + end: string + } + summary: { + productive: YieldBucketJson + reverted: YieldBucketJson + abandoned: YieldBucketJson + total: { costUSD: number; sessions: number } + productiveToRevertedCostRatio: number | null + } + details: SessionYieldJson[] +} + +type YieldBucketJson = { + costUSD: number + sessions: number + costPercent: number + sessionPercent: number +} + +type SessionYieldJson = Omit<SessionYield, 'cost'> & { + costUSD: number +} + +const SAFE_REF_PATTERN = /^[A-Za-z0-9._/\-]+$/ + +function runGit(args: string[], cwd: string): string | null { + try { + return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() + } catch { + return null + } +} + +function isGitRepo(dir: string): boolean { + return runGit(['rev-parse', '--is-inside-work-tree'], dir) === 'true' +} + +function getMainBranch(cwd: string): string { + const result = runGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], cwd) + if (result) { + const branch = result.replace('refs/remotes/origin/', '') + if (SAFE_REF_PATTERN.test(branch)) return branch + } + + const branches = runGit(['branch', '-a'], cwd) ?? '' + if (branches.includes('main')) return 'main' + if (branches.includes('master')) return 'master' + return 'main' +} + +type CommitInfo = { + sha: string + timestamp: Date + inMain: boolean + /** Set when a LATER commit's body says "This reverts commit <sha>" — i.e. the work in this commit was reverted out of main. */ + wasReverted: boolean +} + +/** + * Find SHAs that were the target of a `git revert` ANYWHERE in the repo's + * history (not just the time window). The standard `git revert` body + * format is "This reverts commit <SHA>." which we grep out. + * + * The previous implementation flagged a commit as `isRevert` based on the + * substring "revert" appearing in its OWN subject. Two bugs there: + * 1. Subjects like "Add revert button" matched. + * 2. The session that PERFORMED the revert was tagged "reverted", not the + * session whose work was being reverted — so the original session always + * looked productive even after its work was thrown away. + */ +function getRevertedShas(cwd: string): Set<string> { + const bodies = runGit( + ['log', '--all', '--grep=^This reverts commit', '--format=%B%x1e'], + cwd, + ) ?? '' + const set = new Set<string>() + const re = /This reverts commit ([0-9a-f]{7,40})/g + let m: RegExpExecArray | null + while ((m = re.exec(bodies)) !== null) { + set.add(m[1].toLowerCase()) + } + return set +} + +function getCommitsInRange(cwd: string, since: Date, until: Date, mainBranch: string): CommitInfo[] { + const sinceStr = since.toISOString() + const untilStr = until.toISOString() + + const log = runGit( + ['log', '--all', `--since=${sinceStr}`, `--until=${untilStr}`, '--format=%H|%aI|%s'], + cwd + ) + + if (!log) return [] + + const mainCommits = new Set( + (runGit(['log', mainBranch, '--format=%H'], cwd) ?? '').split('\n').filter(Boolean) + ) + const revertedShas = getRevertedShas(cwd) + + return log.split('\n').filter(Boolean).map(line => { + const [sha] = line.split('|') + const timestamp = line.split('|')[1] ?? '' + return { + sha, + timestamp: new Date(timestamp), + inMain: mainCommits.has(sha), + // wasReverted: matches when ANY later commit's body says + // "This reverts commit <sha>". Compare against the full SHA AND its + // 7-char short prefix to be safe; git revert sometimes records the + // short form. + wasReverted: revertedShas.has(sha.toLowerCase()) || + revertedShas.has(sha.toLowerCase().slice(0, 7)), + } + }) +} + +function categorizeSession( + session: SessionSummary, + commits: CommitInfo[] +): { category: YieldCategory; commitCount: number } { + if (!session.firstTimestamp) { + return { category: 'abandoned', commitCount: 0 } + } + + const sessionStart = new Date(session.firstTimestamp) + const lastTs = session.lastTimestamp ?? session.firstTimestamp + const sessionEnd = new Date(new Date(lastTs).getTime() + 60 * 60 * 1000) // +1 hour + + const relevantCommits = commits.filter(c => + c.timestamp >= sessionStart && c.timestamp <= sessionEnd + ) + + if (relevantCommits.length === 0) { + return { category: 'abandoned', commitCount: 0 } + } + + const inMainCount = relevantCommits.filter(c => c.inMain).length + // A session is "reverted" when at least half of its in-main commits were + // later reverted out (revert detected via "This reverts commit <sha>" + // anywhere later in history, not in the same time window). + const revertedCount = relevantCommits.filter(c => c.inMain && c.wasReverted).length + + if (revertedCount > 0 && revertedCount >= inMainCount / 2) { + return { category: 'reverted', commitCount: relevantCommits.length } + } + + if (inMainCount > 0) { + return { category: 'productive', commitCount: inMainCount } + } + + return { category: 'abandoned', commitCount: relevantCommits.length } +} + +export async function computeYield(range: DateRange, cwd: string): Promise<YieldSummary> { + const projects = await parseAllSessions(range, 'all') + + const summary: YieldSummary = { + productive: { cost: 0, sessions: 0 }, + reverted: { cost: 0, sessions: 0 }, + abandoned: { cost: 0, sessions: 0 }, + total: { cost: 0, sessions: 0 }, + details: [], + } + + // Get all commits in the date range for correlation + const commits = isGitRepo(cwd) + ? getCommitsInRange(cwd, range.start, range.end, getMainBranch(cwd)) + : [] + + for (const project of projects) { + // Try project-specific git repo first, fall back to cwd + const projectCwd = project.projectPath && isGitRepo(project.projectPath) + ? project.projectPath + : cwd + + const projectCommits = projectCwd !== cwd && isGitRepo(projectCwd) + ? getCommitsInRange(projectCwd, range.start, range.end, getMainBranch(projectCwd)) + : commits + + for (const session of project.sessions) { + const { category, commitCount } = categorizeSession(session, projectCommits) + + summary[category].cost += session.totalCostUSD + summary[category].sessions += 1 + summary.total.cost += session.totalCostUSD + summary.total.sessions += 1 + + summary.details.push({ + sessionId: session.sessionId, + project: project.project, + cost: session.totalCostUSD, + category, + commitCount, + }) + } + } + + return summary +} + +export function formatYieldSummary(summary: YieldSummary): string { + const { productive, reverted, abandoned, total } = summary + + const pct = (n: number) => total.cost > 0 ? Math.round((n / total.cost) * 100) : 0 + const fmt = (n: number) => `$${n.toFixed(2)}` + + const lines = [ + '', + `Productive: ${fmt(productive.cost).padStart(8)} (${pct(productive.cost)}%) - ${productive.sessions} sessions shipped to main`, + `Reverted: ${fmt(reverted.cost).padStart(8)} (${pct(reverted.cost)}%) - ${reverted.sessions} sessions were reverted`, + `Abandoned: ${fmt(abandoned.cost).padStart(8)} (${pct(abandoned.cost)}%) - ${abandoned.sessions} sessions never committed`, + '', + `Total: ${fmt(total.cost).padStart(8)} - ${total.sessions} sessions`, + '', + ] + + return lines.join('\n') +} + +export function buildYieldJsonReport( + summary: YieldSummary, + periodLabel: string, + range: DateRange, +): YieldJsonReport { + const bucket = (value: { cost: number; sessions: number }): YieldBucketJson => ({ + costUSD: value.cost, + sessions: value.sessions, + costPercent: summary.total.cost > 0 + ? Math.round((value.cost / summary.total.cost) * 1000) / 10 + : 0, + sessionPercent: summary.total.sessions > 0 + ? Math.round((value.sessions / summary.total.sessions) * 1000) / 10 + : 0, + }) + + return { + period: { + label: periodLabel, + start: range.start.toISOString(), + end: range.end.toISOString(), + }, + summary: { + productive: bucket(summary.productive), + reverted: bucket(summary.reverted), + abandoned: bucket(summary.abandoned), + total: { + costUSD: summary.total.cost, + sessions: summary.total.sessions, + }, + productiveToRevertedCostRatio: summary.reverted.cost > 0 + ? Math.round((summary.productive.cost / summary.reverted.cost) * 100) / 100 + : null, + }, + details: summary.details.map(detail => ({ + sessionId: detail.sessionId, + project: detail.project, + costUSD: detail.cost, + category: detail.category, + commitCount: detail.commitCount, + })), + } +} diff --git a/tests/act-journal.test.ts b/tests/act-journal.test.ts new file mode 100644 index 0000000..5732899 --- /dev/null +++ b/tests/act-journal.test.ts @@ -0,0 +1,215 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { runAction } from '../src/act/apply.js' +import { journalPath, readRecords } from '../src/act/journal.js' +import { sha256 } from '../src/act/backup.js' +import type { ActionRecord } from '../src/act/types.js' + +const roots: string[] = [] + +async function makeRoot(): Promise<{ actionsDir: string; files: string }> { + const root = await mkdtemp(join(tmpdir(), 'codeburn-act-journal-')) + roots.push(root) + const files = join(root, 'files') + await mkdir(files, { recursive: true }) + return { actionsDir: join(root, 'actions'), files } +} + +afterAll(async () => { + for (const root of roots) await rm(root, { recursive: true, force: true }) +}) + +describe('runAction journaling', () => { + it('journals backups and afterHash for edit, create, and move ops', async () => { + const { actionsDir, files } = await makeRoot() + const editPath = join(files, 'edit.txt') + const createPath = join(files, 'new.txt') + const movePath = join(files, 'move.txt') + const moveDest = join(files, 'archive', 'move.txt') + await writeFile(editPath, 'old-edit') + await writeFile(movePath, 'move-body') + + const rec = await runAction({ + kind: 'claude-md-rule', + description: 'test action', + changes: [ + { op: 'edit', path: editPath, content: 'new-edit' }, + { op: 'create', path: createPath, content: 'created' }, + { op: 'move', path: movePath, movedTo: moveDest }, + ], + }, actionsDir) + + expect(rec.status).toBe('applied') + expect(rec.findingId).toBeNull() + expect(rec.changes).toHaveLength(3) + const [edit, create, move] = rec.changes + + expect(edit!.backup).not.toBeNull() + expect(await readFile(join(actionsDir, edit!.backup!), 'utf-8')).toBe('old-edit') + expect(edit!.afterHash).toBe(sha256(Buffer.from('new-edit'))) + expect(await readFile(editPath, 'utf-8')).toBe('new-edit') + + expect(create!.backup).toBeNull() + expect(create!.afterHash).toBe(sha256(Buffer.from('created'))) + expect(await readFile(createPath, 'utf-8')).toBe('created') + + expect(move!.backup).not.toBeNull() + expect(await readFile(join(actionsDir, move!.backup!), 'utf-8')).toBe('move-body') + expect(move!.movedTo).toBe(moveDest) + expect(move!.afterHash).toBe(sha256(Buffer.from('move-body'))) + expect(await readFile(moveDest, 'utf-8')).toBe('move-body') + expect(existsSync(movePath)).toBe(false) + + const records = await readRecords(actionsDir) + expect(records).toHaveLength(1) + expect(records[0]!.id).toBe(rec.id) + }) + + it('rolls back completed mutations and writes no record when a mutation fails', async () => { + const { actionsDir, files } = await makeRoot() + const editPath = join(files, 'edit.txt') + await writeFile(editPath, 'original') + const missingSrc = join(files, 'does-not-exist.txt') + + await expect(runAction({ + kind: 'shell-config', + description: 'failing action', + changes: [ + { op: 'edit', path: editPath, content: 'changed' }, + { op: 'move', path: missingSrc, movedTo: join(files, 'dest.txt') }, + ], + }, actionsDir)).rejects.toThrow() + + expect(await readFile(editPath, 'utf-8')).toBe('original') + expect(existsSync(journalPath(actionsDir))).toBe(false) + }) + + it('skips corrupt journal lines and still loads valid records', async () => { + const { actionsDir } = await makeRoot() + await mkdir(actionsDir, { recursive: true }) + const recA = sampleRecord('11111111-1111-1111-1111-111111111111', 'first') + const recB = sampleRecord('22222222-2222-2222-2222-222222222222', 'second') + await writeFile( + journalPath(actionsDir), + JSON.stringify(recA) + '\n' + '{ this is not json\n' + JSON.stringify(recB) + '\n', + ) + + const records = await readRecords(actionsDir) + expect(records.map(r => r.id)).toEqual([recA.id, recB.id]) + expect(records.map(r => r.description)).toEqual(['first', 'second']) + }) + + it('round-trips a record through JSON (act list --json shape)', async () => { + const { actionsDir, files } = await makeRoot() + const p = join(files, 'f.txt') + await writeFile(p, 'before') + const rec = await runAction({ + kind: 'model-default', + description: 'json shape', + changes: [{ op: 'edit', path: p, content: 'after' }], + }, actionsDir) + + const records = await readRecords(actionsDir) + const roundTripped = JSON.parse(JSON.stringify(records)) as ActionRecord[] + expect(roundTripped).toEqual(records) + + const only = roundTripped[0]! + expect(only).toMatchObject({ id: rec.id, kind: 'model-default', description: 'json shape', status: 'applied' }) + expect(typeof only.at).toBe('string') + expect(only.findingId).toBeNull() + expect(only.changes[0]).toMatchObject({ path: p, op: 'edit' }) + expect(only.changes[0]!.backup).not.toBeNull() + expect(typeof only.changes[0]!.afterHash).toBe('string') + }) +}) + +describe('action lock', () => { + it('refuses to run while a fresh foreign lock is held', async () => { + const { actionsDir, files } = await makeRoot() + await mkdir(actionsDir, { recursive: true }) + await writeFile(join(actionsDir, '.lock'), '') + const target = join(files, 'blocked.txt') + + await expect(runAction({ + kind: 'shell-config', + description: 'blocked by lock', + changes: [{ op: 'create', path: target, content: 'x' }], + }, actionsDir)).rejects.toThrow(/in progress/) + + expect(existsSync(target)).toBe(false) + }) + + it('takes over a lock whose mtime is older than 60s', async () => { + const { actionsDir, files } = await makeRoot() + await mkdir(actionsDir, { recursive: true }) + const lock = join(actionsDir, '.lock') + await writeFile(lock, JSON.stringify({ pid: 1, at: 0 })) + const past = new Date(Date.now() - 61_000) + await utimes(lock, past, past) + const target = join(files, 'takeover.txt') + + const rec = await runAction({ + kind: 'shell-config', + description: 'stale takeover', + changes: [{ op: 'create', path: target, content: 'x' }], + }, actionsDir) + + expect(rec.status).toBe('applied') + expect(await readFile(target, 'utf-8')).toBe('x') + expect(existsSync(lock)).toBe(false) + }) +}) + +describe('act list --json (CLI)', () => { + it('prints full records as JSON, newest first', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-act-cli-')) + roots.push(home) + const actionsDir = join(home, '.config', 'codeburn', 'actions') + const files = join(home, 'work') + await mkdir(files, { recursive: true }) + const p1 = join(files, 'a.txt') + const p2 = join(files, 'b.txt') + await writeFile(p1, 'a') + await writeFile(p2, 'b') + const recOlder = await runAction({ + kind: 'mcp-remove', description: 'older', changes: [{ op: 'edit', path: p1, content: 'a2' }], + }, actionsDir) + const recNewer = await runAction({ + kind: 'mcp-remove', description: 'newer', changes: [{ op: 'edit', path: p2, content: 'b2' }], + }, actionsDir) + + // Anchor the spawn to this checkout so running vitest from another cwd + // cannot execute a different checkout's cli.ts. + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') + const res = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', 'act', 'list', '--json'], { + cwd: repoRoot, + env: { ...process.env, HOME: home, USERPROFILE: home, HOMEPATH: home, HOMEDRIVE: '' }, + encoding: 'utf-8', + }) + + expect(res.status).toBe(0) + const parsed = JSON.parse(res.stdout) as ActionRecord[] + expect(parsed.map(r => r.id)).toEqual([recNewer.id, recOlder.id]) + expect(parsed[0]).toMatchObject({ kind: 'mcp-remove', description: 'newer', status: 'applied', findingId: null }) + expect(parsed[0]!.changes[0]).toMatchObject({ path: p2, op: 'edit' }) + expect(typeof parsed[0]!.changes[0]!.afterHash).toBe('string') + expect(parsed[0]!.changes[0]!.backup).not.toBeNull() + }, 20_000) +}) + +function sampleRecord(id: string, description: string): ActionRecord { + return { + id, + at: new Date().toISOString(), + kind: 'mcp-remove', + findingId: null, + description, + changes: [], + status: 'applied', + } +} diff --git a/tests/act-report.test.ts b/tests/act-report.test.ts new file mode 100644 index 0000000..ae0664c --- /dev/null +++ b/tests/act-report.test.ts @@ -0,0 +1,435 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { journalPath } from '../src/act/journal.js' +import { + buildActReportJson, + buildOptimizeAppliedHeader, + computeActReport, + renderActReport, +} from '../src/act/report.js' +import type { ActionRecord } from '../src/act/types.js' +import type { ProjectSummary } from '../src/types.js' + +type Session = ProjectSummary['sessions'][number] + +const roots: string[] = [] +afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) }) + +const NOW = new Date('2026-07-01T00:00:00Z') +function daysAgo(n: number): string { + return new Date(NOW.getTime() - n * 86_400_000).toISOString() +} + +async function writeJournal(records: unknown[]): Promise<string> { + const root = await mkdtemp(join(tmpdir(), 'codeburn-act-report-')) + roots.push(root) + const actionsDir = join(root, 'actions') + await mkdir(actionsDir, { recursive: true }) + await writeFile(journalPath(actionsDir), records.map(r => JSON.stringify(r)).join('\n') + (records.length ? '\n' : '')) + return actionsDir +} + +function makeSession(id: string, firstTimestamp: string, over: Partial<Session> = {}): Session { + return { + sessionId: id, + project: 'app', + firstTimestamp, + lastTimestamp: firstTimestamp, + totalCostUSD: 1, + totalSavingsUSD: 0, + totalInputTokens: 1000, + totalOutputTokens: 0, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as Session['categoryBreakdown'], + skillBreakdown: {}, + subagentBreakdown: {}, + ...over, + } +} + +function projectOf(sessions: Session[]): ProjectSummary { + return { + project: 'app', + projectPath: '/tmp/app', + sessions, + totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0), + totalSavingsUSD: 0, + totalApiCalls: sessions.length, + totalProxiedCostUSD: 0, + } +} + +function sessionsAt(count: number, ts: string, over: Partial<Session> = {}): Session[] { + return Array.from({ length: count }, (_, i) => makeSession(`s${i}`, ts, over)) +} + +function mcpRecord(over: Partial<ActionRecord> = {}): ActionRecord { + const at = daysAgo(10) + return { + id: 'a1', + at, + kind: 'mcp-remove', + findingId: 'unused-mcp', + description: 'Remove an MCP server from config', + changes: [], + status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 56_000, sessions: 28, metrics: { 'brave-search': 2000 } }, + ...over, + } +} + +const load = (projects: ProjectSummary[]) => async () => projects + +describe('mcp realized delta', () => { + it('multiplies baseline tokens-per-session by post-window sessions (exact)', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + + expect(report.rows).toHaveLength(1) + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(40_000) // 2000 tokens/session * 20 sessions + expect(row.estimatedAtApply).toBe(56_000) + expect(row.estimatedForWindow).toBe(40_000) // window-scaled: 2000 * 20 + expect(row.confidence).toBe('normal') + expect(report.totalRealizedTokens).toBe(40_000) + }) + + it('subtracts keeper sessions that still load the server for project-scope, not a revert', async () => { + const rec = mcpRecord({ kind: 'mcp-project-scope', findingId: 'mcp-project-scope', description: 'Project-scope an MCP server' }) + const actionsDir = await writeJournal([rec]) + const keepers = sessionsAt(5, daysAgo(4), { mcpInventory: ['mcp__brave-search__search'] }) + const cold = sessionsAt(15, daysAgo(5)) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([...cold, ...keepers])]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(30_000) // 2000 x (20 - 5 still loading) + expect(row.estimatedForWindow).toBe(40_000) // 2000 x all 20 window sessions + }) + + it('reports "reverted" with zero savings when the server reappears in the window', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const back = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] }) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(back)]) }) + + const row = report.rows[0]! + expect(row.status).toBe('reverted') + expect(row.realizedTokens).toBe(0) + expect(row.note).toMatch(/reverted by user/) + expect(report.totalRealizedTokens).toBe(0) + }) +}) + +describe('confidence markers', () => { + it('marks low when fewer than 20 post-window sessions', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(10, daysAgo(5)))]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(20_000) // 2000 * 10 + expect(row.confidence).toBe('low') + }) + + it('marks low when volume shifts more than 2x versus baseline', async () => { + // 25 post-window sessions (>= 20, so not the count rule) over 10 days is + // 2.5/day against a 1/day baseline (14 sessions / 14 days) -> 2.5x shift. + const rec = mcpRecord({ baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 56_000, sessions: 14, metrics: { 'brave-search': 2000 } } }) + const actionsDir = await writeJournal([rec]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(25, daysAgo(5)))]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.confidence).toBe('low') + }) + + it('stays normal when volume is comparable to baseline', async () => { + const actionsDir = await writeJournal([mcpRecord()]) // baseline 28/14 = 2/day + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) // 20/10 = 2/day + expect(report.rows[0]!.confidence).toBe('normal') + }) +}) + +describe('eligibility', () => { + it('excludes undone actions and actions younger than 3 days', async () => { + const records = [ + mcpRecord({ id: 'old', at: daysAgo(10) }), + mcpRecord({ id: 'young', at: daysAgo(1) }), + mcpRecord({ id: 'undone', at: daysAgo(20), status: 'undone' }), + ] + const actionsDir = await writeJournal(records) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + + expect(report.rows).toHaveLength(1) + expect(report.rows[0]!.id).toBe('old') + expect(report.activeCount).toBe(2) // old + young are applied; undone is not + }) +}) + +describe('read-edit realized delta', () => { + it('credits the reduction in the read deficit using the detector estimate math', async () => { + // Baseline ratio 1:1 (deficit 3 reads/edit). After window: 120 reads / 40 + // edits = 3:1 (deficit 1). Credit (3 - 1) * 40 edits * 600 = 48000. + const at = daysAgo(10) + const rec: ActionRecord = { + id: 'r1', at, kind: 'claude-md-rule', findingId: 'read-edit-ratio', + description: 'Add the read-edit-ratio rule block', changes: [], status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 12_000, sessions: 28, metrics: { reads: 10, edits: 10 } }, + } + const actionsDir = await writeJournal([rec]) + const session = makeSession('s0', daysAgo(5), { toolBreakdown: { Read: { calls: 120 }, Edit: { calls: 40 } } }) + const filler = sessionsAt(19, daysAgo(4)) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([session, ...filler])]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(48_000) + expect(row.estimatedAtApply).toBe(12_000) + expect(row.estimatedForWindow).toBe(72_000) // deficitThen 3 x 40 edits x 600, same denominator as realized + expect(row.realizedTokens).toBeLessThanOrEqual(row.estimatedForWindow) + expect(row.note).toMatch(/1\.0:1 -> 3\.0:1/) + }) +}) + +describe('round-down discipline', () => { + it('floors non-integer mcp products, never rounds up', async () => { + const rec = mcpRecord({ baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 5000, sessions: 3, metrics: { 'brave-search': 700.7 } } }) + const actionsDir = await writeJournal([rec]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(3, daysAgo(5)))]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(2102) // floor(700.7 x 3 = 2102.1); ceil would be 2103 + expect(row.estimatedForWindow).toBe(2102) + }) + + it('floors non-integer read-edit products, never rounds up', async () => { + // deficitThen = 4 - 10/7 = 18/7; deficitNow = 4 - 20/10 = 2. + // realized = floor((18/7 - 2) x 10 x 600) = floor(3428.57...) = 3428. + // window estimate = floor(18/7 x 10 x 600) = floor(15428.57...) = 15428. + const at = daysAgo(10) + const rec: ActionRecord = { + id: 'rf1', at, kind: 'claude-md-rule', findingId: 'read-edit-ratio', + description: 'Add the read-edit-ratio rule block', changes: [], status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 9000, sessions: 28, metrics: { reads: 10, edits: 7 } }, + } + const actionsDir = await writeJournal([rec]) + const session = makeSession('s0', daysAgo(5), { toolBreakdown: { Read: { calls: 20 }, Edit: { calls: 10 } } }) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([session])]) }) + + const row = report.rows[0]! + expect(row.status).toBe('measured') + expect(row.realizedTokens).toBe(3428) // ceil would be 3429 + expect(row.estimatedForWindow).toBe(15_428) // ceil would be 15429 + }) +}) + +describe('archive realized delta', () => { + it('measures per-item definition tokens times sessions and detects un-archive', async () => { + const at = daysAgo(10) + const kept = join(tmpdir(), 'codeburn-act-report-absent-skill-xyz') // absent -> not reverted + const base = { windowDays: 14, capturedAt: at, estimatedTokens: 160, sessions: 28, metrics: { 'skill-a': 80, 'skill-b': 80 } } + const rec: ActionRecord = { + id: 'ar1', at, kind: 'archive-skill', findingId: 'unused-skills', + description: 'Archive 2 unused skills', status: 'applied', + changes: [{ path: kept, backup: null, op: 'move', movedTo: kept + '.archived', afterHash: '' }], + baseline: base, + } + const actionsDir = await writeJournal([rec]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + expect(report.rows[0]!.status).toBe('measured') + expect(report.rows[0]!.realizedTokens).toBe(3200) // 160 tokens/session * 20 + // Tautology expected: estimate and realized share the formula when nothing + // reverted; the measured signal is the session count and the revert check. + expect(report.rows[0]!.estimatedForWindow).toBe(report.rows[0]!.realizedTokens) + + // Now the original path exists again -> reverted, zero savings. + const restoredPath = join(actionsDir, 'restored-skill') + await writeFile(restoredPath, 'x') + const rec2: ActionRecord = { ...rec, changes: [{ path: restoredPath, backup: null, op: 'move', movedTo: restoredPath + '.archived', afterHash: '' }] } + const dir2 = await writeJournal([rec2]) + const report2 = await computeActReport({ actionsDir: dir2, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + expect(report2.rows[0]!.status).toBe('reverted') + expect(report2.rows[0]!.realizedTokens).toBe(0) + }) +}) + +describe('unmeasured kinds', () => { + it('marks bash cap not measurable but keeps the estimate visible', async () => { + const at = daysAgo(10) + const rec: ActionRecord = { + id: 'b1', at, kind: 'shell-config', findingId: 'bash-output-cap', + description: 'Set the bash output cap', changes: [], status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 3750, sessions: 28, metrics: { calls: 200 } }, + } + const actionsDir = await writeJournal([rec]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + expect(report.rows[0]!.status).toBe('not-measurable') + expect(report.rows[0]!.estimatedAtApply).toBe(3750) + expect(report.rows[0]!.estimatedForWindow).toBe(3750) // no window scaling for unmeasured kinds + expect(report.measuredCount).toBe(0) + }) + + it('marks mcp and archive not measurable when the window has no sessions yet', async () => { + const at = daysAgo(10) + const arch: ActionRecord = { + id: 'z2', at, kind: 'archive-skill', findingId: 'unused-skills', + description: 'Archive 1 unused skill', changes: [], status: 'applied', + baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 80, sessions: 28, metrics: { 'skill-a': 80 } }, + } + const actionsDir = await writeJournal([mcpRecord(), arch]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([]) }) + + expect(report.rows).toHaveLength(2) + for (const row of report.rows) { + expect(row.status).toBe('not-measurable') + expect(row.note).toMatch(/no sessions in the window yet/) + expect(row.realizedTokens).toBe(0) + } + expect(report.measuredCount).toBe(0) + }) +}) + +describe('journal robustness', () => { + const missingAt = { id: 'm1', kind: 'mcp-remove', status: 'applied', description: 'missing at', changes: [] } + const numericAt = { id: 'm2', at: 12345, kind: 'mcp-remove', status: 'applied', description: 'numeric at', changes: [] } + + it('skips malformed records with a note instead of crashing, and drops the optimize header', async () => { + const actionsDir = await writeJournal([missingAt, numericAt]) + const report = await computeActReport({ + actionsDir, now: NOW, + loadProjects: async () => { throw new Error('should not scan when no eligible records remain') }, + }) + + expect(report.malformedRecords).toBe(2) + expect(report.rows).toHaveLength(0) + expect(report.activeCount).toBe(0) + expect(buildOptimizeAppliedHeader(report)).toBeNull() + expect(renderActReport(report)).toMatch(/2 malformed records skipped/) + }) + + it('still measures valid records alongside malformed ones', async () => { + const actionsDir = await writeJournal([missingAt, mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + + expect(report.malformedRecords).toBe(1) + expect(report.rows).toHaveLength(1) + expect(report.rows[0]!.realizedTokens).toBe(40_000) + expect(renderActReport(report)).toMatch(/1 malformed record skipped/) + }) +}) + +describe('optimize header', () => { + it('appears only when a normal-confidence measured token action exists', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + const header = buildOptimizeAppliedHeader(report) + expect(header).toMatch(/^Applied fixes: 1 active, realized ~40\.0K tokens.*over 10 days\. Details: codeburn act report$/) + }) + + it('renders no header when every measured row is low confidence (under-claim)', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(10, daysAgo(5)))]) }) + + expect(report.rows[0]!.status).toBe('measured') + expect(report.rows[0]!.confidence).toBe('low') + expect(report.rows[0]!.realizedTokens).toBe(20_000) // still visible in act report + expect(report.totalRealizedTokens).toBe(20_000) + expect(buildOptimizeAppliedHeader(report)).toBeNull() + }) + + it('sums only normal-confidence rows into the header total', async () => { + // r1: baseline 28/14d = 2/day vs post 20/10d = 2/day -> normal. + // r2: baseline 100/14d = 7.1/day vs 2/day -> >2x shift -> low. + const r1 = mcpRecord({ id: 'n1' }) + const r2 = mcpRecord({ + id: 'l1', + baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 56_000, sessions: 100, metrics: { 'other-server': 2000 } }, + }) + const actionsDir = await writeJournal([r1, r2]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + + expect(report.totalRealizedTokens).toBe(80_000) // both stay visible in act report + const header = buildOptimizeAppliedHeader(report) + expect(header).toMatch(/^Applied fixes: 2 active, realized ~40\.0K tokens/) + }) + + it('returns null and never scans when the journal has no eligible actions', async () => { + const emptyDir = await writeJournal([]) + const report = await computeActReport({ + actionsDir: emptyDir, now: NOW, + loadProjects: async () => { throw new Error('should not scan for an empty journal') }, + }) + expect(report.rows).toHaveLength(0) + expect(report.activeCount).toBe(0) + expect(buildOptimizeAppliedHeader(report)).toBeNull() + }) + + it('records the earliest apply date per finding for re-flagging', async () => { + const records = [ + mcpRecord({ id: 'x1', at: daysAgo(9), findingId: 'unused-mcp' }), + mcpRecord({ id: 'x2', at: daysAgo(4), findingId: 'unused-mcp' }), + ] + const actionsDir = await writeJournal(records) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(3)))]) }) + expect(report.appliedByFinding['unused-mcp']).toBe(daysAgo(9).slice(0, 10)) + }) +}) + +describe('json + render shape', () => { + it('mirrors the rows and totals in --json', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + const json = buildActReportJson(report) as { + actions: Array<Record<string, unknown>> + totals: Record<string, unknown> + footer: string + windowCapDays: number + } + + expect(Array.isArray(json.actions)).toBe(true) + expect(json.actions[0]).toMatchObject({ + kind: 'mcp-remove', + estimatedAtApply: 56_000, + estimatedForWindow: 40_000, + realizedTokens: 40_000, + status: 'measured', + confidence: 'normal', + }) + expect(json.totals).toMatchObject({ realizedTokens: 40_000, measuredActions: 1, activeActions: 1 }) + expect(json.windowCapDays).toBe(30) + expect((json as { malformedRecords?: number }).malformedRecords).toBe(0) + expect(typeof json.footer).toBe('string') + expect(json.footer).toMatch(/correlation/) + }) + + it('renders an empty state without a table when nothing is measurable', async () => { + const emptyDir = await writeJournal([]) + const report = await computeActReport({ actionsDir: emptyDir, now: NOW, loadProjects: async () => [] }) + const out = renderActReport(report) + expect(out).toMatch(/No applied actions to measure yet/) + expect(out).not.toMatch(/Total realized/) + }) + + it('renders a table with a total row when measurements exist', async () => { + const actionsDir = await writeJournal([mcpRecord()]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(5)))]) }) + const out = renderActReport(report) + expect(out).toMatch(/Total realized/) + expect(out).toMatch(/40\.0K/) + expect(out).toMatch(/measures only its own metric/) + expect(out).toMatch(/scaled to the measured window/) + }) +}) diff --git a/tests/act-undo.test.ts b/tests/act-undo.test.ts new file mode 100644 index 0000000..3d37012 --- /dev/null +++ b/tests/act-undo.test.ts @@ -0,0 +1,307 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runAction } from '../src/act/apply.js' +import { appendRecord, readRecords } from '../src/act/journal.js' +import { DriftError, undoAction } from '../src/act/undo.js' +import type { ActionRecord } from '../src/act/types.js' + +const roots: string[] = [] + +async function makeRoot(): Promise<{ actionsDir: string; files: string }> { + const root = await mkdtemp(join(tmpdir(), 'codeburn-act-undo-')) + roots.push(root) + const files = join(root, 'files') + await mkdir(files, { recursive: true }) + return { actionsDir: join(root, 'actions'), files } +} + +afterAll(async () => { + for (const root of roots) await rm(root, { recursive: true, force: true }) +}) + +describe('undoAction', () => { + it('restores byte-identical content for edit, create, and move, then refuses a second undo', async () => { + const { actionsDir, files } = await makeRoot() + const editPath = join(files, 'edit.bin') + const createPath = join(files, 'created.bin') + const movePath = join(files, 'move.bin') + const moveDest = join(files, 'sub', 'move.bin') + const editOriginal = Buffer.from([0, 1, 2, 3, 255, 254]) + const moveOriginal = Buffer.from([10, 20, 30, 40]) + await writeFile(editPath, editOriginal) + await writeFile(movePath, moveOriginal) + + const rec = await runAction({ + kind: 'archive-skill', + description: 'undo test', + changes: [ + { op: 'edit', path: editPath, content: Buffer.from([9, 9, 9]) }, + { op: 'create', path: createPath, content: Buffer.from([7, 7]) }, + { op: 'move', path: movePath, movedTo: moveDest }, + ], + }, actionsDir) + + // 8-char prefix is accepted + await undoAction({ id: rec.id.slice(0, 8) }, { actionsDir }) + + expect(Buffer.compare(await readFile(editPath), editOriginal)).toBe(0) + expect(existsSync(createPath)).toBe(false) + expect(existsSync(moveDest)).toBe(false) + expect(Buffer.compare(await readFile(movePath), moveOriginal)).toBe(0) + + const records = await readRecords(actionsDir) + expect(records).toHaveLength(1) + expect(records[0]!.status).toBe('undone') + expect(records[0]!.undoneAt).toBeTruthy() + + await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toThrow(/already undone/) + }) + + it('undoes the most recent action with --last and leaves earlier actions applied', async () => { + const { actionsDir, files } = await makeRoot() + const first = join(files, 'first.txt') + const second = join(files, 'second.txt') + await writeFile(first, 'first-old') + await writeFile(second, 'second-old') + + const recFirst = await runAction({ + kind: 'claude-md-rule', description: 'first', changes: [{ op: 'edit', path: first, content: 'first-new' }], + }, actionsDir) + await runAction({ + kind: 'claude-md-rule', description: 'second', changes: [{ op: 'edit', path: second, content: 'second-new' }], + }, actionsDir) + + const undone = await undoAction({ last: true }, { actionsDir }) + expect(undone.description).toBe('second') + expect(await readFile(second, 'utf-8')).toBe('second-old') + expect(await readFile(first, 'utf-8')).toBe('first-new') + + const records = await readRecords(actionsDir) + expect(records.find(r => r.id === recFirst.id)!.status).toBe('applied') + }) + + it('refuses to undo a drifted file, but --force proceeds', async () => { + const { actionsDir, files } = await makeRoot() + const p = join(files, 'drift.txt') + await writeFile(p, 'original') + const rec = await runAction({ + kind: 'claude-md-rule', description: 'drift', changes: [{ op: 'edit', path: p, content: 'applied' }], + }, actionsDir) + + await writeFile(p, 'user-modified') + + await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError) + expect((await readRecords(actionsDir))[0]!.status).toBe('applied') + expect(await readFile(p, 'utf-8')).toBe('user-modified') + + await undoAction({ id: rec.id }, { actionsDir, force: true }) + expect(await readFile(p, 'utf-8')).toBe('original') + expect((await readRecords(actionsDir))[0]!.status).toBe('undone') + }) + + it('reverts changes newest-first so overlapping changes restore the original state', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'a.txt') + const dest = join(files, 'b.txt') + await writeFile(src, 'orig') + + const rec = await runAction({ + kind: 'shell-config', + description: 'move then edit', + changes: [ + { op: 'move', path: src, movedTo: dest }, + { op: 'edit', path: dest, content: 'edited' }, + ], + }, actionsDir) + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(src, 'utf-8')).toBe('orig') + expect(existsSync(dest)).toBe(false) + }) + + it('refuses to undo a move when the original path is occupied, then --force overwrites', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'a.txt') + const dest = join(files, 'b.txt') + await writeFile(src, 'moved-bytes') + const rec = await runAction({ + kind: 'archive-skill', description: 'occupied', changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + + await writeFile(src, 'squatter') + + const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e) + expect(err).toBeInstanceOf(DriftError) + expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true) + + await undoAction({ id: rec.id }, { actionsDir, force: true }) + expect(await readFile(src, 'utf-8')).toBe('moved-bytes') + expect(existsSync(dest)).toBe(false) + }) + + it('snapshots an existing move destination and restores both files on undo', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'a.txt') + const dest = join(files, 'b.txt') + await writeFile(src, 'src-bytes') + await writeFile(dest, 'dest-bytes') + + const rec = await runAction({ + kind: 'archive-agent', description: 'move onto dest', changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + expect(rec.changes[0]!.destBackup).not.toBeNull() + expect(await readFile(dest, 'utf-8')).toBe('src-bytes') + expect(await readFile(join(actionsDir, rec.changes[0]!.destBackup!), 'utf-8')).toBe('dest-bytes') + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(src, 'utf-8')).toBe('src-bytes') + expect(await readFile(dest, 'utf-8')).toBe('dest-bytes') + }) + + it('force-undo of a move whose moved file is gone restores from backup and flips status', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'a.txt') + const dest = join(files, 'b.txt') + await writeFile(src, 'precious') + const rec = await runAction({ + kind: 'archive-command', description: 'gone', changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + + await rm(dest) + + await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError) + const undone = await undoAction({ id: rec.id }, { actionsDir, force: true }) + expect(undone.status).toBe('undone') + expect(await readFile(src, 'utf-8')).toBe('precious') + }) + + it('undoing a create that overwrote an existing file restores the prior bytes', async () => { + const { actionsDir, files } = await makeRoot() + const p = join(files, 'exists.txt') + await writeFile(p, 'prior') + + const rec = await runAction({ + kind: 'guard-install', description: 'create over existing', changes: [{ op: 'create', path: p, content: 'new' }], + }, actionsDir) + expect(rec.changes[0]!.backup).not.toBeNull() + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(p, 'utf-8')).toBe('prior') + }) + + it('undoes a plan that touches the same path twice back to the original bytes', async () => { + const { actionsDir, files } = await makeRoot() + const p = join(files, 'twice.txt') + await writeFile(p, 'v0') + + const rec = await runAction({ + kind: 'claude-md-rule', + description: 'same path twice', + changes: [ + { op: 'edit', path: p, content: 'v1' }, + { op: 'edit', path: p, content: 'v2' }, + ], + }, actionsDir) + expect(rec.changes[0]!.afterHash).toBe(rec.changes[1]!.afterHash) + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(p, 'utf-8')).toBe('v0') + }) + + it('rejects an ambiguous id prefix with the match count', async () => { + const { actionsDir } = await makeRoot() + await appendRecord(actionsDir, bareRecord('aaaaaaaa-1111-4111-8111-111111111111', 'one')) + await appendRecord(actionsDir, bareRecord('aaaaaaaa-2222-4222-8222-222222222222', 'two')) + + await expect(undoAction({ id: 'aaaaaaaa' }, { actionsDir })).rejects.toThrow(/matches 2 actions/) + }) + + it('archives a directory and undo restores the tree with nested content byte-identical', async () => { + const { actionsDir, files } = await makeRoot() + const dir = join(files, 'skill') + const dest = join(files, '.archived', 'skill') + const nestedBytes = Buffer.from([1, 2, 3, 250, 0]) + await mkdir(join(dir, 'nested'), { recursive: true }) + await writeFile(join(dir, 'SKILL.md'), 'skill body') + await writeFile(join(dir, 'nested', 'data.bin'), nestedBytes) + + const rec = await runAction({ + kind: 'archive-skill', + description: 'archive dir', + changes: [{ op: 'move', path: dir, movedTo: dest }], + }, actionsDir) + expect(rec.changes[0]!.afterHash).toBe('') + expect(rec.changes[0]!.backup).not.toBeNull() + expect(existsSync(dir)).toBe(false) + expect(await readFile(join(dest, 'SKILL.md'), 'utf-8')).toBe('skill body') + + await undoAction({ id: rec.id }, { actionsDir }) + expect(existsSync(dest)).toBe(false) + expect(await readFile(join(dir, 'SKILL.md'), 'utf-8')).toBe('skill body') + expect(Buffer.compare(await readFile(join(dir, 'nested', 'data.bin')), nestedBytes)).toBe(0) + }) + + it('moves a directory onto an existing destination directory and undo restores both trees', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'agent') + const dest = join(files, 'agent-archived') + await mkdir(src, { recursive: true }) + await mkdir(dest, { recursive: true }) + await writeFile(join(src, 'agent.md'), 'src tree') + await writeFile(join(dest, 'old.md'), 'dest tree') + + const rec = await runAction({ + kind: 'archive-agent', + description: 'dir onto dir', + changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + expect(rec.changes[0]!.destBackup).not.toBeNull() + expect(await readFile(join(dest, 'agent.md'), 'utf-8')).toBe('src tree') + expect(existsSync(join(dest, 'old.md'))).toBe(false) + + await undoAction({ id: rec.id }, { actionsDir }) + expect(await readFile(join(src, 'agent.md'), 'utf-8')).toBe('src tree') + expect(await readFile(join(dest, 'old.md'), 'utf-8')).toBe('dest tree') + expect(existsSync(join(dest, 'agent.md'))).toBe(false) + }) + + it('refuses dir-move undo when the original path is occupied, then --force overwrites', async () => { + const { actionsDir, files } = await makeRoot() + const src = join(files, 'cmd') + const dest = join(files, 'cmd-archived') + await mkdir(src, { recursive: true }) + await writeFile(join(src, 'cmd.md'), 'body') + const rec = await runAction({ + kind: 'archive-command', + description: 'occupied dir', + changes: [{ op: 'move', path: src, movedTo: dest }], + }, actionsDir) + + await mkdir(src, { recursive: true }) + await writeFile(join(src, 'squatter.md'), 'squatter') + + const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e) + expect(err).toBeInstanceOf(DriftError) + expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true) + + await undoAction({ id: rec.id }, { actionsDir, force: true }) + expect(await readFile(join(src, 'cmd.md'), 'utf-8')).toBe('body') + expect(existsSync(join(src, 'squatter.md'))).toBe(false) + expect(existsSync(dest)).toBe(false) + }) +}) + +function bareRecord(id: string, description: string): ActionRecord { + return { + id, + at: new Date().toISOString(), + kind: 'mcp-remove', + findingId: null, + description, + changes: [], + status: 'applied', + } +} diff --git a/tests/antigravity-statusline.test.ts b/tests/antigravity-statusline.test.ts new file mode 100644 index 0000000..8a41494 --- /dev/null +++ b/tests/antigravity-statusline.test.ts @@ -0,0 +1,149 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { delimiter, join } from 'path' +import { describe, expect, it } from 'vitest' + +import { + buildAntigravityHookLookupPath, + installAntigravityStatusLineHook, + resolvePersistentCodeburnPathFromPath, + uninstallAntigravityStatusLineHook, +} from '../src/antigravity-statusline.js' + +describe('Antigravity CLI statusLine hook installer', () => { + async function withTempSettings(run: (dir: string, settingsPath: string) => Promise<void>) { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-agy-hook-')) + const settingsPath = join(dir, 'settings.json') + const binDir = join(dir, 'bin') + const codeburnPath = join(binDir, process.platform === 'win32' ? 'codeburn.cmd' : 'codeburn') + await mkdir(binDir, { recursive: true }) + await writeFile(codeburnPath, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n') + await chmod(codeburnPath, 0o755) + process.env['CODEBURN_ANTIGRAVITY_SETTINGS_PATH'] = settingsPath + process.env['CODEBURN_CACHE_DIR'] = join(dir, 'cache') + process.env.PATH = binDir + + try { + await run(dir, settingsPath) + } finally { + await rm(dir, { recursive: true, force: true }) + } + } + + it('builds a lookup PATH with user paths before fallbacks', () => { + const lookupPath = buildAntigravityHookLookupPath(['/Users/me/.nvm/versions/node/v22.13.0/bin', '/usr/bin'].join(delimiter)) + + expect(lookupPath.split(delimiter)).toContain('/Users/me/.nvm/versions/node/v22.13.0/bin') + if (process.platform !== 'win32') expect(lookupPath.split(delimiter)).toContain('/opt/homebrew/bin') + }) + + it('skips transient npx codeburn shims when resolving the hook command', async () => { + await withTempSettings(async (dir) => { + const npxBin = join(dir, '.npm', '_npx', 'abcd', 'node_modules', '.bin') + const persistentBin = join(dir, 'persistent-bin') + const npxCodeburn = join(npxBin, process.platform === 'win32' ? 'codeburn.cmd' : 'codeburn') + const persistentCodeburn = join(persistentBin, process.platform === 'win32' ? 'codeburn.cmd' : 'codeburn') + await mkdir(npxBin, { recursive: true }) + await mkdir(persistentBin, { recursive: true }) + await writeFile(npxCodeburn, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n') + await writeFile(persistentCodeburn, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n') + await chmod(npxCodeburn, 0o755) + await chmod(persistentCodeburn, 0o755) + + const resolved = await resolvePersistentCodeburnPathFromPath([npxBin, persistentBin].join(delimiter)) + + expect(resolved).toBe(persistentCodeburn) + }) + }) + + it('backs up and restores an existing custom statusLine when forced', async () => { + await withTempSettings(async (dir, settingsPath) => { + const customStatusLine = { + type: 'command', + command: 'custom-statusline', + padding: 1, + } + await writeFile(settingsPath, `${JSON.stringify({ statusLine: customStatusLine }, null, 2)}\n`) + + await expect(installAntigravityStatusLineHook(false)).rejects.toThrow('already has a custom statusLine') + expect(await installAntigravityStatusLineHook(true)).toBe('installed') + + const installed = JSON.parse(await readFile(settingsPath, 'utf-8')) + expect(installed.statusLine.command).toContain('agy-statusline-hook') + expect(installed.statusLine.command).not.toContain('custom-statusline') + + const backupPath = join(dir, 'cache', 'antigravity-statusline-previous.json') + const backup = JSON.parse(await readFile(backupPath, 'utf-8')) + expect(backup.statusLine).toEqual(customStatusLine) + + expect(await uninstallAntigravityStatusLineHook()).toBe('restored') + const restored = JSON.parse(await readFile(settingsPath, 'utf-8')) + expect(restored.statusLine).toEqual(customStatusLine) + }) + }) + + it('installs CodeBurn statusLine when no statusLine exists', async () => { + await withTempSettings(async (_dir, settingsPath) => { + expect(await installAntigravityStatusLineHook(false)).toBe('installed') + expect(await installAntigravityStatusLineHook(false)).toBe('already-installed') + + const settings = JSON.parse(await readFile(settingsPath, 'utf-8')) + expect(settings.statusLine).toMatchObject({ + type: 'command', + padding: 0, + }) + expect(settings.statusLine.command).toContain('agy-statusline-hook') + expect(settings.statusLine.command).toContain(join(_dir, 'bin')) + expect(settings.statusLine.command).not.toContain('dist/cli.js') + }) + }) + + it('repairs an existing stale CodeBurn statusLine command without force', async () => { + await withTempSettings(async (dir, settingsPath) => { + await writeFile(settingsPath, JSON.stringify({ + statusLine: { + type: 'command', + command: "'/usr/local/bin/node' '/Users/me/codeburn-agy-statusline/dist/cli.js' agy-statusline-hook", + padding: 0, + }, + })) + + expect(await installAntigravityStatusLineHook(false)).toBe('installed') + + const settings = JSON.parse(await readFile(settingsPath, 'utf-8')) + expect(settings.statusLine.command).toContain(join(dir, 'bin')) + expect(settings.statusLine.command).toContain('agy-statusline-hook') + expect(settings.statusLine.command).not.toContain('codeburn-agy-statusline/dist/cli.js') + }) + }) + + it('treats a custom statusLine that only mentions the hook token as custom, not CodeBurn-owned', async () => { + await withTempSettings(async (_dir, settingsPath) => { + const custom = 'mybar --note "runs agy-statusline-hook nightly"' + await writeFile(settingsPath, JSON.stringify({ + statusLine: { type: 'command', command: custom, padding: 0 }, + })) + + await expect(installAntigravityStatusLineHook(false)).rejects.toThrow(/custom statusLine/) + + const settings = JSON.parse(await readFile(settingsPath, 'utf-8')) + expect(settings.statusLine.command).toBe(custom) + }) + }) + + it('removes CodeBurn statusLine when there is no previous hook backup', async () => { + await withTempSettings(async (_dir, settingsPath) => { + await writeFile(settingsPath, JSON.stringify({ + statusLine: { + type: 'command', + command: 'codeburn agy-statusline-hook', + padding: 0, + }, + })) + + expect(await uninstallAntigravityStatusLineHook()).toBe('removed') + const settings = JSON.parse(await readFile(settingsPath, 'utf-8')) + expect(settings).not.toHaveProperty('statusLine') + }) + }) +}) diff --git a/tests/audit-report.test.ts b/tests/audit-report.test.ts new file mode 100644 index 0000000..1723712 --- /dev/null +++ b/tests/audit-report.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' + +import { aggregateAudit } from '../src/audit-report.js' +import type { + ProjectSummary, + SessionSummary, + ClassifiedTurn, + ParsedApiCall, + TokenUsage, + TaskCategory, +} from '../src/types.js' + +function emptyTokens(): TokenUsage { + return { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } +} + +function makeCall(usage: Partial<TokenUsage>, costUSD: number, model = 'unknown-model-xyz', provider = 'claude'): ParsedApiCall { + return { + provider, + model, + usage: { ...emptyTokens(), ...usage }, + costUSD, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-09T00:00:00.000Z', + bashCommands: [], + deduplicationKey: `${provider}-${model}-${costUSD}-${usage.inputTokens ?? 0}-${usage.cachedInputTokens ?? 0}`, + } +} + +function makeProject(calls: ParsedApiCall[]): ProjectSummary { + const turn: ClassifiedTurn = { + userMessage: 't', + assistantCalls: calls, + timestamp: '2026-05-09T00:00:00.000Z', + sessionId: 's1', + category: 'feature' as TaskCategory, + retries: 0, + hasEdits: false, + } + const session: SessionSummary = { + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-05-09T00:00:00.000Z', + lastTimestamp: '2026-05-09T00:00:00.000Z', + totalCostUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 0, + turns: [turn], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + } + return { project: 'p', projectPath: 'p', sessions: [session], totalCostUSD: 0, totalApiCalls: 0 } +} + +describe('aggregateAudit', () => { + it('keeps raw fields and exposes codeburn normalizations', async () => { + const anthropicCall = makeCall({ inputTokens: 100, outputTokens: 50, reasoningTokens: 10, cacheReadInputTokens: 200 }, 0.5) + const openaiCall = makeCall({ inputTokens: 100, outputTokens: 50, cachedInputTokens: 300 }, 0.5) + const rows = await aggregateAudit([makeProject([anthropicCall, openaiCall])]) + + expect(rows).toHaveLength(1) + const r = rows[0]! + // raw fields are summed untouched + expect(r.raw.inputTokens).toBe(200) + expect(r.raw.outputTokens).toBe(100) + expect(r.raw.reasoningTokens).toBe(10) + expect(r.raw.cacheReadInputTokens).toBe(200) + expect(r.raw.cachedInputTokens).toBe(300) + // reasoning folds into output for pricing + expect(r.displayed.outputTokens).toBe(110) + // cache read is the SUM of per-call max(anthropic, openai), not max of sums + expect(r.displayed.cacheReadTokens).toBe(500) + // attributed cost is preserved exactly + expect(r.attributedCostUSD).toBeCloseTo(1.0) + }) + + it('returns null rates and zero component cost for an unpriced model', async () => { + const rows = await aggregateAudit([makeProject([makeCall({ inputTokens: 1000 }, 0, 'definitely-not-a-real-model-zzz')])]) + expect(rows).toHaveLength(1) + expect(rows[0]!.rates).toBeNull() + expect(rows[0]!.cost.recomputedTotalUSD).toBe(0) + }) + + it('splits buckets by (provider, model)', async () => { + const rows = await aggregateAudit([makeProject([ + makeCall({ inputTokens: 10 }, 0.1, 'model-a', 'claude'), + makeCall({ inputTokens: 20 }, 0.2, 'model-b', 'claude'), + makeCall({ inputTokens: 30 }, 0.3, 'model-a', 'codex'), + ])]) + expect(rows).toHaveLength(3) + }) +}) diff --git a/tests/bash-commands.test.ts b/tests/bash-commands.test.ts new file mode 100644 index 0000000..3daf439 --- /dev/null +++ b/tests/bash-commands.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest' +import { extractBashCommands } from '../src/bash-utils.js' +import { BASH_TOOLS } from '../src/classifier.js' + +describe('extractBashCommands', () => { + it('extracts single command', () => { + expect(extractBashCommands('git status')).toEqual(['git']) + }) + + it('extracts chained commands with &&', () => { + expect(extractBashCommands('git add . && git commit -m "x"')).toEqual(['git', 'git']) + }) + + it('extracts chained commands with ;', () => { + expect(extractBashCommands('ls; pwd')).toEqual(['ls', 'pwd']) + }) + + it('extracts piped commands', () => { + expect(extractBashCommands('cat file | grep pattern')).toEqual(['cat', 'grep']) + }) + + it('filters out cd', () => { + expect(extractBashCommands('cd /path && git status')).toEqual(['git']) + }) + + it('returns empty for cd only', () => { + expect(extractBashCommands('cd /path')).toEqual([]) + }) + + it('returns empty for empty string', () => { + expect(extractBashCommands('')).toEqual([]) + }) + + it('returns empty for whitespace only', () => { + expect(extractBashCommands(' ')).toEqual([]) + }) + + it('extracts basename from full path binary', () => { + expect(extractBashCommands('/usr/bin/git status')).toEqual(['git']) + }) + + it('handles mixed separators', () => { + expect(extractBashCommands('cd /x && npm install; npm run build | tee log')).toEqual(['npm', 'npm', 'tee']) + }) + + it('handles extra whitespace', () => { + expect(extractBashCommands(' git status ')).toEqual(['git']) + }) + + it('handles command with quotes containing separators', () => { + expect(extractBashCommands('echo "hello && world"')).toEqual(['echo']) + }) + + it('handles quoted separators followed by real separator', () => { + expect(extractBashCommands('echo "hello && world" && git status')).toEqual(['echo', 'git']) + }) + + it('handles single-quoted separators', () => { + expect(extractBashCommands("echo 'hello && world'")).toEqual(['echo']) + }) + + it('skips leading env var assignments', () => { + expect(extractBashCommands('NODE_ENV=prod npm test')).toEqual(['npm']) + expect(extractBashCommands('FOO=bar BAZ=qux ls -la')).toEqual(['ls']) + }) + + it('skips standalone true/false', () => { + expect(extractBashCommands('true && git status')).toEqual(['git']) + expect(extractBashCommands('false || echo done')).toEqual(['echo']) + expect(extractBashCommands('true')).toEqual([]) + }) + + it('handles env vars combined with chained commands', () => { + expect(extractBashCommands('NODE_ENV=test npm test && git push')).toEqual(['npm', 'git']) + }) + + it('skips command wrapper prefixes', () => { + expect(extractBashCommands('rtk git status')).toEqual(['git']) + expect(extractBashCommands('sudo npm install')).toEqual(['npm']) + expect(extractBashCommands('npx vitest --run')).toEqual(['vitest']) + }) + + it('skips prefix combined with env var assignment', () => { + expect(extractBashCommands('DEBUG=1 rtk git status')).toEqual(['git']) + }) + + it('skips nested wrapper prefixes', () => { + expect(extractBashCommands('sudo npx vitest --run')).toEqual(['vitest']) + }) + + it('skips prefix across chained commands', () => { + expect(extractBashCommands('rtk git add . && rtk git commit -m "msg"')).toEqual(['git', 'git']) + }) + + it('keeps a standalone prefix with no following command', () => { + expect(extractBashCommands('rtk')).toEqual(['rtk']) + expect(extractBashCommands('sudo')).toEqual(['sudo']) + }) + + it('keeps prefix when the next token is a flag', () => { + expect(extractBashCommands('nice -n 10 git push')).toEqual(['nice']) + }) + + it('skips env assignment that follows a wrapper prefix', () => { + expect(extractBashCommands('sudo NODE_ENV=production node server.js')).toEqual(['node']) + expect(extractBashCommands('time FOO=1 make build')).toEqual(['make']) + }) + + it('keeps prefix when the next token is quoted', () => { + expect(extractBashCommands('npx "@angular/cli" new app')).toEqual(['npx']) + expect(extractBashCommands("npx 'ts-node' script.ts")).toEqual(['npx']) + }) +}) + +describe('BASH_TOOLS', () => { + it('recognizes Bash', () => { expect(BASH_TOOLS.has('Bash')).toBe(true) }) + it('recognizes BashTool', () => { expect(BASH_TOOLS.has('BashTool')).toBe(true) }) + it('rejects unknown tools', () => { expect(BASH_TOOLS.has('Read')).toBe(false) }) +}) diff --git a/tests/blob-to-text.test.ts b/tests/blob-to-text.test.ts new file mode 100644 index 0000000..aeb7ce3 --- /dev/null +++ b/tests/blob-to-text.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { blobToText, isSqliteBusyError } from '../src/sqlite.js' + +describe('blobToText', () => { + it('returns empty string for null', () => { + expect(blobToText(null)).toBe('') + }) + + it('returns empty string for undefined', () => { + expect(blobToText(undefined)).toBe('') + }) + + it('passes through strings unchanged', () => { + expect(blobToText('hello world')).toBe('hello world') + }) + + it('decodes valid UTF-8 Uint8Array', () => { + const buf = new TextEncoder().encode('café ☕') + expect(blobToText(buf)).toBe('café ☕') + }) + + it('replaces invalid UTF-8 bytes with U+FFFD instead of crashing', () => { + const buf = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x80, 0xfe]) + const result = blobToText(buf) + expect(result).toContain('Hello') + expect(result).toContain('�') + }) + + it('handles truncated multi-byte sequence', () => { + // é in UTF-8 is [0xc3, 0xa9]. Truncate to just [0xc3]. + const buf = new Uint8Array([0x63, 0x61, 0x66, 0xc3]) + const result = blobToText(buf) + expect(result).toBe('caf�') + }) + + it('handles empty Uint8Array', () => { + expect(blobToText(new Uint8Array(0))).toBe('') + }) +}) + +describe('isSqliteBusyError', () => { + it('detects node:sqlite busy errors by errcode', () => { + expect(isSqliteBusyError({ code: 'ERR_SQLITE_ERROR', errcode: 5, errstr: 'database is locked' })).toBe(true) + }) + + it('detects sqlite locked messages', () => { + expect(isSqliteBusyError(new Error('SQLITE_LOCKED: database table is locked'))).toBe(true) + }) + + it('ignores unrelated sqlite errors', () => { + expect(isSqliteBusyError(new Error('no such table: session'))).toBe(false) + }) +}) diff --git a/tests/classifier.test.ts b/tests/classifier.test.ts new file mode 100644 index 0000000..3ef82b5 --- /dev/null +++ b/tests/classifier.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest' + +import { classifyTurn } from '../src/classifier.js' +import type { ParsedApiCall, ParsedTurn } from '../src/types.js' + +function makeCall(opts: Partial<ParsedApiCall> & { tools?: string[]; skills?: string[] }): ParsedApiCall { + const tools = opts.tools ?? [] + return { + provider: 'claude', + model: 'Opus 4.7', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: 0, + tools, + mcpTools: tools.filter(t => t.startsWith('mcp__')), + skills: opts.skills ?? [], + hasAgentSpawn: tools.includes('Agent'), + hasPlanMode: tools.includes('EnterPlanMode'), + speed: 'standard', + timestamp: '2026-05-04T00:00:00Z', + bashCommands: [], + deduplicationKey: 'k', + ...opts, + } +} + +function makeTurn(calls: ParsedApiCall[], userMessage = ''): ParsedTurn { + return { + userMessage, + assistantCalls: calls, + timestamp: '2026-05-04T00:00:00Z', + sessionId: 's1', + } +} + +describe('classifyTurn — Skill subCategory', () => { + it('attaches subCategory when a Skill tool fires alone (input.skill)', () => { + const turn = makeTurn([makeCall({ tools: ['Skill'], skills: ['init'] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBe('init') + }) + + it('attaches subCategory when skill identifier comes via input.name (extracted upstream)', () => { + const turn = makeTurn([makeCall({ tools: ['Skill'], skills: ['atelier'] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBe('atelier') + }) + + it('uses the first skill identifier when a single turn invokes multiple skills', () => { + const turn = makeTurn([makeCall({ tools: ['Skill', 'Skill'], skills: ['review', 'security-review'] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBe('review') + }) + + it('aggregates skills across multiple assistant calls in the same turn', () => { + const turn = makeTurn([ + makeCall({ tools: ['Skill'], skills: ['claude-api'] }), + makeCall({ tools: ['Skill'], skills: ['init'] }), + ]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBe('claude-api') + }) + + it('does not attach subCategory when the Skill tool fires but no skill name was extracted', () => { + const turn = makeTurn([makeCall({ tools: ['Skill'], skills: [] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('general') + expect(c.subCategory).toBeUndefined() + }) + + it('does not attach subCategory when category is not general (e.g. Skill alongside Edit promotes to coding)', () => { + const turn = makeTurn([makeCall({ tools: ['Skill', 'Edit'], skills: ['init'] })]) + const c = classifyTurn(turn) + expect(c.category).toBe('coding') + expect(c.subCategory).toBeUndefined() + }) + + it('does not attach subCategory for non-Skill general turns', () => { + const turn = makeTurn([makeCall({ tools: [] })], 'just chatting') + const c = classifyTurn(turn) + expect(c.subCategory).toBeUndefined() + }) + + it('tolerates missing skills field on legacy ParsedApiCall shape', () => { + const baseCall = makeCall({ tools: ['Skill'], skills: ['init'] }) + const legacyCall = { ...baseCall } as unknown as ParsedApiCall & { skills?: string[] } + delete (legacyCall as { skills?: string[] }).skills + const c = classifyTurn(makeTurn([legacyCall])) + expect(c.category).toBe('general') + expect(c.subCategory).toBeUndefined() + }) +}) + +// Regression coverage for issue #196: feature verbs that lead a message +// were previously hijacked into 'debugging' just because the message contained +// an incidental "error" / "fix" / "issue" word later in the same sentence. +// Now whichever keyword pattern matches earliest wins. +describe('classifyTurn — feature vs debugging precedence (#196)', () => { + function codingTurn(userMessage: string): ParsedTurn { + return makeTurn([makeCall({ tools: ['Edit'] })], userMessage) + } + + it('classifies "add error handling" as feature, not debugging', () => { + const c = classifyTurn(codingTurn('add error handling to the auth module')) + expect(c.category).toBe('feature') + }) + + it('classifies "create an issue tracker" as feature, not debugging', () => { + const c = classifyTurn(codingTurn('create an issue tracker page in the dashboard')) + expect(c.category).toBe('feature') + }) + + it('classifies "implement the 404 page" as feature, not debugging', () => { + const c = classifyTurn(codingTurn('implement the 404 page with a friendly redirect')) + expect(c.category).toBe('feature') + }) + + it('still classifies "fix the layout for the new feature" as debugging', () => { + const c = classifyTurn(codingTurn('fix the layout for the new feature')) + expect(c.category).toBe('debugging') + }) + + it('still classifies a plain bug report as debugging', () => { + const c = classifyTurn(codingTurn('login is broken, traceback below')) + expect(c.category).toBe('debugging') + }) + + it('classifies "refactor the error handling" as refactoring', () => { + const c = classifyTurn(codingTurn('refactor the error handling so it is cleaner')) + expect(c.category).toBe('refactoring') + }) + + it('chat-only message starting with "add" stays feature even with "fix" later', () => { + const c = classifyTurn(makeTurn([], 'add a setting page; we will fix the styles after')) + expect(c.category).toBe('feature') + }) + + it('chat-only message starting with "fix" stays debugging even with "add" later', () => { + const c = classifyTurn(makeTurn([], 'fix the bug introduced when we added the new flag')) + expect(c.category).toBe('debugging') + }) +}) + +describe('classifyTurn — retry detection via toolSequence', () => { + it('detects retries from multi-call turns (Claude-style)', () => { + const turn = makeTurn([ + makeCall({ tools: ['Edit'] }), + makeCall({ tools: ['Bash'] }), + makeCall({ tools: ['Edit'] }), + ], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(1) + }) + + it('detects retries from toolSequence on a single call (Kiro/Goose-style)', () => { + const call = makeCall({ tools: ['Edit', 'Bash'] }) + call.toolSequence = [[{ tool: 'Edit' }], [{ tool: 'Bash' }], [{ tool: 'Edit' }]] + const turn = makeTurn([call], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(1) + }) + + it('returns 0 retries for single call without toolSequence', () => { + const call = makeCall({ tools: ['Edit', 'Bash'] }) + const turn = makeTurn([call], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(0) + }) + + it('counts multiple retries from toolSequence', () => { + const call = makeCall({ tools: ['Edit', 'Bash'] }) + call.toolSequence = [[{ tool: 'Edit' }], [{ tool: 'Bash' }], [{ tool: 'Edit' }], [{ tool: 'Bash' }], [{ tool: 'Edit' }]] + const turn = makeTurn([call], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(2) + }) + + it('ignores toolSequence with only one step', () => { + const call = makeCall({ tools: ['Edit', 'Bash'] }) + call.toolSequence = [[{ tool: 'Edit' }, { tool: 'Bash' }]] + const turn = makeTurn([call], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(0) + }) +}) diff --git a/tests/cli-date.test.ts b/tests/cli-date.test.ts new file mode 100644 index 0000000..f553c70 --- /dev/null +++ b/tests/cli-date.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, it, expect, vi } from 'vitest' +import { + getDateRange, + PERIODS, + PERIOD_LABELS, + parsePeriodOrThrow, + periodInfoFromQuery, + toPeriod, + type Period, +} from '../src/cli-date.js' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('getDateRange', () => { + it('"all" is bounded to the last 6 months, not epoch', () => { + const { range, label } = getDateRange('all') + const now = new Date() + + expect(label).toBe('Last 6 months') + + // Regression guard: must never silently fall back to epoch (the old + // dashboard bug) or any pre-2000 date. + expect(range.start.getFullYear()).toBeGreaterThan(2000) + + const monthsDiff = + (now.getFullYear() - range.start.getFullYear()) * 12 + + (now.getMonth() - range.start.getMonth()) + expect(monthsDiff).toBe(6) + expect(range.start.getDate()).toBe(1) + + // End is today, end of day. + expect(range.end.getHours()).toBe(23) + expect(range.end.getMinutes()).toBe(59) + }) + + it('"all" does not overflow past the target month at end-of-month', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 7, 31, 12, 0, 0)) + + const { range } = getDateRange('all') + + expect(range.start.getFullYear()).toBe(2026) + expect(range.start.getMonth()).toBe(1) + expect(range.start.getDate()).toBe(1) + }) + + it('"week" returns the last 7 days', () => { + const { range, label } = getDateRange('week') + expect(label).toBe('Last 7 Days') + // start = midnight 7 days ago, end = today 23:59:59.999 -> ~8 days span. + const diffDays = (range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24) + expect(diffDays).toBeGreaterThanOrEqual(7) + expect(diffDays).toBeLessThanOrEqual(8) + }) + + it('"month" starts on day 1 of the current month', () => { + const { range } = getDateRange('month') + expect(range.start.getDate()).toBe(1) + expect(range.start.getHours()).toBe(0) + }) + + it('"30days" returns 30 days back', () => { + const { range, label } = getDateRange('30days') + expect(label).toBe('Last 30 Days') + const diffDays = (range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24) + expect(diffDays).toBeGreaterThanOrEqual(30) + expect(diffDays).toBeLessThanOrEqual(31) + }) + + it('"today" starts at local midnight', () => { + const { range } = getDateRange('today') + expect(range.start.getHours()).toBe(0) + expect(range.start.getMinutes()).toBe(0) + expect(range.end.getHours()).toBe(23) + }) + + it('"yesterday" is supported (CLI-only convenience)', () => { + const { range, label } = getDateRange('yesterday') + expect(label).toMatch(/^Yesterday/) + expect(range.start.getHours()).toBe(0) + expect(range.end.getHours()).toBe(23) + }) + + it('unknown period exits with an error instead of silently falling back', () => { + expect(() => getDateRange('not-a-period')).toThrow() + }) +}) + +describe('PERIODS / PERIOD_LABELS', () => { + it('exposes the expected period set', () => { + expect(PERIODS).toEqual(['today', 'week', '30days', 'month', 'all']) + }) + + it('has a label for every period', () => { + for (const p of PERIODS) { + expect(PERIOD_LABELS[p]).toBeTruthy() + } + }) + + it('"all" tab label reflects the 6-month bound', () => { + // Short label used in the dashboard tab strip. The long-form label + // ("Last 6 months") comes from getDateRange().label. + expect(PERIOD_LABELS.all).toBe('6 Months') + }) +}) + +describe('parsePeriodOrThrow', () => { + it('round-trips known periods', () => { + const known: Period[] = ['today', 'week', '30days', 'month', 'all'] + for (const p of known) { + expect(parsePeriodOrThrow(p)).toBe(p) + } + }) + + it('throws on unknown input without calling process.exit', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) + try { + expect(() => parsePeriodOrThrow('garbage')).toThrow(/Unknown period "garbage"/) + expect(exitSpy).not.toHaveBeenCalled() + } finally { + exitSpy.mockRestore() + } + }) +}) + +describe('periodInfoFromQuery', () => { + it('resolves a named period', () => { + const info = periodInfoFromQuery({ period: 'week' }, 'month') + expect(info.label).toBe('Last 7 Days') + }) + + it('throws for an invalid period without calling process.exit', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) + try { + expect(() => periodInfoFromQuery({ period: 'garbage' }, 'month')).toThrow(/Unknown period "garbage"/) + expect(exitSpy).not.toHaveBeenCalled() + } finally { + exitSpy.mockRestore() + } + }) +}) + +describe('toPeriod', () => { + it('round-trips known periods', () => { + const known: Period[] = ['today', 'week', '30days', 'month', 'all'] + for (const p of known) { + expect(toPeriod(p)).toBe(p) + } + }) + + it('exits with an error on unknown input instead of silently falling back', () => { + // Previously toPeriod silently fell back to 'week' for any unrecognized + // value, which let typos like `-p mounth` produce a quiet 7-day report + // while the user thought they were viewing the month. The new behavior + // is to fail loudly via process.exit(1) after writing to stderr. + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) as unknown as ReturnType<typeof vi.spyOn> + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + expect(() => toPeriod('garbage')).toThrow('exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(stderrSpy).toHaveBeenCalled() + } finally { + exitSpy.mockRestore() + stderrSpy.mockRestore() + } + }) +}) diff --git a/tests/cli-deepseek-v4-pricing.test.ts b/tests/cli-deepseek-v4-pricing.test.ts new file mode 100644 index 0000000..02407d0 --- /dev/null +++ b/tests/cli-deepseek-v4-pricing.test.ts @@ -0,0 +1,133 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + HOME: home, + TZ: 'UTC', + }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +function userLine(content: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId: 'deepseek-v4-session', + timestamp, + cwd: '/tmp/deepseek-v4-validation', + message: { role: 'user', content }, + }) +} + +function assistantLine(model: string, timestamp: string, messageId: string, usage: Record<string, number>): string { + return JSON.stringify({ + type: 'assistant', + sessionId: 'deepseek-v4-session', + timestamp, + cwd: '/tmp/deepseek-v4-validation', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model, + content: [ + { type: 'text', text: 'updated pricing code' }, + { type: 'tool_use', id: `tu-${messageId}`, name: 'Edit', input: { file_path: '/tmp/deepseek-v4-validation/pricing.ts', old_string: 'old', new_string: 'new' } }, + ], + usage, + }, + }) +} + +describe('CLI DeepSeek v4 Claude pricing regression', () => { + it('prices DeepSeek v4 Claude sessions even when the runtime LiteLLM cache lacks those models', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-deepseek-v4-cli-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'deepseek-v4-validation') + const cacheDir = join(home, '.cache', 'codeburn') + await mkdir(projectDir, { recursive: true }) + await mkdir(cacheDir, { recursive: true }) + + await writeFile(join(cacheDir, 'litellm-pricing.json'), JSON.stringify({ + timestamp: Date.now(), + data: { + 'gpt-4o-mini': { + inputCostPerToken: 1.5e-7, + outputCostPerToken: 6e-7, + cacheWriteCostPerToken: 0, + cacheReadCostPerToken: 7.5e-8, + webSearchCostPerRequest: 0.01, + fastMultiplier: 1, + }, + }, + })) + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('Use DeepSeek v4 through the Claude-compatible endpoint.', '2026-05-20T10:00:00.000Z'), + assistantLine('deepseek-v4-pro', '2026-05-20T10:01:00.000Z', 'deepseek-v4-pro', { + input_tokens: 2_477_914, + output_tokens: 762_994, + cache_read_input_tokens: 258_556_928, + cache_creation_input_tokens: 0, + }), + userLine('Validate the flash model path too.', '2026-05-20T10:02:00.000Z'), + assistantLine('deepseek-v4-flash', '2026-05-20T10:03:00.000Z', 'deepseek-v4-flash', { + input_tokens: 1_552_573, + output_tokens: 353_914, + cache_read_input_tokens: 48_388_608, + cache_creation_input_tokens: 0, + }), + ].join('\n') + '\n', + ) + + const result = runCli([ + '--format', 'json', + '--from', '2026-05-20', + '--to', '2026-05-20', + '--provider', 'claude', + ], home) + + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + + const report = JSON.parse(result.stdout) as { + overview: { cost: number; calls: number; tokens: { cacheRead: number } } + models: Array<{ name: string; cost: number; calls: number; inputTokens: number; outputTokens: number; cacheReadTokens: number }> + } + const pro = report.models.find(m => m.name === 'DeepSeek v4 Pro') + const flash = report.models.find(m => m.name === 'DeepSeek v4 Flash') + + expect(report.overview.calls).toBe(2) + expect(report.overview.tokens.cacheRead).toBe(306_945_536) + expect(report.overview.cost).toBeCloseTo(3.13091, 5) + + expect(pro).toBeDefined() + expect(pro!.calls).toBe(1) + expect(pro!.inputTokens).toBe(2_477_914) + expect(pro!.outputTokens).toBe(762_994) + expect(pro!.cacheReadTokens).toBe(258_556_928) + expect(pro!.cost).toBeCloseTo(2.678966, 6) + + expect(flash).toBeDefined() + expect(flash!.calls).toBe(1) + expect(flash!.inputTokens).toBe(1_552_573) + expect(flash!.outputTokens).toBe(353_914) + expect(flash!.cacheReadTokens).toBe(48_388_608) + expect(flash!.cost).toBeCloseTo(0.451944, 6) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/cli-devin-model-variants.test.ts b/tests/cli-devin-model-variants.test.ts new file mode 100644 index 0000000..007146e --- /dev/null +++ b/tests/cli-devin-model-variants.test.ts @@ -0,0 +1,98 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + HOMEPATH: home, + HOMEDRIVE: '', + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + TZ: 'UTC', + }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +describe('codeburn report Devin model variants', () => { + it('keeps friendly Devin effort-tier names in JSON model rows and efficiency rows', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-devin-models-')) + try { + await mkdir(join(home, '.config', 'codeburn'), { recursive: true }) + await writeFile(join(home, '.config', 'codeburn', 'config.json'), JSON.stringify({ + devin: { acuUsdRate: 1 }, + })) + + const transcriptsDir = join(home, '.local', 'share', 'devin', 'cli', 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + await writeFile(join(transcriptsDir, 'session-487.json'), JSON.stringify({ + schema_version: '1.4', + session_id: 'session-487', + agent: { model_name: 'GPT-5.4' }, + steps: [ + { + step_id: 1, + message: 'fix the model row', + metadata: { is_user_input: true, created_at: '2026-04-10T09:00:00.000Z' }, + }, + { + step_id: 2, + message: 'editing', + tool_calls: [{ function_name: 'Edit' }], + metadata: { + created_at: '2026-04-10T09:01:00.000Z', + committed_acu_cost: 0.25, + generation_model: 'gpt-5-3-codex-xhigh', + metrics: { input_tokens: 100, output_tokens: 25 }, + }, + }, + ], + })) + + const result = runCli([ + 'report', + '--format', + 'json', + '--from', + '2026-04-10', + '--to', + '2026-04-10', + '--provider', + 'devin', + ], home) + + expect(result.status, result.stderr).toBe(0) + const report = JSON.parse(result.stdout) as { + models: Array<{ + name: string + calls: number + cost: number + editTurns: number + oneShotTurns: number + costPerEdit: number | null + }> + } + + expect(report.models).toHaveLength(1) + expect(report.models[0]).toMatchObject({ + name: 'GPT-5.3 Codex (xhigh)', + calls: 1, + cost: 0.25, + editTurns: 1, + oneShotTurns: 1, + costPerEdit: 0.25, + }) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/cli-export-date-range.test.ts b/tests/cli-export-date-range.test.ts new file mode 100644 index 0000000..73d9e4b --- /dev/null +++ b/tests/cli-export-date-range.test.ts @@ -0,0 +1,96 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + HOME: home, + TZ: 'UTC', + }, + encoding: 'utf-8', + }) +} + +function userLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + message: { role: 'user', content: 'add feature' }, + }) +} + +function assistantLine(sessionId: string, timestamp: string, messageId: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { + input_tokens: 1000, + output_tokens: 100, + }, + }, + }) +} + +describe('codeburn export custom date range', () => { + it('exports a single custom period filtered by --from/--to', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-export-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'app') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'in-range.jsonl'), + [ + userLine('in-range', '2026-04-10T09:00:00Z'), + assistantLine('in-range', '2026-04-10T09:01:00Z', 'msg-in-range'), + ].join('\n'), + ) + await writeFile( + join(projectDir, 'out-of-range.jsonl'), + [ + userLine('out-of-range', '2026-04-11T09:00:00Z'), + assistantLine('out-of-range', '2026-04-11T09:01:00Z', 'msg-out-of-range'), + ].join('\n'), + ) + + const outputPath = join(home, 'custom-export.json') + const result = runCli([ + 'export', + '--format', 'json', + '--from', '2026-04-10', + '--to', '2026-04-10', + '--provider', 'claude', + '--output', outputPath, + ], home) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('Exported (2026-04-10 to 2026-04-10)') + + const exported = JSON.parse(await readFile(outputPath, 'utf-8')) as { + summary: Array<{ Period: string; Sessions: number }> + sessions: Array<{ 'Session ID': string }> + } + expect(exported.summary).toHaveLength(1) + expect(exported.summary[0]?.Period).toBe('2026-04-10 to 2026-04-10') + expect(exported.summary[0]?.Sessions).toBe(1) + expect(exported.sessions.map(s => s['Session ID'])).toEqual(['in-range']) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/cli-json-daily.test.ts b/tests/cli-json-daily.test.ts new file mode 100644 index 0000000..34ad500 --- /dev/null +++ b/tests/cli-json-daily.test.ts @@ -0,0 +1,237 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + HOME: home, + TZ: 'UTC', + }, + encoding: 'utf-8', + }) +} + +function userLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + message: { role: 'user', content: 'do the thing' }, + }) +} + +function assistantEditLine(sessionId: string, timestamp: string, messageId: string): string { + // Includes a tool_use of `Edit` so the parser flags this turn as hasEdits=true. + // Single edit-turn with no retry (one assistant message in the turn) → counts + // as one oneShotTurn. + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [ + { type: 'text', text: 'editing' }, + { type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } }, + ], + usage: { input_tokens: 1000, output_tokens: 100 }, + }, + }) +} + +function assistantNoEditLine(sessionId: string, timestamp: string, messageId: string): string { + // No edit tool — this turn does not count toward editTurns/oneShotTurns, + // but does count toward `turns` and `calls`. + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'just chatting' }], + usage: { input_tokens: 200, output_tokens: 30 }, + }, + }) +} + +describe('codeburn report --format json daily[] one-shot fields (issue #279)', () => { + it('exposes per-day turns / editTurns / oneShotTurns / oneShotRate', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'app') + await mkdir(projectDir, { recursive: true }) + + // Day 1 (2026-04-10): one edit-turn (one-shot), one chat-turn + // Day 2 (2026-04-11): one edit-turn (one-shot) + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', '2026-04-10T09:00:00Z'), + assistantEditLine('s1', '2026-04-10T09:01:00Z', 'm-d1-edit'), + userLine('s1', '2026-04-10T10:00:00Z'), + assistantNoEditLine('s1', '2026-04-10T10:01:00Z', 'm-d1-chat'), + userLine('s1', '2026-04-11T09:00:00Z'), + assistantEditLine('s1', '2026-04-11T09:01:00Z', 'm-d2-edit'), + ].join('\n'), + ) + + const result = runCli([ + '--format', 'json', + '--from', '2026-04-10', + '--to', '2026-04-11', + '--provider', 'claude', + ], home) + + expect(result.status).toBe(0) + + const report = JSON.parse(result.stdout) as { + daily: Array<{ + date: string + cost: number + calls: number + turns: number + editTurns: number + oneShotTurns: number + oneShotRate: number | null + }> + } + + expect(report.daily).toHaveLength(2) + + const day1 = report.daily.find(d => d.date === '2026-04-10') + expect(day1).toBeDefined() + expect(day1!.turns).toBe(2) + expect(day1!.editTurns).toBe(1) + expect(day1!.oneShotTurns).toBe(1) + expect(day1!.oneShotRate).toBe(100) + + const day2 = report.daily.find(d => d.date === '2026-04-11') + expect(day2).toBeDefined() + expect(day2!.turns).toBe(1) + expect(day2!.editTurns).toBe(1) + expect(day2!.oneShotTurns).toBe(1) + expect(day2!.oneShotRate).toBe(100) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('reports null oneShotRate when the day has no edit turns', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'app') + await mkdir(projectDir, { recursive: true }) + + await writeFile( + join(projectDir, 'chat-only.jsonl'), + [ + userLine('s2', '2026-04-10T09:00:00Z'), + assistantNoEditLine('s2', '2026-04-10T09:01:00Z', 'm-chat-1'), + userLine('s2', '2026-04-10T09:30:00Z'), + assistantNoEditLine('s2', '2026-04-10T09:31:00Z', 'm-chat-2'), + ].join('\n'), + ) + + const result = runCli([ + '--format', 'json', + '--from', '2026-04-10', + '--to', '2026-04-10', + '--provider', 'claude', + ], home) + + expect(result.status).toBe(0) + const report = JSON.parse(result.stdout) as { + daily: Array<{ date: string; turns: number; editTurns: number; oneShotTurns: number; oneShotRate: number | null }> + } + const day = report.daily.find(d => d.date === '2026-04-10')! + expect(day.turns).toBe(2) + expect(day.editTurns).toBe(0) + expect(day.oneShotTurns).toBe(0) + // null, not 0 — the rate is undefined when no edits happened, and a + // chat-only day would otherwise read as 0% one-shot which is misleading. + expect(day.oneShotRate).toBeNull() + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('filters a single review day with --day', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'app') + await mkdir(projectDir, { recursive: true }) + + // runCli pins TZ=UTC, so these exact boundaries exercise the inclusive + // start/end of the selected calendar day without local-offset drift. + await writeFile( + join(projectDir, 'day-selector.jsonl'), + [ + userLine('day-before', '2026-04-09T23:59:00Z'), + assistantNoEditLine('day-before', '2026-04-09T23:59:30Z', 'm-before'), + userLine('day-selected', '2026-04-10T00:00:00Z'), + assistantEditLine('day-selected', '2026-04-10T23:59:59Z', 'm-selected'), + userLine('day-after', '2026-04-11T00:00:00Z'), + assistantNoEditLine('day-after', '2026-04-11T00:00:30Z', 'm-after'), + ].join('\n'), + ) + + const result = runCli([ + '--format', 'json', + '--day', '2026-04-10', + '--provider', 'claude', + ], home) + + expect(result.status).toBe(0) + const report = JSON.parse(result.stdout) as { + period: string + periodKey: string + daily: Array<{ date: string; calls: number; editTurns: number }> + projects: Array<{ sessions: number; calls: number }> + } + + expect(report.period).toBe('Day (2026-04-10)') + expect(report.periodKey).toBe('day') + expect(report.daily.map(d => d.date)).toEqual(['2026-04-10']) + expect(report.daily[0]?.calls).toBe(1) + expect(report.daily[0]?.editTurns).toBe(1) + expect(report.projects[0]?.sessions).toBe(1) + expect(report.projects[0]?.calls).toBe(1) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('rejects --day combined with --from/--to', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-')) + + try { + const result = runCli([ + '--format', 'json', + '--day', '2026-04-10', + '--from', '2026-04-10', + '--provider', 'claude', + ], home) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('--day cannot be combined with --from or --to') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/cli-model-savings.test.ts b/tests/cli-model-savings.test.ts new file mode 100644 index 0000000..e492ecb --- /dev/null +++ b/tests/cli-model-savings.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, it, expect } from 'vitest' + +const CLI_TIMEOUT_MS = 10_000 + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + HOMEPATH: home, + HOMEDRIVE: '', + }, + encoding: 'utf-8', + }) +} + +function readConfig(home: string): Promise<Record<string, unknown>> { + return readFile(join(home, '.config', 'codeburn', 'config.json'), 'utf-8') + .then(raw => JSON.parse(raw) as Record<string, unknown>) +} + +describe('codeburn model-savings command', () => { + it('saves, lists, and removes a local-model savings mapping', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-savings-')) + try { + const set = runCli(['model-savings', 'llama3.1:8b', 'gpt-4o'], home) + expect(set.status).toBe(0) + expect(set.stdout).toContain('llama3.1:8b -> gpt-4o') + + const saved = await readConfig(home) + expect(saved.localModelSavings).toEqual({ 'llama3.1:8b': 'gpt-4o' }) + + const list = runCli(['model-savings', '--list'], home) + expect(list.status).toBe(0) + expect(list.stdout).toContain('llama3.1:8b -> gpt-4o') + + const remove = runCli(['model-savings', '--remove', 'llama3.1:8b'], home) + expect(remove.status).toBe(0) + + const after = await readConfig(home) + expect(after.localModelSavings).toBeUndefined() + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) + + it('warns when the same model is also configured in modelAliases', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-savings-')) + try { + expect(runCli(['model-alias', 'llama3.1:8b', 'gpt-4o'], home).status).toBe(0) + const set = runCli(['model-savings', 'llama3.1:8b', 'gpt-4o'], home) + expect(set.status).toBe(0) + expect(set.stdout).toContain('savings take precedence') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) + + it('rejects a remove for an unknown mapping', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-savings-')) + try { + const result = runCli(['model-savings', '--remove', 'unknown:1b'], home) + expect(result.status).toBe(1) + expect(result.stderr).toContain('No savings mapping found') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) +}) diff --git a/tests/cli-plan.test.ts b/tests/cli-plan.test.ts new file mode 100644 index 0000000..14e2b12 --- /dev/null +++ b/tests/cli-plan.test.ts @@ -0,0 +1,150 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, it, expect } from 'vitest' + +const CLI_PLAN_TIMEOUT_MS = 10_000 + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, // os.homedir() uses USERPROFILE on Windows + HOMEPATH: home, + HOMEDRIVE: '', + }, + encoding: 'utf-8', + }) +} + +describe('codeburn plan command', () => { + it('persists provider-keyed plans and clears on reset', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-')) + + try { + const setResult = runCli(['plan', 'set', 'claude-max'], home) + expect(setResult.status).toBe(0) + + const setCodexResult = runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home) + expect(setCodexResult.status).toBe(0) + + const configPath = join(home, '.config', 'codeburn', 'config.json') + const configRaw = await readFile(configPath, 'utf-8') + const config = JSON.parse(configRaw) as { plans?: { claude?: { id?: string; monthlyUsd?: number }; codex?: { id?: string; monthlyUsd?: number } } } + expect(config.plans?.claude?.id).toBe('claude-max') + expect(config.plans?.claude?.monthlyUsd).toBe(200) + expect(config.plans?.codex?.id).toBe('custom') + expect(config.plans?.codex?.monthlyUsd).toBe(200) + + const resetResult = runCli(['plan', 'reset'], home) + expect(resetResult.status).toBe(0) + + const afterResetRaw = await readFile(configPath, 'utf-8') + const afterReset = JSON.parse(afterResetRaw) as { plan?: unknown; plans?: unknown } + expect(afterReset.plan).toBeUndefined() + expect(afterReset.plans).toBeUndefined() + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_PLAN_TIMEOUT_MS) + + it('resets one provider without removing other plans', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-')) + + try { + expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0) + expect(runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home).status).toBe(0) + expect(runCli(['plan', 'reset', '--provider', 'codex'], home).status).toBe(0) + + const configPath = join(home, '.config', 'codeburn', 'config.json') + const configRaw = await readFile(configPath, 'utf-8') + const config = JSON.parse(configRaw) as { plans?: { claude?: { id?: string }; codex?: unknown } } + expect(config.plans?.claude?.id).toBe('claude-max') + expect(config.plans?.codex).toBeUndefined() + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_PLAN_TIMEOUT_MS) + + it('resets the all-provider plan without removing provider-specific plans', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-')) + + try { + expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0) + expect(runCli(['plan', 'reset', '--provider', 'all'], home).status).toBe(0) + + const configPath = join(home, '.config', 'codeburn', 'config.json') + const configRaw = await readFile(configPath, 'utf-8') + const config = JSON.parse(configRaw) as { plans?: { claude?: { id?: string }; all?: unknown } } + expect(config.plans?.claude?.id).toBe('claude-max') + expect(config.plans?.all).toBeUndefined() + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_PLAN_TIMEOUT_MS) + + it('shows all configured plans as json', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-')) + + try { + expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0) + expect(runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home).status).toBe(0) + + const result = runCli(['plan', '--format', 'json'], home) + expect(result.status).toBe(0) + const payload = JSON.parse(result.stdout) as { id?: string; provider?: string; plans?: { claude?: { id?: string }; codex?: { id?: string } } } + expect(payload.id).toBe('claude-max') + expect(payload.provider).toBe('claude') + expect(payload.plans?.claude?.id).toBe('claude-max') + expect(payload.plans?.codex?.id).toBe('custom') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_PLAN_TIMEOUT_MS) + + it('filters shown plans by provider', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-')) + + try { + expect(runCli(['plan', 'set', 'claude-max'], home).status).toBe(0) + expect(runCli(['plan', 'set', 'custom', '--monthly-usd', '200', '--provider', 'codex'], home).status).toBe(0) + + const result = runCli(['plan', '--format', 'json', '--provider', 'codex'], home) + expect(result.status).toBe(0) + const payload = JSON.parse(result.stdout) as { id?: string; provider?: string; plans?: unknown } + expect(payload.id).toBe('custom') + expect(payload.provider).toBe('codex') + expect(payload.plans).toMatchObject({ codex: { id: 'custom' } }) + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_PLAN_TIMEOUT_MS) + + it('rejects all-provider scope for preset plans', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-')) + + try { + const result = runCli(['plan', 'set', 'claude-max', '--provider', 'all'], home) + expect(result.status).toBe(1) + expect(result.stderr).toContain('omit --provider or use --provider claude') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_PLAN_TIMEOUT_MS) + + it('shows invalid reset-day value in error output', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-')) + + try { + const result = runCli(['plan', 'set', 'claude-max', '--reset-day', '99'], home) + expect(result.status).toBe(1) + expect(result.stderr).toContain('--reset-day must be an integer from 1 to 28; got 99.') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_PLAN_TIMEOUT_MS) +}) diff --git a/tests/cli-price-override.test.ts b/tests/cli-price-override.test.ts new file mode 100644 index 0000000..cdbea13 --- /dev/null +++ b/tests/cli-price-override.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, it, expect } from 'vitest' + +const CLI_TIMEOUT_MS = 10_000 + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + HOMEPATH: home, + HOMEDRIVE: '', + }, + encoding: 'utf-8', + }) +} + +function readConfig(home: string): Promise<Record<string, unknown>> { + return readFile(join(home, '.config', 'codeburn', 'config.json'), 'utf-8') + .then(raw => JSON.parse(raw) as Record<string, unknown>) +} + +describe('codeburn price-override command', () => { + it('saves, lists, and removes a model price override', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-price-override-')) + try { + const set = runCli([ + 'price-override', + 'unpriced-provider/test-model', + '--input', + '0.27', + '--output', + '1.10', + '--cache-read', + '0.03', + '--cache-creation', + '0.42', + ], home) + expect(set.status).toBe(0) + expect(set.stdout).toContain('unpriced-provider/test-model') + expect(set.stdout).toContain('USD per 1,000,000 tokens') + + const saved = await readConfig(home) + expect(saved.priceOverrides).toEqual({ + 'unpriced-provider/test-model': { + input: 0.27, + output: 1.1, + cacheRead: 0.03, + cacheCreation: 0.42, + }, + }) + + const list = runCli(['price-override', '--list'], home) + expect(list.status).toBe(0) + expect(list.stdout).toContain('unpriced-provider/test-model') + expect(list.stdout).toContain('input 0.27') + expect(list.stdout).toContain('output 1.1') + + const remove = runCli(['price-override', '--remove', 'unpriced-provider/test-model'], home) + expect(remove.status).toBe(0) + + const after = await readConfig(home) + expect(after.priceOverrides).toBeUndefined() + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) + + it('rejects an invalid rate', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-price-override-')) + try { + const result = runCli(['price-override', 'bad-rate-model', '--input', 'abc', '--output', '1'], home) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Invalid --input') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, CLI_TIMEOUT_MS) +}) diff --git a/tests/cli-provider-validation.test.ts b/tests/cli-provider-validation.test.ts new file mode 100644 index 0000000..4fa9089 --- /dev/null +++ b/tests/cli-provider-validation.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { afterEach, describe, expect, it } from 'vitest' + +import { allProviderNames, getAllProviders } from '../src/providers/index.js' + +let homes: string[] = [] + +afterEach(async () => { + while (homes.length > 0) { + const h = homes.pop() + if (h) await rm(h, { recursive: true, force: true }) + } +}) + +async function makeHome(): Promise<string> { + const home = await mkdtemp(join(tmpdir(), 'codeburn-provider-cli-')) + homes.push(home) + return home +} + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +describe('allProviderNames', () => { + it('is non-empty, sorted, and excludes the "all" sentinel', () => { + const names = allProviderNames() + expect(names.length).toBeGreaterThan(0) + expect([...names]).toEqual([...names].sort()) + expect(names).not.toContain('all') + }) + + it('covers every loadable provider (guards against lazy-list drift)', async () => { + const loaded = (await getAllProviders()).map(p => p.name) + const names = allProviderNames() + for (const name of loaded) { + expect(names).toContain(name) + } + }) +}) + +describe('codeburn --provider validation', () => { + it('rejects an unknown provider with a clear error and exit 1', async () => { + const home = await makeHome() + const res = runCli(['report', '--provider', 'claud', '-p', 'today'], home) + expect(res.status).toBe(1) + expect(res.stderr).toContain('unknown provider "claud"') + expect(res.stderr).toContain('Valid values: all,') + }) + + it('accepts a valid provider', async () => { + const home = await makeHome() + const res = runCli(['status', '--provider', 'claude', '--format', 'json'], home) + expect(res.status).toBe(0) + expect(res.stderr).not.toContain('unknown provider') + }) + + it('accepts the "all" sentinel', async () => { + const home = await makeHome() + const res = runCli(['status', '--provider', 'all', '--format', 'json'], home) + expect(res.status).toBe(0) + }) +}) diff --git a/tests/cli-proxy-path.test.ts b/tests/cli-proxy-path.test.ts new file mode 100644 index 0000000..85b354c --- /dev/null +++ b/tests/cli-proxy-path.test.ts @@ -0,0 +1,154 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Each test spawns `tsx src/cli.ts` several times, which re-transpiles the CLI +// on every spawn. Under full-suite parallel load those spawns contend for CPU +// and can exceed the 5s default, so give this spawn-based file a timeout that +// matches the per-spawn cap below. The slowest test runs ~1.7s in isolation. +vi.setConfig({ testTimeout: 30_000 }) + +let homes: string[] = [] + +afterEach(async () => { + while (homes.length > 0) { + const h = homes.pop() + if (h) await rm(h, { recursive: true, force: true }) + } +}) + +async function makeHome(): Promise<string> { + const home = await mkdtemp(join(tmpdir(), 'codeburn-proxy-cli-')) + homes.push(home) + return home +} + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +const configPath = (home: string) => join(home, '.config', 'codeburn', 'config.json') +async function readConfig(home: string): Promise<Record<string, unknown>> { + return JSON.parse(await readFile(configPath(home), 'utf-8')) +} +async function writeConfig(home: string, obj: unknown): Promise<void> { + await mkdir(join(home, '.config', 'codeburn'), { recursive: true }) + await writeFile(configPath(home), JSON.stringify(obj), 'utf-8') +} + +describe('codeburn proxy-path CLI', () => { + it('adds, lists, dedupes, and removes a proxy path', async () => { + const home = await makeHome() + + expect(runCli(['proxy-path', '--list'], home).stdout).toContain('No proxy paths configured') + + const add = runCli(['proxy-path', '/work/copilot-repo'], home) + expect(add.status).toBe(0) + expect(add.stdout).toContain('Proxy path saved') + expect((await readConfig(home)).proxyPaths).toEqual(['/work/copilot-repo']) + + // Trailing-slash variant is the same path -> deduped, not a second entry. + const dup = runCli(['proxy-path', '/work/copilot-repo/'], home) + expect(dup.stdout).toContain('already configured') + expect((await readConfig(home)).proxyPaths).toEqual(['/work/copilot-repo']) + + expect(runCli(['proxy-path', '--list'], home).stdout).toContain('/work/copilot-repo') + + const rm = runCli(['proxy-path', '--remove', '/work/copilot-repo'], home) + expect(rm.status).toBe(0) + expect((await readConfig(home)).proxyPaths).toBeUndefined() + }) + + it('rejects a relative path and the filesystem root', async () => { + const home = await makeHome() + const rel = runCli(['proxy-path', './rel'], home) + expect(rel.status).toBe(1) + expect(rel.stderr).toContain('absolute') + + const root = runCli(['proxy-path', '/'], home) + expect(root.status).toBe(1) + }) + + it('errors (exit 1) when removing a path that is not configured', async () => { + const home = await makeHome() + const res = runCli(['proxy-path', '--remove', '/not/configured'], home) + expect(res.status).toBe(1) + expect(res.stderr).toContain('No proxy path found') + }) + + it('does not crash on a malformed config (proxyPaths as a non-array)', async () => { + const home = await makeHome() + await writeConfig(home, { proxyPaths: 42 }) + + const list = runCli(['proxy-path', '--list'], home) + expect(list.status).toBe(0) + expect(list.stderr).not.toMatch(/TypeError|is not iterable|is not a function/) + expect(list.stdout).toContain('No proxy paths configured') + + const add = runCli(['proxy-path', '/work/repo'], home) + expect(add.status).toBe(0) + expect(add.stderr).not.toMatch(/TypeError/) + // The garbage was discarded; only the valid absolute path persists. + expect((await readConfig(home)).proxyPaths).toEqual(['/work/repo']) + }) + + it('does not crash on a malformed config (proxyPaths array with non-string entries)', async () => { + const home = await makeHome() + await writeConfig(home, { proxyPaths: [42, null, '/work/keep'] }) + const add = runCli(['proxy-path', '/work/new'], home) + expect(add.status).toBe(0) + expect(add.stderr).not.toMatch(/TypeError|is not a function/) + expect((await readConfig(home)).proxyPaths).toEqual(['/work/keep', '/work/new']) + }) +}) + +describe('codeburn report --format json: proxy overview', () => { + async function writeClaudeSession(home: string, cwd: string): Promise<void> { + const dir = join(home, '.claude', 'projects', 'proxied') + await mkdir(dir, { recursive: true }) + const ts = new Date().toISOString() + const line = JSON.stringify({ + type: 'assistant', sessionId: 's1', timestamp: ts, cwd, + message: { + id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10000, output_tokens: 2000, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }) + await writeFile(join(dir, 's1.jsonl'), line + '\n', 'utf-8') + } + + function overview(home: string): { cost: number; proxiedCost: number; netCost: number } { + const res = runCli(['report', '--period', 'all', '--format', 'json'], home) + expect(res.status).toBe(0) + return JSON.parse(res.stdout).overview + } + + it('reports proxiedCost == cost and netCost == 0 under a proxy path, and netCost == cost without one', async () => { + const home = await makeHome() + const cwd = '/proj/proxiedrepo' + await writeClaudeSession(home, cwd) + + // Baseline: no proxy config -> nothing attributed, net == cost. + const base = overview(home) + expect(base.cost).toBeGreaterThan(0) + expect(base.proxiedCost).toBe(0) + expect(base.netCost).toBeCloseTo(base.cost, 10) + + // With the proxy path -> full cost preserved, fully subscription-covered. + await writeConfig(home, { proxyPaths: [cwd] }) + const proxied = overview(home) + expect(proxied.cost).toBeCloseTo(base.cost, 10) // cost is never modified + expect(proxied.proxiedCost).toBeCloseTo(proxied.cost, 10) + expect(proxied.netCost).toBeCloseTo(0, 10) + expect(proxied.netCost).toBeCloseTo(proxied.cost - proxied.proxiedCost, 10) + }) +}) diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts new file mode 100644 index 0000000..0ff96d9 --- /dev/null +++ b/tests/cli-status-menubar.test.ts @@ -0,0 +1,643 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter as pathDelimiter, join } from 'node:path' +import { spawnSync } from 'node:child_process' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string, extraEnv: Record<string, string | undefined> = {}) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + HOME: home, + TZ: 'UTC', + ...extraEnv, + }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +function userLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + message: { role: 'user', content: 'do the thing' }, + }) +} + +function assistantLine(sessionId: string, timestamp: string, messageId: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [ + { type: 'text', text: 'done' }, + { type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } }, + ], + usage: { input_tokens: 500, output_tokens: 50 }, + }, + }) +} + +describe('codeburn status --format menubar-json', () => { + it('returns valid MenubarPayload with expected top-level fields', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + + const now = new Date() + const h = now.getUTCHours() + const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z') + const ts4 = new Date(base.getTime() + 180_000).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', ts1), + assistantLine('s1', ts2, 'msg-1'), + userLine('s1', ts3), + assistantLine('s1', ts4, 'msg-2'), + ].join('\n'), + ) + + const result = runCli([ + 'status', + '--format', 'menubar-json', + '--period', 'today', + '--provider', 'all', + '--no-optimize', + ], home) + + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + + const payload = JSON.parse(result.stdout) as Record<string, unknown> + + expect(payload).toHaveProperty('generated') + expect(payload).toHaveProperty('current') + expect(payload).toHaveProperty('optimize') + expect(payload).toHaveProperty('history') + + const current = payload['current'] as Record<string, unknown> + expect(current['cost']).toBeGreaterThan(0) + expect(current['calls']).toBe(2) + expect(current['sessions']).toBe(1) + expect(current).toHaveProperty('oneShotRate') + expect(current).toHaveProperty('topActivities') + expect(current).toHaveProperty('topModels') + expect(current).toHaveProperty('providers') + + const history = payload['history'] as { daily: unknown[] } + expect(Array.isArray(history.daily)).toBe(true) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('filters menubar payloads to a selected review day with --day', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-day-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('before', '2026-04-09T23:58:00Z'), + assistantLine('before', '2026-04-09T23:59:00Z', 'msg-before'), + userLine('selected', '2026-04-10T11:59:00Z'), + assistantLine('selected', '2026-04-10T12:00:00Z', 'msg-selected'), + userLine('after', '2026-04-11T00:00:00Z'), + assistantLine('after', '2026-04-11T00:01:00Z', 'msg-after'), + ].join('\n'), + ) + + const result = runCli([ + 'status', + '--format', 'menubar-json', + '--day', '2026-04-10', + '--provider', 'all', + '--no-optimize', + ], home) + + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + + const payload = JSON.parse(result.stdout) as { + current: { + label: string + calls: number + sessions: number + topProjects: Array<{ sessions: number; sessionDetails: Array<{ date: string }> }> + } + } + + expect(payload.current.label).toBe('Day (2026-04-10)') + expect(payload.current.calls).toBe(1) + expect(payload.current.sessions).toBe(1) + expect(payload.current.topProjects).toHaveLength(1) + expect(payload.current.topProjects[0]?.sessions).toBe(1) + expect(payload.current.topProjects[0]?.sessionDetails.map(s => s.date)).toEqual(['2026-04-10']) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('filters the whole menubar payload to a selected Claude config source', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-')) + + try { + const work = join(home, 'claude-work') + const personal = join(home, 'claude-personal') + const slug = 'shared-app' + await mkdir(join(work, 'projects', slug), { recursive: true }) + await mkdir(join(personal, 'projects', slug), { recursive: true }) + + await writeFile( + join(work, 'projects', slug, 'work.jsonl'), + [ + userLine('work', '2026-04-10T11:59:00Z'), + assistantLine('work', '2026-04-10T12:00:00Z', 'msg-work'), + ].join('\n'), + ) + await writeFile( + join(personal, 'projects', slug, 'personal.jsonl'), + [ + userLine('personal', '2026-04-10T12:59:00Z'), + assistantLine('personal', '2026-04-10T13:00:00Z', 'msg-personal'), + ].join('\n'), + ) + + const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) } + const allResult = runCli([ + 'status', + '--format', 'menubar-json', + '--period', 'all', + '--provider', 'all', + '--no-optimize', + ], home, env) + + expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0) + const allPayload = JSON.parse(allResult.stdout) as { + current: { calls: number; sessions: number } + claudeConfigs?: { selectedId: string | null; options: Array<{ id: string; label: string; path: string }> } + } + expect(allPayload.current.calls).toBe(2) + expect(allPayload.current.sessions).toBe(2) + expect(allPayload.claudeConfigs?.selectedId).toBeNull() + expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work']) + + const workSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-work')!.id + const workResult = runCli([ + 'status', + '--format', 'menubar-json', + '--period', 'all', + '--provider', 'all', + '--claude-config-source', workSourceId, + '--no-optimize', + ], home, env) + + expect(workResult.status, `stderr: ${workResult.stderr}`).toBe(0) + const workPayload = JSON.parse(workResult.stdout) as { + current: { calls: number; sessions: number; providers: Record<string, number> } + history: { daily: Array<{ calls: number }> } + claudeConfigs?: { selectedId: string | null } + } + expect(workPayload.claudeConfigs?.selectedId).toBe(workSourceId) + expect(workPayload.current.calls).toBe(1) + expect(workPayload.current.sessions).toBe(1) + expect(Object.keys(workPayload.current.providers)).toEqual(['claude']) + expect(workPayload.history.daily.reduce((sum, d) => sum + d.calls, 0)).toBe(1) + + const invalidResult = runCli([ + 'status', + '--format', 'menubar-json', + '--period', 'all', + '--provider', 'all', + '--claude-config-source', 'claude-config:missing', + '--no-optimize', + ], home, env) + + expect(invalidResult.status, `stderr: ${invalidResult.stderr}`).toBe(0) + const invalidPayload = JSON.parse(invalidResult.stdout) as { + current: { calls: number } + claudeConfigs?: { selectedId: string | null } + } + expect(invalidPayload.current.calls).toBe(2) + expect(invalidPayload.claudeConfigs?.selectedId).toBeNull() + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('keeps idle Claude config options visible for the selected period', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-claude-config-idle-')) + + try { + const work = join(home, 'claude-work') + const personal = join(home, 'claude-personal') + await mkdir(join(work, 'projects', 'app'), { recursive: true }) + await mkdir(join(personal, 'projects', 'app'), { recursive: true }) + + await writeFile( + join(work, 'projects', 'app', 'work.jsonl'), + [ + userLine('work', '2026-04-10T11:59:00Z'), + assistantLine('work', '2026-04-10T12:00:00Z', 'msg-work'), + ].join('\n'), + ) + await writeFile( + join(personal, 'projects', 'app', 'personal.jsonl'), + [ + userLine('personal', '2026-04-09T11:59:00Z'), + assistantLine('personal', '2026-04-09T12:00:00Z', 'msg-personal'), + ].join('\n'), + ) + + const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) } + const allResult = runCli([ + 'status', + '--format', 'menubar-json', + '--day', '2026-04-10', + '--provider', 'all', + '--no-optimize', + ], home, env) + + expect(allResult.status, `stderr: ${allResult.stderr}`).toBe(0) + const allPayload = JSON.parse(allResult.stdout) as { + current: { calls: number } + claudeConfigs?: { options: Array<{ id: string; label: string }> } + } + expect(allPayload.current.calls).toBe(1) + expect(allPayload.claudeConfigs?.options.map(o => o.label).sort()).toEqual(['claude-personal', 'claude-work']) + + const personalSourceId = allPayload.claudeConfigs!.options.find(o => o.label === 'claude-personal')!.id + const personalResult = runCli([ + 'status', + '--format', 'menubar-json', + '--day', '2026-04-10', + '--provider', 'all', + '--claude-config-source', personalSourceId, + '--no-optimize', + ], home, env) + + expect(personalResult.status, `stderr: ${personalResult.stderr}`).toBe(0) + const personalPayload = JSON.parse(personalResult.stdout) as { + current: { calls: number; sessions: number; providers: Record<string, number> } + claudeConfigs?: { selectedId: string | null } + } + expect(personalPayload.claudeConfigs?.selectedId).toBe(personalSourceId) + expect(personalPayload.current.calls).toBe(0) + expect(personalPayload.current.sessions).toBe(0) + expect(personalPayload.current.providers).toEqual({ claude: 0 }) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('rejects --claude-config-source combined with a non-Claude --provider', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cfg-provider-')) + try { + const work = join(home, 'claude-work') + const personal = join(home, 'claude-personal') + await mkdir(join(work, 'projects', 'app'), { recursive: true }) + await mkdir(join(personal, 'projects', 'app'), { recursive: true }) + const env = { CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter) } + + const result = runCli([ + 'status', '--format', 'menubar-json', '--period', 'all', + '--provider', 'codex', '--claude-config-source', 'claude-config:whatever', + '--no-optimize', + ], home, env) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('--claude-config-source cannot be combined with --provider codex') + + // 'claude' is allowed (a config scopes Claude usage). + const okClaude = runCli([ + 'status', '--format', 'menubar-json', '--period', 'all', + '--provider', 'claude', '--no-optimize', + ], home, env) + expect(okClaude.status, `stderr: ${okClaude.stderr}`).toBe(0) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('surfaces Claude Desktop sessions as their own bucket so config sum equals All', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-desktop-')) + try { + const work = join(home, 'claude-work') + const personal = join(home, 'claude-personal') + await mkdir(join(work, 'projects', 'app'), { recursive: true }) + await mkdir(join(personal, 'projects', 'app'), { recursive: true }) + await writeFile(join(work, 'projects', 'app', 'w.jsonl'), + [userLine('w', '2026-04-10T11:59:00Z'), assistantLine('w', '2026-04-10T12:00:00Z', 'mw')].join('\n')) + await writeFile(join(personal, 'projects', 'app', 'p.jsonl'), + [userLine('p', '2026-04-10T12:59:00Z'), assistantLine('p', '2026-04-10T13:00:00Z', 'mp')].join('\n')) + + // A fake Claude Desktop sessions tree. + const desktop = join(home, 'desktop-sessions') + const dProj = join(desktop, 'appid', 'ws', 'local_s1', '.claude', 'projects', 'space') + await mkdir(dProj, { recursive: true }) + await writeFile(join(dProj, 'd.jsonl'), + [userLine('d', '2026-04-10T13:59:00Z'), assistantLine('d', '2026-04-10T14:00:00Z', 'md')].join('\n')) + + const env = { + CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter), + CODEBURN_DESKTOP_SESSIONS_DIR: desktop, + } + const result = runCli([ + 'status', '--format', 'menubar-json', '--period', 'all', '--provider', 'all', '--no-optimize', + ], home, env) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + const payload = JSON.parse(result.stdout) as { + current: { calls: number } + claudeConfigs?: { options: Array<{ id: string; label: string }> } + } + // 3 sessions total: work + personal + desktop. + expect(payload.current.calls).toBe(3) + // The selector lists all three, including a Claude Desktop bucket. + const labels = payload.claudeConfigs?.options.map(o => o.label).sort() + expect(labels).toContain('Claude Desktop') + expect(labels).toHaveLength(3) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('includes LingTai TUI usage and activity categories in menubar payloads', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-lingtai-')) + + try { + const lingtaiHome = join(home, '.lingtai') + const agentDir = join(lingtaiHome, 'agent') + await mkdir(join(agentDir, 'logs'), { recursive: true }) + await writeFile(join(agentDir, '.agent.json'), JSON.stringify({ + agent_id: 'agent-1', + agent_name: 'LingTai Agent', + llm: { model: 'gpt-4o' }, + })) + + const now = new Date() + const h = now.getUTCHours() + const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(agentDir, 'logs', 'token_ledger.jsonl'), + [ + JSON.stringify({ source: 'main', ts: ts1, input: 1000, cached: 100, output: 100, model: 'gpt-4o' }), + JSON.stringify({ source: 'tc_wake', ts: ts2, input: 2000, cached: 500, output: 200, model: 'gpt-4o' }), + JSON.stringify({ source: 'summarize_apriori', ts: ts3, input: 1500, cached: 300, output: 150, model: 'gpt-4o' }), + ].join('\n') + '\n', + ) + + const result = runCli([ + 'status', + '--format', 'menubar-json', + '--period', 'today', + '--provider', 'lingtai-tui', + '--no-optimize', + ], home, { + LINGTAI_HOME: lingtaiHome, + LINGTAI_TUI_GLOBAL_DIR: join(home, '.lingtai-tui'), + }) + + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + + const payload = JSON.parse(result.stdout) as { + current: { + cost: number + calls: number + providers: Record<string, number> + topActivities: Array<{ name: string; turns: number }> + } + } + + expect(payload.current.cost).toBeGreaterThan(0) + expect(payload.current.calls).toBe(3) + expect(payload.current.providers['lingtai tui']).toBeGreaterThan(0) + expect(payload.current.topActivities.map(a => a.name).sort()).toEqual([ + 'Conversation', + 'Delegation', + 'Planning', + ]) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('attaches combined local-only usage for --scope combined and omits it for local scope', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + + const now = new Date() + const h = now.getUTCHours() + const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', ts1), + assistantLine('s1', ts2, 'msg-1'), + ].join('\n'), + ) + + const combinedResult = runCli([ + 'status', + '--format', 'menubar-json', + '--scope', 'combined', + '--period', 'today', + '--provider', 'all', + '--no-optimize', + ], home) + + expect(combinedResult.status, `stderr: ${combinedResult.stderr}`).toBe(0) + + const payload = JSON.parse(combinedResult.stdout) as { + current: { + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + } + history: { + daily: Array<{ cacheWriteTokens?: number; cacheReadTokens?: number }> + } + combined?: { + perDevice: Array<{ + id: string + local: boolean + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheCreateTokens: number + cacheReadTokens: number + totalTokens: number + }> + combined: { + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheCreateTokens: number + cacheReadTokens: number + totalTokens: number + deviceCount: number + reachableCount: number + } + } + } + + expect(payload.combined).toBeDefined() + expect(payload.combined!.perDevice).toHaveLength(1) + const local = payload.combined!.perDevice[0]! + const cacheCreateTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheWriteTokens ?? 0), 0) + const cacheReadTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheReadTokens ?? 0), 0) + const totalTokens = payload.current.inputTokens + payload.current.outputTokens + cacheCreateTokens + cacheReadTokens + + expect(local).toMatchObject({ + id: 'local', + local: true, + cost: payload.current.cost, + calls: payload.current.calls, + sessions: payload.current.sessions, + inputTokens: payload.current.inputTokens, + outputTokens: payload.current.outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens, + }) + expect(payload.combined!.combined).toEqual({ + cost: payload.current.cost, + calls: payload.current.calls, + sessions: payload.current.sessions, + inputTokens: payload.current.inputTokens, + outputTokens: payload.current.outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens, + deviceCount: 1, + reachableCount: 1, + }) + + const localResult = runCli([ + 'status', + '--format', 'menubar-json', + '--scope', 'local', + '--period', 'today', + '--provider', 'all', + '--no-optimize', + ], home) + expect(localResult.status, `stderr: ${localResult.stderr}`).toBe(0) + expect(JSON.parse(localResult.stdout)).not.toHaveProperty('combined') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it.each([ + ['--provider', ['--provider', 'claude']], + ['--project', ['--project', 'x']], + ['--exclude', ['--exclude', 'y']], + ])('rejects combined scope with filtered local payloads from %s', async (_name, filterArgs) => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-filter-')) + + try { + const result = runCli([ + 'status', + '--format', 'menubar-json', + '--scope', 'combined', + ...filterArgs, + '--no-optimize', + ], home) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('rejects invalid menubar-json scope values', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-scope-')) + + try { + const result = runCli([ + 'status', + '--format', 'menubar-json', + '--scope', 'remote', + '--no-optimize', + ], home) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('unknown scope "remote"') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('still emits a valid combined menubar payload when the remotes store is corrupt', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-corrupt-remotes-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + const now = new Date() + const h = now.getUTCHours() + const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + await writeFile(join(projectDir, 'session.jsonl'), [userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1')].join('\n')) + + // Corrupt the remotes store the combined path reads. The menubar must + // still get a valid payload (combined degrades to local-only). + const sharingDir = join(home, '.config', 'codeburn', 'sharing') + await mkdir(sharingDir, { recursive: true }) + await writeFile(join(sharingDir, 'remote-devices.json'), '{ this is : not valid json ]') + + const result = runCli([ + 'status', '--format', 'menubar-json', '--scope', 'combined', + '--period', 'today', '--no-optimize', + ], home) + + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + const payload = JSON.parse(result.stdout) as { + current: { cost: number } + combined?: { perDevice: unknown[]; combined: { deviceCount: number; reachableCount: number } } + } + expect(payload.combined).toBeDefined() + expect(payload.combined!.perDevice).toHaveLength(1) + expect(payload.combined!.combined.deviceCount).toBe(1) + expect(payload.combined!.combined.reachableCount).toBe(1) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/codex-cache-invalidation.test.ts b/tests/codex-cache-invalidation.test.ts new file mode 100644 index 0000000..cfba9c4 --- /dev/null +++ b/tests/codex-cache-invalidation.test.ts @@ -0,0 +1,108 @@ +// Regression for the codex stale-cache path (#478 follow-up, same class as +// the kiro bug #618/#619). session-cache.json serves unchanged session files +// without invoking the provider parser, so bumping CODEX_CACHE_VERSION alone +// does NOT re-attribute already-cached sessions. Registering `codex` in +// PROVIDER_PARSE_VERSIONS changes the provider envFingerprint, which discards +// the stale section and forces a re-parse. This exercises the full +// parseAllSessions pipeline against a cache seeded with the PRE-fix +// fingerprint and asserts the mcp-cli MCP attribution is recovered. + +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest' +import { mkdir, rm, readFile, writeFile } from 'fs/promises' +import { createHash } from 'crypto' +import { join } from 'path' + +import { clearSessionCache, parseAllSessions } from '../src/parser.js' + +const testRoot = vi.hoisted(() => { + const root = `${process.env['TMPDIR'] || '/tmp'}/codex-stale-repro-${process.pid}-${Date.now()}` + process.env['HOME'] = `${root}/home` + process.env['USERPROFILE'] = `${root}/home` + process.env['CODEX_HOME'] = `${root}/codex` + return root +}) + +const CODEX_HOME = join(testRoot, 'codex') +const CACHE_DIR = join(testRoot, 'cache') + +// computeEnvFingerprint('codex') as staged (no PROVIDER_PARSE_VERSIONS entry): +// hash of just CODEX_HOME. This is what sits in every existing user cache. +function preFixFingerprint(): string { + return createHash('sha256').update(`CODEX_HOME=${CODEX_HOME}`).digest('hex').slice(0, 16) +} + +beforeEach(() => { + process.env['HOME'] = join(testRoot, 'home') + process.env['USERPROFILE'] = join(testRoot, 'home') + process.env['CODEX_HOME'] = CODEX_HOME + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR +}) + +afterAll(async () => { + await rm(testRoot, { recursive: true, force: true }) +}) + +function allMcpServers(projects: Awaited<ReturnType<typeof parseAllSessions>>): string[] { + const servers: string[] = [] + for (const p of projects) { + for (const s of p.sessions) { + servers.push(...Object.keys(s.mcpBreakdown)) + } + } + return servers +} + +describe('codex parser change invalidates stale session-cache (#478/#513)', () => { + it('re-parses unchanged codex files after a parser attribution change', async () => { + const sessionDir = join(CODEX_HOME, 'sessions', '2026', '04', '14') + await mkdir(sessionDir, { recursive: true }) + await mkdir(CACHE_DIR, { recursive: true }) + const lines = [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-04-14T10:00:00Z', payload: { session_id: 'sess-stale', model: 'gpt-5.5', cwd: '/Users/test/proj', originator: 'codex_cli_rs' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'call mcp via cli' }] } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:30Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: "bash -lc \"mcp-cli call github get_issue '{}'\"" }) } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:01:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 300, output_tokens: 100 }, total_token_usage: { total_tokens: 400 } } } }), + ] + await writeFile(join(sessionDir, 'rollout-stale.jsonl'), lines.join('\n') + '\n') + + // Run 1: cold cache, fixed parser. MCP attribution present (sanity). + clearSessionCache() + const fresh = await parseAllSessions(undefined, 'codex') + expect(allMcpServers(fresh)).toContain('github') + + // Simulate a user whose session-cache.json was written by the PRE-fix + // release: pre-fix envFingerprint, unchanged file fingerprint, cached + // turns lack the mcp__ tool. Also reset codex-results.json to v4 so the + // provider (if it runs at all) must genuinely re-parse. + const cachePath = join(CACHE_DIR, 'session-cache.json') + const cache = JSON.parse(await readFile(cachePath, 'utf8')) + cache.providers.codex.envFingerprint = preFixFingerprint() + for (const f of Object.values(cache.providers.codex.files) as any[]) { + for (const turn of f.turns) { + for (const call of turn.calls) { + call.tools = call.tools.filter((t: string) => !t.startsWith('mcp__')) + if (call.toolSequence) { + call.toolSequence = call.toolSequence.filter((step: any[]) => !step.some(c => c.tool.startsWith('mcp__'))) + } + } + } + } + await writeFile(cachePath, JSON.stringify(cache)) + const codexCachePath = join(CACHE_DIR, 'codex-results.json') + const codexCache = JSON.parse(await readFile(codexCachePath, 'utf8')) + codexCache.version = 4 + for (const f of Object.values(codexCache.files) as any[]) { + for (const call of f.calls ?? []) { + call.tools = (call.tools ?? []).filter((t: string) => !t.startsWith('mcp__')) + } + } + await writeFile(codexCachePath, JSON.stringify(codexCache)) + + clearSessionCache() + const second = await parseAllSessions(undefined, 'codex') + // FIXED: `codex` is now in PROVIDER_PARSE_VERSIONS, so the pre-fix + // envFingerprint no longer matches, the stale section is discarded, the + // unchanged file re-parses, and the mcp-cli attribution reappears. + expect(allMcpServers(second)).toContain('github') + }) +}) diff --git a/tests/codex-credits.test.ts b/tests/codex-credits.test.ts new file mode 100644 index 0000000..cfa6c48 --- /dev/null +++ b/tests/codex-credits.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { codexCredits, codexCreditRate } from '../src/codex-credits.js' + +describe('codexCreditRate', () => { + it('resolves the documented per-model rates', () => { + expect(codexCreditRate('gpt-5.5')).toEqual({ input: 125, cachedInput: 12.5, output: 750 }) + expect(codexCreditRate('gpt-5.4')).toEqual({ input: 62.5, cachedInput: 6.25, output: 375 }) + expect(codexCreditRate('gpt-5.4-mini')).toEqual({ input: 18.75, cachedInput: 1.875, output: 113 }) + }) + + it('tolerates codex suffix variants and casing', () => { + expect(codexCreditRate('GPT-5.5-codex')?.input).toBe(125) + expect(codexCreditRate('gpt-5.4-codex-mini')?.input).toBe(18.75) + }) + + it('returns null for models with no known credit rate', () => { + expect(codexCreditRate('gpt-4o')).toBeNull() + expect(codexCreditRate('claude-opus-4-8')).toBeNull() + }) +}) + +describe('codexCredits', () => { + it('charges 1M input tokens at the input rate', () => { + expect(codexCredits('gpt-5.5', { inputTokens: 1_000_000, cachedReadTokens: 0, outputTokens: 0 })).toBe(125) + }) + + it('charges 1M output tokens at the output rate', () => { + expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 0, outputTokens: 1_000_000 })).toBe(750) + }) + + it('charges cache-read tokens at the cheaper cached rate', () => { + expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 1_000_000, outputTokens: 0 })).toBe(12.5) + }) + + it('folds reasoning tokens into the output rate', () => { + // 500k output + 500k reasoning = 1M output-billed => 750 credits. + expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 0, outputTokens: 500_000, reasoningTokens: 500_000 })).toBe(750) + }) + + it('sums a mixed record (gpt-5.4)', () => { + // 2M input (125) + 1M cached (6.25) + 0.5M output (187.5) = 318.75 + const credits = codexCredits('gpt-5.4', { inputTokens: 2_000_000, cachedReadTokens: 1_000_000, outputTokens: 500_000 }) + expect(credits).toBeCloseTo(125 + 6.25 + 187.5, 6) + }) + + it('clamps negative / non-finite token counts to 0', () => { + expect(codexCredits('gpt-5.5', { inputTokens: -100, cachedReadTokens: NaN, outputTokens: 1_000_000 })).toBe(750) + }) + + it('returns null for an unknown model', () => { + expect(codexCredits('gpt-4o', { inputTokens: 1_000_000, cachedReadTokens: 0, outputTokens: 0 })).toBeNull() + }) +}) diff --git a/tests/compare-stats.test.ts b/tests/compare-stats.test.ts new file mode 100644 index 0000000..63d3534 --- /dev/null +++ b/tests/compare-stats.test.ts @@ -0,0 +1,570 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { aggregateModelStats, computeComparison, computeCategoryComparison, computeWorkingStyle, scanSelfCorrections, type ModelStats } from '../src/compare-stats.js' +import type { ProjectSummary, SessionSummary, ClassifiedTurn } from '../src/types.js' + +function makeTurn(model: string, cost: number, opts: { hasEdits?: boolean; retries?: number; outputTokens?: number; inputTokens?: number; cacheRead?: number; cacheWrite?: number; timestamp?: string; category?: string; hasAgentSpawn?: boolean; hasPlanMode?: boolean; speed?: 'standard' | 'fast'; tools?: string[] } = {}): ClassifiedTurn { + const defaultTools = opts.tools ?? (opts.hasEdits ? ['Edit'] : ['Read']) + return { + timestamp: opts.timestamp ?? '2026-04-15T10:00:00Z', + category: (opts.category ?? 'coding') as ClassifiedTurn['category'], + retries: opts.retries ?? 0, + hasEdits: opts.hasEdits ?? false, + userMessage: '', + assistantCalls: [{ + provider: 'claude', + model, + usage: { + inputTokens: opts.inputTokens ?? 100, + outputTokens: opts.outputTokens ?? 200, + cacheCreationInputTokens: opts.cacheWrite ?? 500, + cacheReadInputTokens: opts.cacheRead ?? 5000, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: cost, + tools: defaultTools, + mcpTools: [], + skills: [], + hasAgentSpawn: opts.hasAgentSpawn ?? false, + hasPlanMode: opts.hasPlanMode ?? false, + speed: opts.speed ?? 'standard' as const, + timestamp: opts.timestamp ?? '2026-04-15T10:00:00Z', + bashCommands: [], + deduplicationKey: `key-${Math.random()}`, + }], + } +} + +function makeProject(turns: ClassifiedTurn[]): ProjectSummary { + const session: SessionSummary = { + sessionId: 'test-session', + project: 'test-project', + firstTimestamp: turns[0]?.timestamp ?? '', + lastTimestamp: turns[turns.length - 1]?.timestamp ?? '', + totalCostUSD: turns.reduce((s, t) => s + t.assistantCalls.reduce((s2, c) => s2 + c.costUSD, 0), 0), + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: turns.reduce((s, t) => s + t.assistantCalls.length, 0), + turns, + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {} as SessionSummary['skillBreakdown'], + } + return { + project: 'test-project', + projectPath: '/test', + sessions: [session], + totalCostUSD: session.totalCostUSD, + totalApiCalls: session.apiCalls, + } +} + +describe('aggregateModelStats', () => { + it('aggregates calls, cost, and tokens per model', () => { + const project = makeProject([ + makeTurn('opus-4-6', 0.10, { outputTokens: 200, inputTokens: 50, cacheRead: 5000, cacheWrite: 500 }), + makeTurn('opus-4-6', 0.15, { outputTokens: 300, inputTokens: 80, cacheRead: 6000, cacheWrite: 600 }), + makeTurn('opus-4-7', 0.25, { outputTokens: 800, inputTokens: 100, cacheRead: 7000, cacheWrite: 700 }), + ]) + const stats = aggregateModelStats([project]) + const m6 = stats.find(s => s.model === 'opus-4-6')! + const m7 = stats.find(s => s.model === 'opus-4-7')! + + expect(m6.calls).toBe(2) + expect(m6.cost).toBeCloseTo(0.25) + expect(m6.outputTokens).toBe(500) + expect(m7.calls).toBe(1) + expect(m7.cost).toBeCloseTo(0.25) + expect(m7.outputTokens).toBe(800) + }) + + it('attributes turn-level metrics to the primary model', () => { + const project = makeProject([ + makeTurn('opus-4-6', 0.10, { hasEdits: true, retries: 0 }), + makeTurn('opus-4-6', 0.10, { hasEdits: true, retries: 2 }), + makeTurn('opus-4-7', 0.20, { hasEdits: true, retries: 0 }), + makeTurn('opus-4-7', 0.20, { hasEdits: false }), + ]) + const stats = aggregateModelStats([project]) + const m6 = stats.find(s => s.model === 'opus-4-6')! + const m7 = stats.find(s => s.model === 'opus-4-7')! + + expect(m6.editTurns).toBe(2) + expect(m6.oneShotTurns).toBe(1) + expect(m6.retries).toBe(2) + expect(m7.editTurns).toBe(1) + expect(m7.oneShotTurns).toBe(1) + expect(m7.totalTurns).toBe(2) + }) + + it('tracks firstSeen and lastSeen timestamps', () => { + const project = makeProject([ + makeTurn('opus-4-6', 0.10, { timestamp: '2026-04-10T08:00:00Z' }), + makeTurn('opus-4-6', 0.10, { timestamp: '2026-04-15T20:00:00Z' }), + ]) + const stats = aggregateModelStats([project]) + const m = stats.find(s => s.model === 'opus-4-6')! + expect(m.firstSeen).toBe('2026-04-10T08:00:00Z') + expect(m.lastSeen).toBe('2026-04-15T20:00:00Z') + }) + + it('filters out <synthetic> model entries', () => { + const project = makeProject([ + makeTurn('<synthetic>', 0, {}), + makeTurn('opus-4-6', 0.10, {}), + ]) + const stats = aggregateModelStats([project]) + expect(stats.find(s => s.model === '<synthetic>')).toBeUndefined() + expect(stats).toHaveLength(1) + }) + + it('returns empty array for no projects', () => { + expect(aggregateModelStats([])).toEqual([]) + }) + + it('tracks editCost for edit turns', () => { + const project = makeProject([ + makeTurn('opus-4-6', 0.10, { hasEdits: true }), + makeTurn('opus-4-6', 0.20, { hasEdits: true }), + makeTurn('opus-4-6', 0.50, { hasEdits: false }), + ]) + const stats = aggregateModelStats([project]) + const m = stats.find(s => s.model === 'opus-4-6')! + expect(m.editCost).toBeCloseTo(0.30) + }) + + it('sorts by cost descending', () => { + const project = makeProject([ + makeTurn('cheap-model', 0.01), + makeTurn('expensive-model', 5.00), + ]) + const stats = aggregateModelStats([project]) + expect(stats[0].model).toBe('expensive-model') + expect(stats[1].model).toBe('cheap-model') + }) +}) + +function makeStats(overrides: Partial<ModelStats> = {}): ModelStats { + return { + model: 'test-model', + calls: 100, + cost: 10, + outputTokens: 50000, + inputTokens: 10000, + cacheReadTokens: 20000, + cacheWriteTokens: 5000, + totalTurns: 200, + editTurns: 80, + oneShotTurns: 60, + retries: 20, + selfCorrections: 10, + editCost: 8, + firstSeen: '2026-04-01T00:00:00Z', + lastSeen: '2026-04-15T00:00:00Z', + ...overrides, + } +} + +describe('computeComparison', () => { + it('computes normalized metrics and picks winners correctly', () => { + const a = makeStats({ calls: 100, cost: 10, outputTokens: 50000, inputTokens: 10000, cacheReadTokens: 20000, cacheWriteTokens: 5000, editTurns: 80, oneShotTurns: 60, retries: 20, selfCorrections: 10, totalTurns: 200 }) + const b = makeStats({ calls: 100, cost: 8, outputTokens: 40000, inputTokens: 10000, cacheReadTokens: 20000, cacheWriteTokens: 5000, editTurns: 80, oneShotTurns: 60, retries: 20, selfCorrections: 10, totalTurns: 200 }) + const rows = computeComparison(a, b) + + const costRow = rows.find(r => r.label === 'Cost / call')! + expect(costRow.valueA).toBeCloseTo(0.1) + expect(costRow.valueB).toBeCloseTo(0.08) + expect(costRow.winner).toBe('b') + + const outputRow = rows.find(r => r.label === 'Output tok / call')! + expect(outputRow.valueA).toBe(500) + expect(outputRow.valueB).toBe(400) + expect(outputRow.winner).toBe('b') + }) + + it('returns null values for one-shot rate and retry rate when editTurns is zero', () => { + const a = makeStats({ editTurns: 0, oneShotTurns: 0, retries: 0 }) + const b = makeStats({ editTurns: 80, oneShotTurns: 60, retries: 20 }) + const rows = computeComparison(a, b) + + const oneShotRow = rows.find(r => r.label === 'One-shot rate')! + expect(oneShotRow.valueA).toBeNull() + expect(oneShotRow.winner).toBe('none') + + const retryRow = rows.find(r => r.label === 'Retry rate')! + expect(retryRow.valueA).toBeNull() + expect(retryRow.winner).toBe('none') + }) + + it('returns tie when values are equal', () => { + const a = makeStats({ calls: 100, cost: 10 }) + const b = makeStats({ calls: 100, cost: 10 }) + const rows = computeComparison(a, b) + + const costRow = rows.find(r => r.label === 'Cost / call')! + expect(costRow.winner).toBe('tie') + }) + + it('computes cost per edit correctly', () => { + const a = makeStats({ editTurns: 40, editCost: 4 }) + const b = makeStats({ editTurns: 80, editCost: 4 }) + const rows = computeComparison(a, b) + const editRow = rows.find(r => r.label === 'Cost / edit')! + expect(editRow.valueA).toBeCloseTo(0.10) + expect(editRow.valueB).toBeCloseTo(0.05) + expect(editRow.winner).toBe('b') + }) + + it('picks higher value as winner for cache hit rate', () => { + const a = makeStats({ inputTokens: 5000, cacheReadTokens: 30000, cacheWriteTokens: 5000 }) + const b = makeStats({ inputTokens: 10000, cacheReadTokens: 10000, cacheWriteTokens: 5000 }) + const rows = computeComparison(a, b) + + const cacheRow = rows.find(r => r.label === 'Cache hit rate')! + const totalA = 5000 + 30000 + 5000 + const totalB = 10000 + 10000 + 5000 + expect(cacheRow.valueA).toBeCloseTo(30000 / totalA * 100) + expect(cacheRow.valueB).toBeCloseTo(10000 / totalB * 100) + expect(cacheRow.winner).toBe('a') + }) +}) + +function jsonlLine(type: string, model: string, text: string, timestamp = '2026-04-15T10:00:00Z'): string { + if (type === 'assistant') { + return JSON.stringify({ + type: 'assistant', timestamp, + message: { model, content: [{ type: 'text', text }], id: `msg-${Math.random()}`, usage: { input_tokens: 0, output_tokens: 0 } }, + }) + } + return JSON.stringify({ type: 'user', timestamp, message: { role: 'user', content: text } }) +} + +describe('scanSelfCorrections', () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('counts apology patterns per model', async () => { + const sessionDir = join(tmpDir, 'session-abc') + await mkdir(sessionDir) + const lines = [ + jsonlLine('assistant', 'opus-4-6', 'I apologize for the confusion.'), + jsonlLine('assistant', 'opus-4-6', 'Here is the result.'), + jsonlLine('assistant', 'sonnet-4-6', 'I was wrong about that.'), + jsonlLine('user', '', 'Do this'), + ] + await writeFile(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n') + + const result = await scanSelfCorrections([tmpDir]) + expect(result.get('opus-4-6')).toBe(1) + expect(result.get('sonnet-4-6')).toBe(1) + }) + + it('does not count non-apology text', async () => { + const sessionDir = join(tmpDir, 'session-xyz') + await mkdir(sessionDir) + const lines = [ + jsonlLine('assistant', 'opus-4-6', 'Here is the updated code.'), + jsonlLine('assistant', 'opus-4-6', 'Let me fix that for you.'), + ] + await writeFile(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n') + + const result = await scanSelfCorrections([tmpDir]) + expect(result.get('opus-4-6')).toBeUndefined() + expect(result.size).toBe(0) + }) + + it('returns empty map for missing directory', async () => { + const result = await scanSelfCorrections([join(tmpDir, 'nonexistent')]) + expect(result.size).toBe(0) + }) + + it('returns empty map for empty directory', async () => { + const result = await scanSelfCorrections([tmpDir]) + expect(result.size).toBe(0) + }) + + it('scans subagent directories', async () => { + const sessionDir = join(tmpDir, 'session-sub') + const subagentsDir = join(sessionDir, 'subagents') + await mkdir(subagentsDir, { recursive: true }) + const lines = [ + jsonlLine('assistant', 'haiku-4-6', 'My mistake, let me redo that.'), + ] + await writeFile(join(subagentsDir, 'sub.jsonl'), lines.join('\n') + '\n') + + const result = await scanSelfCorrections([tmpDir]) + expect(result.get('haiku-4-6')).toBe(1) + }) + + it('skips <synthetic> models', async () => { + const sessionDir = join(tmpDir, 'session-synth') + await mkdir(sessionDir) + const lines = [ + jsonlLine('assistant', '<synthetic>', 'I apologize for the error.'), + ] + await writeFile(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n') + + const result = await scanSelfCorrections([tmpDir]) + expect(result.get('<synthetic>')).toBeUndefined() + expect(result.size).toBe(0) + }) + + it('accumulates counts across multiple sessions and directories', async () => { + const sessionA = join(tmpDir, 'session-a') + const sessionB = join(tmpDir, 'session-b') + await mkdir(sessionA) + await mkdir(sessionB) + + await writeFile(join(sessionA, 'a.jsonl'), [ + jsonlLine('assistant', 'opus-4-6', 'I was wrong.', '2026-04-15T10:00:00Z'), + jsonlLine('assistant', 'opus-4-6', 'My bad!', '2026-04-15T10:01:00Z'), + ].join('\n') + '\n') + + await writeFile(join(sessionB, 'b.jsonl'), [ + jsonlLine('assistant', 'opus-4-6', 'I apologize.', '2026-04-15T10:02:00Z'), + ].join('\n') + '\n') + + const result = await scanSelfCorrections([tmpDir]) + expect(result.get('opus-4-6')).toBe(3) + }) + + it('handles malformed JSON lines gracefully', async () => { + const sessionDir = join(tmpDir, 'session-bad') + await mkdir(sessionDir) + await writeFile(join(sessionDir, 'bad.jsonl'), [ + 'not valid json', + jsonlLine('assistant', 'opus-4-6', 'I apologize.'), + ].join('\n') + '\n') + + const result = await scanSelfCorrections([tmpDir]) + expect(result.get('opus-4-6')).toBe(1) + }) + + it('accepts multiple sessionDirs and merges counts', async () => { + const dir2 = await mkdtemp(join(tmpdir(), 'codeburn-test2-')) + try { + const sessionA = join(tmpDir, 'session-a') + const sessionB = join(dir2, 'session-b') + await mkdir(sessionA) + await mkdir(sessionB) + + await writeFile(join(sessionA, 'a.jsonl'), [ + jsonlLine('assistant', 'sonnet-4-6', 'My mistake.', '2026-04-15T10:00:00Z'), + ].join('\n') + '\n') + + await writeFile(join(sessionB, 'b.jsonl'), [ + jsonlLine('assistant', 'sonnet-4-6', 'I was wrong.', '2026-04-15T10:01:00Z'), + ].join('\n') + '\n') + + const result = await scanSelfCorrections([tmpDir, dir2]) + expect(result.get('sonnet-4-6')).toBe(2) + } finally { + await rm(dir2, { recursive: true, force: true }) + } + }) +}) + +describe('computeCategoryComparison', () => { + it('returns per-category one-shot rates for both models', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { hasEdits: true, retries: 0, category: 'coding' }), + makeTurn('model-a', 0.10, { hasEdits: true, retries: 1, category: 'coding' }), + makeTurn('model-b', 0.10, { hasEdits: true, retries: 0, category: 'coding' }), + makeTurn('model-b', 0.10, { hasEdits: true, retries: 0, category: 'coding' }), + makeTurn('model-a', 0.10, { hasEdits: true, retries: 0, category: 'debugging' }), + makeTurn('model-b', 0.10, { hasEdits: true, retries: 1, category: 'debugging' }), + ]) + const result = computeCategoryComparison([project], 'model-a', 'model-b') + + const coding = result.find(r => r.category === 'coding')! + expect(coding.editTurnsA).toBe(2) + expect(coding.oneShotRateA).toBeCloseTo(50) + expect(coding.editTurnsB).toBe(2) + expect(coding.oneShotRateB).toBeCloseTo(100) + expect(coding.winner).toBe('b') + + const debugging = result.find(r => r.category === 'debugging')! + expect(debugging.oneShotRateA).toBeCloseTo(100) + expect(debugging.oneShotRateB).toBeCloseTo(0) + expect(debugging.winner).toBe('a') + }) + + it('skips categories with no edit turns', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { hasEdits: false, category: 'conversation' }), + makeTurn('model-b', 0.10, { hasEdits: false, category: 'conversation' }), + makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }), + ]) + const result = computeCategoryComparison([project], 'model-a', 'model-b') + expect(result.find(r => r.category === 'conversation')).toBeUndefined() + expect(result).toHaveLength(1) + }) + + it('sorts by total turns descending', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }), + makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }), + makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }), + makeTurn('model-b', 0.10, { hasEdits: true, category: 'coding' }), + makeTurn('model-a', 0.10, { hasEdits: true, category: 'debugging' }), + ]) + const result = computeCategoryComparison([project], 'model-a', 'model-b') + expect(result[0].category).toBe('coding') + }) + + it('returns null one-shot rate when model has no edits in category', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }), + makeTurn('model-b', 0.10, { hasEdits: false, category: 'coding' }), + ]) + const result = computeCategoryComparison([project], 'model-a', 'model-b') + const coding = result.find(r => r.category === 'coding')! + expect(coding.oneShotRateA).toBeCloseTo(100) + expect(coding.oneShotRateB).toBeNull() + expect(coding.winner).toBe('none') + }) +}) + +describe('computeWorkingStyle', () => { + it('computes delegation and planning rates', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { hasAgentSpawn: true }), + makeTurn('model-a', 0.10, {}), + makeTurn('model-a', 0.10, { hasPlanMode: true }), + makeTurn('model-b', 0.10, {}), + makeTurn('model-b', 0.10, {}), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + + const delegation = result.find(r => r.label === 'Delegation rate')! + expect(delegation.valueA).toBeCloseTo(100 / 3) + expect(delegation.valueB).toBeCloseTo(0) + + const planning = result.find(r => r.label === 'Planning rate')! + expect(planning.valueA).toBeCloseTo(100 / 3) + expect(planning.valueB).toBeCloseTo(0) + }) + + it('computes avg tools per turn', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { hasEdits: true }), + makeTurn('model-a', 0.10, {}), + makeTurn('model-b', 0.10, { hasEdits: true }), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const tools = result.find(r => r.label === 'Avg tools / turn')! + expect(tools.valueA).toBeCloseTo(1) + expect(tools.valueB).toBeCloseTo(1) + }) + + it('computes fast mode usage', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { speed: 'fast' }), + makeTurn('model-a', 0.10, {}), + makeTurn('model-b', 0.10, { speed: 'fast' }), + makeTurn('model-b', 0.10, { speed: 'fast' }), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const fast = result.find(r => r.label === 'Fast mode usage')! + expect(fast.valueA).toBeCloseTo(50) + expect(fast.valueB).toBeCloseTo(100) + }) + + it('returns null for models with no turns', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, {}), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const delegation = result.find(r => r.label === 'Delegation rate')! + expect(delegation.valueA).toBeCloseTo(0) + expect(delegation.valueB).toBeNull() + }) + + it('counts TaskCreate as planning', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { tools: ['TaskCreate'] }), + makeTurn('model-a', 0.10, { tools: ['Read'] }), + makeTurn('model-a', 0.10, { tools: ['Edit'] }), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const planning = result.find(r => r.label === 'Planning rate')! + expect(planning.valueA).toBeCloseTo(100 / 3) + }) + + it('counts TaskUpdate as planning', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { tools: ['TaskUpdate'] }), + makeTurn('model-a', 0.10, { tools: ['Read'] }), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const planning = result.find(r => r.label === 'Planning rate')! + expect(planning.valueA).toBeCloseTo(50) + }) + + it('counts TodoWrite as planning', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { tools: ['TodoWrite', 'Read'] }), + makeTurn('model-a', 0.10, { tools: ['Bash'] }), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const planning = result.find(r => r.label === 'Planning rate')! + expect(planning.valueA).toBeCloseTo(50) + }) + + it('counts turn with planning tool + edits as planning', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { tools: ['TaskCreate', 'Edit', 'Read'] }), + makeTurn('model-a', 0.10, { tools: ['Edit'] }), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const planning = result.find(r => r.label === 'Planning rate')! + expect(planning.valueA).toBeCloseTo(50) + }) + + it('does not count regular tools as planning', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { tools: ['Read', 'Grep', 'Glob'] }), + makeTurn('model-a', 0.10, { tools: ['Edit', 'Bash'] }), + makeTurn('model-a', 0.10, { tools: ['Agent'] }), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const planning = result.find(r => r.label === 'Planning rate')! + expect(planning.valueA).toBeCloseTo(0) + }) + + it('counts planning once per turn even with multiple planning tools', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { tools: ['TaskCreate', 'TaskUpdate', 'TaskCreate'] }), + makeTurn('model-a', 0.10, { tools: ['Read'] }), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const planning = result.find(r => r.label === 'Planning rate')! + expect(planning.valueA).toBeCloseTo(50) + }) + + it('hasPlanMode still triggers planning rate', () => { + const project = makeProject([ + makeTurn('model-a', 0.10, { hasPlanMode: true, tools: ['Read'] }), + makeTurn('model-a', 0.10, { tools: ['Read'] }), + ]) + const result = computeWorkingStyle([project], 'model-a', 'model-b') + const planning = result.find(r => r.label === 'Planning rate')! + expect(planning.valueA).toBeCloseTo(50) + }) +}) diff --git a/tests/content-utils.test.ts b/tests/content-utils.test.ts new file mode 100644 index 0000000..34a00ee --- /dev/null +++ b/tests/content-utils.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { normalizeContentBlocks } from '../src/content-utils.js' + +describe('normalizeContentBlocks', () => { + it('passes an array of blocks through unchanged', () => { + const blocks = [{ type: 'text', text: 'hi' }, { type: 'tool_use', text: '' }] + expect(normalizeContentBlocks(blocks)).toBe(blocks) + }) + + it('wraps a string as a single text block (the issue #441 case)', () => { + expect(normalizeContentBlocks('hello world')).toEqual([{ type: 'text', text: 'hello world' }]) + }) + + it('returns an empty array for null / undefined', () => { + expect(normalizeContentBlocks(null)).toEqual([]) + expect(normalizeContentBlocks(undefined)).toEqual([]) + }) + + it('returns an empty array for other non-array values', () => { + // Defensive against corrupt records: a number/object content must not throw downstream. + expect(normalizeContentBlocks(42 as unknown as string)).toEqual([]) + expect(normalizeContentBlocks({ type: 'text' } as unknown as string)).toEqual([]) + }) + + it('drops null/undefined elements inside an array (avoids the same crash one level down)', () => { + const dirty = [{ type: 'text', text: 'ok' }, null, undefined, { type: 'tool_use' }] as unknown as Array<{ type?: string }> + const out = normalizeContentBlocks(dirty) + expect(out).toEqual([{ type: 'text', text: 'ok' }, { type: 'tool_use' }]) + expect(() => out.filter(b => b.type === 'text')).not.toThrow() + }) + + it('returns the same reference for a clean array (no copy)', () => { + const clean = [{ type: 'text', text: 'a' }, { type: 'tool_use' }] + expect(normalizeContentBlocks(clean)).toBe(clean) + }) + + it('the result is always safe to .filter/.some over', () => { + const inputs = ['a string', null, undefined, [{ type: 'text' }], [{ type: 'text' }, null]] as const + for (const input of inputs) { + expect(() => normalizeContentBlocks(input as never).filter(b => b.type === 'text')).not.toThrow() + } + }) +}) diff --git a/tests/currency-rounding.test.ts b/tests/currency-rounding.test.ts new file mode 100644 index 0000000..385e069 --- /dev/null +++ b/tests/currency-rounding.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { convertCost, roundForActiveCurrency, getFractionDigits } from '../src/currency.js' +import { CurrencyState } from '../src/currency.js' +import * as currencyMod from '../src/currency.js' + +// We poke the module-level state directly via switchCurrency for these tests. +// Each test restores USD afterwards so it doesn't bleed. +async function setActive(code: string, rate: number): Promise<void> { + // switchCurrency does network + persistence; for unit tests we set the + // active state directly via the module's internal state. Since the module + // doesn't expose a setter, we go through getCurrency()'s state and patch. + // Instead use the public switchCurrency only when offline: nope, just + // exploit the fact that the module exports `getCurrency` which returns a + // ref. We can't easily mock fetch. So we test only convertCost (which uses + // active.rate) and rounding helpers — both pure functions of the state. + const state = currencyMod.getCurrency() + // @ts-expect-error — directly mutating for test + state.code = code + // @ts-expect-error + state.rate = rate + // @ts-expect-error + state.symbol = code +} + +beforeEach(async () => { + await setActive('USD', 1) +}) + +afterEach(async () => { + await setActive('USD', 1) +}) + +describe('convertCost — no rounding contract', () => { + it('returns unrounded float for USD (rate=1)', () => { + expect(convertCost(1.234567)).toBe(1.234567) + expect(convertCost(0.001)).toBe(0.001) + }) + + it('returns unrounded float for non-USD currencies', async () => { + await setActive('JPY', 150) + // 1 USD * 150 = 150, but a fractional input must NOT be rounded by convertCost. + expect(convertCost(0.123456)).toBeCloseTo(18.5184, 4) + expect(convertCost(1.5)).toBe(225) + }) + + it('rounding is the caller\'s responsibility (display vs export)', async () => { + // Regression guard: previously convertCost did its own rounding which + // produced ¥412.37 in CSV exports while the dashboard rendered ¥412. + // Confirm we now return the raw value and the caller decides. + await setActive('JPY', 150) + const raw = convertCost(2.7491) + expect(raw).toBe(412.365) // unrounded + expect(roundForActiveCurrency(raw)).toBe(412) // currency-aware rounding for export + }) +}) + +describe('roundForActiveCurrency', () => { + it('USD rounds to 2 decimals', async () => { + await setActive('USD', 1) + expect(roundForActiveCurrency(1.2345)).toBe(1.23) + expect(roundForActiveCurrency(1.235)).toBeCloseTo(1.24, 2) + expect(roundForActiveCurrency(0.005)).toBe(0.01) + }) + + it('JPY rounds to whole numbers', async () => { + await setActive('JPY', 150) + expect(roundForActiveCurrency(412.37)).toBe(412) + expect(roundForActiveCurrency(412.5)).toBe(413) + expect(roundForActiveCurrency(0.4)).toBe(0) + }) + + it('KRW rounds to whole numbers', async () => { + await setActive('KRW', 1300) + expect(roundForActiveCurrency(15999.7)).toBe(16000) + }) + + it('EUR rounds to 2 decimals like USD', async () => { + await setActive('EUR', 0.92) + expect(roundForActiveCurrency(1.2345)).toBe(1.23) + }) + + it('matches the display contract: roundForActiveCurrency(convertCost(x)) is what users see', async () => { + await setActive('JPY', 150) + // Dashboard displays via formatCost which uses getFractionDigits=0 for JPY. + // CSV exports must produce the same integer value, not a 2-decimal float. + expect(roundForActiveCurrency(convertCost(2.75))).toBe(413) + expect(roundForActiveCurrency(convertCost(2.745))).toBe(412) + }) +}) + +describe('getFractionDigits', () => { + it('returns 0 for zero-fraction currencies', () => { + expect(getFractionDigits('JPY')).toBe(0) + expect(getFractionDigits('KRW')).toBe(0) + expect(getFractionDigits('CLP')).toBe(0) + }) + + it('returns 2 for typical currencies', () => { + expect(getFractionDigits('USD')).toBe(2) + expect(getFractionDigits('EUR')).toBe(2) + expect(getFractionDigits('GBP')).toBe(2) + expect(getFractionDigits('INR')).toBe(2) + expect(getFractionDigits('CNY')).toBe(2) + expect(getFractionDigits('RON')).toBe(2) + }) +}) diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts new file mode 100644 index 0000000..9892319 --- /dev/null +++ b/tests/daily-cache.test.ts @@ -0,0 +1,378 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFile, rm } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import type { ProjectSummary } from '../src/types.js' + +import { + addNewDays, + DAILY_CACHE_VERSION, + type DailyCache, + type DailyEntry, + getDaysInRange, + ensureCacheHydrated, + loadDailyCache, + saveDailyCache, + withDailyCacheLock, +} from '../src/daily-cache.js' + +function emptyDay(date: string, cost = 0, calls = 0): DailyEntry { + return { + date, + cost, + savingsUSD: 0, + calls, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: {}, + categories: {}, + providers: {}, + } +} + +const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-cache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) + +beforeEach(() => { + process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT +}) + +afterEach(async () => { + vi.useRealTimers() + if (existsSync(TMP_CACHE_ROOT)) { + await rm(TMP_CACHE_ROOT, { recursive: true, force: true }) + } +}) + +describe('loadDailyCache', () => { + it('returns an empty cache when the file does not exist', async () => { + const cache = await loadDailyCache() + expect(cache.version).toBe(DAILY_CACHE_VERSION) + expect(cache.lastComputedDate).toBeNull() + expect(cache.days).toEqual([]) + }) + + it('returns an empty cache when the file contains invalid JSON', async () => { + const { writeFile, mkdir } = await import('fs/promises') + await mkdir(TMP_CACHE_ROOT, { recursive: true }) + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'not valid json{{', 'utf-8') + const cache = await loadDailyCache() + expect(cache.days).toEqual([]) + }) + + it('returns an empty cache and backs up when version is too old to migrate', async () => { + const saved = { + version: 1, + lastComputedDate: '2026-04-10', + days: [{ date: '2026-04-10', cost: 10, calls: 5 }], + } + const { writeFile, mkdir } = await import('fs/promises') + await mkdir(TMP_CACHE_ROOT, { recursive: true }) + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') + const cache = await loadDailyCache() + expect(cache.days).toEqual([]) + expect(cache.lastComputedDate).toBeNull() + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(true) + }) + + it('discards a v2 cache and starts fresh (provider rollups would be stale)', async () => { + // MIN_SUPPORTED_VERSION was raised to DAILY_CACHE_VERSION because the + // migration path cannot recompute the providers / categories / models + // rollups from session data (the cache does not retain raw sessions), + // so a migrated old cache would carry forward stale provider totals + // for the full retention window. Older caches now get discarded and + // recomputed from scratch on next run. + const saved = { + version: 2, + lastComputedDate: '2026-04-10', + days: [{ + date: '2026-04-10', cost: 10, calls: 5, sessions: 2, + inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100, + models: { 'claude-opus-4-6': { calls: 5, cost: 10, inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100 } }, + }], + } + const { writeFile, mkdir } = await import('fs/promises') + await mkdir(TMP_CACHE_ROOT, { recursive: true }) + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') + const cache = await loadDailyCache() + expect(cache.version).toBe(DAILY_CACHE_VERSION) + expect(cache.days).toEqual([]) + expect(cache.lastComputedDate).toBeNull() + // Old cache is renamed to .v2.bak rather than deleted. + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(true) + }) + + it('discards a v5 cache because cached Claude costs predate 1-hour cache pricing', async () => { + const saved = { + version: 5, + lastComputedDate: '2026-05-01', + days: [{ + date: '2026-05-01', + cost: 0.37575, + calls: 1, + sessions: 1, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 60_120, + editTurns: 0, + oneShotTurns: 0, + models: { 'Opus 4.7': { calls: 1, cost: 0.37575, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 60_120 } }, + categories: {}, + providers: { claude: { calls: 1, cost: 0.37575 } }, + }], + } + const { writeFile, mkdir } = await import('fs/promises') + await mkdir(TMP_CACHE_ROOT, { recursive: true }) + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') + const cache = await loadDailyCache() + expect(cache.version).toBe(DAILY_CACHE_VERSION) + expect(cache.days).toEqual([]) + expect(cache.lastComputedDate).toBeNull() + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(true) + }) + + it('round-trips a valid cache through save and load', async () => { + const saved: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-hash-1', + lastComputedDate: '2026-04-10', + days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)], + } + await saveDailyCache(saved) + const loaded = await loadDailyCache() + expect(loaded).toEqual(saved) + }) +}) + +describe('saveDailyCache', () => { + it('writes atomically so no temp file is left after a successful save', async () => { + const saved: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-hash-1', + lastComputedDate: '2026-04-10', + days: [emptyDay('2026-04-10', 5)], + } + await saveDailyCache(saved) + const { readdir } = await import('fs/promises') + const files = await readdir(TMP_CACHE_ROOT) + const tempLeftovers = files.filter(f => f.endsWith('.tmp')) + expect(tempLeftovers).toEqual([]) + const finalFile = await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'utf-8') + expect(JSON.parse(finalFile)).toEqual(saved) + }) +}) + +describe('addNewDays', () => { + it('returns a new cache with the added days sorted ascending by date', () => { + const base: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + lastComputedDate: '2026-04-08', + days: [emptyDay('2026-04-07', 3), emptyDay('2026-04-08', 5)], + } + const updated = addNewDays(base, [emptyDay('2026-04-10', 9), emptyDay('2026-04-09', 7)], '2026-04-10') + expect(updated.days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09', '2026-04-10']) + expect(updated.lastComputedDate).toBe('2026-04-10') + }) + + it('replaces existing days with incoming data (last write wins)', () => { + const base: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + lastComputedDate: '2026-04-08', + days: [emptyDay('2026-04-08', 5)], + } + const updated = addNewDays(base, [emptyDay('2026-04-08', 99)], '2026-04-08') + const aprilEight = updated.days.find(d => d.date === '2026-04-08')! + expect(aprilEight.cost).toBe(99) + }) + + it('does not regress lastComputedDate if incoming newestDate is older', () => { + const base: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + lastComputedDate: '2026-04-10', + days: [emptyDay('2026-04-10', 5)], + } + const updated = addNewDays(base, [emptyDay('2026-04-05', 3)], '2026-04-05') + expect(updated.lastComputedDate).toBe('2026-04-10') + }) + + it('skips prune when newestDate is malformed (does not silently drop all days)', () => { + // Regression guard: a corrupt newestDate string used to produce a NaN + // cutoff, which made `d.date >= "Invalid Date"` always false and + // wiped every cached day on the next merge. The guard now leaves + // the entries untouched so the next valid run can prune normally. + const base: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + lastComputedDate: '2026-04-10', + days: [emptyDay('2026-04-08', 1), emptyDay('2026-04-09', 2), emptyDay('2026-04-10', 3)], + } + const updated = addNewDays(base, [], 'not-a-date') + expect(updated.days.map(d => d.date)).toEqual(['2026-04-08', '2026-04-09', '2026-04-10']) + }) + + it('still prunes when newestDate is valid', () => { + const old = '2020-01-01' + const recent = '2026-04-10' + const base: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + lastComputedDate: recent, + days: [emptyDay(old, 1), emptyDay(recent, 2)], + } + const updated = addNewDays(base, [], recent) + // 730-day retention from 2026-04-10 → cutoff ~2024-04-11; 2020-01-01 must be gone. + expect(updated.days.find(d => d.date === old)).toBeUndefined() + expect(updated.days.find(d => d.date === recent)).toBeDefined() + }) +}) + +describe('getDaysInRange', () => { + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + lastComputedDate: '2026-04-10', + days: [ + emptyDay('2026-04-05', 1), + emptyDay('2026-04-06', 2), + emptyDay('2026-04-07', 3), + emptyDay('2026-04-08', 4), + emptyDay('2026-04-09', 5), + emptyDay('2026-04-10', 6), + ], + } + + it('returns inclusive start and end range', () => { + const days = getDaysInRange(cache, '2026-04-07', '2026-04-09') + expect(days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09']) + }) + + it('returns empty when range is entirely outside cache', () => { + expect(getDaysInRange(cache, '2026-03-01', '2026-03-10')).toEqual([]) + expect(getDaysInRange(cache, '2026-05-01', '2026-05-10')).toEqual([]) + }) + + it('clips to available cache days when range extends beyond', () => { + const days = getDaysInRange(cache, '2026-04-09', '2026-04-20') + expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10']) + }) +}) + +describe('ensureCacheHydrated', () => { + it('does not recompute yesterday after it has already been cached', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z')) + + const saved: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + lastComputedDate: '2026-06-11', + days: [emptyDay('2026-06-11', 5, 10)], + } + await saveDailyCache(saved) + + let parseCalls = 0 + const hydrated = await ensureCacheHydrated( + async () => { + parseCalls += 1 + return [] + }, + () => [], + ) + + expect(parseCalls).toBe(0) + expect(hydrated).toEqual(saved) + }) + + it('drops a cached today/future entry so it is recomputed live, keeping yesterday cached', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z')) + + // A "today" entry can only exist via a backward clock change or a stale + // cache; it must be purged so today is served live, not from a frozen entry. + const saved: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + lastComputedDate: '2026-06-12', + days: [emptyDay('2026-06-11', 5, 10), emptyDay('2026-06-12', 9, 20)], + } + await saveDailyCache(saved) + + let parseCalls = 0 + const hydrated = await ensureCacheHydrated( + async () => { + parseCalls += 1 + return [] + }, + () => [], + ) + + expect(parseCalls).toBe(0) + expect(hydrated.days.map(d => d.date)).toEqual(['2026-06-11']) + expect(hydrated.lastComputedDate).toBe('2026-06-11') + }) +}) + +describe('withDailyCacheLock', () => { + it('serializes concurrent operations', async () => { + const sequence: string[] = [] + const op = async (tag: string): Promise<void> => { + await withDailyCacheLock(async () => { + sequence.push(`start-${tag}`) + await new Promise(r => setTimeout(r, 20)) + sequence.push(`end-${tag}`) + }) + } + await Promise.all([op('a'), op('b'), op('c')]) + for (let i = 0; i < sequence.length; i += 2) { + expect(sequence[i]?.startsWith('start-')).toBe(true) + expect(sequence[i + 1]?.startsWith('end-')).toBe(true) + expect(sequence[i]!.slice(6)).toBe(sequence[i + 1]!.slice(4)) + } + }) +}) + +describe('ensureCacheHydrated: savings config invalidation', () => { + it('discards cached days when the savingsConfigHash changes between calls', async () => { + // Seed a cache with a day OLDER than yesterday so the hydration window + // (which keeps `d.date < yesterdayStr`) actually retains it. + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) + const twoDaysAgoStr = `${twoDaysAgo.getFullYear()}-${String(twoDaysAgo.getMonth() + 1).padStart(2, '0')}-${String(twoDaysAgo.getDate()).padStart(2, '0')}` + const seeded: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + lastComputedDate: twoDaysAgoStr, + days: [emptyDay(twoDaysAgoStr, 1.5, 3)], + } + await saveDailyCache(seeded) + + const parseSessions = async (): Promise<ProjectSummary[]> => [] + const aggregateDays = (): DailyEntry[] => [] + + // Hash mismatch → ensureCacheHydrated must drop the stale day and start fresh. + const rehydrated = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-B') + expect(rehydrated.savingsConfigHash).toBe('cfg-B') + expect(rehydrated.days).toEqual([]) + + // Same hash → cached days survive. + const seeded2: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-C', + lastComputedDate: twoDaysAgoStr, + days: [emptyDay(twoDaysAgoStr, 1.5, 3)], + } + await saveDailyCache(seeded2) + const preserved = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-C') + expect(preserved.days).toHaveLength(1) + expect(preserved.days[0]!.date).toBe(twoDaysAgoStr) + }) +}) diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts new file mode 100644 index 0000000..da802f1 --- /dev/null +++ b/tests/dashboard.test.ts @@ -0,0 +1,146 @@ +import { homedir } from 'os' + +import { describe, it, expect } from 'vitest' + +import { shortProject } from '../src/dashboard.js' +import { formatCost } from '../src/format.js' +import type { ProjectSummary, SessionSummary } from '../src/types.js' + +const EMPTY_CATEGORY_BREAKDOWN = { + coding: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + 'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, +} satisfies SessionSummary['categoryBreakdown'] + +function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z'): SessionSummary { + return { + sessionId: id, + project: 'test-project', + firstTimestamp: timestamp, + lastTimestamp: timestamp, + totalCostUSD: cost, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN }, + skillBreakdown: {}, + } +} + +function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary { + return { + project: name, + projectPath: name, + sessions, + totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0), + totalApiCalls: sessions.reduce((s, x) => s + x.apiCalls, 0), + } +} + +// Logic replicated from TopSessions component +function getTopSessions(projects: ProjectSummary[], n = 5) { + const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectPath: p.projectPath }))) + return [...all].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, n) +} + +// Logic replicated from ProjectBreakdown component +function avgCostLabel(project: ProjectSummary): string { + return project.sessions.length > 0 + ? formatCost(project.totalCostUSD / project.sessions.length) + : '-' +} + +describe('TopSessions - top-5 selection', () => { + it('returns all sessions when fewer than 5 exist', () => { + const project = makeProject('proj', [ + makeSession('s1', 1.0), + makeSession('s2', 2.0), + ]) + const top = getTopSessions([project]) + expect(top).toHaveLength(2) + expect(top[0].totalCostUSD).toBe(2.0) + expect(top[1].totalCostUSD).toBe(1.0) + }) + + it('returns exactly 5 when more than 5 sessions exist', () => { + const sessions = [0.1, 0.5, 3.0, 1.0, 0.8, 2.0].map((cost, i) => + makeSession(`s${i}`, cost) + ) + const project = makeProject('proj', sessions) + const top = getTopSessions([project]) + expect(top).toHaveLength(5) + expect(top[0].totalCostUSD).toBe(3.0) + expect(top[4].totalCostUSD).toBe(0.5) + }) + + it('is stable on tied costs - preserves input order for equal values', () => { + const sessions = [ + makeSession('s1', 1.0), + makeSession('s2', 1.0), + makeSession('s3', 1.0), + ] + const project = makeProject('proj', sessions) + const top = getTopSessions([project]) + expect(top.map(s => s.sessionId)).toEqual(['s1', 's2', 's3']) + }) +}) + +describe('shortProject - path shortening', () => { + const home = homedir() + + it('preserves directory names containing dashes', () => { + expect(shortProject(`${home}/work/my-project`)).toBe('work/my-project') + }) + + it('preserves directory names containing dots', () => { + expect(shortProject(`${home}/work/my.app.io`)).toBe('work/my.app.io') + }) + + it('returns "home" for the home dir itself', () => { + expect(shortProject(home)).toBe('home') + }) + + it('does not strip a sibling whose name shares the home prefix', () => { + const sibling = `${home}-backup/proj` + expect(shortProject(sibling).endsWith('proj')).toBe(true) + expect(shortProject(sibling)).not.toMatch(/^-/) + }) + + it('keeps only the last 3 segments for deeply nested paths', () => { + expect(shortProject(`${home}/a/b/c/d/e/f`)).toBe('d/e/f') + }) + + it('handles paths outside the home dir', () => { + expect(shortProject('/opt/myproject')).toBe('opt/myproject') + }) +}) + +describe('avg/s in ProjectBreakdown', () => { + it('returns dash for a project with no sessions', () => { + const project = makeProject('proj', []) + expect(avgCostLabel(project)).toBe('-') + }) + + it('returns formatted average cost across sessions', () => { + const sessions = [makeSession('s1', 2.0), makeSession('s2', 4.0)] + const project = makeProject('proj', sessions) + expect(avgCostLabel(project)).toBe(formatCost(3.0)) + }) +}) diff --git a/tests/date-range-filter.test.ts b/tests/date-range-filter.test.ts new file mode 100644 index 0000000..e21b906 --- /dev/null +++ b/tests/date-range-filter.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, it, expect, vi } from 'vitest' +import { formatDateRangeLabel, formatDayRangeLabel, parseDateRangeFlags, parseDayFlag, shiftDay } from '../src/cli-date.js' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('parseDateRangeFlags', () => { + it('returns null when neither flag is provided', () => { + expect(parseDateRangeFlags(undefined, undefined)).toBeNull() + }) + + it('parses a symmetric range in local time', () => { + const range = parseDateRangeFlags('2026-04-07', '2026-04-10') + expect(range).not.toBeNull() + expect(range!.start.getFullYear()).toBe(2026) + expect(range!.start.getMonth()).toBe(3) + expect(range!.start.getDate()).toBe(7) + expect(range!.start.getHours()).toBe(0) + expect(range!.end.getDate()).toBe(10) + expect(range!.end.getHours()).toBe(23) + expect(range!.end.getMinutes()).toBe(59) + expect(range!.end.getSeconds()).toBe(59) + }) + + it('accepts --from alone (open-ended to today 23:59:59)', () => { + const range = parseDateRangeFlags('2026-04-01', undefined) + expect(range).not.toBeNull() + expect(range!.start.getDate()).toBe(1) + expect(range!.end.getHours()).toBe(23) + }) + + it('accepts --to alone with a 6-month default start', () => { + // Previously the missing --from defaulted to epoch (1970), opening a + // 55-year scan window that was almost never what the user meant. The + // default is now 6 months back from now, matching the dashboard's + // "6 Months" period boundary. + const range = parseDateRangeFlags(undefined, '2026-04-10') + expect(range).not.toBeNull() + expect(range!.start.getTime()).toBeGreaterThan(new Date(0).getTime()) + const sixMonthsMs = 6 * 31 * 24 * 60 * 60 * 1000 + const ageMs = Date.now() - range!.start.getTime() + expect(ageMs).toBeLessThanOrEqual(sixMonthsMs + 1000) + expect(ageMs).toBeGreaterThanOrEqual(sixMonthsMs - 1000) + expect(range!.end.getDate()).toBe(10) + }) + + it('throws when --from > --to', () => { + expect(() => parseDateRangeFlags('2026-04-10', '2026-04-07')) + .toThrow('--from must not be after --to') + }) + + it('throws on a non-ISO string', () => { + expect(() => parseDateRangeFlags('April 7', undefined)) + .toThrow('Invalid date format') + }) + + it('throws on wrong digit count', () => { + expect(() => parseDateRangeFlags('26-4-7', undefined)) + .toThrow('Invalid date format') + }) + + it('rejects month/day overflow instead of silently rolling forward', () => { + // Without overflow validation, JS Date silently turns Feb 31 into Mar 3 + // and 13/32 into 02/01 of the following year. That made `--from + // 2026-02-31 --to 2026-03-15` quietly drop sessions on Feb 28 - Mar 2. + expect(() => parseDateRangeFlags('2026-02-31', '2026-03-15')) + .toThrow('Invalid date "2026-02-31"') + expect(() => parseDateRangeFlags('2026-13-01', undefined)) + .toThrow('Invalid date "2026-13-01"') + expect(() => parseDateRangeFlags('2026-04-31', undefined)) + .toThrow('Invalid date "2026-04-31"') + expect(() => parseDateRangeFlags(undefined, '2026-02-30')) + .toThrow('Invalid date "2026-02-30"') + // Leap-day check: 2024 is a leap year, 2025 is not. + expect(parseDateRangeFlags('2024-02-29', '2024-03-01')).not.toBeNull() + expect(() => parseDateRangeFlags('2025-02-29', undefined)) + .toThrow('Invalid date "2025-02-29"') + }) + + it('same day is valid (start midnight, end 23:59:59)', () => { + const range = parseDateRangeFlags('2026-04-10', '2026-04-10') + expect(range).not.toBeNull() + expect(range!.start.getDate()).toBe(10) + expect(range!.end.getDate()).toBe(10) + }) + + it('formats custom range labels consistently', () => { + expect(formatDateRangeLabel('2026-04-07', '2026-04-10')).toBe('2026-04-07 to 2026-04-10') + expect(formatDateRangeLabel(undefined, '2026-04-10')).toBe('all to 2026-04-10') + expect(formatDateRangeLabel('2026-04-07', undefined)).toBe('2026-04-07 to today') + }) +}) + +describe('parseDayFlag', () => { + it('returns null when no day is provided', () => { + expect(parseDayFlag(undefined)).toBeNull() + }) + + it('parses an explicit day as local midnight through end of day', () => { + const selected = parseDayFlag('2026-04-10') + expect(selected).not.toBeNull() + expect(selected!.day).toBe('2026-04-10') + expect(selected!.label).toBe('Day (2026-04-10)') + expect(selected!.range.start.getFullYear()).toBe(2026) + expect(selected!.range.start.getMonth()).toBe(3) + expect(selected!.range.start.getDate()).toBe(10) + expect(selected!.range.start.getHours()).toBe(0) + expect(selected!.range.end.getDate()).toBe(10) + expect(selected!.range.end.getHours()).toBe(23) + expect(selected!.range.end.getMinutes()).toBe(59) + expect(selected!.range.end.getSeconds()).toBe(59) + }) + + it('resolves yesterday as the previous local calendar day after midnight', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 23, 0, 5, 0)) + + const selected = parseDayFlag('yesterday') + + expect(selected!.day).toBe('2026-05-22') + expect(selected!.range.start.getDate()).toBe(22) + expect(selected!.range.end.getDate()).toBe(22) + }) + + it('supports today and day shifting', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 23, 12, 0, 0)) + + expect(parseDayFlag('today')!.day).toBe('2026-05-23') + expect(formatDayRangeLabel('2026-05-22')).toBe('Day (2026-05-22)') + expect(shiftDay('2026-05-22', -1)).toBe('2026-05-21') + expect(shiftDay('2026-05-22', 1)).toBe('2026-05-23') + }) + + it('rejects malformed or overflowing day values', () => { + expect(() => parseDayFlag('May 22')).toThrow('Invalid date format') + expect(() => parseDayFlag('2026-02-31')).toThrow('Invalid date "2026-02-31"') + expect(() => shiftDay('2026-13-01', 1)).toThrow('Invalid date "2026-13-01"') + }) +}) diff --git a/tests/day-aggregator-savings.test.ts b/tests/day-aggregator-savings.test.ts new file mode 100644 index 0000000..a7a5f10 --- /dev/null +++ b/tests/day-aggregator-savings.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' + +import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js' +import type { ParsedApiCall, ProjectSummary, SessionSummary, Turn } from '../src/types.js' + +function makeCall(timestamp: string, opts: { costUSD: number; savingsUSD?: number; savingsBaselineModel?: string; model?: string }): ParsedApiCall { + return { + provider: 'claude', + model: opts.model ?? 'local-model', + usage: { + inputTokens: 100, + outputTokens: 200, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 50, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: opts.costUSD, + savingsUSD: opts.savingsUSD, + savingsBaselineModel: opts.savingsBaselineModel, + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp, + bashCommands: [], + deduplicationKey: `dk-${timestamp}-${opts.costUSD}-${opts.savingsUSD ?? 0}`, + } +} + +function makeTurn(timestamp: string, calls: ParsedApiCall[], category: string = 'coding'): Turn { + return { + userMessage: 'u', + timestamp, + sessionId: 's', + category: category as Turn['category'], + retries: 0, + hasEdits: false, + assistantCalls: calls, + } as Turn +} + +function makeSession(sessions: SessionSummary[]): ProjectSummary { + const totalCostUSD = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0) + const totalSavingsUSD = sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0) + const totalApiCalls = sessions.reduce((s, sess) => s + sess.apiCalls, 0) + return { + project: 'p', + projectPath: '/p', + sessions, + totalCostUSD, + totalSavingsUSD, + totalApiCalls, + } +} + +describe('aggregateProjectsIntoDays: savings totals', () => { + it('rolls up day, model, category, and provider savings separately from cost', () => { + const turn = makeTurn('2026-04-10T10:00:00', [ + makeCall('2026-04-10T10:00:00', { costUSD: 0, savingsUSD: 5, savingsBaselineModel: 'gpt-4o' }), + ]) + const turn2 = makeTurn('2026-04-10T10:01:00', [ + makeCall('2026-04-10T10:01:00', { costUSD: 2, savingsUSD: 0, model: 'gpt-4o' }), + ]) + const project: ProjectSummary = { + project: 'p', + projectPath: '/p', + sessions: [{ + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-04-10T10:00:00', + lastTimestamp: '2026-04-10T10:01:00', + totalCostUSD: 2, + totalSavingsUSD: 5, + totalInputTokens: 200, + totalOutputTokens: 400, + totalCacheReadTokens: 100, + totalCacheWriteTokens: 0, + apiCalls: 2, + turns: [turn, turn2], + modelBreakdown: { 'Local Model': { calls: 1, costUSD: 0, savingsUSD: 5, tokens: { inputTokens: 100, outputTokens: 200, cacheCreationInputTokens: 0, cacheReadInputTokens: 50, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 } }, 'gpt-4o': { calls: 1, costUSD: 2, savingsUSD: 0, tokens: { inputTokens: 100, outputTokens: 200, cacheCreationInputTokens: 0, cacheReadInputTokens: 50, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 } } }, + toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: { coding: { turns: 1, costUSD: 2, savingsUSD: 5, retries: 0, editTurns: 0, oneShotTurns: 0 } }, + skillBreakdown: {}, subagentBreakdown: {}, + }], + totalCostUSD: 2, + totalSavingsUSD: 5, + totalApiCalls: 2, + } + const days = aggregateProjectsIntoDays([project]) + expect(days).toHaveLength(1) + const day = days[0]! + expect(day.cost).toBe(2) + expect(day.savingsUSD).toBe(5) + expect(day.models['local-model']).toMatchObject({ calls: 1, cost: 0, savingsUSD: 5 }) + expect(day.models['gpt-4o']).toMatchObject({ calls: 1, cost: 2, savingsUSD: 0 }) + expect(day.providers['claude']).toMatchObject({ calls: 2, cost: 2, savingsUSD: 5 }) + expect(day.categories.coding).toMatchObject({ turns: 2, cost: 2, savingsUSD: 5 }) + }) +}) + +describe('buildPeriodDataFromDays: savings totals', () => { + it('threads savings through to model and category rollups', () => { + const days = [ + { + date: '2026-04-09', + cost: 2, + savingsUSD: 5, + calls: 1, + sessions: 1, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: { 'local-model': { calls: 1, cost: 0, savingsUSD: 5, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + categories: { coding: { turns: 1, cost: 0, savingsUSD: 5, editTurns: 0, oneShotTurns: 0 } }, + providers: { claude: { calls: 1, cost: 0, savingsUSD: 5 } }, + }, + { + date: '2026-04-10', + cost: 3, + savingsUSD: 0, + calls: 1, + sessions: 1, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: { 'gpt-4o': { calls: 1, cost: 3, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + categories: { coding: { turns: 1, cost: 3, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } }, + providers: { claude: { calls: 1, cost: 3, savingsUSD: 0 } }, + }, + ] + const pd = buildPeriodDataFromDays(days, '7 Days') + expect(pd.savingsUSD).toBe(5) + const coding = pd.categories.find(c => c.name === 'Coding')! + expect(coding.savingsUSD).toBe(5) + const local = pd.models.find(m => m.name === 'local-model')! + expect(local.savingsUSD).toBe(5) + expect(local.cost).toBe(0) + }) +}) diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts new file mode 100644 index 0000000..d20eb06 --- /dev/null +++ b/tests/day-aggregator.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest' + +import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from '../src/day-aggregator.js' +import type { ProjectSummary } from '../src/types.js' + +function makeProject(overrides: Partial<ProjectSummary> & { sessions: ProjectSummary['sessions'] }): ProjectSummary { + return { + project: 'p', + projectPath: '/p', + totalCostUSD: overrides.sessions.reduce((s, sess) => s + sess.totalCostUSD, 0), + totalApiCalls: overrides.sessions.reduce((s, sess) => s + sess.apiCalls, 0), + ...overrides, + } +} + +function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provider = 'claude') { + return { + provider, + model, + usage: { + inputTokens: 100, + outputTokens: 200, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 50, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard' as const, + timestamp, + bashCommands: [], + deduplicationKey: `dk-${timestamp}-${costUSD}`, + } +} + +describe('aggregateProjectsIntoDays', () => { + it('buckets api calls by calendar date derived from timestamp', () => { + const projects: ProjectSummary[] = [ + makeProject({ + sessions: [{ + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-04-09T10:00:00', + lastTimestamp: '2026-04-10T08:00:00', + totalCostUSD: 10, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 2, + turns: [ + { + userMessage: 'hi', + timestamp: '2026-04-09T10:00:00', + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: true, + assistantCalls: [ + makeCall('2026-04-09T10:00:00', 4), + makeCall('2026-04-10T08:00:00', 6), + ], + }, + ], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + }), + ] + + const days = aggregateProjectsIntoDays(projects) + expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10']) + expect(days[0]!.cost).toBe(4) + expect(days[0]!.calls).toBe(1) + expect(days[1]!.cost).toBe(6) + expect(days[1]!.calls).toBe(1) + }) + + it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => { + const projects: ProjectSummary[] = [ + makeProject({ + sessions: [{ + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-04-09T10:00:00', + lastTimestamp: '2026-04-09T10:05:00', + totalCostUSD: 3, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [ + { + userMessage: 'hi', + timestamp: '2026-04-09T10:00:00', + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: true, + assistantCalls: [makeCall('2026-04-09T10:00:00', 3)], + }, + ], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + }), + ] + const days = aggregateProjectsIntoDays(projects) + const day = days[0]! + expect(day.editTurns).toBe(1) + expect(day.oneShotTurns).toBe(1) + expect(day.categories['coding']).toEqual({ + turns: 1, + cost: 3, + savingsUSD: 0, + editTurns: 1, + oneShotTurns: 1, + }) + }) + + it('counts a session under its firstTimestamp date', () => { + const projects: ProjectSummary[] = [ + makeProject({ + sessions: [{ + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-04-09T23:59:00', + lastTimestamp: '2026-04-10T00:10:00', + totalCostUSD: 1, + totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + apiCalls: 0, + turns: [], + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + }), + ] + const days = aggregateProjectsIntoDays(projects) + const expectedDate = dateKey('2026-04-09T23:59:00') + expect(days[0]!.date).toBe(expectedDate) + expect(days[0]!.sessions).toBe(1) + }) + + it('aggregates per-model and per-provider totals inside each day', () => { + const projects: ProjectSummary[] = [ + makeProject({ + sessions: [{ + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-04-10T10:00:00', + lastTimestamp: '2026-04-10T10:00:00', + totalCostUSD: 10, + totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + apiCalls: 2, + turns: [ + { + userMessage: 'x', timestamp: '2026-04-10T10:00:00', sessionId: 's1', + category: 'coding', retries: 0, hasEdits: false, + assistantCalls: [ + makeCall('2026-04-10T10:00:00', 7, 'Opus 4.7', 'claude'), + makeCall('2026-04-10T10:00:00', 3, 'gpt-5', 'codex'), + ], + }, + ], + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + }), + ] + const days = aggregateProjectsIntoDays(projects) + const day = days[0]! + expect(day.models['Opus 4.7']).toEqual({ + calls: 1, cost: 7, savingsUSD: 0, + inputTokens: 100, outputTokens: 200, + cacheReadTokens: 50, cacheWriteTokens: 0, + }) + expect(day.models['gpt-5']).toEqual({ + calls: 1, cost: 3, savingsUSD: 0, + inputTokens: 100, outputTokens: 200, + cacheReadTokens: 50, cacheWriteTokens: 0, + }) + expect(day.providers['claude']).toEqual({ calls: 1, cost: 7, savingsUSD: 0 }) + expect(day.providers['codex']).toEqual({ calls: 1, cost: 3, savingsUSD: 0 }) + }) +}) + +describe('buildPeriodDataFromDays', () => { + function makeDay(date: string, cost: number) { + return { + date, + cost, + calls: 10, + sessions: 2, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 300, + cacheWriteTokens: 0, + editTurns: 3, + oneShotTurns: 2, + models: { + 'Opus 4.7': { calls: 8, cost: cost * 0.8, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + 'Haiku 4.5': { calls: 2, cost: cost * 0.2, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + categories: { 'coding': { turns: 2, cost: cost * 0.5, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 } }, + providers: { 'claude': { calls: 10, cost, savingsUSD: 0 } }, + } + } + + it('sums cost, calls, sessions, tokens across days', () => { + const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)] + const pd = buildPeriodDataFromDays(days, '7 Days') + expect(pd.label).toBe('7 Days') + expect(pd.cost).toBe(30) + expect(pd.calls).toBe(20) + expect(pd.sessions).toBe(4) + expect(pd.inputTokens).toBe(200) + expect(pd.outputTokens).toBe(400) + expect(pd.cacheReadTokens).toBe(600) + }) + + it('merges per-model totals across days and sorts by cost desc', () => { + const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)] + const pd = buildPeriodDataFromDays(days, 'Today') + expect(pd.models[0]!.name).toBe('Opus 4.7') + expect(pd.models[0]!.cost).toBeCloseTo(24) + expect(pd.models[1]!.name).toBe('Haiku 4.5') + expect(pd.models[1]!.cost).toBeCloseTo(6) + }) + + it('merges per-category totals and keeps editTurns + oneShotTurns per category', () => { + const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)] + const pd = buildPeriodDataFromDays(days, 'Today') + const coding = pd.categories.find(c => c.name === 'Coding')! + expect(coding.turns).toBe(4) + expect(coding.editTurns).toBe(4) + expect(coding.oneShotTurns).toBe(2) + expect(coding.cost).toBeCloseTo(15) + }) + + it('returns empty period totals when no days supplied', () => { + const pd = buildPeriodDataFromDays([], 'Today') + expect(pd.cost).toBe(0) + expect(pd.calls).toBe(0) + expect(pd.sessions).toBe(0) + expect(pd.categories).toEqual([]) + expect(pd.models).toEqual([]) + }) + + it('attributes a midnight-straddling turn to the first assistant call date, not the user message date', () => { + // Regression for the bug that shipped in 0.8.2-0.8.4: when a user message + // sat on one side of midnight and the assistant response landed on the other, + // day-aggregator.ts bucketed by assistant time but renderStatusBar bucketed + // by user time, so the menubar and `codeburn status` disagreed on Today. + // The invariant for both surfaces: a turn is counted on the day its first + // assistant call actually ran. + const userTs = '2026-04-20T23:58:00Z' + const assistantTs = '2026-04-21T00:30:00Z' + const assistantLocal = new Date(assistantTs) + const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}` + + const projects: ProjectSummary[] = [ + makeProject({ + sessions: [{ + sessionId: 's1', + project: 'p', + firstTimestamp: userTs, + lastTimestamp: assistantTs, + totalCostUSD: 5, + totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [{ + userMessage: 'ask', + timestamp: userTs, + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: false, + assistantCalls: [makeCall(assistantTs, 5)], + }], + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {} as never, + }], + }), + ] + + const days = aggregateProjectsIntoDays(projects) + const costDay = days.find(d => d.cost === 5) + expect(costDay, 'turn cost must be bucketed somewhere').toBeDefined() + expect(costDay!.date).toBe(expectedDate) + expect(costDay!.calls).toBe(1) + }) +}) diff --git a/tests/export.test.ts b/tests/export.test.ts new file mode 100644 index 0000000..1980322 --- /dev/null +++ b/tests/export.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, readFile, readdir, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { exportCsv, exportJson, type PeriodExport } from '../src/export.js' +import type { ProjectSummary } from '../src/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'export-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function makeProject(projectPath: string): ProjectSummary { + return { + project: projectPath, + projectPath, + sessions: [ + { + sessionId: 'sess-001', + project: projectPath, + firstTimestamp: '2026-04-14T10:00:00Z', + lastTimestamp: '2026-04-14T10:01:00Z', + totalCostUSD: 1.23, + totalInputTokens: 100, + totalOutputTokens: 50, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [ + { + userMessage: '=SUM(1,2)', + timestamp: '2026-04-14T10:00:00Z', + sessionId: 'sess-001', + category: 'coding', + retries: 0, + hasEdits: true, + assistantCalls: [ + { + provider: 'claude', + model: '+danger-model', + usage: { + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: 1.23, + tools: ['Read'], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-04-14T10:00:00Z', + bashCommands: ['@malicious'], + deduplicationKey: 'dedup-1', + }, + ], + }, + ], + modelBreakdown: { + '+danger-model': { + calls: 1, + costUSD: 1.23, + tokens: { + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + }, + }, + toolBreakdown: { + Read: { calls: 1 }, + }, + mcpBreakdown: {}, + bashBreakdown: { + '@malicious': { calls: 1 }, + }, + categoryBreakdown: { + coding: { turns: 1, costUSD: 1.23, retries: 0, editTurns: 1, oneShotTurns: 1 }, + debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + 'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 }, + }, + skillBreakdown: {}, + }, + ], + totalCostUSD: 1.23, + totalApiCalls: 1, + } +} + +describe('exportCsv', () => { + it('prefixes formula-like cells to prevent CSV injection', async () => { + const periods: PeriodExport[] = [ + { + label: '30 Days', + projects: [makeProject('=cmd,calc')], + }, + ] + + const outputPath = join(tmpDir, 'report.csv') + const folder = await exportCsv(periods, outputPath) + // exportCsv now writes a folder of clean one-table-per-file CSVs, so the formula-prefix + // guard is scattered across files. Concatenate them for the assertion surface. + const [projects, models, shell] = await Promise.all([ + readFile(join(folder, 'projects.csv'), 'utf-8'), + readFile(join(folder, 'models.csv'), 'utf-8'), + readFile(join(folder, 'shell-commands.csv'), 'utf-8'), + ]) + const content = projects + models + shell + + expect(content).toContain("\"'=cmd,calc\"") + expect(content).toContain("'+danger-model") + expect(content).toContain("'@malicious") + }) + + it('escapes tab and carriage-return prefixes in CSV cells', async () => { + const periods: PeriodExport[] = [ + { + label: '30 Days', + projects: [makeProject('\tcmd'), makeProject('\rcmd')], + }, + ] + + const outputPath = join(tmpDir, 'tab-cr.csv') + const folder = await exportCsv(periods, outputPath) + const projects = await readFile(join(folder, 'projects.csv'), 'utf-8') + expect(projects).toContain("'\tcmd") + expect(projects).toContain("'\rcmd") + }) + + it('includes per-model efficiency metrics', async () => { + const periods: PeriodExport[] = [ + { + label: '30 Days', + projects: [makeProject('app')], + }, + ] + + const outputPath = join(tmpDir, 'models.csv') + const folder = await exportCsv(periods, outputPath) + const models = await readFile(join(folder, 'models.csv'), 'utf-8') + + expect(models).toContain('Edit Turns') + expect(models).toContain('One-shot Rate (%)') + expect(models).toContain('Retries/Edit') + expect(models).toContain('Cost/Edit') + expect(models).toContain(',1,100,0,') + }) + + it('does not crash when periods array is empty', async () => { + const outputPath = join(tmpDir, 'empty.csv') + const folder = await exportCsv([], outputPath) + const entries = await readdir(folder) + expect(entries.length).toBeGreaterThanOrEqual(0) + }) + + it('describes detail files without hardcoding a 30-day window', async () => { + const periods: PeriodExport[] = [ + { + label: '2026-04-07 to 2026-04-10', + projects: [makeProject('app')], + }, + ] + + const outputPath = join(tmpDir, 'custom.csv') + const folder = await exportCsv(periods, outputPath) + const readme = await readFile(join(folder, 'README.txt'), 'utf-8') + + expect(readme).toContain('selected detail period') + expect(readme).not.toContain('30-day window') + }) + + it('writes MCP server usage to mcp.csv', async () => { + const project = makeProject('app') + project.sessions[0]!.mcpBreakdown = { node_repl: { calls: 5 } } + const periods: PeriodExport[] = [{ label: '30 Days', projects: [project] }] + + const folder = await exportCsv(periods, join(tmpDir, 'mcp.csv')) + const mcp = await readFile(join(folder, 'mcp.csv'), 'utf-8') + + expect(mcp).toContain('Server,Calls,Share (%)') + expect(mcp).toContain('node_repl,5,100') + }) +}) + +describe('exportJson', () => { + it('includes an mcp section with per-server usage', async () => { + const project = makeProject('app') + project.sessions[0]!.mcpBreakdown = { node_repl: { calls: 3 }, github: { calls: 1 } } + const periods: PeriodExport[] = [{ label: '30 Days', projects: [project] }] + + const outputPath = join(tmpDir, 'export.json') + const saved = await exportJson(periods, outputPath) + const data = JSON.parse(await readFile(saved, 'utf-8')) + + expect(Array.isArray(data.mcp)).toBe(true) + expect(data.mcp).toEqual([ + { Server: 'node_repl', Calls: 3, 'Share (%)': 75 }, + { Server: 'github', Calls: 1, 'Share (%)': 25 }, + ]) + }) +}) diff --git a/tests/fetch-utils.test.ts b/tests/fetch-utils.test.ts new file mode 100644 index 0000000..e613f8b --- /dev/null +++ b/tests/fetch-utils.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { createServer, type Server } from 'node:http' +import { type AddressInfo } from 'node:net' + +import { fetchWithTimeout } from '../src/fetch-utils.js' + +let server: Server + +afterEach(async () => { + await new Promise<void>(resolve => server?.close(() => resolve())) +}) + +function listen(handler: (respond: () => void) => void): Promise<string> { + return new Promise(resolve => { + server = createServer((_req, res) => handler(() => res.end('{"ok":true}'))) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve(`http://127.0.0.1:${port}/`) + }) + }) +} + +describe('fetchWithTimeout', () => { + it('aborts when the server never responds, within the timeout window', async () => { + // Accept the request but never reply — the half-open-network case. + const url = await listen(() => { /* never respond */ }) + + const start = Date.now() + await expect(fetchWithTimeout(url, {}, 150)).rejects.toMatchObject({ name: 'TimeoutError' }) + const elapsed = Date.now() - start + + // Fails fast at ~the timeout, not hanging indefinitely. + expect(elapsed).toBeLessThan(2000) + }) + + it('returns the response when the server replies before the timeout', async () => { + const url = await listen(respond => respond()) + + const res = await fetchWithTimeout(url, {}, 2000) + expect(res.ok).toBe(true) + expect(await res.json()).toEqual({ ok: true }) + }) + + it('still aborts on timeout when the caller also passes a signal', async () => { + const url = await listen(() => { /* never respond */ }) + const controller = new AbortController() + + await expect(fetchWithTimeout(url, { signal: controller.signal }, 150)) + .rejects.toMatchObject({ name: 'TimeoutError' }) + }) +}) diff --git a/tests/fixtures/antigravity-cli-current/brain/fixture-current-cli/.system_generated/logs/transcript.jsonl b/tests/fixtures/antigravity-cli-current/brain/fixture-current-cli/.system_generated/logs/transcript.jsonl new file mode 100644 index 0000000..b642823 --- /dev/null +++ b/tests/fixtures/antigravity-cli-current/brain/fixture-current-cli/.system_generated/logs/transcript.jsonl @@ -0,0 +1,2 @@ +{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-06-21T13:32:13Z","content":"<USER_REQUEST>sanitized fixture prompt</USER_REQUEST>"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-06-21T13:32:14Z","content":"sanitized fixture response","thinking":"sanitized fixture reasoning"} diff --git a/tests/fixtures/antigravity-cli-current/gen-metadata.json b/tests/fixtures/antigravity-cli-current/gen-metadata.json new file mode 100644 index 0000000..d9742d8 --- /dev/null +++ b/tests/fixtures/antigravity-cli-current/gen-metadata.json @@ -0,0 +1,10 @@ +{ + "conversationId": "fixture-current-cli", + "layout": "~/.gemini/antigravity-cli/conversations/<conversation-id>.db with sibling brain/<conversation-id>/.system_generated/logs/transcript.jsonl", + "rows": [ + { + "idx": 0, + "hex": "120202032213666978747572652d63757272656e742d636c690ace01222508f80710b9ec0118da05301848930550475a12666978747572652d726573706f6e73652d319a011267656d696e692d70726f2d64656661756c74a201230a0d7472616a6563746f72795f69641212666978747572652d7472616a6563746f7279a201140a0b757365645f636c61756465120566616c7365a201140a0f6c6173745f737465705f696e646578120132a201230a0a6d6f64656c5f656e756d12154d4f44454c5f504c414345484f4c4445525f4d3136aa011547656d696e6920332e312050726f20284869676829" + } + ] +} diff --git a/tests/fixtures/cursor-agent/workspace-less/projects/agent-transcripts/1031d227-0c67-4e17-8954-0b6e2b3322f0/1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl b/tests/fixtures/cursor-agent/workspace-less/projects/agent-transcripts/1031d227-0c67-4e17-8954-0b6e2b3322f0/1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl new file mode 100644 index 0000000..d5a29e9 --- /dev/null +++ b/tests/fixtures/cursor-agent/workspace-less/projects/agent-transcripts/1031d227-0c67-4e17-8954-0b6e2b3322f0/1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl @@ -0,0 +1,2 @@ +{"role":"user","message":{"content":[{"type":"text","text":"<user_query>\nRun a quick smoke test\n</user_query>"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Smoke test passed."}]}} diff --git a/tests/fixtures/open-design/namespaces/release-stable/data/runs/run-mixed/events.jsonl b/tests/fixtures/open-design/namespaces/release-stable/data/runs/run-mixed/events.jsonl new file mode 100644 index 0000000..d8e5896 --- /dev/null +++ b/tests/fixtures/open-design/namespaces/release-stable/data/runs/run-mixed/events.jsonl @@ -0,0 +1,5 @@ +{"id":"evt-start","event":"start","timestamp":"2026-06-22T10:00:00.000Z","data":{"model":"openai-codex:gpt-5.5"}} +{"id":"evt-codex-usage","event":"agent","timestamp":1782122405000,"data":{"type":"usage","usage":{"input_tokens":1000,"output_tokens":200,"cached_read_tokens":50,"thought_tokens":25,"total_tokens":1200},"costUsd":null}} +{"id":"evt-status-glm","event":"agent","timestamp":"2026-06-22T10:00:10.000Z","data":{"type":"status","model":"glm-5.2"}} +{"id":"evt-glm-usage","event":"agent","timestamp":"2026-06-22T10:00:15.000Z","data":{"type":"usage","usage":{"input_tokens":3000,"output_tokens":400,"cached_read_tokens":100,"thought_tokens":60,"total_tokens":3560},"costUsd":null}} +{"id":"evt-glm-usage","event":"agent","timestamp":"2026-06-22T10:00:16.000Z","data":{"type":"usage","usage":{"input_tokens":9999,"output_tokens":9999,"cached_read_tokens":9999,"thought_tokens":9999,"total_tokens":39996},"costUsd":null}} diff --git a/tests/fixtures/open-design/namespaces/release-stable/data/runs/run-no-usage/events.jsonl b/tests/fixtures/open-design/namespaces/release-stable/data/runs/run-no-usage/events.jsonl new file mode 100644 index 0000000..aa3e0ed --- /dev/null +++ b/tests/fixtures/open-design/namespaces/release-stable/data/runs/run-no-usage/events.jsonl @@ -0,0 +1,3 @@ +{"id":"evt-start","event":"start","timestamp":"2026-06-22T11:00:00.000Z","data":{"model":"glm-5.2"}} +{"id":"evt-status","event":"agent","timestamp":"2026-06-22T11:00:05.000Z","data":{"type":"status","model":"openai-codex:gpt-5.5"}} +{"id":"evt-message","event":"agent","timestamp":"2026-06-22T11:00:10.000Z","data":{"type":"message","text":"no usage was emitted"}} diff --git a/tests/fixtures/open-design/namespaces/release-stable/data/runs/run-start-seeded/events.jsonl b/tests/fixtures/open-design/namespaces/release-stable/data/runs/run-start-seeded/events.jsonl new file mode 100644 index 0000000..a4d5aaa --- /dev/null +++ b/tests/fixtures/open-design/namespaces/release-stable/data/runs/run-start-seeded/events.jsonl @@ -0,0 +1,3 @@ +{"id":"evt-start","event":"start","timestamp":"2026-06-22T12:00:00.000Z","data":{"model":"glm-5.2"}} +{"id":"evt-before-transition","event":"agent","timestamp":"2026-06-22T12:00:05.000Z","data":{"type":"usage","usage":{"input_tokens":777,"output_tokens":33,"cached_read_tokens":7,"thought_tokens":3,"total_tokens":820},"costUsd":null}} +{"id":"evt-status","event":"agent","timestamp":"2026-06-22T12:00:10.000Z","data":{"type":"status","model":"openai-codex:gpt-5.5"}} diff --git a/tests/fixtures/security/proto-bash.jsonl b/tests/fixtures/security/proto-bash.jsonl new file mode 100644 index 0000000..8e6b5e7 --- /dev/null +++ b/tests/fixtures/security/proto-bash.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-bash","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"command":"/x/__proto__"}}],"usage":{"input_tokens":1,"output_tokens":1}}} diff --git a/tests/fixtures/security/proto-model.jsonl b/tests/fixtures/security/proto-model.jsonl new file mode 100644 index 0000000..0aabf71 --- /dev/null +++ b/tests/fixtures/security/proto-model.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-model","type":"message","role":"assistant","model":"__proto__","content":[{"type":"text","text":"x"}],"usage":{"input_tokens":1,"output_tokens":1}}} diff --git a/tests/fixtures/security/proto-tool.jsonl b/tests/fixtures/security/proto-tool.jsonl new file mode 100644 index 0000000..a93a853 --- /dev/null +++ b/tests/fixtures/security/proto-tool.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-tool","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"t1","name":"__proto__","input":{}}],"usage":{"input_tokens":1,"output_tokens":1}}} diff --git a/tests/fs-utils.test.ts b/tests/fs-utils.test.ts new file mode 100644 index 0000000..13e7464 --- /dev/null +++ b/tests/fs-utils.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mkdtemp, writeFile, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + MAX_SESSION_FILE_BYTES, + MAX_STREAM_SESSION_FILE_BYTES, + readSessionFile, + readSessionLines, +} from '../src/fs-utils.js' + +describe('readSessionFile', () => { + const tmpDirs: string[] = [] + + afterEach(async () => { + delete process.env.CODEBURN_VERBOSE + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } + }) + + async function tmpPath(content: string | Buffer): Promise<string> { + const base = await mkdtemp(join(tmpdir(), 'codeburn-fs-')) + tmpDirs.push(base) + const p = join(base, 'x.jsonl') + await writeFile(p, content) + return p + } + + it('returns content for small files via readFile fast path', async () => { + const p = await tmpPath('hello\nworld\n') + expect(await readSessionFile(p)).toBe('hello\nworld\n') + }) + + it('returns content for large files under the full-file cap', async () => { + const size = 8 * 1024 * 1024 + const p = await tmpPath(Buffer.alloc(size, 'a')) + const got = await readSessionFile(p) + expect(got).not.toBeNull() + expect(got!.length).toBe(size) + }) + + it('returns null and skips files over the cap', async () => { + const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'b')) + expect(await readSessionFile(p)).toBeNull() + }) + + it('emits stderr warning under CODEBURN_VERBOSE=1 for skipped file', async () => { + process.env.CODEBURN_VERBOSE = '1' + const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'c')) + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + await readSessionFile(p) + expect(spy).toHaveBeenCalled() + const msg = (spy.mock.calls[0][0] as string) + expect(msg).toContain('codeburn') + expect(msg).toContain('oversize') + spy.mockRestore() + }) + + it('returns null on stat failure without throwing', async () => { + expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull() + }) +}) + +describe('readSessionLines', () => { + const tmpDirs: string[] = [] + + afterEach(async () => { + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } + }) + + async function tmpPath(content: string): Promise<string> { + const base = await mkdtemp(join(tmpdir(), 'codeburn-lines-')) + tmpDirs.push(base) + const p = join(base, 'session.jsonl') + await writeFile(p, content) + return p + } + + it('yields all lines from a file', async () => { + const p = await tmpPath('line1\nline2\nline3\n') + const lines: string[] = [] + for await (const line of readSessionLines(p)) lines.push(line) + expect(lines).toEqual(['line1', 'line2', 'line3']) + }) + + it('skips old large lines before materializing the full line', async () => { + const oldLine = `{"type":"assistant","timestamp":"2026-01-01T00:00:00Z","payload":"${'x'.repeat(100_000)}"}` + const newLine = '{"type":"assistant","timestamp":"2026-05-01T00:00:00Z"}' + const p = await tmpPath(`${oldLine}\n${newLine}\n`) + const lines: string[] = [] + for await (const line of readSessionLines(p, head => head.includes('2026-01-01'))) { + lines.push(line) + } + expect(lines).toEqual([newLine]) + }) + + it('yields large lines as Buffers when requested', async () => { + const largeLine = `{"type":"assistant","timestamp":"2026-05-01T00:00:00Z","payload":"${'x'.repeat(100_000)}"}` + const p = await tmpPath(`${largeLine}\nsmall\n`) + const lines: Array<string | Buffer> = [] + for await (const line of readSessionLines(p, undefined, { largeLineAsBuffer: true })) { + lines.push(line) + } + expect(Buffer.isBuffer(lines[0])).toBe(true) + expect(lines[1]).toBe('small') + }) + + it('does not leak file descriptors when generator is abandoned early', async () => { + const content = Array.from({ length: 1000 }, (_, i) => `line-${i}`).join('\n') + const p = await tmpPath(content) + const gen = readSessionLines(p) + await gen.next() + await gen.return(undefined) + }) + + it('reads from startByteOffset, yielding only lines after the offset', async () => { + const content = 'line1\nline2\nline3\n' + const p = await tmpPath(content) + const offset = Buffer.byteLength('line1\n') + const lines: string[] = [] + for await (const line of readSessionLines(p, undefined, { startByteOffset: offset })) { + lines.push(line) + } + expect(lines).toEqual(['line2', 'line3']) + }) + + it('byteOffsetTracker tracks position after last complete newline', async () => { + const content = 'aaa\nbbb\nccc\n' + const p = await tmpPath(content) + const tracker = { lastCompleteLineOffset: 0 } + const lines: string[] = [] + for await (const line of readSessionLines(p, undefined, { byteOffsetTracker: tracker })) { + lines.push(line) + } + expect(lines).toEqual(['aaa', 'bbb', 'ccc']) + expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content)) + }) + + it('byteOffsetTracker accounts for startByteOffset', async () => { + const content = 'line1\nline2\nline3\n' + const p = await tmpPath(content) + const offset = Buffer.byteLength('line1\n') + const tracker = { lastCompleteLineOffset: 0 } + for await (const _line of readSessionLines(p, undefined, { startByteOffset: offset, byteOffsetTracker: tracker })) {} + expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content)) + }) + + it('byteOffsetTracker excludes trailing partial line (no final newline)', async () => { + const content = 'line1\nline2\npartial' + const p = await tmpPath(content) + const tracker = { lastCompleteLineOffset: 0 } + for await (const _line of readSessionLines(p, undefined, { byteOffsetTracker: tracker })) {} + expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength('line1\nline2\n')) + }) + + it('byteOffsetTracker updates for skipped lines too', async () => { + const content = 'skip-me\nkeep-me\n' + const p = await tmpPath(content) + const tracker = { lastCompleteLineOffset: 0 } + const lines: string[] = [] + for await (const line of readSessionLines(p, head => head.includes('skip-me'), { byteOffsetTracker: tracker })) { + lines.push(line) + } + expect(lines).toEqual(['keep-me']) + expect(tracker.lastCompleteLineOffset).toBe(Buffer.byteLength(content)) + }) + + it('reads files at or under the stream cap', async () => { + const p = await tmpPath('a\nb\nc\n') + const lines: string[] = [] + for await (const line of readSessionLines(p, undefined, { maxBytes: 1024 })) lines.push(line) + expect(lines).toEqual(['a', 'b', 'c']) + }) + + it('skips files over the stream cap and surfaces a notice without CODEBURN_VERBOSE', async () => { + const p = await tmpPath('a\nb\nc\n') + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const lines: string[] = [] + for await (const line of readSessionLines(p, undefined, { maxBytes: 1 })) lines.push(line) + expect(lines).toEqual([]) + expect(spy).toHaveBeenCalled() + expect(spy.mock.calls[0][0] as string).toContain('skipped oversize session') + spy.mockRestore() + }) + + it('stream cap is generous enough for multi-GB Codex sessions', () => { + expect(MAX_STREAM_SESSION_FILE_BYTES).toBe(4 * 1024 * 1024 * 1024) + }) +}) diff --git a/tests/guard-hooks.test.ts b/tests/guard-hooks.test.ts new file mode 100644 index 0000000..ef5d6b2 --- /dev/null +++ b/tests/guard-hooks.test.ts @@ -0,0 +1,371 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { appendFile, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { dedupeStreamingMessageIds, parseApiCall, parseJsonlLine } from '../src/parser.js' +import { computeSessionUsage, emptyCache, readCache, writeAllow, writeCache } from '../src/guard/usage.js' +import { runGuardHook, runGuardStatusline } from '../src/guard/hooks.js' +import { writeGuardConfig, DEFAULT_GUARD_CONFIG } from '../src/guard/store.js' +import { buildFlags, matchFlag, writeFlags, type GuardFlags } from '../src/guard/flags.js' +import type { ProjectSummary } from '../src/types.js' + +const roots: string[] = [] +async function tmp(): Promise<string> { + const d = await mkdtemp(join(tmpdir(), 'codeburn-guard-hooks-')) + roots.push(d) + return d +} +afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) }) + +type Tool = { name: string; input?: Record<string, unknown> } +function assistantLine(id: string, o: { inTok?: number; outTok?: number; tools?: Tool[]; ts?: string } = {}): string { + const content: Record<string, unknown>[] = [{ type: 'text', text: 'ok' }] + for (const t of o.tools ?? []) content.push({ type: 'tool_use', id: `${t.name}-${id}`, name: t.name, input: t.input ?? {} }) + return JSON.stringify({ + type: 'assistant', + timestamp: o.ts ?? '2026-07-01T00:00:00.000Z', + message: { + model: 'claude-sonnet-4-20250514', + id, + type: 'message', + role: 'assistant', + usage: { input_tokens: o.inTok ?? 1_000_000, output_tokens: o.outTok ?? 200_000, cache_read_input_tokens: 0 }, + content, + }, + }) + '\n' +} + +async function transcript(lines: string[]): Promise<string> { + const dir = await tmp() + const path = join(dir, 'session.jsonl') + await writeFile(path, lines.join(''), 'utf-8') + return path +} + +const SID = 'sess-1' +function hookInput(path: string): string { + return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'PreToolUse', tool_name: 'Bash' }) +} + +// The shipped pipeline's cost for a transcript: parse every line, dedupe +// streaming copies last-wins exactly like parseSessionFile does, sum call +// costs. The guard's incremental fold must match this to the cent. +async function shippedDedupedCost(path: string): Promise<number> { + const entries = (await readFile(path, 'utf-8')) + .split('\n') + .filter(l => l.trim()) + .map(l => parseJsonlLine(l)) + .filter((e): e is NonNullable<ReturnType<typeof parseJsonlLine>> => e !== null) + return dedupeStreamingMessageIds(entries).reduce((s, e) => s + (parseApiCall(e)?.costUSD ?? 0), 0) +} + +describe('incremental session cache', () => { + it('parses only the appended tail and totals match a cold parse', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + expect(r1.resumedFrom).toBe(0) + expect(r1.cache.costUSD).toBeGreaterThan(0) + const offset1 = r1.cache.byteOffset + + await appendFile(path, assistantLine('c') + assistantLine('d'), 'utf-8') + const size = (await stat(path)).size + + const prev = await readCache(SID, base) + const r2 = await computeSessionUsage(prev, path) + + // Bytes-read assertion: the second pass resumed exactly where the first + // stopped and consumed only the appended region (not the whole file). + expect(r2.resumedFrom).toBe(offset1) + expect(r2.cache.byteOffset).toBe(size) + expect(r2.cache.byteOffset - r2.resumedFrom).toBeGreaterThan(0) + expect(r2.resumedFrom).toBeLessThan(size) + + const cold = await computeSessionUsage(emptyCache(SID), path) + expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10) + expect(r2.cache.costUSD).toBeGreaterThan(r1.cache.costUSD) + }) + + it('resets to a cold parse when the transcript shrinks (rotation)', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + + await writeFile(path, assistantLine('z'), 'utf-8') // smaller file, new content + const r2 = await computeSessionUsage(await readCache(SID, base), path) + expect(r2.resumedFrom).toBe(0) + const cold = await computeSessionUsage(emptyCache(SID), path) + expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10) + }) +}) + +describe('streaming duplicates (per-message-id replace)', () => { + it('counts a message written 3x with identical usage once, matching the shipped dedup', async () => { + const dup = assistantLine('msg-dup', { inTok: 500_000, outTok: 100_000 }) + const path = await transcript([dup, dup, dup, assistantLine('msg-other')]) + const singlePath = await transcript([dup, assistantLine('msg-other')]) + + const { cache } = await computeSessionUsage(emptyCache(SID), path) + expect(cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + const single = await computeSessionUsage(emptyCache(SID), singlePath) + expect(cache.costUSD).toBeCloseTo(single.cache.costUSD, 10) + }) + + it('keeps the last copy when streamed usage grows', async () => { + const copies = [10_000, 50_000, 120_000].map(outTok => assistantLine('msg-grow', { outTok })) + const path = await transcript(copies) + const lastOnly = await transcript([copies[2]!]) + + const { cache } = await computeSessionUsage(emptyCache(SID), path) + expect(cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + const last = await computeSessionUsage(emptyCache(SID), lastOnly) + expect(cache.costUSD).toBeCloseTo(last.cache.costUSD, 10) + }) + + it('replaces across incremental invocations when a later copy arrives', async () => { + const base = await tmp() + const path = await transcript([assistantLine('msg-x', { outTok: 20_000 })]) + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + + await appendFile(path, assistantLine('msg-x', { outTok: 90_000 }) + assistantLine('msg-y'), 'utf-8') + const r2 = await computeSessionUsage(await readCache(SID, base), path) + expect(r2.resumedFrom).toBe(r1.cache.byteOffset) + expect(r2.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + }) +}) + +describe('trailing complete line without newline', () => { + it('does not double count the line once it completes (A,B,C then D)', async () => { + const base = await tmp() + const a = assistantLine('msg-a') + const b = assistantLine('msg-b') + const c = assistantLine('msg-c') + const d = assistantLine('msg-d') + const dir = await tmp() + const path = join(dir, 'session.jsonl') + await writeFile(path, a + b + c.slice(0, -1), 'utf-8') // C complete but unterminated + + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + // C was folded, but the offset stops after B's newline. + expect(r1.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + expect(r1.cache.byteOffset).toBe((a + b).length) + + await appendFile(path, '\n' + d, 'utf-8') + const r2 = await computeSessionUsage(await readCache(SID, base), path) + // C is re-read as a replace, not a second add: totals equal a cold parse. + const cold = await computeSessionUsage(emptyCache(SID), path) + expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10) + expect(r2.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10) + }) +}) + +describe('git commit detection', () => { + it('requires commit to be the git subcommand', async () => { + const cases: [string, boolean][] = [ + ['git commit -m x', true], + ['git -c user.name=x commit', true], + ['npm test && git commit -am done', true], + ['git add src\ngit commit -m "step"', true], // newline-separated multi-command Bash call + ['git log --grep commit', false], + ['git diff && echo commit', false], + ['echo git then commit', false], + ['git diff\ncommit notes to self', false], // commit on the next line is not a git subcommand + ] + for (const [command, expected] of cases) { + const path = await transcript([assistantLine('msg-1', { tools: [{ name: 'Bash', input: { command } }] })]) + const { cache } = await computeSessionUsage(emptyCache(SID), path) + expect(cache.sawGitCommit, command).toBe(expected) + } + }) +}) + +describe('budget hook (PreToolUse)', () => { + async function costOf(path: string): Promise<number> { + return (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD + } + + it('stays silent below the soft cap', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a')]) + const c = await costOf(path) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 2, hardUSD: c * 4 }, base) + expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('') + }) + + it('warns once on the soft cap, then suppresses the repeat', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + const c = await costOf(path) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.5, hardUSD: c * 10 }, base) + + const first = await runGuardHook('pretooluse', hookInput(path), { base }) + expect(JSON.parse(first).systemMessage).toContain('soft cap') + const second = await runGuardHook('pretooluse', hookInput(path), { base }) + expect(second).toBe('') + }) + + it('blocks on the hard cap with a deny decision, and allow lifts it', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + const c = await costOf(path) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.2, hardUSD: c * 0.5 }, base) + + const blocked = JSON.parse(await runGuardHook('pretooluse', hookInput(path), { base })) + expect(blocked.hookSpecificOutput.hookEventName).toBe('PreToolUse') + expect(blocked.hookSpecificOutput.permissionDecision).toBe('deny') + expect(blocked.hookSpecificOutput.permissionDecisionReason).toContain('guard allow') + + await writeAllow(SID, base) + expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('') + }) + + it('disables the cap when the threshold is null', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: null, hardUSD: null }, base) + expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('') + }) +}) + +describe('yield checkpoint (Stop)', () => { + function stopInput(path: string): string { + return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'Stop' }) + } + async function withCheckpoint(base: string, path: string): Promise<void> { + const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 0.5 }, base) + } + + it('fires once for an expensive no-edit no-commit session', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + await withCheckpoint(base, path) + const first = JSON.parse(await runGuardHook('stop', stopInput(path), { base })) + expect(first.systemMessage).toContain('no edits or commits') + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') // once per session + }) + + it('stays silent when cost is below the checkpoint', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a')]) + const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 5 }, base) + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') + }) + + it('stays silent when the session made an edit', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a', { tools: [{ name: 'Edit', input: { file_path: '/x' } }] }), assistantLine('b')]) + await withCheckpoint(base, path) + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') + }) + + it('stays silent when the session ran git commit', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a', { tools: [{ name: 'Bash', input: { command: 'git commit -m wip' } }] }), assistantLine('b')]) + await withCheckpoint(base, path) + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') + }) +}) + +describe('session opener (SessionStart)', () => { + it('emits the matching opener text for a flagged project and nothing when stale', async () => { + const base = await tmp() + const projectPath = '/tmp/flagged-project' + const flags: GuardFlags = { generatedAt: new Date().toISOString(), projects: [{ path: projectPath, openers: ['DELIVERABLE OPENER'] }] } + await writeFlags(flags, base) + + const input = JSON.stringify({ session_id: SID, cwd: projectPath, hook_event_name: 'SessionStart', source: 'startup' }) + const out = JSON.parse(await runGuardHook('sessionstart', input, { base })) + expect(out.hookSpecificOutput.hookEventName).toBe('SessionStart') + expect(out.hookSpecificOutput.additionalContext).toBe('DELIVERABLE OPENER') + + // Unflagged cwd -> nothing. + const other = JSON.stringify({ session_id: SID, cwd: '/tmp/other', hook_event_name: 'SessionStart' }) + expect(await runGuardHook('sessionstart', other, { base })).toBe('') + + // Stale flag list (> 7 days) -> nothing. + const stale: GuardFlags = { generatedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(), projects: flags.projects } + await writeFlags(stale, base) + expect(await runGuardHook('sessionstart', input, { base })).toBe('') + }) + + it('builds flags from optimize candidate detectors and matches by project path', async () => { + // A project whose only session has real spend but zero edit turns is a + // low-worth candidate; buildFlags should flag its projectPath. + const projects = [lowWorthProject()] + const flags = await buildFlags(projects as unknown as ProjectSummary[]) + expect(flags.projects.length).toBeGreaterThan(0) + expect(matchFlag(flags, '/repo/alpha').length).toBeGreaterThan(0) + expect(matchFlag(flags, '/repo/alpha/src')).toEqual(matchFlag(flags, '/repo/alpha')) // subdir matches + expect(matchFlag(flags, '/repo/beta')).toEqual([]) + }) +}) + +describe('fail-open: malformed stdin', () => { + it('every handler exits with empty output on garbage input', async () => { + const base = await tmp() + for (const ev of ['pretooluse', 'sessionstart', 'stop']) { + expect(await runGuardHook(ev, 'not json {', { base })).toBe('') + expect(await runGuardHook(ev, '', { base })).toBe('') + } + expect(await runGuardHook('unknownevent', JSON.stringify({ session_id: SID }), { base })).toBe('') + expect(await runGuardStatusline('not json {', { base })).toBe('') + }) + + it('handlers stay silent when the transcript path is missing', async () => { + const base = await tmp() + const noPath = JSON.stringify({ session_id: SID, hook_event_name: 'PreToolUse' }) + expect(await runGuardHook('pretooluse', noPath, { base })).toBe('') + expect(await runGuardHook('stop', noPath, { base })).toBe('') + }) +}) + +describe('statusline', () => { + it('prints one line with the session cost', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a')]) + const out = await runGuardStatusline(JSON.stringify({ session_id: SID, transcript_path: path }), { base }) + expect(out.startsWith('codeburn guard $')).toBe(true) + expect(out).not.toContain('\n') + }) +}) + +// A minimal ProjectSummary shaped just enough for findLowWorthCandidates: +// meaningful cost, no delivery command, and no edit turns. +function lowWorthProject(): Record<string, unknown> { + const turn = { + userMessage: 'do a thing', + assistantCalls: [{ costUSD: 40, tools: [], bashCommands: [], timestamp: '2026-07-01T00:00:00Z' }], + timestamp: '2026-07-01T00:00:00Z', + sessionId: 's-alpha', + category: 'exploration', + retries: 0, + hasEdits: false, + } + const session = { + sessionId: 's-alpha', + project: 'alpha', + firstTimestamp: '2026-07-01T00:00:00Z', + lastTimestamp: '2026-07-01T01:00:00Z', + totalCostUSD: 40, + totalSavingsUSD: 0, + totalInputTokens: 100, totalOutputTokens: 100, totalReasoningTokens: 0, + totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [turn], + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {}, + } + return { + project: 'alpha', + projectPath: '/repo/alpha', + sessions: [session], + totalCostUSD: 40, totalSavingsUSD: 0, totalApiCalls: 1, totalProxiedCostUSD: 0, + } +} diff --git a/tests/guard-install.test.ts b/tests/guard-install.test.ts new file mode 100644 index 0000000..8c1e877 --- /dev/null +++ b/tests/guard-install.test.ts @@ -0,0 +1,158 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runAction } from '../src/act/apply.js' +import { readRecords } from '../src/act/journal.js' +import { + buildInstall, buildUninstall, inspectInstall, settingsPathFor, + GUARD_HOOK_PREFIX, GUARD_STATUSLINE_COMMAND, +} from '../src/guard/settings.js' + +const roots: string[] = [] +async function makeRoot(): Promise<{ settings: string; actionsDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'codeburn-guard-install-')) + roots.push(root) + await mkdir(join(root, '.claude'), { recursive: true }) + return { settings: join(root, '.claude', 'settings.json'), actionsDir: join(root, 'actions') } +} +afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) }) + +function canonical(obj: unknown): string { + return JSON.stringify(obj, null, 2) + '\n' +} +async function readJson(path: string): Promise<any> { + return JSON.parse(await readFile(path, 'utf-8')) +} + +describe('settingsPathFor', () => { + it('resolves project (default) and global scopes', () => { + expect(settingsPathFor({ cwd: '/repo' })).toBe(join('/repo', '.claude', 'settings.json')) + expect(settingsPathFor({ project: '/x' })).toBe(join('/x', '.claude', 'settings.json')) + expect(settingsPathFor({ global: true })).toContain(join('.claude', 'settings.json')) + }) +}) + +describe('guard install', () => { + it('creates settings with all three hook events when none exists', async () => { + const { settings, actionsDir } = await makeRoot() + await rm(settings, { force: true }) + const built = buildInstall(settings) + expect(built.plan).not.toBeNull() + expect(built.plan!.kind).toBe('guard-install') + await runAction(built.plan!, actionsDir) + + const doc = await readJson(settings) + expect(Object.keys(doc.hooks).sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop']) + expect(doc.hooks.PreToolUse[0].hooks[0].command).toBe(`${GUARD_HOOK_PREFIX} pretooluse`) + expect(doc.hooks.SessionStart[0].matcher).toBe('startup') + expect(doc.hooks.Stop[0].matcher).toBeUndefined() // Stop takes no matcher + // journaled + undoable + const records = await readRecords(actionsDir) + expect(records[0]!.kind).toBe('guard-install') + }) + + it('appends to pre-existing user hooks without disturbing them', async () => { + const { settings, actionsDir } = await makeRoot() + const original = canonical({ + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }, + }) + await writeFile(settings, original) + + await runAction(buildInstall(settings).plan!, actionsDir) + const doc = await readJson(settings) + const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command)) + expect(commands).toContain('my-own-hook.sh') + expect(commands).toContain(`${GUARD_HOOK_PREFIX} pretooluse`) + }) + + it('is idempotent: re-install adds nothing', async () => { + const { settings, actionsDir } = await makeRoot() + await rm(settings, { force: true }) + await runAction(buildInstall(settings).plan!, actionsDir) + const again = buildInstall(settings) + expect(again.plan).toBeNull() + expect(again.notes.join(' ')).toContain('already present') + }) + + it('configures the statusline only when none exists', async () => { + const { settings, actionsDir } = await makeRoot() + await rm(settings, { force: true }) + await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir) + expect((await readJson(settings)).statusLine.command).toBe(GUARD_STATUSLINE_COMMAND) + + // A pre-existing statusline is refused, not overwritten. + const { settings: s2, actionsDir: a2 } = await makeRoot() + await writeFile(s2, canonical({ statusLine: { type: 'command', command: 'my-statusline.sh' } })) + const built = buildInstall(s2, { statusline: true }) + expect(built.notes.join(' ')).toContain('statusline is already configured') + await runAction(built.plan!, a2) + expect((await readJson(s2)).statusLine.command).toBe('my-statusline.sh') + }) + + it('refuses to apply a plan when the settings file changed after it was built', async () => { + const { settings, actionsDir } = await makeRoot() + await writeFile(settings, canonical({ permissions: { allow: [] } })) + const built = buildInstall(settings) + expect(built.plan).not.toBeNull() + + const concurrent = canonical({ permissions: { allow: ['Bash(ls:*)'] } }) + await writeFile(settings, concurrent) + + await expect(runAction(built.plan!, actionsDir)).rejects.toThrow(/changed since the plan was built/) + expect(await readFile(settings, 'utf-8')).toBe(concurrent) // the concurrent edit survives + expect(await readRecords(actionsDir)).toEqual([]) // nothing journaled + }) +}) + +describe('guard uninstall', () => { + it('removes exactly our entries and restores byte-identical settings', async () => { + const { settings, actionsDir } = await makeRoot() + const original = canonical({ + permissions: { allow: [] }, + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }, + }) + await writeFile(settings, original) + + await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir) + // ours are present now + const mid = inspectInstall(settings) + expect(mid.hooks.sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop']) + expect(mid.statusline).toBe(true) + + await runAction(buildUninstall(settings).plan!, actionsDir) + expect(await readFile(settings, 'utf-8')).toBe(original) // byte-identical + const after = inspectInstall(settings) + expect(after.hooks).toEqual([]) + expect(after.statusline).toBe(false) + }) + + it('preserves a user hook that shares the PreToolUse array', async () => { + const { settings, actionsDir } = await makeRoot() + await writeFile(settings, canonical({ + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }, + })) + await runAction(buildInstall(settings).plan!, actionsDir) + await runAction(buildUninstall(settings).plan!, actionsDir) + const doc = await readJson(settings) + const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command)) + expect(commands).toEqual(['my-own-hook.sh']) + }) + + it('reports nothing to do on a settings file without our hooks', async () => { + const { settings } = await makeRoot() + await writeFile(settings, canonical({ hooks: {} })) + const built = buildUninstall(settings) + expect(built.plan).toBeNull() + expect(built.notes.join(' ')).toContain('no codeburn guard hooks') + }) + + it('reports nothing to do when the settings file is absent', async () => { + const { settings } = await makeRoot() + await rm(settings, { force: true }) + expect(existsSync(settings)).toBe(false) + const built = buildUninstall(settings) + expect(built.plan).toBeNull() + }) +}) diff --git a/tests/kiro-cache-invalidation.test.ts b/tests/kiro-cache-invalidation.test.ts new file mode 100644 index 0000000..725ba5b --- /dev/null +++ b/tests/kiro-cache-invalidation.test.ts @@ -0,0 +1,152 @@ +// Regression test for the Kiro stale-cache path (#618, #619). +// +// Before this fix the Kiro parser returned 0 turns for every IDE execution +// file that stores content under `context.messages[].entries`. Those empty +// results were cached in session-cache.json keyed by file fingerprint, so +// shipping a fixed parser alone is not enough: unchanged files would keep +// their cached `turns: []` forever. The fix registers kiro in +// PROVIDER_PARSE_VERSIONS, which changes the provider envFingerprint and +// makes `parseProviderSources` discard the stale section on first run. +// +// This test exercises the full `parseAllSessions` pipeline against a seeded +// session-cache.json, in both directions: +// - a cache seeded with the CURRENT fingerprint is honored (zero-turn entry +// stays, proving the seed is structurally valid and actually trusted) +// - a cache seeded with the PRE-FIX fingerprint is discarded and the file +// is re-parsed, recovering the calls the broken parser missed + +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest' +import { mkdir, rm, writeFile } from 'fs/promises' +import { createHash } from 'crypto' +import { join } from 'path' + +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { + CACHE_VERSION, + computeEnvFingerprint, + fingerprintFile, + type SessionCache, +} from '../src/session-cache.js' + +// The kiro provider singleton captures homedir() when its module is first +// imported, so HOME must point at the test root before ../src/parser.js is +// evaluated. vi.hoisted runs ahead of the static imports above (but after +// tests/setup/env-isolation.ts, whose per-test beforeEach re-sandboxes env +// vars — anything read at *call* time, like CODEBURN_CACHE_DIR, must be +// re-asserted in this file's own beforeEach). +const testRoot = vi.hoisted(() => { + const root = `${process.env['TMPDIR'] || '/tmp'}/kiro-cache-inv-${process.pid}-${Date.now()}` + process.env['HOME'] = `${root}/home` + process.env['USERPROFILE'] = `${root}/home` + return root +}) + +const HOME = join(testRoot, 'home') +const CACHE_DIR = join(testRoot, 'cache') + +function kiroAgentDir(): string { + if (process.platform === 'darwin') { + return join(HOME, 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + } + if (process.platform === 'win32') { + return join(HOME, 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + } + return join(HOME, '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') +} + +// What computeEnvFingerprint('kiro') returned before kiro had an entry in +// PROVIDER_PARSE_VERSIONS: no env vars, no parser version, i.e. a hash of +// zero parts. This is the fingerprint sitting in every pre-fix cache. +function preFixFingerprint(): string { + return createHash('sha256').update([].join('\0')).digest('hex').slice(0, 16) +} + +// Writes one IDE execution file in the context.messages[].entries format that +// the pre-fix parser turned into 0 turns, and returns its path. +async function seedExecutionFile(): Promise<string> { + const dir = join(kiroAgentDir(), 'a'.repeat(32), 'b'.repeat(32)) + await mkdir(dir, { recursive: true }) + const path = join(dir, 'exec-stale-001') + await writeFile(path, JSON.stringify({ + executionId: 'exec-stale-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1780000000000, + chatSessionId: 'session-stale-001', + context: { + messages: [ + { role: 'human', entries: [{ type: 'text', text: 'What is TypeScript?' }] }, + { role: 'bot', entries: [{ type: 'text', text: 'TypeScript is a typed superset of JavaScript.' }] }, + ], + }, + })) + return path +} + +async function seedCache(execPath: string, envFingerprint: string): Promise<void> { + const fp = await fingerprintFile(execPath) + if (!fp) throw new Error('failed to fingerprint seeded execution file') + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + kiro: { + envFingerprint, + files: { + [execPath]: { fingerprint: fp, mcpInventory: [], turns: [] }, + }, + }, + }, + } + await mkdir(CACHE_DIR, { recursive: true }) + await writeFile(join(CACHE_DIR, 'session-cache.json'), JSON.stringify(cache)) +} + +async function parseKiroCalls() { + const projects = await parseAllSessions(undefined, 'kiro') + return projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) +} + +beforeEach(async () => { + // Runs after env-isolation's global beforeEach, which cleared this var. + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR + clearSessionCache() + await rm(testRoot, { recursive: true, force: true }) +}) + +afterAll(async () => { + clearSessionCache() + await rm(testRoot, { recursive: true, force: true }) +}) + +describe('Kiro session cache invalidation', () => { + it('registers a kiro parser version in the env fingerprint', () => { + expect(computeEnvFingerprint('kiro')).not.toBe(preFixFingerprint()) + }) + + it('control: a zero-turn cache entry at the current fingerprint is honored', async () => { + const execPath = await seedExecutionFile() + await seedCache(execPath, computeEnvFingerprint('kiro')) + + const calls = await parseKiroCalls() + + // The seeded cache is structurally valid and trusted: the unchanged file + // is not re-parsed, so the stale zero-turn entry yields no calls. This + // guards the regression test below against passing for the wrong reason + // (an invalid or unread seed being silently ignored). + expect(calls).toHaveLength(0) + }) + + it('regression: a pre-fix cache fingerprint forces a re-parse that recovers the calls', async () => { + const execPath = await seedExecutionFile() + await seedCache(execPath, preFixFingerprint()) + + const calls = await parseKiroCalls() + + expect(calls).toHaveLength(1) + expect(calls[0]!.usage.inputTokens).toBeGreaterThan(0) + expect(calls[0]!.usage.outputTokens).toBeGreaterThan(0) + }) +}) diff --git a/tests/local-model-savings.test.ts b/tests/local-model-savings.test.ts new file mode 100644 index 0000000..fd8e9f9 --- /dev/null +++ b/tests/local-model-savings.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, afterEach } from 'vitest' + +import { + calculateCost, + calculateLocalModelSavings, + getLocalModelSavingsConfigHash, + getLocalSavingsBaseline, + loadPricing, + setLocalModelSavings, +} from '../src/models.js' + +afterEach(() => setLocalModelSavings({})) + +describe('setLocalModelSavings / getLocalSavingsBaseline', () => { + it('returns undefined when no mapping is configured', () => { + setLocalModelSavings({}) + expect(getLocalSavingsBaseline('llama3.1:8b')).toBeUndefined() + }) + + it('returns the baseline name for a configured source model', () => { + setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' }) + expect(getLocalSavingsBaseline('llama3.1:8b')).toBe('gpt-4o') + }) + + it('uses Object.hasOwn so __proto__ cannot be coerced via the prototype chain', () => { + // Regression for the prototype-pollution test: a hostile model name + // like `__proto__` used to resolve to Object.prototype because plain + // object bracket lookup walks the prototype chain. + setLocalModelSavings({}) + expect(getLocalSavingsBaseline('__proto__')).toBeUndefined() + expect(getLocalSavingsBaseline('constructor')).toBeUndefined() + expect(getLocalSavingsBaseline('toString')).toBeUndefined() + }) + + it('refuses non-string keys defensively', () => { + setLocalModelSavings({}) + expect(getLocalSavingsBaseline('' as unknown as string)).toBeUndefined() + expect(getLocalSavingsBaseline(undefined as unknown as string)).toBeUndefined() + }) + + it('getLocalModelSavingsConfigHash is stable across sort order and empty for no mappings', () => { + setLocalModelSavings({}) + expect(getLocalModelSavingsConfigHash()).toBe('') + + setLocalModelSavings({ a: 'gpt-4o', b: 'claude-opus-4-6' }) + const h1 = getLocalModelSavingsConfigHash() + setLocalModelSavings({ b: 'claude-opus-4-6', a: 'gpt-4o' }) + const h2 = getLocalModelSavingsConfigHash() + expect(h1).toBe(h2) + expect(h1).not.toBe('') + }) + + it('changes the hash when the baseline mapping changes', () => { + setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' }) + const h1 = getLocalModelSavingsConfigHash() + setLocalModelSavings({ 'llama3.1:8b': 'gpt-5' }) + const h2 = getLocalModelSavingsConfigHash() + expect(h1).not.toBe(h2) + }) +}) + +describe('calculateLocalModelSavings', () => { + it('returns null when no mapping is configured for the model', () => { + setLocalModelSavings({}) + const out = calculateLocalModelSavings('llama3.1:8b', 1_000_000, 200_000, 0, 0, 0) + expect(out).toBeNull() + }) + + it('returns null when the baseline model is unknown to the pricing snapshot', () => { + setLocalModelSavings({ 'llama3.1:8b': 'unknown-paid-model-xyz' }) + const out = calculateLocalModelSavings('llama3.1:8b', 1_000, 1_000, 0, 0, 0) + expect(out).toBeNull() + }) + + it('returns the baseline cost as savings for a configured mapping', async () => { + await loadPricing() + setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' }) + const expected = calculateCost('gpt-4o', 1_000_000, 200_000, 50_000, 800_000, 0) + const out = calculateLocalModelSavings('llama3.1:8b', 1_000_000, 200_000, 50_000, 800_000, 0) + expect(out).not.toBeNull() + expect(out!.savingsUSD).toBeCloseTo(expected) + expect(out!.baselineModel).toBe('gpt-4o') + }) + + it('respects speed and web-search inputs in the baseline calculation', async () => { + await loadPricing() + setLocalModelSavings({ local: 'gpt-4o' }) + const standard = calculateLocalModelSavings('local', 1_000, 500, 0, 0, 2, 'standard') + expect(standard).not.toBeNull() + // Web search is a flat $0.01 per request, so the standard path with 2 + // web search requests should include 2 cents of counterfactual spend. + expect(standard!.savingsUSD).toBeGreaterThan(0.02) + }) +}) diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts new file mode 100644 index 0000000..a19ddc4 --- /dev/null +++ b/tests/mcp-coverage.test.ts @@ -0,0 +1,656 @@ +import { describe, it, expect } from 'vitest' + +import { + aggregateMcpCoverage, + detectMcpProfileAdvisor, + detectMcpToolCoverage, + estimateMcpSchemaCost, +} from '../src/optimize.js' +import type { + ClassifiedTurn, + ParsedApiCall, + ProjectSummary, + SessionSummary, + TaskCategory, + TokenUsage, +} from '../src/types.js' + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const ZERO_USAGE: TokenUsage = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, +} + +function makeCall(opts: { + tools?: string[] + cacheCreation?: number + cacheRead?: number + cost?: number +} = {}): ParsedApiCall { + const tools = opts.tools ?? [] + return { + provider: 'claude', + model: 'Opus 4.7', + usage: { + ...ZERO_USAGE, + cacheCreationInputTokens: opts.cacheCreation ?? 0, + cacheReadInputTokens: opts.cacheRead ?? 0, + }, + costUSD: opts.cost ?? 0, + tools, + mcpTools: tools.filter(t => t.startsWith('mcp__')), + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-04T00:00:00Z', + bashCommands: [], + deduplicationKey: 'k', + } +} + +function makeTurn(calls: ParsedApiCall[]): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: calls, + timestamp: '2026-05-04T00:00:00Z', + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: false, + } +} + +function makeSession(opts: { + sessionId?: string + inventory?: string[] + turns?: ClassifiedTurn[] + mcpBreakdown?: Record<string, { calls: number }> +}): SessionSummary { + const turns = opts.turns ?? [] + const apiCalls = turns.reduce((s, t) => s + t.assistantCalls.length, 0) + const emptyCategoryBreakdown = {} as Record<TaskCategory, { turns: number; costUSD: number; retries: number; editTurns: number; oneShotTurns: number }> + return { + sessionId: opts.sessionId ?? 's1', + project: 'p', + firstTimestamp: '2026-05-04T00:00:00Z', + lastTimestamp: '2026-05-04T00:00:00Z', + totalCostUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls, + turns, + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: opts.mcpBreakdown ?? {}, + bashBreakdown: {}, + categoryBreakdown: emptyCategoryBreakdown, + skillBreakdown: {}, + ...(opts.inventory ? { mcpInventory: opts.inventory } : {}), + } +} + +function project(sessions: SessionSummary[]): ProjectSummary { + return projectNamed('p', sessions) +} + +function projectNamed(name: string, sessions: SessionSummary[]): ProjectSummary { + return { + project: name, + projectPath: `/tmp/${name}`, + sessions, + totalCostUSD: 0, + totalApiCalls: sessions.reduce((s, ses) => s + ses.apiCalls, 0), + } +} + +// --------------------------------------------------------------------------- +// aggregateMcpCoverage +// --------------------------------------------------------------------------- + +describe('aggregateMcpCoverage', () => { + it('returns empty list when no session has MCP inventory', () => { + const projects = [project([makeSession({})])] + expect(aggregateMcpCoverage(projects)).toEqual([]) + }) + + it('reports per-server tools available, invoked, and unused', () => { + const inventory = [ + 'mcp__hf__hub_repo_search', + 'mcp__hf__paper_search', + 'mcp__hf__hf_doc_search', + ] + const turns = [ + makeTurn([makeCall({ tools: ['mcp__hf__hub_repo_search'] })]), + ] + const sessions = [ + makeSession({ inventory, turns, mcpBreakdown: { hf: { calls: 1 } } }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + + expect(result).toHaveLength(1) + expect(result[0]!.server).toBe('hf') + expect(result[0]!.toolsAvailable).toBe(3) + expect(result[0]!.toolsInvoked).toBe(1) + expect(result[0]!.unusedTools).toEqual([ + 'mcp__hf__hf_doc_search', + 'mcp__hf__paper_search', + ]) + expect(result[0]!.coverageRatio).toBeCloseTo(1 / 3, 5) + expect(result[0]!.invocations).toBe(1) + expect(result[0]!.loadedSessions).toBe(1) + }) + + it('unions inventory across multiple sessions for the same server', () => { + const sessions = [ + makeSession({ sessionId: 'a', inventory: ['mcp__x__a', 'mcp__x__b'] }), + makeSession({ sessionId: 'b', inventory: ['mcp__x__b', 'mcp__x__c'] }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + expect(result[0]!.toolsAvailable).toBe(3) + expect(result[0]!.loadedSessions).toBe(2) + }) + + it('separates servers with similar names', () => { + const sessions = [ + makeSession({ inventory: ['mcp__hf__a', 'mcp__hugface__a'] }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + expect(result.map(r => r.server).sort()).toEqual(['hf', 'hugface']) + }) + + it('skips invocations without inventory (foreign server, no inventory observed)', () => { + // A server can show up only via a call. We still report it so the + // operator knows it was invoked, but coverage is 0/0 and it is not a + // candidate for the unused-coverage finding. + const turns = [makeTurn([makeCall({ tools: ['mcp__ghost__t1'] })])] + const sessions = [ + makeSession({ turns, mcpBreakdown: { ghost: { calls: 1 } } }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + // No inventory entry -> aggregator drops the server from the report + // because we cannot reason about coverage without an inventory baseline. + expect(result).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// estimateMcpSchemaCost — cache-aware accounting +// --------------------------------------------------------------------------- + +describe('estimateMcpSchemaCost', () => { + it('charges first cacheCreation turn at full price, subsequent turns at cache-read', () => { + const turns = [ + makeTurn([makeCall({ cacheCreation: 50_000 })]), // first turn: write + makeTurn([makeCall({ cacheRead: 60_000 })]), // ongoing: read + makeTurn([makeCall({ cacheRead: 60_000 })]), + ] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + mcpBreakdown: { svc: { calls: 0 } }, + })] + // 30 unused tools * 400 token estimate = 12_000 schema tokens + // cap by call cache buckets so we never overclaim + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(12_000) // capped by 50k creation, 12k schema fits + expect(cost.cacheReadTokens).toBe(24_000) // 12k + 12k across two ongoing turns + // effective = write * 1.25 + read * 0.10 (cache pricing) + expect(cost.effectiveInputTokens).toBeCloseTo(12_000 * 1.25 + 24_000 * 0.10, 5) + }) + + it('caps by available cache bucket so we never overclaim', () => { + const turns = [makeTurn([makeCall({ cacheCreation: 1_000 })])] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + mcpBreakdown: { svc: { calls: 0 } }, + })] + // 30*400 = 12k schema tokens, but the call only had 1k cache-creation, + // so we should not claim more than 1k of overhead for that turn. + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(1_000) + }) + + it('returns zero when no unused tools', () => { + const sessions = [makeSession({ + inventory: ['mcp__svc__t1'], + turns: [makeTurn([makeCall({ cacheCreation: 5000 })])], + })] + const cost = estimateMcpSchemaCost(0, [project(sessions)], 'svc') + expect(cost).toEqual({ cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 }) + }) + + it('counts cache write AND cache read on the same call', () => { + // A long session can have a cache rebuild mid-stream where one call + // reports both buckets. The estimator must charge both, not skip the + // read because of the write. + const turns = [makeTurn([ + makeCall({ cacheCreation: 50_000, cacheRead: 30_000 }), + ])] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + mcpBreakdown: { svc: { calls: 0 } }, + })] + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(12_000) // capped at 50k creation + expect(cost.cacheReadTokens).toBe(12_000) // capped at 30k read + }) + + it('counts every cache rebuild, not just the first one', () => { + // Sessions that span more than 5 minutes can rebuild the cache + // multiple times. The estimator should treat every cacheCreation + // bucket as another write. + const turns = [makeTurn([ + makeCall({ cacheCreation: 50_000 }), + makeCall({ cacheCreation: 50_000 }), // rebuild after cache TTL + makeCall({ cacheRead: 60_000 }), + ])] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + mcpBreakdown: { svc: { calls: 0 } }, + })] + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(24_000) // both rebuilds counted + expect(cost.cacheReadTokens).toBe(12_000) + }) + + it('skips sessions where the server was never loaded', () => { + const turns = [makeTurn([makeCall({ cacheCreation: 100_000 })])] + const sessions = [makeSession({ + inventory: ['mcp__other__t1'], + turns, + })] + const cost = estimateMcpSchemaCost(10, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(0) + }) + + it('requires observed inventory for the server, not just invocations', () => { + // Session invoked the server (mcpBreakdown set, mcpTools called) but + // never reported a deferred_tools_delta for it. Cost should be 0 to + // stay consistent with aggregateMcpCoverage's loadedSessions rule. + const turns = [makeTurn([ + makeCall({ tools: ['mcp__svc__t1'], cacheCreation: 100_000 }), + ])] + const sessions = [makeSession({ + // No inventory at all + turns, + mcpBreakdown: { svc: { calls: 1 } }, + })] + const cost = estimateMcpSchemaCost(10, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(0) + expect(cost.cacheReadTokens).toBe(0) + }) + + it('caps combined unused-schema budget across multiple flagged servers', () => { + // Two flagged servers, each with 30 unused tools (12k schema each = + // 24k combined). One call has a 50k cache-creation bucket. The + // combined cap means total write tokens reported is min(24k, 50k) = + // 24k, not 24k + 24k = 48k. + const inventory = [ + ...Array.from({ length: 30 }, (_, i) => `mcp__a__t${i}`), + ...Array.from({ length: 30 }, (_, i) => `mcp__b__t${i}`), + ] + const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] + const sessions = [makeSession({ inventory, turns })] + const cost = estimateMcpSchemaCost( + { a: 30, b: 30 }, + [project(sessions)], + ['a', 'b'], + ) + expect(cost.cacheWriteTokens).toBe(24_000) + }) + + it('still works with the single-server signature (backward compat)', () => { + const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] + const sessions = [makeSession({ + inventory: Array.from({ length: 30 }, (_, i) => `mcp__svc__t${i}`), + turns, + })] + const cost = estimateMcpSchemaCost(30, [project(sessions)], 'svc') + expect(cost.cacheWriteTokens).toBe(12_000) + }) +}) + +// --------------------------------------------------------------------------- +// detectMcpToolCoverage — finding emission with thresholds +// --------------------------------------------------------------------------- + +describe('detectMcpToolCoverage', () => { + it('returns null when no inventory exists at all', () => { + expect(detectMcpToolCoverage([project([makeSession({})])])).toBeNull() + }) + + it('does not flag a server with healthy coverage', () => { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const turns = [makeTurn( + Array.from({ length: 8 }, (_, i) => makeCall({ tools: [`mcp__svc__t${i}`] })), + )] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns }), + makeSession({ sessionId: 'b', inventory, turns }), + ] + // 8/20 = 40% coverage, above the 20% threshold -> no finding + expect(detectMcpToolCoverage([project(sessions)])).toBeNull() + }) + + it('does not flag a server with too few tools (signal too noisy)', () => { + // Below MCP_COVERAGE_MIN_TOOLS=10 + const inventory = ['mcp__svc__a', 'mcp__svc__b'] + const sessions = [ + makeSession({ sessionId: 'a', inventory }), + makeSession({ sessionId: 'b', inventory }), + ] + expect(detectMcpToolCoverage([project(sessions)])).toBeNull() + }) + + it('does not flag if seen in only one session (insufficient evidence)', () => { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const sessions = [makeSession({ inventory })] + expect(detectMcpToolCoverage([project(sessions)])).toBeNull() + }) + + it('flags a large server with low coverage across multiple sessions', () => { + const inventory = Array.from({ length: 30 }, (_, i) => `mcp__hf__t${i}`) + const turns = [makeTurn([ + makeCall({ tools: ['mcp__hf__t0'], cacheCreation: 100_000 }), + ])] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns, mcpBreakdown: { hf: { calls: 1 } } }), + makeSession({ sessionId: 'b', inventory, turns, mcpBreakdown: { hf: { calls: 1 } } }), + ] + const finding = detectMcpToolCoverage([project(sessions)]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('1 MCP server') + expect(finding!.title).toContain('low tool coverage') + expect(finding!.explanation).toContain('hf') + expect(finding!.explanation).toContain('1/30') + expect(finding!.fix.type).toBe('command') + expect((finding!.fix as { text: string }).text).toContain("claude mcp remove 'hf'") + expect(finding!.tokensSaved).toBeGreaterThan(0) + }) + + it('escalates impact to high when token waste crosses the threshold', () => { + const inventory = Array.from({ length: 60 }, (_, i) => `mcp__big__t${i}`) + // 60 tools * 400 tokens = 24k schema. With many sessions and large + // cache-creation buckets, total effective tokens easily clear 200k. + const turns = [makeTurn([ + makeCall({ tools: ['mcp__big__t0'], cacheCreation: 50_000 }), + makeCall({ cacheRead: 60_000 }), + makeCall({ cacheRead: 60_000 }), + ])] + // Need enough sessions so the per-session ~28.8k effective tokens + // (24k write + 48k read × 0.10) sum past the 200k high-impact threshold. + const sessions = Array.from({ length: 8 }, (_, i) => + makeSession({ sessionId: `s${i}`, inventory, turns, mcpBreakdown: { big: { calls: 1 } } }), + ) + const finding = detectMcpToolCoverage([project(sessions)]) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('high') + }) + + it('does not count invocation-only sessions toward loadedSessions', () => { + // Server `svc` has inventory in only one session, but is invoked in + // a second session that never observed the schema. Pre-fix this + // would have satisfied the >=2 session threshold; it must not now. + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const turns = [makeTurn([ + makeCall({ tools: ['mcp__svc__t0'], cacheCreation: 50_000 }), + ])] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns, mcpBreakdown: { svc: { calls: 1 } } }), + // No inventory — this shouldn't be considered a "loaded" session. + makeSession({ sessionId: 'b', turns, mcpBreakdown: { svc: { calls: 1 } } }), + ] + expect(detectMcpToolCoverage([project(sessions)])).toBeNull() + }) + + it('does not let invocations of un-inventoried tools inflate coverage', () => { + // Inventory has 20 tools, none invoked. Calls hit a 21st tool that + // never appeared in any deferred_tools_delta (could be a renamed/ + // removed tool from an older session config). Coverage must stay 0% + // and unusedCount must not go negative. + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const turns = [makeTurn([makeCall({ tools: ['mcp__svc__ghost'] })])] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns, mcpBreakdown: { svc: { calls: 1 } } }), + makeSession({ sessionId: 'b', inventory, turns, mcpBreakdown: { svc: { calls: 1 } } }), + ] + const result = aggregateMcpCoverage([project(sessions)]) + expect(result[0]!.toolsAvailable).toBe(20) + expect(result[0]!.toolsInvoked).toBe(0) + expect(result[0]!.coverageRatio).toBe(0) + expect(result[0]!.unusedTools).toHaveLength(20) + }) + + it('handles multiple flagged servers and pluralises the title', () => { + const sessions: SessionSummary[] = [] + for (const server of ['svc1', 'svc2']) { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) + const turns = [makeTurn([ + makeCall({ tools: [`mcp__${server}__t0`], cacheCreation: 50_000 }), + ])] + sessions.push( + makeSession({ sessionId: `${server}-a`, inventory, turns, mcpBreakdown: { [server]: { calls: 1 } } }), + makeSession({ sessionId: `${server}-b`, inventory, turns, mcpBreakdown: { [server]: { calls: 1 } } }), + ) + } + const finding = detectMcpToolCoverage([project(sessions)]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('2 MCP servers') + expect((finding!.fix as { text: string }).text.split('\n')).toHaveLength(2) + }) +}) + +// --------------------------------------------------------------------------- +// detectMcpProfileAdvisor — project-scoping recommendations +// --------------------------------------------------------------------------- + +describe('detectMcpProfileAdvisor', () => { + const smallInventory = Array.from({ length: 4 }, (_, i) => `mcp__github__t${i}`) + + it('flags a server loaded across projects but invoked only in one hot project', () => { + const hotTurns = [makeTurn([ + makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 }), + makeCall({ tools: ['mcp__github__t1'], cacheCreation: 10_000 }), + ])] + const coldTurns = [makeTurn([makeCall({ cacheCreation: 10_000 })])] + const projects = [ + projectNamed('api', [ + makeSession({ inventory: smallInventory, turns: hotTurns, mcpBreakdown: { github: { calls: 2 } } }), + ]), + projectNamed('web', [ + makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }), + ]), + projectNamed('docs', [ + makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }), + ]), + ] + + const finding = detectMcpProfileAdvisor(projects) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('project-scoped') + expect(finding!.explanation).toContain('github') + expect(finding!.explanation).toContain('/tmp/api') + expect(finding!.explanation).toContain('/tmp/web') + expect(finding!.explanation).toContain('/tmp/docs') + expect(finding!.tokensSaved).toBe(4000) + expect(finding!.fix.type).toBe('paste') + if (finding!.fix.type === 'paste') { + expect(finding!.fix.destination).toBe('prompt') + expect(finding!.fix.text).toContain('Keep github available for /tmp/api') + expect(finding!.fix.text).toContain('/tmp/web') + expect(finding!.fix.text).toContain('/tmp/docs') + } + }) + + it('does not flag servers used evenly across loaded projects', () => { + const projects = ['api', 'web', 'docs'].map(name => projectNamed(name, [ + makeSession({ + inventory: smallInventory, + turns: [makeTurn([makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 })])], + mcpBreakdown: { github: { calls: 2 } }, + }), + ])) + + expect(detectMcpProfileAdvisor(projects)).toBeNull() + }) + + it('allows a hot profile shared by two projects', () => { + const projects = [ + projectNamed('api', [ + makeSession({ + inventory: smallInventory, + turns: [makeTurn([ + makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 }), + makeCall({ tools: ['mcp__github__t1'], cacheCreation: 10_000 }), + ])], + mcpBreakdown: { github: { calls: 2 } }, + }), + ]), + projectNamed('web', [ + makeSession({ + inventory: smallInventory, + turns: [makeTurn([ + makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 }), + makeCall({ tools: ['mcp__github__t1'], cacheCreation: 10_000 }), + ])], + mcpBreakdown: { github: { calls: 2 } }, + }), + ]), + projectNamed('docs', [ + makeSession({ + inventory: smallInventory, + turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])], + mcpBreakdown: { github: { calls: 0 } }, + }), + ]), + projectNamed('playground', [ + makeSession({ + inventory: smallInventory, + turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])], + mcpBreakdown: { github: { calls: 0 } }, + }), + ]), + ] + + const finding = detectMcpProfileAdvisor(projects) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('/tmp/api') + expect(finding!.explanation).toContain('/tmp/web') + expect(finding!.explanation).toContain('/tmp/docs') + expect(finding!.explanation).toContain('/tmp/playground') + }) + + it('caps profile savings once when multiple candidate servers share cold sessions', () => { + const githubInventory = Array.from({ length: 4 }, (_, i) => `mcp__github__t${i}`) + const slackInventory = Array.from({ length: 4 }, (_, i) => `mcp__slack__t${i}`) + const inventory = [...githubInventory, ...slackInventory] + const projects = [ + projectNamed('api', [ + makeSession({ + inventory, + turns: [makeTurn([ + makeCall({ tools: ['mcp__github__t0'] }), + makeCall({ tools: ['mcp__github__t1'] }), + makeCall({ tools: ['mcp__slack__t0'] }), + makeCall({ tools: ['mcp__slack__t1'] }), + ])], + mcpBreakdown: { github: { calls: 2 }, slack: { calls: 2 } }, + }), + ]), + projectNamed('web', [ + makeSession({ + inventory, + turns: [makeTurn([makeCall({ cacheCreation: 2_000 })])], + mcpBreakdown: { github: { calls: 0 }, slack: { calls: 0 } }, + }), + ]), + projectNamed('docs', [ + makeSession({ + inventory, + turns: [makeTurn([makeCall({ cacheCreation: 2_000 })])], + mcpBreakdown: { github: { calls: 0 }, slack: { calls: 0 } }, + }), + ]), + ] + + const finding = detectMcpProfileAdvisor(projects) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('2 MCP servers') + expect(finding!.explanation).toContain('github') + expect(finding!.explanation).toContain('slack') + expect(finding!.tokensSaved).toBe(5000) + }) + + it('requires at least three loaded projects before recommending a profile', () => { + const projects = [ + projectNamed('api', [ + makeSession({ + inventory: smallInventory, + turns: [makeTurn([makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 })])], + mcpBreakdown: { github: { calls: 2 } }, + }), + ]), + projectNamed('web', [ + makeSession({ + inventory: smallInventory, + turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])], + mcpBreakdown: { github: { calls: 0 } }, + }), + ]), + ] + + expect(detectMcpProfileAdvisor(projects)).toBeNull() + }) + + it('does not duplicate low tool coverage findings for the same server', () => { + const inventory = Array.from({ length: 12 }, (_, i) => `mcp__huge__t${i}`) + const projects = [ + projectNamed('api', [ + makeSession({ + inventory, + turns: [makeTurn([makeCall({ tools: ['mcp__huge__t0'], cacheCreation: 20_000 })])], + mcpBreakdown: { huge: { calls: 3 } }, + }), + ]), + projectNamed('web', [ + makeSession({ + inventory, + turns: [makeTurn([makeCall({ cacheCreation: 20_000 })])], + mcpBreakdown: { huge: { calls: 0 } }, + }), + ]), + projectNamed('docs', [ + makeSession({ + inventory, + turns: [makeTurn([makeCall({ cacheCreation: 20_000 })])], + mcpBreakdown: { huge: { calls: 0 } }, + }), + ]), + ] + const coverage = [{ + server: 'huge', + toolsAvailable: 12, + toolsInvoked: 1, + unusedTools: Array.from({ length: 11 }, (_, i) => `mcp__huge__t${i + 1}`), + invocations: 3, + loadedSessions: 3, + coverageRatio: 1 / 12, + }] + + expect(detectMcpProfileAdvisor(projects, coverage)).toBeNull() + }) +}) diff --git a/tests/mcp-redact.test.ts b/tests/mcp-redact.test.ts new file mode 100644 index 0000000..2315708 --- /dev/null +++ b/tests/mcp-redact.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { pseudonym, redactProjectNames } from '../src/mcp/redact.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function payload(): MenubarPayload { + const base = { + name: 'secret-client-repo', cost: 5, sessions: 2, avgCostPerSession: 2.5, + sessionDetails: [{ cost: 3, calls: 5, inputTokens: 100, outputTokens: 50, date: '2026-06-01', models: [{ name: 'Opus', cost: 3 }] }], + } + return { + generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, history: { daily: [] }, + current: { + label: 'Today', cost: 5, calls: 10, sessions: 2, oneShotRate: null, inputTokens: 0, outputTokens: 0, + cacheHitPercent: 0, topActivities: [], topModels: [], providers: {}, + topProjects: [base], modelEfficiency: [], + topSessions: [{ project: 'secret-client-repo', cost: 5, calls: 10, date: '2026-06-01' }], + retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, + routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +describe('redact', () => { + it('pseudonym is stable and path-free', () => { + expect(pseudonym('a')).toBe(pseudonym('a')) + expect(pseudonym('secret-client-repo')).toMatch(/^project-[0-9a-f]{6}$/) + expect(pseudonym('a/b/c')).not.toContain('/') + }) + it('hashes project names by default, preserves numbers', () => { + const out = redactProjectNames(payload(), false) + expect(out.current.topProjects[0]!.name).toMatch(/^project-[0-9a-f]{6}$/) + expect(out.current.topSessions[0]!.project).toMatch(/^project-[0-9a-f]{6}$/) + expect(out.current.topProjects[0]!.cost).toBe(5) + }) + it('redacts session details when hashing', () => { + const out = redactProjectNames(payload(), false) + const details = out.current.topProjects[0]!.sessionDetails! + expect(details).toHaveLength(1) + expect(details[0]!.date).toBe('') + expect(details[0]!.models).toEqual([]) + expect(details[0]!.cost).toBe(3) + }) + it('same project name gets same pseudonym in topProjects and topSessions', () => { + const out = redactProjectNames(payload(), false) + expect(out.current.topProjects[0]!.name).toBe(out.current.topSessions[0]!.project) + }) + it('keeps real names and session details when include=true', () => { + const out = redactProjectNames(payload(), true) + expect(out.current.topProjects[0]!.name).toBe('secret-client-repo') + expect(out.current.topProjects[0]!.sessionDetails![0]!.date).toBe('2026-06-01') + }) +}) diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts new file mode 100644 index 0000000..e022af2 --- /dev/null +++ b/tests/mcp-server.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { createServer } from '../src/mcp/server.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function fakePayload(calls = 100): MenubarPayload { + return { + generated: '', optimize: { findingCount: 1, savingsUSD: 2, topFindings: [{ title: 'X', impact: 'high', savingsUSD: 2 }] }, history: { daily: [] }, + current: { + label: 'Today', cost: 9, calls, sessions: 1, oneShotRate: 0.5, inputTokens: 10, outputTokens: 5, cacheHitPercent: 50, + topActivities: [{ name: 'feature', cost: 9, turns: 5, oneShotRate: 0.5 }], topModels: [{ name: 'Opus 4.8', cost: 9, calls }], + providers: { 'claude code': 9 }, topProjects: [{ name: 'real-repo', cost: 9, sessions: 1, avgCostPerSession: 9, sessionDetails: [] }], + modelEfficiency: [], topSessions: [{ project: 'real-repo', cost: 9, calls, date: '2026-06-01' }], + retryTax: { totalUSD: 1, retries: 2, editTurns: 5, byModel: [{ name: 'Opus 4.8', taxUSD: 1, retries: 2, retriesPerEdit: 0.4 }] }, + routingWaste: { totalSavingsUSD: 1, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +async function connect(aggregate: (p: unknown, o: unknown) => Promise<MenubarPayload>) { + const server = createServer({ version: 'test', aggregate: aggregate as never }) + const [a, b] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '1' }) + await Promise.all([server.connect(a), client.connect(b)]) + return client +} + +describe('mcp server', () => { + it('exposes exactly two read-only tools', async () => { + const client = await connect(async () => fakePayload()) + const { tools } = await client.listTools() + expect(tools.map(t => t.name).sort()).toEqual(['get_savings', 'get_usage']) + expect(tools.find(t => t.name === 'get_usage')!.annotations?.readOnlyHint).toBe(true) + }) + it('get_usage hashes project names by default', async () => { + const client = await connect(async () => fakePayload()) + const res = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project' } }) + expect(JSON.stringify(res)).not.toContain('real-repo') + expect(JSON.stringify(res)).toMatch(/project-[0-9a-f]{6}/) + expect(res.isError).toBeFalsy() + }) + it('get_usage reveals names when opted in', async () => { + const client = await connect(async () => fakePayload()) + const res = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project', include_project_names: true } }) + expect(JSON.stringify(res)).toContain('real-repo') + }) + it('empty data returns a friendly message, not a zero table', async () => { + const client = await connect(async () => fakePayload(0)) + const res = await client.callTool({ name: 'get_usage', arguments: { period: 'today' } }) + expect(String((res.content as Array<{ text: string }>)[0].text).toLowerCase()).toContain('no usage') + }) + it('aggregator failure surfaces as isError', async () => { + const client = await connect(async () => { throw new Error('boom') }) + const res = await client.callTool({ name: 'get_savings', arguments: {} }) + expect(res.isError).toBe(true) + expect(JSON.stringify(res)).toContain('boom') + }) +}) diff --git a/tests/mcp-tables.test.ts b/tests/mcp-tables.test.ts new file mode 100644 index 0000000..e0d32e4 --- /dev/null +++ b/tests/mcp-tables.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { renderSummaryTable, renderBreakdownTable, renderSavingsTable } from '../src/mcp/tables.js' +import type { MenubarPayload } from '../src/menubar-json.js' + +function payload(): MenubarPayload { + return { + generated: '', optimize: { findingCount: 1, savingsUSD: 2.5, topFindings: [{ title: 'Trim system prompt', impact: 'high', savingsUSD: 2.5 }] }, history: { daily: [] }, + current: { + label: 'Last 7 Days', cost: 12.5, calls: 100, sessions: 4, oneShotRate: 0.5, inputTokens: 1000, outputTokens: 500, + cacheHitPercent: 80, topActivities: [{ name: 'feature', cost: 8, turns: 30, oneShotRate: 0.6 }], + topModels: [{ name: 'Opus 4.8', cost: 10, calls: 60 }], providers: { 'claude code': 12.5 }, + topProjects: [{ name: 'project-abc123', cost: 12.5, sessions: 4, avgCostPerSession: 3.125, sessionDetails: [] }], + modelEfficiency: [], topSessions: [], + retryTax: { totalUSD: 1.2, retries: 4, editTurns: 20, byModel: [{ name: 'Opus 4.8', taxUSD: 1.2, retries: 4, retriesPerEdit: 0.2 }] }, + routingWaste: { totalSavingsUSD: 3, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [{ name: 'Opus 4.8', costPerEdit: 0.05, editTurns: 20, actualUSD: 1, counterfactualUSD: 0.2, savingsUSD: 0.8 }] }, + tools: [], skills: [], subagents: [], mcpServers: [], + }, + } as MenubarPayload +} + +describe('tables', () => { + it('summary shows headline cost and top models', () => { + const t = renderSummaryTable(payload()) + expect(t).toContain('Last 7 Days') + expect(t).toContain('Opus 4.8') + expect(t).toContain('| Model | Cost | Calls |') + }) + it('breakdown by provider lists providers', () => { + expect(renderBreakdownTable(payload(), 'provider', 20)).toContain('claude code') + }) + it('breakdown handles empty dimension gracefully', () => { + const p = payload(); p.current.topActivities = [] + expect(renderBreakdownTable(p, 'task', 20)).toContain('no data') + }) + it('savings shows retry tax and routing waste', () => { + const t = renderSavingsTable(payload()) + expect(t).toContain('Retry tax') + expect(t).toContain('Routing waste') + expect(t).toContain('Trim system prompt') + }) +}) diff --git a/tests/menubar-installer.test.ts b/tests/menubar-installer.test.ts new file mode 100644 index 0000000..129fbac --- /dev/null +++ b/tests/menubar-installer.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { + buildPersistentCodeburnLookupPath, + formatGitHubReleaseLookupError, + resolveLatestMenubarReleaseAssets, + resolveMenubarReleaseAssets, + resolvePersistentCodeburnPathFromWhichOutput, + resolveProxyUrlForUrl, + resolveVersionedMenubarReleaseAssets, + shouldFallbackToReleaseApi, + type ReleaseResponse, +} from '../src/menubar-installer.js' + +function asset(name: string) { + return { name, browser_download_url: `https://example.test/${name}` } +} + +describe('resolveMenubarReleaseAssets', () => { + it('ignores dev zips and pairs the checksum with the versioned zip', () => { + const release: ReleaseResponse = { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-dev.zip'), + asset('CodeBurnMenubar-dev.zip.sha256'), + asset('CodeBurnMenubar-v0.9.8.zip'), + asset('CodeBurnMenubar-v0.9.8.zip.sha256'), + ], + } + + const resolved = resolveMenubarReleaseAssets(release) + + expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.8.zip') + expect(resolved.checksum?.name).toBe('CodeBurnMenubar-v0.9.8.zip.sha256') + }) + + it('fails when a release only contains dev assets', () => { + const release: ReleaseResponse = { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-dev.zip'), + asset('CodeBurnMenubar-dev.zip.sha256'), + ], + } + + expect(() => resolveMenubarReleaseAssets(release)).toThrow(/versioned zip/) + }) + + it('fails when the versioned checksum is missing', () => { + const release: ReleaseResponse = { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-v0.9.8.zip'), + ], + } + + expect(() => resolveMenubarReleaseAssets(release)).toThrow(/Missing checksum/) + }) + + it('selects the newest mac release instead of the newest repo release', () => { + const releases: ReleaseResponse[] = [ + { + tag_name: 'v0.9.9', + assets: [ + asset('codeburn-0.9.9.tgz'), + ], + }, + { + tag_name: 'mac-v0.9.8', + assets: [ + asset('CodeBurnMenubar-v0.9.8.zip'), + asset('CodeBurnMenubar-v0.9.8.zip.sha256'), + ], + }, + ] + + const resolved = resolveLatestMenubarReleaseAssets(releases) + + expect(resolved.release.tag_name).toBe('mac-v0.9.8') + expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.8.zip') + }) + + it('builds direct release asset URLs from the CLI version', () => { + const resolved = resolveVersionedMenubarReleaseAssets('0.9.15') + + expect(resolved.release.tag_name).toBe('mac-v0.9.15') + expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.15.zip') + expect(resolved.zip.browser_download_url).toBe( + 'https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.15/CodeBurnMenubar-v0.9.15.zip' + ) + expect(resolved.checksum.name).toBe('CodeBurnMenubar-v0.9.15.zip.sha256') + expect(resolved.checksum.browser_download_url).toBe( + 'https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.15/CodeBurnMenubar-v0.9.15.zip.sha256' + ) + }) + + it('normalizes a leading v when building direct release URLs', () => { + const resolved = resolveVersionedMenubarReleaseAssets('v0.9.15') + + expect(resolved.release.tag_name).toBe('mac-v0.9.15') + expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.15.zip') + }) + + it('falls back to the release API only for missing direct assets', () => { + expect(shouldFallbackToReleaseApi(404)).toBe(true) + expect(shouldFallbackToReleaseApi(410)).toBe(true) + expect(shouldFallbackToReleaseApi(403)).toBe(false) + expect(shouldFallbackToReleaseApi(429)).toBe(false) + expect(shouldFallbackToReleaseApi(500)).toBe(false) + }) + + it('explains likely rate limiting for GitHub API 403 and 429 errors', () => { + const headerValues: Record<string, string> = { + 'retry-after': '120', + 'x-ratelimit-reset': '1783539204', + } + const headers = { get: (name: string) => headerValues[name] ?? null } + + expect(formatGitHubReleaseLookupError(403, headers)).toContain( + 'GitHub may be rate limiting unauthenticated release API requests' + ) + expect(formatGitHubReleaseLookupError(403, headers)).toContain('retry-after=120') + expect(formatGitHubReleaseLookupError(429, headers)).toContain('x-ratelimit-reset=1783539204') + }) + + it('preserves the caller PATH when building the persistent CLI lookup PATH', () => { + const lookupPath = buildPersistentCodeburnLookupPath('/Users/me/.nvm/versions/node/v22.13.0/bin:/usr/bin') + + expect(lookupPath.split(':')).toContain('/Users/me/.nvm/versions/node/v22.13.0/bin') + expect(lookupPath.split(':')).toContain('/opt/homebrew/bin') + }) + + it('selects a persistent codeburn binary when npx is first in which output', () => { + const resolved = resolvePersistentCodeburnPathFromWhichOutput([ + '/Users/me/.npm/_npx/abcd/node_modules/.bin/codeburn', + '/Users/me/.nvm/versions/node/v22.13.0/bin/codeburn', + ].join('\n')) + + expect(resolved).toBe('/Users/me/.nvm/versions/node/v22.13.0/bin/codeburn') + }) + + it('shows the install guidance instead of a raw env failure when only npx is available', () => { + expect(() => resolvePersistentCodeburnPathFromWhichOutput( + '/Users/me/.npm/_npx/abcd/node_modules/.bin/codeburn' + )).toThrow(/Install CodeBurn globally first/) + }) + + it('uses HTTPS proxy for GitHub HTTPS downloads', () => { + const proxyUrl = resolveProxyUrlForUrl('https://api.github.com/repos/getagentseal/codeburn/releases', { + HTTPS_PROXY: 'http://proxy.company.test:8080', + }) + + expect(proxyUrl).toBe('http://proxy.company.test:8080') + }) + + it('bypasses proxy when NO_PROXY matches the download host', () => { + const proxyUrl = resolveProxyUrlForUrl('https://api.github.com/repos/getagentseal/codeburn/releases', { + HTTPS_PROXY: 'http://proxy.company.test:8080', + NO_PROXY: '.github.com', + }) + + expect(proxyUrl).toBeUndefined() + }) +}) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts new file mode 100644 index 0000000..e685b27 --- /dev/null +++ b/tests/menubar-json.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest' + +import { buildMenubarPayload, type CombinedUsage, type PeriodData, type ProviderCost } from '../src/menubar-json.js' +import type { OptimizeResult } from '../src/optimize.js' + +function emptyPeriod(label: string): PeriodData { + return { + label, + cost: 0, + calls: 0, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + categories: [], + models: [], + } +} + +describe('buildMenubarPayload', () => { + it('emits the full schema with current-period metrics and iso timestamp', () => { + const period: PeriodData = { + label: '7 Days', + cost: 1248.01, + calls: 11231, + sessions: 97, + inputTokens: 19100, + outputTokens: 675600, + cacheReadTokens: 0, + cacheWriteTokens: 0, + categories: [], + models: [], + } + const payload = buildMenubarPayload(period, [], null) + + expect(payload.generated).toMatch(/^\d{4}-\d{2}-\d{2}T/) + expect(payload.current.label).toBe('7 Days') + expect(payload.current.cost).toBe(1248.01) + expect(payload.current.calls).toBe(11231) + expect(payload.current.sessions).toBe(97) + expect(payload.current.inputTokens).toBe(19100) + expect(payload.current.outputTokens).toBe(675600) + }) + + it('exposes period-scoped cache tokens on current, decoupled from the 365-day history backfill (#583)', () => { + const period: PeriodData = { + label: '30 Days', + cost: 5, calls: 10, sessions: 2, + inputTokens: 1000, outputTokens: 2000, + // What `report -p 30days` shows in the terminal for the same window. + cacheReadTokens: 1_391_100_000, + cacheWriteTokens: 42_000_000, + categories: [], models: [], + } + // history.daily is the full BACKFILL_DAYS (365) trend, whose cache totals are + // far larger than the selected period. The web cards used to sum these, which + // is the bug in #583. current must mirror the period, not the backfill. + const dailyHistory = [ + { date: '2025-07-15', cost: 0, savingsUSD: 0, calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 1_800_000_000, cacheWriteTokens: 60_000_000, topModels: [] }, + { date: '2026-06-30', cost: 0, savingsUSD: 0, calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 270_000_000, cacheWriteTokens: 12_000_000, topModels: [] }, + ] + const payload = buildMenubarPayload(period, [], null, dailyHistory) + + // current carries the period totals verbatim ... + expect(payload.current.cacheReadTokens).toBe(1_391_100_000) + expect(payload.current.cacheWriteTokens).toBe(42_000_000) + // ... and is independent of the larger history-backfill sum the cards used before. + const historyReadSum = dailyHistory.reduce((s, d) => s + d.cacheReadTokens, 0) + const historyWriteSum = dailyHistory.reduce((s, d) => s + d.cacheWriteTokens, 0) + expect(historyReadSum).toBeGreaterThan(payload.current.cacheReadTokens) + expect(historyWriteSum).toBeGreaterThan(payload.current.cacheWriteTokens) + }) + + it('computes per-category oneShotRate from editTurns and skips categories without edits', () => { + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [ + { name: 'Coding', cost: 15.83, turns: 7, editTurns: 7, oneShotTurns: 6 }, + { name: 'Conversation', cost: 16.69, turns: 47, editTurns: 0, oneShotTurns: 0 }, + ], + models: [], + } + const payload = buildMenubarPayload(period, [], null) + + const coding = payload.current.topActivities.find(a => a.name === 'Coding')! + expect(coding.oneShotRate).toBeCloseTo(6 / 7) + + const conv = payload.current.topActivities.find(a => a.name === 'Conversation')! + expect(conv.oneShotRate).toBeNull() + }) + + it('computes aggregate oneShotRate across categories with edits', () => { + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [ + { name: 'Coding', cost: 1, turns: 7, editTurns: 10, oneShotTurns: 8 }, + { name: 'Debugging', cost: 1, turns: 5, editTurns: 10, oneShotTurns: 6 }, + { name: 'Conversation', cost: 1, turns: 40, editTurns: 0, oneShotTurns: 0 }, + ], + models: [], + } + const payload = buildMenubarPayload(period, [], null) + expect(payload.current.oneShotRate).toBeCloseTo((8 + 6) / (10 + 10)) + }) + + it('returns null aggregate oneShotRate when no categories have editTurns', () => { + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [{ name: 'Conversation', cost: 1, turns: 5, editTurns: 0, oneShotTurns: 0 }], + models: [], + } + const payload = buildMenubarPayload(period, [], null) + expect(payload.current.oneShotRate).toBeNull() + }) + + it('filters out the synthetic model and caps topModels at 20 so multi-model users see all their models', () => { + const models = Array.from({ length: 30 }, (_, i) => ({ + name: `Model${i}`, cost: 30 - i, calls: 100, + })) + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [], + models: [{ name: '<synthetic>', cost: 99, calls: 0 }, ...models], + } + const payload = buildMenubarPayload(period, [], null) + expect(payload.current.topModels.find(m => m.name === '<synthetic>')).toBeUndefined() + expect(payload.current.topModels).toHaveLength(20) + expect(payload.current.topModels[0].name).toBe('Model0') + }) + + it('caps topActivities at 20 so all task categories can surface', () => { + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: Array.from({ length: 25 }, (_, i) => ({ + name: `Cat${i}`, cost: 1, turns: 1, editTurns: 1, oneShotTurns: 1, + })), + models: [], + } + const payload = buildMenubarPayload(period, [], null) + expect(payload.current.topActivities).toHaveLength(20) + }) + + it('computes cacheHitPercent from cache reads over input plus cache reads', () => { + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 900, + cacheWriteTokens: 0, + categories: [], + models: [], + } + const payload = buildMenubarPayload(period, [], null) + expect(payload.current.cacheHitPercent).toBeCloseTo(90) + }) + + it('returns zero cacheHitPercent when there is no input or cache traffic', () => { + const payload = buildMenubarPayload(emptyPeriod('Today'), [], null) + expect(payload.current.cacheHitPercent).toBe(0) + }) + + it('handles null optimize as empty findings block', () => { + const payload = buildMenubarPayload(emptyPeriod('Today'), [], null) + expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] }) + }) + + it('converts tokensSaved to savingsUSD via costRate and caps topFindings at 10', () => { + const findings = Array.from({ length: 15 }, (_, i) => ({ + title: `F${i}`, explanation: '', impact: 'low' as const, tokensSaved: 1000, + fix: { type: 'paste' as const, label: '', text: '' }, + })) + const optimize: OptimizeResult = { + findings, + costRate: 0.00002, + healthScore: 60, + healthGrade: 'C', + } + const payload = buildMenubarPayload(emptyPeriod('Today'), [], optimize) + + expect(payload.optimize.findingCount).toBe(15) + expect(payload.optimize.topFindings).toHaveLength(10) + expect(payload.optimize.topFindings[0].title).toBe('F0') + expect(payload.optimize.topFindings[0].savingsUSD).toBeCloseTo(1000 * 0.00002) + expect(payload.optimize.savingsUSD).toBeCloseTo(15 * 1000 * 0.00002) + }) + + it('maps providers into a lowercased dict inside the current-period block', () => { + const providers: ProviderCost[] = [ + { name: 'Claude Code', cost: 76.45 }, + { name: 'Cursor', cost: 2.18 }, + { name: 'Codex', cost: 1.5 }, + ] + const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null) + expect(payload.current.providers).toEqual({ 'claude code': 76.45, cursor: 2.18, codex: 1.5 }) + }) + + it('keeps zero-cost providers in the dict so installed-but-unused providers still render as tabs', () => { + const providers: ProviderCost[] = [ + { name: 'Claude', cost: 76.45 }, + { name: 'Codex', cost: 0 }, + { name: 'Cursor', cost: 2.18 }, + ] + const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null) + expect(payload.current.providers).toEqual({ claude: 76.45, codex: 0, cursor: 2.18 }) + }) + + it('includes up to 365 daily history entries sorted ascending by date', () => { + const history = Array.from({ length: 400 }, (_, i) => { + const d = new Date(2025, 0, 1) + d.setDate(d.getDate() + i) + return { + date: d.toISOString().slice(0, 10), + cost: i, + calls: i * 10, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: [], + } + }) + const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history) + expect(payload.history.daily).toHaveLength(365) + expect(payload.history.daily[0]!.date < payload.history.daily[364]!.date).toBe(true) + expect(payload.history.daily[364]!.date).toBe(history[399]!.date) + }) + + it('preserves token fields in dailyHistory entries', () => { + const history = [ + { date: '2026-04-15', cost: 10, calls: 50, inputTokens: 100, outputTokens: 200, cacheReadTokens: 5000, cacheWriteTokens: 800, topModels: [{ name: 'Opus 4.7', cost: 8, calls: 40, inputTokens: 80, outputTokens: 160 }] }, + { date: '2026-04-16', cost: 20, calls: 75, inputTokens: 150, outputTokens: 350, cacheReadTokens: 8000, cacheWriteTokens: 1200, topModels: [] }, + ] + const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history) + expect(payload.history.daily[0]).toEqual(history[0]) + expect(payload.history.daily[1]).toEqual(history[1]) + }) + + it('returns empty history when none supplied', () => { + const payload = buildMenubarPayload(emptyPeriod('Today'), [], null) + expect(payload.history.daily).toEqual([]) + }) + + it('drops providers with negative cost defensively', () => { + const providers: ProviderCost[] = [ + { name: 'Claude', cost: 76.45 }, + { name: 'Broken', cost: -1 }, + ] + const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null) + expect(payload.current.providers).toEqual({ claude: 76.45 }) + }) + + it('omits combined usage by default and accepts the documented combined shape when attached', () => { + const payload = buildMenubarPayload(emptyPeriod('Today'), [], null) + expect(payload).not.toHaveProperty('combined') + + const combined: CombinedUsage = { + perDevice: [ + { + id: 'local', + name: 'Mac Studio', + local: true, + cost: 1, + calls: 2, + sessions: 1, + inputTokens: 100, + outputTokens: 50, + cacheCreateTokens: 10, + cacheReadTokens: 20, + totalTokens: 180, + }, + ], + combined: { + cost: 1, + calls: 2, + sessions: 1, + inputTokens: 100, + outputTokens: 50, + cacheCreateTokens: 10, + cacheReadTokens: 20, + totalTokens: 180, + deviceCount: 1, + reachableCount: 1, + }, + } + payload.combined = combined + + expect(payload.combined).toEqual(combined) + }) + + it('emits Claude config selector metadata only when multiple configs are available', () => { + const oneConfig = buildMenubarPayload(emptyPeriod('Today'), [], null, undefined, undefined, undefined, undefined, { + selectedId: null, + options: [{ id: 'claude-config:a', label: 'claude-work', path: '/tmp/claude-work' }], + }) + expect(oneConfig).not.toHaveProperty('claudeConfigs') + + const twoConfigs = buildMenubarPayload(emptyPeriod('Today'), [], null, undefined, undefined, undefined, undefined, { + selectedId: 'claude-config:b', + options: [ + { id: 'claude-config:a', label: 'claude-work', path: '/tmp/claude-work' }, + { id: 'claude-config:b', label: 'claude-personal', path: '/tmp/claude-personal' }, + ], + }) + + expect(twoConfigs.claudeConfigs).toEqual({ + selectedId: 'claude-config:b', + options: [ + { id: 'claude-config:a', label: 'claude-work', path: '/tmp/claude-work' }, + { id: 'claude-config:b', label: 'claude-personal', path: '/tmp/claude-personal' }, + ], + }) + }) +}) diff --git a/tests/menubar-savings.test.ts b/tests/menubar-savings.test.ts new file mode 100644 index 0000000..ba416ab --- /dev/null +++ b/tests/menubar-savings.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' + +import { buildMenubarPayload, type LocalModelSavings, type PeriodData } from '../src/menubar-json.js' + +function basePeriod(overrides: Partial<PeriodData> = {}): PeriodData { + return { + label: '7 Days', + cost: 0, + savingsUSD: 0, + calls: 0, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + categories: [], + models: [], + ...overrides, + } +} + +describe('buildMenubarPayload: local-model savings', () => { + it('defaults localModelSavings to an empty block when no breakdown is provided', () => { + const payload = buildMenubarPayload(basePeriod(), [], null) + expect(payload.current.localModelSavings).toEqual({ totalUSD: 0, calls: 0, byModel: [], byProvider: [] }) + }) + + it('threads the localModelSavings breakdown into the payload when supplied', () => { + const breakdown: LocalModelSavings = { + totalUSD: 12.34, + calls: 7, + byModel: [ + { name: 'llama3.1:8b', calls: 4, actualUSD: 0, savingsUSD: 7.21, baselineModel: 'gpt-4o', inputTokens: 1234, outputTokens: 567 }, + { name: 'qwen2.5:32b', calls: 3, actualUSD: 0, savingsUSD: 5.13, baselineModel: 'claude-opus-4-6', inputTokens: 4321, outputTokens: 876 }, + ], + byProvider: [ + { name: 'ollama', calls: 7, savingsUSD: 12.34 }, + ], + } + const payload = buildMenubarPayload(basePeriod(), [], null, undefined, undefined, undefined, { + localModelSavings: breakdown, + }) + expect(payload.current.localModelSavings).toEqual(breakdown) + }) + + it('exposes savingsUSD and savingsBaselineModel on top models', () => { + const payload = buildMenubarPayload(basePeriod({ + models: [ + { name: 'Local Model', cost: 0, savingsUSD: 5, calls: 1 }, + { name: 'gpt-4o', cost: 2, savingsUSD: 0, calls: 1 }, + ], + }), [], null) + const local = payload.current.topModels.find(m => m.name === 'Local Model')! + expect(local.savingsUSD).toBe(5) + expect(local.cost).toBe(0) + const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')! + expect(paid.savingsUSD).toBe(0) + }) + + it('surfaces savingsUSD on top projects and top sessions', () => { + const payload = buildMenubarPayload(basePeriod({ + cost: 2, + savingsUSD: 5, + projects: [ + { name: 'p', cost: 2, savingsUSD: 5, sessions: 1, sessionDetails: [{ cost: 2, savingsUSD: 5, calls: 1, inputTokens: 0, outputTokens: 0, date: '2026-04-10', models: [{ name: 'Local Model', cost: 0, savingsUSD: 5 }] }] }, + ], + topSessions: [{ project: 'p', cost: 2, savingsUSD: 5, calls: 1, date: '2026-04-10' }], + }), [], null) + const proj = payload.current.topProjects[0]! + expect(proj.savingsUSD).toBe(5) + expect(proj.sessionDetails[0]!.savingsUSD).toBe(5) + expect(proj.sessionDetails[0]!.models[0]!.savingsUSD).toBe(5) + const session = payload.current.topSessions[0]! + expect(session.savingsUSD).toBe(5) + }) + + it('emits savingsUSD and per-model breakdown in history entries', () => { + const payload = buildMenubarPayload(basePeriod(), [], null, [ + { date: '2026-04-10', cost: 2, savingsUSD: 5, calls: 1, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, topModels: [{ name: 'Local Model', cost: 0, savingsUSD: 5, calls: 1, inputTokens: 0, outputTokens: 0 }] }, + ]) + expect(payload.history.daily[0]!.savingsUSD).toBe(5) + expect(payload.history.daily[0]!.topModels[0]!.savingsUSD).toBe(5) + }) + + it('keeps topActivities savingsUSD aligned with category rollups', () => { + const payload = buildMenubarPayload(basePeriod({ + categories: [{ name: 'Coding', cost: 0, savingsUSD: 5, turns: 1, editTurns: 0, oneShotTurns: 0 }], + }), [], null) + expect(payload.current.topActivities[0]!.savingsUSD).toBe(5) + }) +}) diff --git a/tests/minimax.test.ts b/tests/minimax.test.ts new file mode 100644 index 0000000..cfc3e25 --- /dev/null +++ b/tests/minimax.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' + +import { getModelCosts, getShortModelName } from '../src/models.js' + +// Verifies MiniMax pricing loaded from FALLBACK_PRICING (no network call). +// pricingCache stays null until loadPricing() runs, so getModelCosts falls +// through to FALLBACK_PRICING which is what we want to validate here. + +describe('MiniMax model pricing', () => { + it('returns pricing for MiniMax-M2.7', () => { + const costs = getModelCosts('MiniMax-M2.7') + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(0.3e-6) + expect(costs!.outputCostPerToken).toBe(1.2e-6) + expect(costs!.cacheReadCostPerToken).toBe(0.06e-6) + expect(costs!.cacheWriteCostPerToken).toBe(0.375e-6) + expect(costs!.fastMultiplier).toBe(1) + }) + + it('returns pricing for MiniMax-M2.7-highspeed', () => { + const costs = getModelCosts('MiniMax-M2.7-highspeed') + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(0.6e-6) + expect(costs!.outputCostPerToken).toBe(2.4e-6) + expect(costs!.cacheReadCostPerToken).toBe(0.06e-6) + expect(costs!.cacheWriteCostPerToken).toBe(0.375e-6) + expect(costs!.fastMultiplier).toBe(1) + }) + + it('returns official pricing for MiniMax-M3', () => { + // MiniMax moved M3 to tiered pricing (platform.minimax.io pay-as-you-go): + // $0.3/$1.2 per M is the official standard tier (inputs up to 512K), with + // $0.6/$2.4 above 512K. The snapshot carries the standard tier, matching + // how other tiered models are priced here. + const costs = getModelCosts('MiniMax-M3') + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(0.3e-6) + expect(costs!.outputCostPerToken).toBe(1.2e-6) + }) + + it('highspeed pricing is distinct from base model pricing', () => { + const base = getModelCosts('MiniMax-M2.7') + const fast = getModelCosts('MiniMax-M2.7-highspeed') + expect(fast!.inputCostPerToken).toBeGreaterThan(base!.inputCostPerToken) + expect(fast!.outputCostPerToken).toBeGreaterThan(base!.outputCostPerToken) + }) + + it('returns short name for MiniMax-M2.7', () => { + expect(getShortModelName('MiniMax-M2.7')).toBe('MiniMax M2.7') + }) + + it('returns short name for MiniMax-M2.7-highspeed', () => { + expect(getShortModelName('MiniMax-M2.7-highspeed')).toBe('MiniMax M2.7 Highspeed') + }) + + it('handles MiniMax model ID with date suffix', () => { + expect(getShortModelName('MiniMax-M2.7-20260101')).toBe('MiniMax M2.7') + }) +}) diff --git a/tests/model-efficiency.test.ts b/tests/model-efficiency.test.ts new file mode 100644 index 0000000..cd7ae89 --- /dev/null +++ b/tests/model-efficiency.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' + +import { aggregateModelEfficiency } from '../src/model-efficiency.js' +import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js' + +function call(model: string, costUSD = 1): ParsedApiCall { + return { + provider: 'claude', + model, + usage: { + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD, + tools: ['Edit'], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-05T00:00:00Z', + bashCommands: [], + deduplicationKey: `${model}-${costUSD}`, + } +} + +function turn(model: string, opts: { hasEdits?: boolean; retries?: number; costUSD?: number } = {}): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: [call(model, opts.costUSD ?? 1)], + timestamp: '2026-05-05T00:00:00Z', + sessionId: 's1', + category: 'coding', + retries: opts.retries ?? 0, + hasEdits: opts.hasEdits ?? true, + } +} + +function multiModelTurn(calls: ParsedApiCall[], opts: { retries?: number; hasEdits?: boolean } = {}): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: calls, + timestamp: '2026-05-05T00:00:00Z', + sessionId: 's1', + category: 'coding', + retries: opts.retries ?? 0, + hasEdits: opts.hasEdits ?? true, + } +} + +function project(turns: ClassifiedTurn[]): ProjectSummary { + const session: SessionSummary = { + sessionId: 's1', + project: 'app', + firstTimestamp: '2026-05-05T00:00:00Z', + lastTimestamp: '2026-05-05T00:00:00Z', + totalCostUSD: turns.reduce((sum, t) => sum + t.assistantCalls.reduce((s, c) => s + c.costUSD, 0), 0), + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: turns.reduce((sum, t) => sum + t.assistantCalls.length, 0), + turns, + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + } + return { + project: 'app', + projectPath: '/app', + sessions: [session], + totalCostUSD: session.totalCostUSD, + totalApiCalls: session.apiCalls, + } +} + +describe('aggregateModelEfficiency', () => { + it('computes one-shot, retry, and cost-per-edit metrics by display model', () => { + const stats = aggregateModelEfficiency([project([ + turn('claude-sonnet-4-5', { hasEdits: true, retries: 0, costUSD: 2 }), + turn('claude-sonnet-4-5', { hasEdits: true, retries: 2, costUSD: 4 }), + turn('claude-opus-4-6', { hasEdits: true, retries: 0, costUSD: 10 }), + turn('claude-sonnet-4-5', { hasEdits: false, retries: 0, costUSD: 3 }), + ])]) + + const sonnet = stats.get('Sonnet 4.5') + expect(sonnet?.editTurns).toBe(2) + expect(sonnet?.oneShotTurns).toBe(1) + expect(sonnet?.oneShotRate).toBe(50) + expect(sonnet?.retriesPerEdit).toBe(1) + expect(sonnet?.costPerEditUSD).toBe(3) + + const opus = stats.get('Opus 4.6') + expect(opus?.oneShotRate).toBe(100) + }) + + it('returns no stats for non-edit turns', () => { + const stats = aggregateModelEfficiency([project([ + turn('claude-sonnet-4-5', { hasEdits: false }), + ])]) + + expect(stats.size).toBe(0) + }) + + it('attributes a multi-model turn to the first non-synthetic model', () => { + const stats = aggregateModelEfficiency([project([ + multiModelTurn([ + call('<synthetic>', 0), + call('claude-opus-4-6', 2), + call('claude-sonnet-4-5', 1), + ], { retries: 0, hasEdits: true }), + ])]) + + expect(stats.has('Opus 4.6')).toBe(true) + expect(stats.has('Sonnet 4.5')).toBe(false) + expect(stats.has('<synthetic>')).toBe(false) + const opus = stats.get('Opus 4.6')! + expect(opus.editTurns).toBe(1) + expect(opus.oneShotTurns).toBe(1) + expect(opus.costPerEditUSD).toBe(3) + }) + + it('skips a turn whose calls are all synthetic', () => { + const stats = aggregateModelEfficiency([project([ + multiModelTurn([ + call('<synthetic>', 0), + call('<synthetic>', 0), + ], { retries: 0, hasEdits: true }), + ])]) + + expect(stats.size).toBe(0) + }) +}) diff --git a/tests/models-hoist.test.ts b/tests/models-hoist.test.ts new file mode 100644 index 0000000..324e6ff --- /dev/null +++ b/tests/models-hoist.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest' +import { calculateCost, getModelCosts, getShortModelName } from '../src/models.js' + +// Lock down the post-hoist refactor: every model name a real user has +// emitted in the last year should resolve to the same display name and +// the same costs as before. If this list grows or shrinks, the refactor +// is fine — it's the per-name resolution that must stay stable. +const KNOWN_NAMES = [ + 'claude-opus-4-7', + 'claude-opus-4-6', + 'claude-opus-4-5', + 'claude-sonnet-4-6', + 'claude-sonnet-4-5', + 'claude-haiku-4-5', + 'claude-3-5-sonnet', + 'claude-3-5-haiku', + 'claude-opus-4-7-20250101', + 'claude-sonnet-4-6-20250929', + 'anthropic/claude-opus-4-7', + 'anthropic--claude-4.6-opus', + 'anthropic--claude-4.6-sonnet', + 'claude-4.6-sonnet', + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-5-pro', + 'gpt-5.1', + 'gpt-5.1-codex', + 'gpt-5.1-codex-mini', + 'gpt-5.2', + 'gpt-5.2-low', + 'gpt-5.3-codex', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-3.1-pro-preview', + 'gemini-3-flash-preview', + 'gemini-3.1-pro', + 'gemini-3-flash', + 'cursor-auto', + 'cursor-agent-auto', + 'copilot-auto', + 'copilot-openai-auto', + 'kiro-auto', + 'cline-auto', + 'qwen-auto', + 'kimi-auto', + 'kimi-for-coding', + 'kimi-k2-thinking-turbo', + 'kimi-k2.6', + 'o3', + 'o4-mini', + 'deepseek-coder', + 'deepseek-coder-max', + 'deepseek-r1', + 'MiniMax-M2.7', + 'MiniMax-M2.7-highspeed', +] + +describe('post-hoist resolution stability', () => { + it('every known model resolves to a non-empty short name', () => { + for (const name of KNOWN_NAMES) { + const short = getShortModelName(name) + expect(short, `short name for ${name}`).toBeTruthy() + expect(typeof short, `short name for ${name}`).toBe('string') + } + }) + + it('gpt-5-mini does NOT collide with gpt-5 (longest-prefix wins)', () => { + expect(getShortModelName('gpt-5-mini')).toBe('GPT-5 Mini') + expect(getShortModelName('gpt-5')).toBe('GPT-5') + expect(getShortModelName('gpt-5-nano')).toBe('GPT-5 Nano') + expect(getShortModelName('gpt-5-pro')).toBe('GPT-5 Pro') + }) + + it('gpt-5.1-codex-mini does NOT collapse to gpt-5.1-codex or gpt-5', () => { + expect(getShortModelName('gpt-5.1-codex-mini')).toBe('GPT-5.1 Codex Mini') + expect(getShortModelName('gpt-5.1-codex')).toBe('GPT-5.1 Codex') + expect(getShortModelName('gpt-5.1')).toBe('GPT-5.1') + }) + + it('claude-haiku-4-5 does NOT collapse to claude-haiku-4 or claude-3-5-haiku', () => { + expect(getShortModelName('claude-haiku-4-5')).toBe('Haiku 4.5') + expect(getShortModelName('claude-3-5-haiku')).toBe('Haiku 3.5') + }) + + it('kimi managed aliases resolve to priced Kimi models', () => { + expect(getShortModelName('kimi-auto')).toBe('Kimi (auto)') + expect(getShortModelName('kimi-for-coding')).toBe('Kimi K2 Thinking') + expect(getShortModelName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo') + expect(getShortModelName('kimi-k2.6')).toBe('Kimi K2.6') + expect(getModelCosts('kimi-auto')?.inputCostPerToken).toBeGreaterThan(0) + }) + + it('getModelCosts returns positive token costs for every known name', () => { + for (const name of KNOWN_NAMES) { + const c = getModelCosts(name) + expect(c, `costs for ${name}`).not.toBeNull() + expect(c!.inputCostPerToken).toBeGreaterThan(0) + expect(c!.outputCostPerToken).toBeGreaterThan(0) + } + }) + + it('calculateCost is stable for a typical Sonnet 4.6 turn', () => { + // 1k input, 2k output, 50k cache read — common Claude Code shape. + const cost = calculateCost('claude-sonnet-4-6', 1000, 2000, 0, 50_000, 0) + expect(cost).toBeGreaterThan(0) + expect(Number.isFinite(cost)).toBe(true) + }) + + it('calculateCost clamps NaN/negative inputs to 0', () => { + const c1 = calculateCost('claude-sonnet-4-6', NaN, 1000, 0, 0, 0) + const c2 = calculateCost('claude-sonnet-4-6', 0, 1000, 0, 0, 0) + expect(c1).toBe(c2) + const c3 = calculateCost('claude-sonnet-4-6', -1000, 1000, 0, 0, 0) + expect(c3).toBe(c2) + }) + + it('repeated calls return the same cost (memoized sort cache is consistent)', () => { + const a = getModelCosts('gpt-5-mini') + const b = getModelCosts('gpt-5-mini') + const c = getModelCosts('gpt-5-mini') + expect(a).toEqual(b) + expect(b).toEqual(c) + }) +}) diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts new file mode 100644 index 0000000..0d3c780 --- /dev/null +++ b/tests/models-report.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect } from 'vitest' +import chalk from 'chalk' +import stripAnsi from 'strip-ansi' + +import { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv, type ModelReportRow } from '../src/models-report.js' +import type { + ProjectSummary, + SessionSummary, + ClassifiedTurn, + ParsedApiCall, + TokenUsage, + TaskCategory, +} from '../src/types.js' + +function emptyTokens(): TokenUsage { + return { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } +} + +function makeCall(opts: { + provider: string + model: string + costUSD: number + input?: number + output?: number + cacheWrite?: number + cacheRead?: number +}): ParsedApiCall { + return { + provider: opts.provider, + model: opts.model, + usage: { + ...emptyTokens(), + inputTokens: opts.input ?? 0, + outputTokens: opts.output ?? 0, + cacheCreationInputTokens: opts.cacheWrite ?? 0, + cacheReadInputTokens: opts.cacheRead ?? 0, + }, + costUSD: opts.costUSD, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-09T00:00:00.000Z', + bashCommands: [], + deduplicationKey: `${opts.provider}-${opts.model}-${opts.costUSD}`, + } +} + +function makeTurn(category: TaskCategory, calls: ParsedApiCall[]): ClassifiedTurn { + return { + userMessage: 'test', + assistantCalls: calls, + timestamp: '2026-05-09T00:00:00.000Z', + sessionId: 's1', + category, + retries: 0, + hasEdits: false, + } +} + +function makeSession(turns: ClassifiedTurn[]): SessionSummary { + return { + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-05-09T00:00:00.000Z', + lastTimestamp: '2026-05-09T00:00:00.000Z', + totalCostUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 0, + turns, + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + } +} + +function makeProject(turns: ClassifiedTurn[]): ProjectSummary { + return { + project: 'p', + projectPath: '/tmp/p', + sessions: [makeSession(turns)], + totalCostUSD: 0, + totalApiCalls: 0, + } +} + +describe('aggregateModels', () => { + it('groups by (provider, model) and sorts by cost descending in default mode', async () => { + const project = makeProject([ + makeTurn('feature', [ + makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 1000, output: 200, cacheWrite: 500, cacheRead: 8000, costUSD: 5.0 }), + ]), + makeTurn('debugging', [ + makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 800, output: 100, cacheWrite: 300, cacheRead: 5000, costUSD: 3.5 }), + ]), + makeTurn('feature', [ + makeCall({ provider: 'codex', model: 'gpt-5', input: 600, output: 80, costUSD: 1.2 }), + ]), + ]) + const rows = await aggregateModels([project]) + expect(rows.map(r => `${r.provider}:${r.model}`)).toEqual(['claude:claude-sonnet-4-6', 'codex:gpt-5']) + const claudeRow = rows[0]! + expect(claudeRow.inputTokens).toBe(1800) + expect(claudeRow.outputTokens).toBe(300) + expect(claudeRow.cacheWriteTokens).toBe(800) + expect(claudeRow.cacheReadTokens).toBe(13000) + expect(claudeRow.costUSD).toBeCloseTo(8.5, 6) + expect(claudeRow.calls).toBe(2) + expect(claudeRow.totalTokens).toBe(1800 + 300 + 800 + 13000) + }) + + it('computes Codex credits per model and leaves non-Codex / unknown models null', async () => { + const rows = await aggregateModels([makeProject([ + // gpt-5.5: 1M non-cached input (125) + 1M cached read (12.5) + 1M output (750) = 887.5 credits + makeTurn('feature', [ + makeCall({ provider: 'codex', model: 'gpt-5.5', input: 1_000_000, output: 1_000_000, cacheRead: 1_000_000, costUSD: 9 }), + ]), + // codex but no known credit rate -> null + makeTurn('feature', [ + makeCall({ provider: 'codex', model: 'gpt-5', input: 1000, output: 80, costUSD: 1.2 }), + ]), + // non-codex provider -> null even if tokens present + makeTurn('feature', [ + makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', input: 1000, output: 200, costUSD: 5 }), + ]), + ])]) + const byKey = Object.fromEntries(rows.map(r => [`${r.provider}:${r.model}`, r])) + expect(byKey['codex:gpt-5.5']!.credits).toBeCloseTo(887.5, 6) + expect(byKey['codex:gpt-5']!.credits).toBeNull() + expect(byKey['claude:claude-sonnet-4-6']!.credits).toBeNull() + }) + + it('includes credits in the JSON output', async () => { + const rows = await aggregateModels([makeProject([ + makeTurn('feature', [ + makeCall({ provider: 'codex', model: 'gpt-5.5', input: 0, output: 1_000_000, cacheRead: 0, costUSD: 9 }), + ]), + ])]) + const parsed = JSON.parse(renderJson(rows)) + expect(parsed[0].credits).toBeCloseTo(750, 6) + }) + + it('does not double-count cache reads when a provider sets both cache fields', async () => { + // Providers like codex/mux/codebuff populate cacheReadInputTokens AND + // cachedInputTokens with the same value (Anthropic vs OpenAI vocabulary for + // the same tokens). The report must count them once, not sum them. + const call = makeCall({ provider: 'mux', model: 'claude-opus-4-8', input: 100, output: 50, cacheRead: 4000, costUSD: 2.0 }) + call.usage.cachedInputTokens = 4000 // mirrors cacheReadInputTokens, as those providers do + + const rows = await aggregateModels([makeProject([makeTurn('feature', [call])])]) + expect(rows[0]!.cacheReadTokens).toBe(4000) // not 8000 + }) + + it('reports the dominant task type with its cost share in default mode', async () => { + const project = makeProject([ + makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 6.0, input: 100, output: 20 })]), + makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]), + makeTurn('refactoring', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]), + ]) + const rows = await aggregateModels([project]) + expect(rows[0]!.topCategory).toBe('feature') + expect(rows[0]!.topCategoryShare).toBeCloseTo(0.6, 3) + }) + + it('explodes rows by task in byTask mode and groups them so renderer can blank repeats', async () => { + const project = makeProject([ + makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 6.0, input: 100, output: 20 })]), + makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]), + makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 1.0, input: 60, output: 10 })]), + ]) + const rows = await aggregateModels([project], { byTask: true }) + expect(rows).toHaveLength(3) + // Group order: claude (8.0) before codex (1.0); within claude, feature (6.0) before debugging (2.0). + expect(rows.map(r => `${r.provider}:${r.model}:${r.category}`)).toEqual([ + 'claude:claude-sonnet-4-6:feature', + 'claude:claude-sonnet-4-6:debugging', + 'codex:gpt-5:feature', + ]) + }) + + it('respects taskFilter by excluding non-matching turns from every bucket', async () => { + const project = makeProject([ + makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 5.0, input: 100, output: 20 })]), + makeTurn('debugging', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]), + ]) + const rows = await aggregateModels([project], { taskFilter: 'feature' }) + expect(rows).toHaveLength(1) + expect(rows[0]!.costUSD).toBeCloseTo(5.0, 6) + }) + + it('applies topN and minCost filters', async () => { + const project = makeProject([ + makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 5.0, input: 100, output: 20 })]), + makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 0.5, input: 50, output: 10 })]), + makeTurn('feature', [makeCall({ provider: 'cursor', model: 'auto', costUSD: 0.001, input: 10, output: 1 })]), + ]) + const top = await aggregateModels([project], { topN: 1 }) + expect(top).toHaveLength(1) + const above = await aggregateModels([project], { minCost: 0.01 }) + expect(above.find(r => r.provider === 'cursor')).toBeUndefined() + }) + + it('counts reasoning tokens as output tokens', async () => { + const project = makeProject([ + makeTurn('feature', [ + { + provider: 'codex', + model: 'gpt-5', + usage: { ...emptyTokens(), inputTokens: 100, outputTokens: 50, reasoningTokens: 200 }, + costUSD: 1.0, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-09T00:00:00.000Z', + bashCommands: [], + deduplicationKey: 'k', + }, + ]), + ]) + const rows = await aggregateModels([project]) + expect(rows[0]!.outputTokens).toBe(250) + }) +}) + +describe('renderTable', () => { + function visibleWidth(line: string): number { + return stripAnsi(line).length + } + + function row(partial: Partial<ModelReportRow>): ModelReportRow { + return { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + category: null, + inputTokens: 0, + outputTokens: 0, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUSD: 0, + savingsUSD: 0, + savingsBaselineModel: '', + calls: 0, + credits: null, + ...partial, + } + } + + it('blanks repeated provider/model cells in byTask mode but keeps them in default mode', () => { + const rows: ModelReportRow[] = [ + row({ category: 'feature', costUSD: 7.78, inputTokens: 512_000, outputTokens: 98_000, cacheWriteTokens: 1_400_000, cacheReadTokens: 6_200_000, totalTokens: 8_210_000 }), + row({ category: 'debugging', costUSD: 5.31, inputTokens: 380_000, outputTokens: 71_000, cacheWriteTokens: 920_000, cacheReadTokens: 4_100_000, totalTokens: 5_471_000 }), + ] + const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 200 }) + const lines = out.split('\n') + // Layout: top border, header, header-separator, data..., bottom border. + const dataLines = lines.slice(3, -1) + expect(dataLines[0]).toContain('Sonnet 4.6') + expect(dataLines[0]).toContain('Feature Dev') + expect(dataLines[1]).not.toContain('Sonnet 4.6') + expect(dataLines[1]).not.toContain('Claude') + expect(dataLines[1]).toContain('Debugging') + }) + + it('keeps provider/model cells on every row in default mode', () => { + const rows: ModelReportRow[] = [ + row({ topCategory: 'feature', topCategoryShare: 0.6, costUSD: 5.0 }), + row({ provider: 'codex', providerDisplayName: 'Codex', model: 'gpt-5', modelDisplayName: 'GPT-5', topCategory: 'debugging', topCategoryShare: 0.4, costUSD: 1.2 }), + ] + const out = renderTable(rows, { byTask: false, showTotals: false, terminalWidth: 200 }) + const dataLines = out.split('\n').slice(3, -1) + expect(dataLines[0]).toContain('Sonnet 4.6') + expect(dataLines[1]).toContain('GPT-5') + }) + + it('drops cache columns when terminal is narrow', () => { + const rows: ModelReportRow[] = [row({ topCategory: 'feature', topCategoryShare: 1, costUSD: 1 })] + const wide = renderTable(rows, { showTotals: false, terminalWidth: 200 }) + const narrow = renderTable(rows, { showTotals: false, terminalWidth: 80 }) + expect(wide).toContain('Cache Write') + expect(narrow).not.toContain('Cache Write') + expect(narrow).not.toContain('Cache Read') + }) + + it('expands table borders to the available terminal width by default', () => { + const rows: ModelReportRow[] = [ + row({ category: 'coding', costUSD: 1.0, inputTokens: 46_300, outputTokens: 3_700_000, cacheWriteTokens: 16_300_000, cacheReadTokens: 1_569_800_000, totalTokens: 1_589_800_000 }), + row({ category: 'delegation', costUSD: 0.5, inputTokens: 44_200, outputTokens: 1_900_000, cacheWriteTokens: 9_400_000, cacheReadTokens: 499_600_000, totalTokens: 511_000_000 }), + ] + const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 132 }) + const lines = out.split('\n') + expect(visibleWidth(lines[0]!)).toBe(132) + expect(visibleWidth(lines[1]!)).toBe(132) + expect(visibleWidth(lines.at(-1)!)).toBe(132) + }) + + it('keeps every colored table row aligned to the same visible width', () => { + const originalLevel = chalk.level + chalk.level = 1 + try { + const rows: ModelReportRow[] = [ + row({ category: 'coding', costUSD: 978.89, inputTokens: 46_300, outputTokens: 3_700_000, cacheWriteTokens: 16_300_000, cacheReadTokens: 1_569_800_000, totalTokens: 1_589_800_000 }), + row({ category: 'delegation', costUSD: 357.0, inputTokens: 44_200, outputTokens: 1_900_000, cacheWriteTokens: 9_400_000, cacheReadTokens: 499_600_000, totalTokens: 511_000_000 }), + row({ category: 'exploration', costUSD: 324.86, inputTokens: 96_800, outputTokens: 1_600_000, cacheWriteTokens: 16_600_000, cacheReadTokens: 359_400_000, totalTokens: 377_800_000 }), + ] + const out = renderTable(rows, { byTask: true, terminalWidth: 160 }) + const widths = out.split('\n').map(visibleWidth) + expect(new Set(widths)).toEqual(new Set([160])) + } finally { + chalk.level = originalLevel + } + }) + + it('can render compact tables when fullWidth is disabled', () => { + const rows: ModelReportRow[] = [ + row({ category: 'coding', costUSD: 1.0, inputTokens: 46_300, outputTokens: 3_700_000, totalTokens: 1_589_800_000 }), + ] + const out = renderTable(rows, { byTask: true, showTotals: false, terminalWidth: 160, fullWidth: false }) + expect(visibleWidth(out.split('\n')[0]!)).toBeLessThan(160) + }) + + it('emits a footer totals row by default and suppresses it under showTotals=false', () => { + const rows: ModelReportRow[] = [row({ costUSD: 1.0, inputTokens: 100, totalTokens: 100 })] + expect(renderTable(rows, { showTotals: true })).toContain('Total') + expect(renderTable(rows, { showTotals: false })).not.toMatch(/^\s*Total/m) + }) +}) + +describe('renderMarkdown', () => { + it('produces a GitHub-flavored markdown table with right-aligned numeric columns', () => { + const rows: ModelReportRow[] = [ + { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + category: null, + topCategory: 'feature', + topCategoryShare: 0.6, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 1.5, + calls: 1, + }, + ] + const md = renderMarkdown(rows, { showTotals: false }) + const lines = md.split('\n') + expect(lines[0]).toBe('| Provider | Model | Top Task | Input | Output | Cache Write | Cache Read | Total | Cost | Saved |') + expect(lines[1]).toBe('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + expect(lines[2]).toContain('| Claude |') + expect(lines[2]).toContain('`Sonnet 4.6`') + expect(lines[2]).toContain('Feature Dev (60%)') + }) + + it('escapes pipe characters in provider/model names', () => { + const rows: ModelReportRow[] = [ + { + provider: 'odd', + providerDisplayName: 'A|B', + model: 'm|n', + modelDisplayName: 'M|N', + category: null, + topCategory: 'feature', + topCategoryShare: 1, + inputTokens: 0, + outputTokens: 0, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUSD: 0, + calls: 0, + }, + ] + const md = renderMarkdown(rows, { showTotals: false }) + expect(md).toContain('A\\|B') + expect(md).toContain('M\\|N') + }) + + it('emits a bold totals row when showTotals is true', () => { + const rows: ModelReportRow[] = [ + { + provider: 'p', + providerDisplayName: 'P', + model: 'm', + modelDisplayName: 'M', + category: null, + topCategory: 'feature', + topCategoryShare: 1, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 1.5, + calls: 1, + }, + ] + const md = renderMarkdown(rows) + expect(md).toContain('**Total**') + }) +}) + +describe('renderJson', () => { + it('emits a JSON array with the documented field shape', () => { + const rows: ModelReportRow[] = [ + { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + category: null, + topCategory: 'feature', + topCategoryCost: 6.0, + topCategoryShare: 0.6, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 1.5, + calls: 1, + }, + ] + const parsed = JSON.parse(renderJson(rows)) as Array<Record<string, unknown>> + expect(parsed).toHaveLength(1) + expect(parsed[0]).toMatchObject({ + provider: 'claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + topCategory: 'feature', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + calls: 1, + }) + }) +}) + +describe('renderCsv', () => { + it('produces a header row followed by one row per ModelReportRow', () => { + const rows: ModelReportRow[] = [ + { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-sonnet-4-6', + modelDisplayName: 'Sonnet 4.6', + category: null, + topCategory: 'feature', + topCategoryShare: 0.6, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 1.5, + savingsUSD: 0, + calls: 1, + }, + ] + const csv = renderCsv(rows) + const lines = csv.split('\n') + expect(lines[0]).toBe('provider,model,top_task,top_task_share,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,total_tokens,calls,cost_usd,savings_usd,savings_baseline_model') + expect(lines[1]).toBe('Claude,Sonnet 4.6,Feature Dev,0.6000,100,50,0,0,150,1,1.500000,0.000000,') + }) + + it('escapes commas in provider/model cells', () => { + const rows: ModelReportRow[] = [ + { + provider: 'weird', + providerDisplayName: 'Weird, Co.', + model: 'm', + modelDisplayName: 'M', + category: null, + topCategory: 'feature', + topCategoryShare: 1.0, + inputTokens: 0, + outputTokens: 0, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUSD: 0, + savingsUSD: 0, + calls: 0, + }, + ] + const csv = renderCsv(rows) + expect(csv.split('\n')[1]).toContain('"Weird, Co."') + }) +}) diff --git a/tests/models.test.ts b/tests/models.test.ts new file mode 100644 index 0000000..1f70385 --- /dev/null +++ b/tests/models.test.ts @@ -0,0 +1,790 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, it, expect, beforeAll, afterEach } from 'vitest' + +import { + findUnpricedModels, + getModelCosts, + getShortModelName, + calculateCost, + loadPricing, + setModelAliases, + setPriceOverrides, + setLocalModelSavings, + getLocalModelSavingsConfigHash, + getPriceOverridesConfigHash, +} from '../src/models.js' +import { getDailyCacheConfigHash } from '../src/usage-aggregator.js' + +beforeAll(async () => { + await loadPricing() +}) + +afterEach(() => { + setModelAliases({}) + setPriceOverrides({}) + setLocalModelSavings({}) +}) + +describe('getModelCosts', () => { + it('does not match short canonical against longer pricing key', () => { + const costs = getModelCosts('gpt-4') + if (costs) { + expect(costs.inputCostPerToken).not.toBe(2.5e-6) + } + }) + + it('returns correct pricing for gpt-4o vs gpt-4o-mini', () => { + const mini = getModelCosts('gpt-4o-mini') + const full = getModelCosts('gpt-4o') + expect(mini).not.toBeNull() + expect(full).not.toBeNull() + expect(mini!.inputCostPerToken).toBeLessThan(full!.inputCostPerToken) + }) + + it('returns fallback pricing for known Claude models', () => { + const costs = getModelCosts('claude-opus-4-6-20260205') + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(5e-6) + }) + + it('prices lowercase glm-5.2 (Hermes spelling) the same as capitalized GLM-5.2', () => { + const lower = getModelCosts('glm-5.2') + const upper = getModelCosts('GLM-5.2') + expect(lower).not.toBeNull() + expect(upper).not.toBeNull() + expect(lower!.inputCostPerToken).toBe(upper!.inputCostPerToken) + expect(lower!.outputCostPerToken).toBe(upper!.outputCostPerToken) + }) +}) + +describe('getShortModelName', () => { + it('maps gpt-4o-mini correctly (not gpt-4o)', () => { + expect(getShortModelName('gpt-4o-mini-2024-07-18')).toBe('GPT-4o Mini') + }) + + it('maps gpt-4o correctly', () => { + expect(getShortModelName('gpt-4o-2024-08-06')).toBe('GPT-4o') + }) + + it('maps gpt-4.1-mini correctly (not gpt-4.1)', () => { + expect(getShortModelName('gpt-4.1-mini-2025-04-14')).toBe('GPT-4.1 Mini') + }) + + it('maps gpt-5.4-mini correctly (not gpt-5.4)', () => { + expect(getShortModelName('gpt-5.4-mini')).toBe('GPT-5.4 Mini') + }) + + // Regression for #461: spark is a distinct variant, not a reasoning suffix. + it('maps gpt-5.3-codex-spark to its own label (not GPT-5.3 Codex)', () => { + const name = getShortModelName('gpt-5.3-codex-spark') + expect(name).not.toBe('GPT-5.3 Codex') + expect(name).toBe('GPT-5.3 Codex Spark') + }) + + it('maps gpt-5.3-codex reasoning suffixes to the base label', () => { + expect(getShortModelName('gpt-5.3-codex-high')).toBe('GPT-5.3 Codex') + expect(getShortModelName('gpt-5.3-codex-low')).toBe('GPT-5.3 Codex') + }) + + it('maps claude-opus-4-6 with date suffix', () => { + expect(getShortModelName('claude-opus-4-6-20260205')).toBe('Opus 4.6') + }) + + // Regression for #420: claude-opus-4-8 must get its own line, not collapse + // into the generic "Opus 4" bucket via the shorter claude-opus-4 prefix. + it('maps claude-opus-4-8 to its own line (not Opus 4)', () => { + expect(getShortModelName('claude-opus-4-8')).toBe('Opus 4.8') + }) + + // A future version is derived from the id with no hand-maintained entry. + it('derives an unreleased claude version with no SHORT_NAMES entry', () => { + expect(getShortModelName('claude-sonnet-5-2')).toBe('Sonnet 5.2') + expect(getShortModelName('claude-haiku-5')).toBe('Haiku 5') + expect(getShortModelName('claude-opus-9-9-20300101')).toBe('Opus 9.9') + }) + + it('shows the real model name for pricing-sibling aliases, not the internal key', () => { + // GLM-5.2 (and its lowercase Hermes spelling) price via the glm-5p1 sibling; + // reports must show GLM-5.2, not the pricing key. + expect(getShortModelName('GLM-5.2')).toBe('GLM-5.2') + expect(getShortModelName('glm-5.2')).toBe('GLM-5.2') + expect(getShortModelName('glm-5p1')).toBe('GLM-5.2') + // Grok Build prices via the grok-build-0.1 sibling. + expect(getShortModelName('grok-build')).toBe('Grok Build') + expect(getShortModelName('grok-build-0.1')).toBe('Grok Build') + // grok-composer has no alias, just a missing display entry. + expect(getShortModelName('grok-composer-2.5-fast')).toBe('Grok Composer 2.5 Fast') + }) +}) + +describe('claude-fable-5 pricing + name', () => { + it('prices at $10/M input, $50/M output via models.dev/OpenRouter gap-fill', () => { + expect(calculateCost('claude-fable-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(10, 6) + expect(calculateCost('claude-fable-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(50, 6) + }) + it('shows its own display name', () => { + expect(getShortModelName('claude-fable-5')).toBe('Fable 5') + }) +}) + +describe('builtin aliases - getModelCosts', () => { + it('resolves anthropic--claude-4.6-opus', () => { + expect(getModelCosts('anthropic--claude-4.6-opus')).not.toBeNull() + }) + + it('resolves anthropic--claude-4.6-sonnet', () => { + expect(getModelCosts('anthropic--claude-4.6-sonnet')).not.toBeNull() + }) + + it('resolves anthropic--claude-4.5-opus', () => { + expect(getModelCosts('anthropic--claude-4.5-opus')).not.toBeNull() + }) + + it('resolves anthropic--claude-4.5-sonnet', () => { + expect(getModelCosts('anthropic--claude-4.5-sonnet')).not.toBeNull() + }) + + it('resolves anthropic--claude-4.5-haiku', () => { + expect(getModelCosts('anthropic--claude-4.5-haiku')).not.toBeNull() + }) + + it('resolves double-wrapped anthropic/anthropic--claude-4.6-opus', () => { + expect(getModelCosts('anthropic/anthropic--claude-4.6-opus')).not.toBeNull() + }) + + it('resolves double-wrapped anthropic/anthropic--claude-4.6-sonnet', () => { + expect(getModelCosts('anthropic/anthropic--claude-4.6-sonnet')).not.toBeNull() + }) + + it('resolves double-wrapped anthropic/anthropic--claude-4.5-haiku', () => { + expect(getModelCosts('anthropic/anthropic--claude-4.5-haiku')).not.toBeNull() + }) + + it('OMP opus resolves to same pricing as canonical claude-opus-4-6', () => { + expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-opus-4-6')) + }) + + it('OMP sonnet resolves to same pricing as canonical claude-sonnet-4-6', () => { + expect(getModelCosts('anthropic--claude-4.6-sonnet')).toEqual(getModelCosts('claude-sonnet-4-6')) + }) + + it('OMP haiku resolves to same pricing as canonical claude-haiku-4-5', () => { + expect(getModelCosts('anthropic--claude-4.5-haiku')).toEqual(getModelCosts('claude-haiku-4-5')) + }) +}) + +describe('builtin aliases - getShortModelName', () => { + it('anthropic--claude-4.6-opus -> Opus 4.6', () => { + expect(getShortModelName('anthropic--claude-4.6-opus')).toBe('Opus 4.6') + }) + + it('anthropic--claude-4.6-sonnet -> Sonnet 4.6', () => { + expect(getShortModelName('anthropic--claude-4.6-sonnet')).toBe('Sonnet 4.6') + }) + + it('anthropic--claude-4.5-opus -> Opus 4.5', () => { + expect(getShortModelName('anthropic--claude-4.5-opus')).toBe('Opus 4.5') + }) + + it('anthropic--claude-4.5-sonnet -> Sonnet 4.5', () => { + expect(getShortModelName('anthropic--claude-4.5-sonnet')).toBe('Sonnet 4.5') + }) + + it('anthropic--claude-4.5-haiku -> Haiku 4.5', () => { + expect(getShortModelName('anthropic--claude-4.5-haiku')).toBe('Haiku 4.5') + }) + + it('anthropic/anthropic--claude-4.6-opus -> Opus 4.6', () => { + expect(getShortModelName('anthropic/anthropic--claude-4.6-opus')).toBe('Opus 4.6') + }) +}) + +describe('Antigravity Gemini 3.5 Flash variants resolve to pricing', () => { + const variants = [ + 'gemini-3.5-flash', + 'gemini-3.5-flash-high', + 'gemini-3.5-flash-medium', + 'gemini-3.5-flash-low', + 'Gemini 3.5 Flash (High)', + ] + + for (const variant of variants) { + it(`${variant} resolves to Gemini 3.5 Flash`, () => { + expect(getModelCosts(variant)).toEqual(getModelCosts('gemini-3.5-flash')) + expect(getShortModelName(variant)).toBe('Gemini 3.5 Flash') + }) + } + + it('calculates non-zero cost for high thinking labels', () => { + expect(calculateCost('gemini-3.5-flash-high', 1000, 100, 0, 0, 0)).toBeGreaterThan(0) + }) +}) + +describe('user aliases via setModelAliases', () => { + it('user alias resolves for getModelCosts', () => { + setModelAliases({ 'my-internal-model': 'claude-sonnet-4-6' }) + expect(getModelCosts('my-internal-model')).toEqual(getModelCosts('claude-sonnet-4-6')) + }) + + it('user alias resolves for getShortModelName', () => { + setModelAliases({ 'my-internal-model': 'claude-opus-4-6' }) + expect(getShortModelName('my-internal-model')).toBe('Opus 4.6') + }) + + it('user alias overrides builtin', () => { + setModelAliases({ 'anthropic--claude-4.6-opus': 'claude-sonnet-4-5' }) + expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-sonnet-4-5')) + }) + + it('resetting aliases restores builtins', () => { + setModelAliases({ 'anthropic--claude-4.6-opus': 'claude-sonnet-4-5' }) + setModelAliases({}) + expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-opus-4-6')) + }) +}) + +describe('user price overrides', () => { + it('prices a model missing from the pricing snapshot', () => { + const model = 'zz-price-override-missing-model-390' + expect(getModelCosts(model)).toBeNull() + + setPriceOverrides({ + [model]: { input: 1.25, output: 2.5 }, + }) + + const costs = getModelCosts(model) + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(1.25e-6) + expect(costs!.outputCostPerToken).toBe(2.5e-6) + expect(calculateCost(model, 1_000_000, 1_000_000, 0, 0, 0)).toBe(3.75) + }) + + it('wins over snapshot pricing and configured aliases', () => { + setModelAliases({ + 'price-override-aliased-model': 'claude-opus-4-6', + 'price-override-canonical-source': 'price-override-canonical-target', + }) + setPriceOverrides({ + 'gpt-4o': { input: 7, output: 8 }, + 'claude-opus-4-6': { input: 4, output: 5 }, + 'price-override-aliased-model': { input: 2, output: 3 }, + 'price-override-canonical-target': { input: 6, output: 7 }, + }) + + expect(getModelCosts('gpt-4o')!.inputCostPerToken).toBe(7e-6) + expect(getModelCosts('price-override-aliased-model')!.inputCostPerToken).toBe(2e-6) + expect(getModelCosts('price-override-canonical-source')!.inputCostPerToken).toBe(6e-6) + }) + + it('converts USD per 1,000,000 tokens to per-token ModelCosts exactly', () => { + const model = 'price-override-unit-conversion' + setPriceOverrides({ + [model]: { input: 1, output: 0 }, + }) + + expect(getModelCosts(model)!.inputCostPerToken).toBe(1e-6) + expect(calculateCost(model, 1_000_000, 0, 0, 0, 0)).toBe(1) + }) + + it('defaults cache rates from input pricing when omitted', () => { + const model = 'price-override-cache-defaults' + setPriceOverrides({ + [model]: { input: 10, output: 20 }, + }) + + const costs = getModelCosts(model) + expect(costs).not.toBeNull() + expect(costs!.cacheWriteCostPerToken).toBeCloseTo(12.5e-6, 12) + expect(costs!.cacheReadCostPerToken).toBeCloseTo(1e-6, 12) + }) + + it('wins for case-insensitive and prefix matches without shadowing a more-specific exact snapshot entry', () => { + const miniSnapshot = getModelCosts('gpt-5-mini') + expect(miniSnapshot).not.toBeNull() + + setPriceOverrides({ + 'gpt-5': { input: 91, output: 92 }, + }) + + expect(getModelCosts('GPT-5')!.inputCostPerToken).toBe(91e-6) + expect(getModelCosts('gpt-5-foo')!.inputCostPerToken).toBe(91e-6) + + const mini = getModelCosts('gpt-5-mini') + expect(mini).not.toBeNull() + expect(mini!.inputCostPerToken).toBe(miniSnapshot!.inputCostPerToken) + expect(mini!.outputCostPerToken).toBe(miniSnapshot!.outputCostPerToken) + }) + + it('includes builtin and user price overrides in the daily cache config hash', () => { + setLocalModelSavings({ local: 'gpt-4o' }) + setPriceOverrides({}) + + // The builtin overrides always participate, so a release that edits them + // invalidates cached daily costs even with no user overrides configured. + const builtinOnly = getPriceOverridesConfigHash() + expect(builtinOnly).toContain('builtin:') + expect(getPriceOverridesConfigHash()).toBe(builtinOnly) + const baseline = getDailyCacheConfigHash() + + setPriceOverrides({ 'price-hash-model': { input: 1, output: 2 } }) + const firstCombined = getDailyCacheConfigHash() + + setPriceOverrides({ 'price-hash-model': { input: 3, output: 2 } }) + const secondCombined = getDailyCacheConfigHash() + + expect(firstCombined).not.toBe(baseline) + expect(secondCombined).not.toBe(baseline) + expect(secondCombined).not.toBe(firstCombined) + }) +}) + +describe('calculateCost - OMP names produce non-zero cost', () => { + it('calculates cost for anthropic--claude-4.6-opus', () => { + expect(calculateCost('anthropic--claude-4.6-opus', 1000, 200, 0, 0, 0)).toBeGreaterThan(0) + }) + + it('calculates cost for anthropic/anthropic--claude-4.6-sonnet', () => { + expect(calculateCost('anthropic/anthropic--claude-4.6-sonnet', 1000, 200, 0, 0, 0)).toBeGreaterThan(0) + }) +}) + +describe('Warp Claude variants resolve to pricing', () => { + const cases: Array<[string, string]> = [ + ['claude-4-6-sonnet-high', 'claude-sonnet-4-6'], + ['claude-4-6-sonnet-low', 'claude-sonnet-4-6'], + ['claude-4-6-sonnet-medium', 'claude-sonnet-4-6'], + ['claude-4-6-sonnet-high-fast', 'claude-sonnet-4-6'], + ['claude-4-7-opus-xhigh', 'claude-opus-4-7'], + ['claude-4-7-opus-xhigh-fast', 'claude-opus-4-7'], + ] + + for (const [input, expectedAlias] of cases) { + it(`${input} resolves to ${expectedAlias} pricing`, () => { + const costs = getModelCosts(input) + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBeGreaterThan(0) + const expected = getModelCosts(expectedAlias) + expect(expected).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken) + expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) + }) + + it(`${input} calculates non-zero cost`, () => { + expect(calculateCost(input, 1000, 200, 0, 0, 0)).toBeGreaterThan(0) + }) + } +}) + +describe('calculateCost - Claude cache write durations', () => { + it('prices 1-hour cache writes at 1.6x the 5-minute cache write rate', () => { + const fiveMinute = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0) + const oneHour = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0, 'standard', 1_000_000) + const mixed = calculateCost('claude-opus-4-7', 0, 0, 100_000, 0, 0, 'standard', 60_000) + + expect(fiveMinute).toBeCloseTo(6.25, 6) + expect(oneHour).toBeCloseTo(10, 6) + expect(mixed).toBeCloseTo(0.85, 6) + }) +}) + +describe('existing model names still resolve', () => { + it('canonical claude-opus-4-6', () => { + expect(getModelCosts('claude-opus-4-6')).not.toBeNull() + }) + + it('canonical claude-sonnet-4-5', () => { + expect(getModelCosts('claude-sonnet-4-5')).not.toBeNull() + }) + + it('date-stamped claude-sonnet-4-20250514', () => { + expect(getModelCosts('claude-sonnet-4-20250514')).not.toBeNull() + }) + + it('pinned claude-sonnet-4-6@20250929', () => { + expect(getModelCosts('claude-sonnet-4-6@20250929')).not.toBeNull() + }) + + it('anthropic/-prefixed anthropic/claude-opus-4-6', () => { + expect(getModelCosts('anthropic/claude-opus-4-6')).not.toBeNull() + }) + + // #420: 4.8 has its own LiteLLM pricing tier ($5/$25), so it must not fall + // through the prefix match to the older, 3x-pricier claude-opus-4 ($15/$75). + it('claude-opus-4-8 prices at its own tier, not original claude-opus-4', () => { + const v48 = getModelCosts('claude-opus-4-8') + expect(v48).not.toBeNull() + // $5/$25 per M tokens — the 4.6/4.7 tier, not the original opus-4 $15/$75. + expect(v48!.inputCostPerToken).toBeCloseTo(0.000005, 12) + expect(v48!.outputCostPerToken).toBeCloseTo(0.000025, 12) + expect(v48!.inputCostPerToken).not.toEqual(getModelCosts('claude-opus-4')!.inputCostPerToken) + }) +}) + +// Issue #159: every model name Cursor emits in its SQLite database must +// resolve to a non-zero pricing entry, otherwise the dashboard shows $0 for +// that model. Each case asserts the resolved pricing identity matches the +// pricing of the expected canonical key, so an accidental alias swap (e.g. +// `claude-4.6-opus` aliased to a haiku entry) fails the test even though +// haiku also has positive pricing. +describe('Cursor model variants resolve to pricing', () => { + const cases: Array<[string, string]> = [ + // Sonnet family + ['claude-4-sonnet', 'claude-sonnet-4'], + ['claude-4-sonnet-1m', 'claude-sonnet-4'], + ['claude-4-sonnet-thinking', 'claude-sonnet-4-5'], + ['claude-4.5-sonnet', 'claude-sonnet-4-5'], + ['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'], + ['claude-4.6-sonnet', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-high', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-low', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-thinking', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-high-thinking', 'claude-sonnet-4-6'], + // Opus family + ['claude-4-opus', 'claude-opus-4'], + ['claude-4.5-opus', 'claude-opus-4-5'], + ['claude-4.5-opus-high', 'claude-opus-4-5'], + ['claude-4.5-opus-low', 'claude-opus-4-5'], + ['claude-4.5-opus-medium', 'claude-opus-4-5'], + ['claude-4.5-opus-high-thinking', 'claude-opus-4-5'], + ['claude-4.6-opus', 'claude-opus-4-6'], + ['claude-4.6-opus-fast-mode', 'claude-opus-4-6'], + ['claude-4.6-opus-high', 'claude-opus-4-6'], + ['claude-4.6-opus-low', 'claude-opus-4-6'], + ['claude-4.6-opus-medium', 'claude-opus-4-6'], + ['claude-4.6-opus-high-thinking', 'claude-opus-4-6'], + ['claude-4.7-opus', 'claude-opus-4-7'], + ['claude-opus-4-7-thinking-high', 'claude-opus-4-7'], + // Haiku family + ['claude-4.5-haiku', 'claude-haiku-4-5'], + ['claude-4.6-haiku', 'claude-haiku-4-5'], + // Cursor auto proxy + ['cursor-auto', 'claude-sonnet-4-5'], + // OpenAI variants Cursor emits + ['gpt-5', 'gpt-5'], + ['gpt-5-fast', 'gpt-5'], + ['gpt-5.2', 'gpt-5.2'], + ['gpt-5.2-low', 'gpt-5'], + // Direct LiteLLM hits where no alias is required + ['grok-code-fast-1', 'grok-code-fast-1'], + ['gemini-3-pro', 'gemini-3-pro-preview'], + ] + + for (const [input, expectedAlias] of cases) { + it(`${input} resolves to ${expectedAlias} pricing`, () => { + const costs = getModelCosts(input) + expect(costs, `${input} should resolve to pricing (and not produce $0 in the dashboard)`).not.toBeNull() + expect(costs!.inputCostPerToken).toBeGreaterThan(0) + expect(costs!.outputCostPerToken).toBeGreaterThan(0) + const expected = getModelCosts(expectedAlias) + expect(expected, `expected target ${expectedAlias} should itself resolve`).not.toBeNull() + // Identity check: the alias must produce the SAME pricing object as + // the canonical key, not just any non-zero pricing. Catches drift + // where a future edit re-points an alias at a wrong-but-positive entry. + expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken) + expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) + }) + } +}) + +describe('Cursor house model pricing', () => { + const cases: Array<[string, { input: number; output: number; cacheWrite: number; cacheRead: number }]> = [ + ['composer-2.5', { input: 0.5, output: 2.5, cacheWrite: 0.5, cacheRead: 0.2 }], + ['composer-2', { input: 0.5, output: 2.5, cacheWrite: 0.5, cacheRead: 0.2 }], + ['composer-1.5', { input: 3.5, output: 17.5, cacheWrite: 3.5, cacheRead: 0.35 }], + ['composer-1', { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 }], + ] + + for (const [model, rates] of cases) { + it(`${model} uses Cursor-published rates instead of Claude Sonnet proxy pricing`, () => { + const costs = getModelCosts(model) + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBeCloseTo(rates.input * 1e-6, 12) + expect(costs!.outputCostPerToken).toBeCloseTo(rates.output * 1e-6, 12) + expect(costs!.cacheWriteCostPerToken).toBeCloseTo(rates.cacheWrite * 1e-6, 12) + expect(costs!.cacheReadCostPerToken).toBeCloseTo(rates.cacheRead * 1e-6, 12) + }) + } +}) + +// Regression: LiteLLM ships `snowflake/claude-4-opus` ($5/M, a gateway rate), +// which the bundler strips to a bare `claude-4-opus` snapshot key. Without the +// alias-precedence guard in getModelCosts, that bare reseller key shadows the +// curated alias `claude-4-opus -> claude-opus-4` and mis-prices Opus 4 at a +// third of its official list price. Pin the official number so a re-shadowing +// fails loudly rather than silently under-reporting spend. +describe('alias precedence over stripped reseller keys', () => { + it('claude-4-opus resolves to the official Opus 4 list price, not a gateway discount', () => { + const aliased = getModelCosts('claude-4-opus') + const canonical = getModelCosts('claude-opus-4') + expect(aliased).not.toBeNull() + expect(canonical).not.toBeNull() + expect(aliased!.inputCostPerToken).toBe(canonical!.inputCostPerToken) + expect(aliased!.outputCostPerToken).toBe(canonical!.outputCostPerToken) + expect(aliased!.inputCostPerToken).toBe(15e-6) + expect(aliased!.outputCostPerToken).toBe(75e-6) + }) + + it('the explicit provider prefix is still honored for the gateway rate', () => { + // The guard fires only for the bare name; a fully-qualified gateway id must + // still return that gateway's own price when LiteLLM publishes one. + const gateway = getModelCosts('snowflake/claude-4-opus') + const bare = getModelCosts('claude-4-opus') + expect(gateway).not.toBeNull() + expect(gateway!.inputCostPerToken).toBeLessThan(bare!.inputCostPerToken) + }) +}) + +// The case-insensitive index that lets `MiniMax-M3` reach a lowercase +// `minimax-m3` slug must NOT let a case-mismatched query resolve to one of +// LiteLLM's [0,0] price stubs (e.g. `GigaChat-2-Max`). Doing so would flip an +// honest null (which fires the "no pricing data, will show $0" warning) into a +// silent $0 and hide real spend. A case-EXACT query still finds the stub. +describe('zero-priced stubs do not satisfy case-insensitive lookup', () => { + it('a case-mismatched query to a [0,0] stub stays null', () => { + expect(getModelCosts('gigachat-2-max')).toBeNull() + }) + + it('the case-exact stub still resolves (just at zero cost)', () => { + const exact = getModelCosts('GigaChat-2-Max') + expect(exact).not.toBeNull() + expect(exact!.inputCostPerToken).toBe(0) + }) +}) + +describe('DeepSeek v4 models resolve to pricing', () => { + it('deepseek-v4-pro has current official discounted pricing', () => { + const costs = getModelCosts('deepseek-v4-pro') + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(4.35e-7) + expect(costs!.outputCostPerToken).toBe(8.7e-7) + expect(costs!.cacheReadCostPerToken).toBe(3.625e-9) + expect(costs!.cacheWriteCostPerToken).toBe(0) + }) + + it('deepseek-v4-flash has current official pricing', () => { + const costs = getModelCosts('deepseek-v4-flash') + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(1.4e-7) + expect(costs!.outputCostPerToken).toBe(2.8e-7) + expect(costs!.cacheReadCostPerToken).toBe(2.8e-9) + expect(costs!.cacheWriteCostPerToken).toBe(0) + }) + + it('provider-prefixed DeepSeek v4 names resolve to the same pricing', () => { + expect(getModelCosts('deepseek/deepseek-v4-pro')).toEqual(getModelCosts('deepseek-v4-pro')) + expect(getModelCosts('deepseek/deepseek-v4-flash')).toEqual(getModelCosts('deepseek-v4-flash')) + }) + + it('calculates non-zero costs for observed DeepSeek v4 Claude usage', () => { + const pro = calculateCost('deepseek-v4-pro', 2_477_914, 762_994, 0, 258_556_928, 0) + const flash = calculateCost('deepseek-v4-flash', 1_552_573, 353_914, 0, 48_388_608, 0) + + expect(pro).toBeCloseTo(2.68, 2) + expect(flash).toBeCloseTo(0.45, 2) + }) + + it('uses DeepSeek v4 display names', () => { + expect(getShortModelName('deepseek-v4-pro')).toBe('DeepSeek v4 Pro') + expect(getShortModelName('deepseek-v4-flash')).toBe('DeepSeek v4 Flash') + }) + + it('keeps bundled DeepSeek v4 fallback entries when runtime pricing cache is stale', async () => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-pricing-cache-')) + + try { + process.env['CODEBURN_CACHE_DIR'] = cacheRoot + await mkdir(cacheRoot, { recursive: true }) + await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({ + timestamp: Date.now(), + data: { + 'gpt-4o-mini': { + inputCostPerToken: 9e-7, + outputCostPerToken: 1.8e-6, + cacheWriteCostPerToken: 0, + cacheReadCostPerToken: 9e-8, + webSearchCostPerRequest: 0.01, + fastMultiplier: 1, + }, + }, + }), 'utf-8') + + await loadPricing() + + expect(getModelCosts('gpt-4o-mini')!.inputCostPerToken).toBe(9e-7) + expect(getModelCosts('deepseek-v4-pro')!.inputCostPerToken).toBe(4.35e-7) + expect(getModelCosts('deepseek-v4-flash')!.inputCostPerToken).toBe(1.4e-7) + } finally { + await rm(cacheRoot, { recursive: true, force: true }) + await loadPricing() + } + }) +}) + +describe('provider pricing suffix variants', () => { + const cases: Array<[string, string]> = [ + ['GLM-4.7-TEE', 'glm-4.7'], + ['glm-4.7:thinking', 'glm-4.7'], + ['Kimi-K2.5-TEE', 'kimi-k2.5'], + ['deepseek-v4-pro:cloud', 'deepseek-v4-pro'], + ['glm-5:thinking', 'glm-5'], + ['kimi-k2.6:thinking', 'kimi-k2.6'], + ['deepseek-v4-flash:thinking', 'deepseek-v4-flash'], + ['minimax-m3:cloud', 'minimax-m3'], + ] + + for (const [input, expectedBase] of cases) { + it(`${input} resolves through ${expectedBase}`, () => { + const costs = getModelCosts(input) + const expected = getModelCosts(expectedBase) + expect(costs).not.toBeNull() + expect(expected).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken) + expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) + }) + } + + it('does not strip arbitrary local runtime tags', () => { + expect(getModelCosts('qwen3.6:35b-a3b-bf16')).toBeNull() + }) + + it('does not strip free-tier markers into paid pricing', () => { + expect(getModelCosts('mimo-v2-flash:free')).toBeNull() + }) +}) + +describe('observed provider model aliases', () => { + const cases: Array<[string, string]> = [ + ['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'], + ['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'], + ] + + for (const [input, expectedModel] of cases) { + it(`${input} resolves through ${expectedModel}`, () => { + const costs = getModelCosts(input) + const expected = getModelCosts(expectedModel) + expect(costs).not.toBeNull() + expect(expected).not.toBeNull() + expect(costs).toEqual(expected) + expect(calculateCost(input, 1_000_000, 1_000_000, 0, 0, 0)).toBeGreaterThan(0) + }) + } + + it('does not map dated Qwen3 Max to a reseller price without provider context', () => { + expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull() + expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0) + }) +}) + +describe('findUnpricedModels', () => { + it('flags an unknown paid-looking model with $0 cost and skips priced ones', () => { + const rows = [ + { model: 'claude-opus-4-6', calls: 10, cost: 2.5, tokens: 5000 }, + { model: 'zz-mystery-paid-model-999', calls: 3, cost: 0, tokens: 1200 }, + ] + const unpriced = findUnpricedModels(rows) + expect(unpriced).toEqual([{ model: 'zz-mystery-paid-model-999', calls: 3, tokens: 1200 }]) + }) + + it('never flags a row that carries real cost, even when the lookup misses', () => { + // Aggregation keys rows by display name; the lookup misses but the row was + // priced at parse time, so it must not be reported as unpriced. + const unpriced = findUnpricedModels([ + { model: 'Opus 4.8', calls: 100, cost: 42.5, tokens: 1_000_000 }, + { model: 'zz-unknown-but-priced-elsewhere', calls: 5, cost: 0.01, tokens: 500 }, + ]) + expect(unpriced).toEqual([]) + }) + + it('flags $0 display-name rows even when the raw id would price today', () => { + // Droid prices the lowercased display name ("claude sonnet 4.6" -> no + // pricing -> $0) and the parser keys the row by display name. Those + // tokens really entered the report at $0, so the row must be flagged + // even though claude-sonnet-4-6 itself is priced. + const unpriced = findUnpricedModels([ + { model: 'Sonnet 4.6', calls: 12, cost: 0, tokens: 500_000 }, + ]) + expect(unpriced).toEqual([{ model: 'Sonnet 4.6', calls: 12, tokens: 500_000 }]) + }) + + it('flags zero-rate pricing stubs but not explicit zero-rate user overrides', async () => { + // LiteLLM ships [0,0] stubs for models it lists but has no price for; + // a stub hit means "unknown price", not "free". + const cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-pricing-cache-')) + try { + process.env['CODEBURN_CACHE_DIR'] = cacheRoot + await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({ + timestamp: Date.now(), + data: { + 'zz-zero-stub-model': { + inputCostPerToken: 0, + outputCostPerToken: 0, + cacheWriteCostPerToken: 0, + cacheReadCostPerToken: 0, + webSearchCostPerRequest: 0, + fastMultiplier: 1, + }, + }, + }), 'utf-8') + await loadPricing() + + expect(getModelCosts('zz-zero-stub-model')).not.toBeNull() + const rows = [{ model: 'zz-zero-stub-model', calls: 3, cost: 0, tokens: 1100 }] + expect(findUnpricedModels(rows)).toHaveLength(1) + + // An explicit user override at zero rates means "this model is free". + setPriceOverrides({ 'zz-zero-stub-model': { input: 0, output: 0 } }) + expect(findUnpricedModels(rows)).toEqual([]) + + // A prefix override cannot prove intent: getModelCosts resolves table + // hits before prefix overrides, so the $0 came from the stub, not the + // user. Still flagged. + setPriceOverrides({ 'zz-zero-stub': { input: 0, output: 0 } }) + expect(findUnpricedModels(rows)).toHaveLength(1) + } finally { + delete process.env['CODEBURN_CACHE_DIR'] + await rm(cacheRoot, { recursive: true, force: true }) + setPriceOverrides({}) + await loadPricing() + } + }) + + it('skips synthetic, empty, local-looking, and zero-usage rows', () => { + const unpriced = findUnpricedModels([ + { model: '<synthetic>', calls: 5, cost: 0, tokens: 100 }, + { model: '', calls: 5, cost: 0, tokens: 100 }, + { model: 'llama3.1:8b', calls: 5, cost: 0, tokens: 100 }, + { model: 'zz-quantized-model-bf16', calls: 5, cost: 0, tokens: 100 }, + { model: 'zz-no-usage-model', calls: 0, cost: 0, tokens: 0 }, + ]) + expect(unpriced).toEqual([]) + }) + + it('heals when the user configures an alias or a price override', () => { + const model = 'zz-proxy-renamed-model-x1' + expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toHaveLength(1) + + setModelAliases({ [model]: 'claude-opus-4-6' }) + expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([]) + setModelAliases({}) + + setPriceOverrides({ [model]: { input: 1, output: 2 } }) + expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([]) + }) + + it('skips models mapped via model-savings (intentionally $0)', () => { + const model = 'zz-my-local-runner' + expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toHaveLength(1) + setLocalModelSavings({ [model]: 'gpt-4o' }) + expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([]) + }) + + it('sorts by tokens, then calls', () => { + const unpriced = findUnpricedModels([ + { model: 'zz-small', calls: 9, cost: 0, tokens: 10 }, + { model: 'zz-big', calls: 1, cost: 0, tokens: 9999 }, + ]) + expect(unpriced.map(u => u.model)).toEqual(['zz-big', 'zz-small']) + }) +}) diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts new file mode 100644 index 0000000..2b58228 --- /dev/null +++ b/tests/optimize-apply.test.ts @@ -0,0 +1,655 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { PassThrough, Writable } from 'node:stream' + +import { planFor, planFindings, type PlanContext } from '../src/act/plans.js' +import { renderApplyList, runOptimizeApply, type ApplyOptions } from '../src/act/optimize-apply.js' +import { runAction } from '../src/act/apply.js' +import { undoAction } from '../src/act/undo.js' +import { readRecords, shortId } from '../src/act/journal.js' +import { + detectBloatedClaudeMd, + detectDuplicateReads, + detectJunkReads, + detectLowReadEditRatio, + detectMcpToolCoverage, +} from '../src/optimize.js' +import type { + FindingApply, + FindingId, + McpServerCoverage, + ToolCall, + WasteAction, + WasteFinding, +} from '../src/optimize.js' + +const roots: string[] = [] + +type Fixture = { root: string; home: string; project: string; actionsDir: string } + +async function makeFixture(): Promise<Fixture> { + const root = await mkdtemp(join(tmpdir(), 'codeburn-optimize-apply-')) + roots.push(root) + const home = join(root, 'home') + const project = join(root, 'project') + await mkdir(home, { recursive: true }) + await mkdir(project, { recursive: true }) + return { root, home, project, actionsDir: join(root, 'actions') } +} + +afterAll(async () => { + for (const root of roots) await rm(root, { recursive: true, force: true }) +}) + +function makeFinding(id: FindingId, fix: WasteAction, apply?: FindingApply): WasteFinding { + return { id, title: id, explanation: '', impact: 'medium', tokensSaved: 1000, fix, ...(apply ? { apply } : {}) } +} + +const CMD_FIX: WasteAction = { type: 'command', label: '', text: '' } + +async function hashTree(dir: string): Promise<string> { + const h = createHash('sha256') + async function walk(d: string): Promise<void> { + const entries = (await readdir(d, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)) + for (const entry of entries) { + const full = join(d, entry.name) + if (entry.isDirectory()) { + h.update('D:' + full + '\n') + await walk(full) + } else { + h.update('F:' + full + '\n') + h.update(await readFile(full)) + } + } + } + await walk(dir) + return h.digest('hex') +} + +describe('mcp-remove plan', () => { + it('deletes exactly the named server, leaves other keys untouched, and undo restores byte-identical', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + const original = JSON.stringify({ + mcpServers: { alpha: { command: 'a' }, beta: { command: 'b', args: ['x'] } }, + numFoo: 3, + nested: { keep: true }, + }, null, 2) + '\n' + await writeFile(claudeJson, original) + await writeFile(join(fx.project, '.mcp.json'), JSON.stringify({ mcpServers: { gamma: {} } }, null, 2) + '\n') + + const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['beta'] }) + const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project }) + expect(plan).not.toBeNull() + expect(plan!.changes).toHaveLength(1) + expect(plan!.changes[0]!.path).toBe(claudeJson) + + const rec = await runAction(plan!, fx.actionsDir) + + const after = JSON.parse(await readFile(claudeJson, 'utf-8')) + expect(after.mcpServers).toEqual({ alpha: { command: 'a' } }) + expect(after.numFoo).toBe(3) + expect(after.nested).toEqual({ keep: true }) + // Untouched sibling config file. + expect(JSON.parse(await readFile(join(fx.project, '.mcp.json'), 'utf-8')).mcpServers).toEqual({ gamma: {} }) + // 2-space indent + trailing newline contract. + expect(await readFile(claudeJson, 'utf-8')).toBe(JSON.stringify(after, null, 2) + '\n') + + await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir }) + expect(await readFile(claudeJson, 'utf-8')).toBe(original) + }) +}) + +describe('mcp-project-scope plan', () => { + it('moves the entry from the global config into the keeper project .mcp.json, creating it when missing', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + const serverValue = { command: 'srv', args: ['--flag'], env: { A: '1' } } + const original = JSON.stringify({ mcpServers: { srv: serverValue }, other: 1 }, null, 2) + '\n' + await writeFile(claudeJson, original) + + const keeper = join(fx.root, 'keeper') + await mkdir(keeper, { recursive: true }) + const keeperMcp = join(keeper, '.mcp.json') + expect(existsSync(keeperMcp)).toBe(false) + + const finding = makeFinding('mcp-project-scope', { type: 'paste', destination: 'prompt', label: '', text: '' }, { + kind: 'mcp-project-scope', + servers: [{ server: 'srv', keepProjects: [keeper], removeProjects: [] }], + }) + const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project }) + expect(plan).not.toBeNull() + + const rec = await runAction(plan!, fx.actionsDir) + + expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({}) + expect(existsSync(keeperMcp)).toBe(true) + expect(JSON.parse(await readFile(keeperMcp, 'utf-8')).mcpServers).toEqual({ srv: serverValue }) + + await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir }) + expect(await readFile(claudeJson, 'utf-8')).toBe(original) + expect(existsSync(keeperMcp)).toBe(false) + }) +}) + +describe('unparseable config file', () => { + it('reports the parse error, skips that server, and still applies the servers it can read', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ mcpServers: { good: { command: 'g' } } }, null, 2) + '\n') + const brokenMcp = join(fx.project, '.mcp.json') + await writeFile(brokenMcp, '{ this is not valid json,,, ') + + const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['good', 'bad'] }) + const { plan, notes } = planFindings([finding], { homeDir: fx.home, cwd: fx.project })[0]! + + expect(notes.some(n => /could not parse/.test(n) && n.includes('.mcp.json'))).toBe(true) + expect(notes.some(n => n.includes('bad'))).toBe(true) + expect(plan).not.toBeNull() + expect(plan!.changes.map(c => c.path)).toEqual([claudeJson]) + + await runAction(plan!, fx.actionsDir) + expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({}) + // The broken file is left exactly as-is. + expect(await readFile(brokenMcp, 'utf-8')).toBe('{ this is not valid json,,, ') + }) +}) + +describe('archive plan', () => { + it('archives a skill dir and an agent file, round-trips undo, and suffixes a colliding name with -2', async () => { + const fx = await makeFixture() + const skillsDir = join(fx.home, '.claude', 'skills') + const agentsDir = join(fx.home, '.claude', 'agents') + await mkdir(join(skillsDir, 'foo'), { recursive: true }) + await writeFile(join(skillsDir, 'foo', 'SKILL.md'), 'skill body') + // Pre-existing archive with the same name forces the -2 suffix. + await mkdir(join(skillsDir, '.archived', 'foo'), { recursive: true }) + await writeFile(join(skillsDir, '.archived', 'foo', 'SKILL.md'), 'old archived') + await mkdir(agentsDir, { recursive: true }) + await writeFile(join(agentsDir, 'bar.md'), 'agent body') + + const skillFinding = makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }) + const skillPlan = planFor(skillFinding, { homeDir: fx.home, cwd: fx.project }) + expect(skillPlan!.changes[0]).toMatchObject({ + op: 'move', + path: join(skillsDir, 'foo'), + movedTo: join(skillsDir, '.archived', 'foo-2'), + }) + const skillRec = await runAction(skillPlan!, fx.actionsDir) + expect(existsSync(join(skillsDir, 'foo'))).toBe(false) + expect(await readFile(join(skillsDir, '.archived', 'foo-2', 'SKILL.md'), 'utf-8')).toBe('skill body') + // The pre-existing archive is preserved. + expect(await readFile(join(skillsDir, '.archived', 'foo', 'SKILL.md'), 'utf-8')).toBe('old archived') + + const agentFinding = makeFinding('unused-agents', CMD_FIX, { kind: 'archive', names: ['bar'] }) + const agentPlan = planFor(agentFinding, { homeDir: fx.home, cwd: fx.project }) + expect(agentPlan!.changes[0]).toMatchObject({ + op: 'move', + path: join(agentsDir, 'bar.md'), + movedTo: join(agentsDir, '.archived', 'bar.md'), + }) + const agentRec = await runAction(agentPlan!, fx.actionsDir) + expect(existsSync(join(agentsDir, 'bar.md'))).toBe(false) + + await undoAction({ id: agentRec.id }, { actionsDir: fx.actionsDir }) + await undoAction({ id: skillRec.id }, { actionsDir: fx.actionsDir }) + expect(await readFile(join(agentsDir, 'bar.md'), 'utf-8')).toBe('agent body') + expect(await readFile(join(skillsDir, 'foo', 'SKILL.md'), 'utf-8')).toBe('skill body') + expect(existsSync(join(skillsDir, '.archived', 'foo-2'))).toBe(false) + }) +}) + +describe('claude-md rule plan', () => { + it('appends a fresh marker block, replaces it in place on re-apply, and undo removes it', async () => { + const fx = await makeFixture() + const claudeMd = join(fx.project, 'CLAUDE.md') + const original = '# Project\n\nExisting rules.\n' + await writeFile(claudeMd, original) + + const first = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read before editing.' }) + const firstPlan = planFor(first, { homeDir: fx.home, cwd: fx.project }) + const firstRec = await runAction(firstPlan!, fx.actionsDir) + + let body = await readFile(claudeMd, 'utf-8') + expect(body).toContain('# Project') + expect(body).toContain('<!-- codeburn:begin read-edit-ratio -->') + expect(body).toContain('Read before editing.') + expect(body).toContain('<!-- codeburn:end read-edit-ratio -->') + + // Second apply with the same id replaces the block instead of duplicating. + const second = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read first, then edit.' }) + const secondPlan = planFor(second, { homeDir: fx.home, cwd: fx.project }) + const secondRec = await runAction(secondPlan!, fx.actionsDir) + + body = await readFile(claudeMd, 'utf-8') + expect(body.match(/codeburn:begin read-edit-ratio/g)).toHaveLength(1) + expect(body).toContain('Read first, then edit.') + expect(body).not.toContain('Read before editing.') + + await undoAction({ id: secondRec.id }, { actionsDir: fx.actionsDir }) + await undoAction({ id: firstRec.id }, { actionsDir: fx.actionsDir }) + expect(await readFile(claudeMd, 'utf-8')).toBe(original) + }) +}) + +describe('shell-config plan', () => { + it('writes the bash cap inside # markers to the rc chosen from $SHELL', async () => { + const fx = await makeFixture() + const finding = makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }) + const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' }) + expect(plan!.changes[0]!.path).toBe(join(fx.home, '.zshrc')) + + await runAction(plan!, fx.actionsDir) + const body = await readFile(join(fx.home, '.zshrc'), 'utf-8') + expect(body).toBe('# codeburn:begin bash-output-cap\nexport BASH_MAX_OUTPUT_LENGTH=15000\n# codeburn:end bash-output-cap\n') + }) +}) + +describe('dry-run', () => { + it('leaves the fixture tree byte-identical when only planning', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ mcpServers: { s: { command: 'c' } } }, null, 2) + '\n') + await mkdir(join(fx.home, '.claude', 'skills', 'ghost'), { recursive: true }) + await writeFile(join(fx.home, '.claude', 'skills', 'ghost', 'SKILL.md'), 'x') + await writeFile(join(fx.project, 'CLAUDE.md'), '# rules\n') + + const findings: WasteFinding[] = [ + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }), + makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['ghost'] }), + makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'rule' }), + makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }), + ] + + const before = await hashTree(fx.root) + const plans = planFindings(findings, { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' }) + // Exercise the exact rendering the dry-run path prints. + renderApplyList(plans.filter(p => p.plan !== null), plans.filter(p => p.plan === null), 0.000002) + const after = await hashTree(fx.root) + + expect(plans.every(p => p.plan !== null)).toBe(true) + expect(after).toBe(before) + }) +}) + +describe('finding-id regression guard', () => { + const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/ + const KNOWN: ReadonlySet<FindingId> = new Set<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', + ]) + + it('every finding produced by a detector run carries a stable, known, non-empty id', async () => { + const fx = await makeFixture() + const bigClaudeMd = '# Rules\n' + Array.from({ length: 260 }, (_, i) => `- rule ${i}`).join('\n') + '\n' + await writeFile(join(fx.project, 'CLAUDE.md'), bigClaudeMd) + + function read(file: string, session = 's1'): ToolCall { + return { name: 'Read', input: { file_path: file }, sessionId: session, project: 'p' } + } + const calls: ToolCall[] = [ + read('/p/node_modules/a.js'), read('/p/node_modules/b.js'), read('/p/dist/c.js'), + ...Array.from({ length: 6 }, () => read('/p/src/app.ts')), + ...Array.from({ length: 10 }, (): ToolCall => ({ name: 'Edit', input: {}, sessionId: 's1', project: 'p' })), + ] + const coverage: McpServerCoverage[] = [{ + server: 'x', toolsAvailable: 20, toolsInvoked: 1, + unusedTools: Array.from({ length: 19 }, (_, i) => `mcp__x__t${i}`), + invocations: 1, loadedSessions: 3, coverageRatio: 0.05, + }] + + const findings = [ + detectLowReadEditRatio(calls), + detectJunkReads(calls), + detectDuplicateReads(calls), + detectBloatedClaudeMd(new Set([fx.project])), + detectMcpToolCoverage([], coverage), + ].filter((f): f is WasteFinding => f !== null) + + expect(findings.length).toBeGreaterThanOrEqual(5) + for (const f of findings) { + expect(f.id).toBeTruthy() + expect(f.id).toMatch(KEBAB) + expect(KNOWN.has(f.id)).toBe(true) + } + const ids = findings.map(f => f.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) + +describe('unused-mcp plan', () => { + it('builds a remove-everywhere plan, including other projects[*] entries', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ + mcpServers: { u: { command: 'u' } }, + projects: { '/some/other': { mcpServers: { u: { command: 'u' }, keepme: {} } } }, + }, null, 2) + '\n') + + const finding = makeFinding('unused-mcp', CMD_FIX, { kind: 'mcp-remove', servers: ['u'] }) + const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project }) + expect(plan).not.toBeNull() + expect(plan!.kind).toBe('mcp-remove') + + await runAction(plan!, fx.actionsDir) + const after = JSON.parse(await readFile(claudeJson, 'utf-8')) + expect(after.mcpServers).toEqual({}) + expect(after.projects['/some/other'].mcpServers).toEqual({ keepme: {} }) + }) +}) + +describe('BOM handling', () => { + it('parses a config file with a UTF-8 BOM', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, '' + JSON.stringify({ mcpServers: { b: {} }, keep: 1 }, null, 2) + '\n') + + const { plan, notes } = planFindings( + [makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['b'] })], + { homeDir: fx.home, cwd: fx.project }, + )[0]! + expect(notes).toEqual([]) + expect(plan).not.toBeNull() + const written = JSON.parse(String(plan!.changes[0]!.content)) + expect(written).toEqual({ mcpServers: {}, keep: 1 }) + }) +}) + +// --------------------------------------------------------------------------- +// runOptimizeApply end-to-end (injected stdio, crafted findings, fixture home) +// --------------------------------------------------------------------------- + +const ANSI = /\[[0-9;]*m/g + +type Io = { + input: PassThrough + output: Writable + errorOutput: Writable + stdout(): string + stderr(): string +} + +function makeIo(answer?: string): Io { + const input = new PassThrough() + input.end(answer ?? '') + const outChunks: Buffer[] = [] + const errChunks: Buffer[] = [] + const output = new Writable({ write(c, _e, cb) { outChunks.push(Buffer.from(c)); cb() } }) + const errorOutput = new Writable({ write(c, _e, cb) { errChunks.push(Buffer.from(c)); cb() } }) + return { + input, + output, + errorOutput, + stdout: () => Buffer.concat(outChunks).toString('utf-8').replace(ANSI, ''), + stderr: () => Buffer.concat(errChunks).toString('utf-8').replace(ANSI, ''), + } +} + +function applyOpts(fx: Fixture, io: Io, extra: Partial<ApplyOptions> & { findings: WasteFinding[] }): ApplyOptions { + const ctx: PlanContext = { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' } + return { + ctx, + actionsDir: fx.actionsDir, + input: io.input, + output: io.output, + errorOutput: io.errorOutput, + ...extra, + } +} + +// One mcp server, one skill, one shell cap: three appliable findings in a +// stable 1/2/3 order for the picker tests. +async function threeFindingFixture(): Promise<{ fx: Fixture; findings: WasteFinding[] }> { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ mcpServers: { a: { command: 'a' } } }, null, 2) + '\n') + await mkdir(join(fx.home, '.claude', 'skills', 'foo'), { recursive: true }) + await writeFile(join(fx.home, '.claude', 'skills', 'foo', 'SKILL.md'), 'x') + const findings: WasteFinding[] = [ + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['a'] }), + makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }), + makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }), + ] + return { fx, findings } +} + +describe('runOptimizeApply end-to-end', () => { + it('--yes applies every plan and prints journal short ids with the undo hint', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true })) + + const records = await readRecords(fx.actionsDir) + expect(records).toHaveLength(3) + const out = io.stdout() + for (const rec of records) { + expect(out).toContain(`Applied ${shortId(rec.id)}`) + expect(out).toContain(`Undo anytime: codeburn act undo ${shortId(rec.id)}`) + } + expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({}) + expect(existsSync(join(fx.home, '.claude', 'skills', '.archived', 'foo'))).toBe(true) + expect(existsSync(join(fx.home, '.zshrc'))).toBe(true) + }) + + it('interactive pick "2" applies only the second plan', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo('2\n') + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings })) + + const records = await readRecords(fx.actionsDir) + expect(records).toHaveLength(1) + expect(records[0]!.kind).toBe('archive-skill') + expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({ a: { command: 'a' } }) + expect(existsSync(join(fx.home, '.zshrc'))).toBe(false) + }) + + it('interactive pick "1,3" applies the first and third plans', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo('1,3\n') + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings })) + + const records = await readRecords(fx.actionsDir) + expect(records.map(r => r.kind).sort()).toEqual(['mcp-remove', 'shell-config']) + expect(existsSync(join(fx.home, '.claude', 'skills', 'foo'))).toBe(true) + }) + + it('a garbage answer applies nothing and prints the empty outcome', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo('wat\n') + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings })) + + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + expect(io.stdout()).toContain('Nothing applied.') + }) + + it('EOF at the prompt prints "Nothing applied." and leaves the exit code untouched', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo() + const prevExit = process.exitCode + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings })) + + expect(process.exitCode).toBe(prevExit) + expect(io.stdout()).toContain('Nothing applied.') + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + }) + + it('--only restricts the applied set', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true, only: 'unused-skills' })) + + const records = await readRecords(fx.actionsDir) + expect(records).toHaveLength(1) + expect(records[0]!.kind).toBe('archive-skill') + expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({ a: { command: 'a' } }) + }) + + it('--only with an unknown or not-appliable id errors with the valid ids and exit code 2', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo() + const prevExit = process.exitCode + try { + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true, only: 'read-edit-ratio' })) + + expect(process.exitCode).toBe(2) + const err = io.stderr() + expect(err).toContain('read-edit-ratio') + expect(err).toContain('Appliable ids for this run:') + expect(err).toContain('mcp-low-coverage') + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + expect(io.stdout()).not.toContain('No appliable config-class fixes') + } finally { + process.exitCode = prevExit + } + }) + + it('--yes skips claude-md plans with a reason unless explicitly selected via --only', async () => { + const { fx, findings } = await threeFindingFixture() + const claudeMdFinding = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read first.' }) + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [claudeMdFinding, ...findings], yes: true })) + + expect(io.stdout()).toContain('Skipped read-edit-ratio: CLAUDE.md edits are not applied with --yes') + expect(existsSync(join(fx.project, 'CLAUDE.md'))).toBe(false) + expect((await readRecords(fx.actionsDir)).map(r => r.kind)).not.toContain('claude-md-rule') + + const fx2 = await makeFixture() + const io2 = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx2, io2, { findings: [claudeMdFinding], yes: true, only: 'read-edit-ratio' })) + + expect((await readRecords(fx2.actionsDir)).map(r => r.kind)).toEqual(['claude-md-rule']) + expect(await readFile(join(fx2.project, 'CLAUDE.md'), 'utf-8')).toContain('<!-- codeburn:begin read-edit-ratio -->') + }) + + it('project-scope leaves unrelated projects[*] entries untouched and previews the cold removals', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + const serverValue = { command: 'srv' } + await writeFile(claudeJson, JSON.stringify({ + mcpServers: { srv: serverValue }, + projects: { + '/cold/one': { mcpServers: { srv: serverValue } }, + '/unrelated/proj': { mcpServers: { srv: serverValue, keepme: {} } }, + }, + }, null, 2) + '\n') + const keeper = join(fx.root, 'keeper') + await mkdir(keeper, { recursive: true }) + + const finding = makeFinding('mcp-project-scope', CMD_FIX, { + kind: 'mcp-project-scope', + servers: [{ server: 'srv', keepProjects: [keeper], removeProjects: ['/cold/one'] }], + }) + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true })) + + expect(io.stdout()).toContain('(removes srv from 1 project entry: /cold/one)') + + const after = JSON.parse(await readFile(claudeJson, 'utf-8')) + expect(after.mcpServers).toEqual({}) + expect(after.projects['/cold/one'].mcpServers).toEqual({}) + expect(after.projects['/unrelated/proj'].mcpServers).toEqual({ srv: serverValue, keepme: {} }) + expect(JSON.parse(await readFile(join(keeper, '.mcp.json'), 'utf-8')).mcpServers).toEqual({ srv: serverValue }) + }) + + it('surfaces parse-error notes when every plan resolves to null', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), 'not json{{{') + const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['ghost'] }) + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true })) + + const out = io.stdout() + expect(out).toContain('No appliable config-class fixes') + expect(out).toContain('could not parse') + expect(out).toContain('.claude.json') + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + }) + + it('renders notes under manual findings alongside appliable ones', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), 'not json{{{') + await mkdir(join(fx.home, '.claude', 'skills', 'foo'), { recursive: true }) + await writeFile(join(fx.home, '.claude', 'skills', 'foo', 'SKILL.md'), 'x') + const findings: WasteFinding[] = [ + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['ghost'] }), + makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }), + ] + const io = makeIo() + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, dryRun: true })) + + const out = io.stdout() + expect(out).toContain('[mcp-low-coverage] manual') + expect(out).toContain('could not parse') + }) +}) + +describe('stale-plan detection', () => { + it('rejects when the target changed after the plan was built, leaving nothing behind', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ mcpServers: { s: {} } }, null, 2) + '\n') + const plan = planFor( + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }), + { homeDir: fx.home, cwd: fx.project }, + ) + expect(plan).not.toBeNull() + + const interim = JSON.stringify({ mcpServers: { s: {}, addedMeanwhile: {} } }, null, 2) + '\n' + await writeFile(claudeJson, interim) + + await expect(runAction(plan!, fx.actionsDir)).rejects.toThrow(/changed since the plan was built; re-run codeburn optimize --apply/) + expect(await readFile(claudeJson, 'utf-8')).toBe(interim) + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + const backups = await readdir(join(fx.actionsDir, 'backups')).catch(() => []) + expect(backups).toEqual([]) + }) + + it('rejects a plan expecting an absent file when the file appeared', async () => { + const fx = await makeFixture() + const claudeMd = join(fx.project, 'CLAUDE.md') + const plan = planFor( + makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'rule' }), + { homeDir: fx.home, cwd: fx.project }, + ) + expect(plan!.changes[0]).toMatchObject({ op: 'create', expectedHash: null }) + + await writeFile(claudeMd, '# appeared meanwhile\n') + + await expect(runAction(plan!, fx.actionsDir)).rejects.toThrow(/changed since the plan was built/) + expect(await readFile(claudeMd, 'utf-8')).toBe('# appeared meanwhile\n') + expect(await readRecords(fx.actionsDir)).toHaveLength(0) + }) + + it('applies when the target still matches the expected hash', async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ mcpServers: { s: {} } }, null, 2) + '\n') + const plan = planFor( + makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }), + { homeDir: fx.home, cwd: fx.project }, + ) + expect(plan!.changes[0]!.op === 'move' ? undefined : plan!.changes[0]!.expectedHash).toMatch(/^[0-9a-f]{64}$/) + + const rec = await runAction(plan!, fx.actionsDir) + expect(rec.status).toBe('applied') + expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({}) + }) + + it('a change without expectedHash skips validation (framework back-compat)', async () => { + const fx = await makeFixture() + const p = join(fx.home, 'free.txt') + await writeFile(p, 'anything at all') + + const rec = await runAction({ + kind: 'claude-md-rule', + description: 'no expected hash', + changes: [{ op: 'edit', path: p, content: 'overwritten' }], + }, fx.actionsDir) + expect(rec.status).toBe('applied') + expect(await readFile(p, 'utf-8')).toBe('overwritten') + }) +}) diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts new file mode 100644 index 0000000..2364e08 --- /dev/null +++ b/tests/optimize-fs.test.ts @@ -0,0 +1,425 @@ +import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest' +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import * as fsUtils from '../src/fs-utils.js' + +vi.mock('os', async () => { + const actual = await vi.importActual<typeof import('os')>('os') + const fs = await vi.importActual<typeof import('fs')>('fs') + const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/codeburn-home-') + fs.mkdirSync(fakeHome + '/.claude', { recursive: true }) + process.env['CODEBURN_TEST_FAKE_HOME'] = fakeHome + return { ...actual, homedir: () => fakeHome } +}) + +const FAKE_HOME_FOR_MOCK = process.env['CODEBURN_TEST_FAKE_HOME']! + +import { + detectBloatedClaudeMd, + detectUnusedMcp, + detectBashBloat, + detectGhostCommands, + loadMcpConfigs, + scanJsonlFile, + scanAndDetect, + type ToolCall, +} from '../src/optimize.js' +import { + estimateContextBudget, + discoverProjectCwd, +} from '../src/context-budget.js' + +// ============================================================================ +// Helpers for filesystem fixtures +// ============================================================================ + +const FIXTURE_ROOTS: string[] = [FAKE_HOME_FOR_MOCK] + +function makeFixtureRoot(): string { + const dir = mkdtempSync(join(tmpdir(), 'codeburn-test-')) + FIXTURE_ROOTS.push(dir) + return dir +} + +function writeFile(path: string, content: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, content) +} + +function touchOld(path: string, daysAgo: number): void { + const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000) + utimesSync(path, past, past) +} + +afterAll(() => { + for (const dir of FIXTURE_ROOTS) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +// ============================================================================ +// detectBloatedClaudeMd (including @-import expansion) +// ============================================================================ + +describe('detectBloatedClaudeMd', () => { + it('flags a CLAUDE.md with more than 200 lines', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + const content = Array.from({ length: 300 }, (_, i) => `line ${i}`).join('\n') + writeFile(join(projectDir, 'CLAUDE.md'), content) + const finding = detectBloatedClaudeMd(new Set([projectDir])) + expect(finding).not.toBeNull() + }) + + it('expands @-imports and counts transitive load', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile( + join(projectDir, 'CLAUDE.md'), + 'line 1\nline 2\n@./rules.md\n@./conventions.md\n', + ) + writeFile(join(projectDir, 'rules.md'), Array.from({ length: 120 }, (_, i) => `rule ${i}`).join('\n')) + writeFile(join(projectDir, 'conventions.md'), Array.from({ length: 120 }, (_, i) => `conv ${i}`).join('\n')) + const finding = detectBloatedClaudeMd(new Set([projectDir])) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('2 @-imports') + }) + + it('does not flag a lean CLAUDE.md under 200 lines with no imports', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, 'CLAUDE.md'), 'just a few\nlines\nhere\n') + expect(detectBloatedClaudeMd(new Set([projectDir]))).toBeNull() + }) + + it('does not recurse infinitely on circular @-imports', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, 'CLAUDE.md'), '@./a.md\n') + writeFile(join(projectDir, 'a.md'), '@./b.md\n') + writeFile(join(projectDir, 'b.md'), '@./a.md\n') + expect(() => detectBloatedClaudeMd(new Set([projectDir]))).not.toThrow() + }) + + it('ignores @ tokens that are not paths (emails, npm scopes)', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile( + join(projectDir, 'CLAUDE.md'), + Array.from({ length: 250 }, (_, i) => + i === 10 ? '@user@example.com' : + i === 20 ? '@org/package' : + `line ${i}` + ).join('\n'), + ) + const finding = detectBloatedClaudeMd(new Set([projectDir])) + expect(finding).not.toBeNull() + // "with N @-imports" suffix appears only when non-zero imports were resolved + expect(finding!.explanation).not.toMatch(/with \d+ @-import/) + }) +}) + +// ============================================================================ +// loadMcpConfigs + detectUnusedMcp +// ============================================================================ + +describe('loadMcpConfigs', () => { + it('returns empty map when no configs exist', () => { + const root = makeFixtureRoot() + const servers = loadMcpConfigs([root]) + expect(servers.size).toBe(0) + }) + + it('reads servers from project .mcp.json', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { foo: { command: 'foo' }, bar: { command: 'bar' } }, + })) + const servers = loadMcpConfigs([projectDir]) + expect(servers.has('foo')).toBe(true) + expect(servers.has('bar')).toBe(true) + }) + + it('normalizes server names by replacing colons with underscores', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { 'plugin:context7:context7': { command: 'ctx' } }, + })) + const servers = loadMcpConfigs([projectDir]) + expect(servers.has('plugin_context7_context7')).toBe(true) + expect(servers.get('plugin_context7_context7')!.original).toBe('plugin:context7:context7') + }) + + it('handles malformed JSON without crashing', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), '{ not valid json') + expect(() => loadMcpConfigs([projectDir])).not.toThrow() + expect(loadMcpConfigs([projectDir]).size).toBe(0) + }) +}) + +describe('detectUnusedMcp', () => { + it('flags servers configured but never called', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { ghost: { command: 'x' } }, + })) + const configFile = join(projectDir, '.mcp.json') + touchOld(configFile, 30) + const finding = detectUnusedMcp([], [], new Set([projectDir])) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('ghost') + }) + + it('does not flag servers configured within 24 hours', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { freshly_added: { command: 'x' } }, + })) + expect(detectUnusedMcp([], [], new Set([projectDir]))).toBeNull() + }) + + it('does not flag servers that were called', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ + mcpServers: { used: { command: 'x' } }, + })) + touchOld(join(projectDir, '.mcp.json'), 30) + const calls: ToolCall[] = [ + { name: 'mcp__used__some_tool', input: {}, sessionId: 's1', project: 'p1' }, + ] + expect(detectUnusedMcp(calls, [], new Set([projectDir]))).toBeNull() + }) +}) + +// ============================================================================ +// detectBashBloat +// ============================================================================ + +describe('detectBashBloat', () => { + it('flags when env var is unset (uses default 30K)', () => { + const finding = detectBashBloat() + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('medium') + }) + + it('does not flag when env var is at recommended 15K', () => { + process.env['BASH_MAX_OUTPUT_LENGTH'] = '15000' + expect(detectBashBloat()).toBeNull() + }) + + it('does not flag when env var is below recommended', () => { + process.env['BASH_MAX_OUTPUT_LENGTH'] = '10000' + expect(detectBashBloat()).toBeNull() + }) + + it('flags when env var is above 15K', () => { + process.env['BASH_MAX_OUTPUT_LENGTH'] = '50000' + const finding = detectBashBloat() + expect(finding).not.toBeNull() + }) +}) + +// ============================================================================ +// detectGhostCommands (the pure-function ghost detector) +// ============================================================================ + +describe('detectGhostCommands', () => { + it('returns null when no commands are defined', async () => { + expect(await detectGhostCommands([])).toBeNull() + }) + + it('does not match /tmp or /usr or other path prefixes as command usage', async () => { + const messages = [ + 'check /tmp/debug.log', + 'look at /usr/local/bin', + 'rm -rf /var/cache', + ] + expect(await detectGhostCommands(messages)).toBeNull() + }) + + it('matches <command-name> tags in user messages', async () => { + const messages = ['<command-name>review</command-name>'] + expect(await detectGhostCommands(messages)).toBeNull() + }) +}) + +// ============================================================================ +// scanJsonlFile +// ============================================================================ + +describe('scanJsonlFile', () => { + it('returns empty result for nonexistent file', async () => { + const result = await scanJsonlFile('/nonexistent/path.jsonl', 'p1', undefined) + expect(result.calls).toEqual([]) + expect(result.cwds).toEqual([]) + expect(result.apiCalls).toEqual([]) + expect(result.userMessages).toEqual([]) + }) + + it('parses tool_use blocks from assistant entries', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'session.jsonl') + const now = new Date().toISOString() + const lines = [ + JSON.stringify({ + type: 'assistant', + timestamp: now, + message: { + content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/x/foo.ts' } }], + }, + }), + ] + writeFile(filePath, lines.join('\n')) + const result = await scanJsonlFile(filePath, 'p1', undefined) + expect(result.calls).toHaveLength(1) + expect(result.calls[0].name).toBe('Read') + }) + + it('skips malformed JSONL lines without crashing', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'session.jsonl') + writeFile(filePath, 'this is not json\n{broken\n{"type":"assistant","message":{"content":[]}}\n') + const result = await scanJsonlFile(filePath, 'p1', undefined) + expect(result.calls).toEqual([]) + }) + + it('uses readSessionLines (streaming) rather than readSessionFile (full-string load)', async () => { + const readSessionLinesSpy = vi.spyOn(fsUtils, 'readSessionLines') + const readSessionFileSpy = vi.spyOn(fsUtils, 'readSessionFile') + const root = makeFixtureRoot() + const filePath = join(root, 'session.jsonl') + const now = new Date().toISOString() + writeFile(filePath, JSON.stringify({ + type: 'assistant', timestamp: now, + message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] }, + })) + await scanJsonlFile(filePath, 'p1', undefined) + expect(readSessionLinesSpy).toHaveBeenCalledWith(filePath, undefined, { largeLineAsBuffer: true }) + expect(readSessionFileSpy).not.toHaveBeenCalled() + readSessionLinesSpy.mockRestore() + readSessionFileSpy.mockRestore() + }) + + it('processes all entries in a large multi-line file without truncation', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'session.jsonl') + const now = new Date().toISOString() + const ENTRY_COUNT = 500 + const lines = Array.from({ length: ENTRY_COUNT }, (_, i) => + JSON.stringify({ + type: 'assistant', + timestamp: now, + message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: `/file-${i}.ts` } }] }, + }), + ) + writeFile(filePath, lines.join('\n')) + const result = await scanJsonlFile(filePath, 'p1', undefined) + expect(result.calls).toHaveLength(ENTRY_COUNT) + }) + + it('respects date-range filter for assistant entries', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'session.jsonl') + const old = '2020-01-01T00:00:00Z' + const now = new Date().toISOString() + writeFile(filePath, [ + JSON.stringify({ + type: 'assistant', timestamp: old, + message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/old' } }] }, + }), + JSON.stringify({ + type: 'assistant', timestamp: now, + message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/new' } }] }, + }), + ].join('\n')) + const today = new Date() + const start = new Date(today.getFullYear(), today.getMonth(), today.getDate()) + const result = await scanJsonlFile(filePath, 'p1', { start, end: today }) + expect(result.calls).toHaveLength(1) + expect((result.calls[0].input as Record<string, unknown>).file_path).toBe('/new') + }) +}) + +// ============================================================================ +// scanAndDetect (top-level integration) +// ============================================================================ + +describe('scanAndDetect', () => { + it('returns healthy result for empty projects', async () => { + const result = await scanAndDetect([]) + expect(result.findings).toEqual([]) + expect(result.healthScore).toBe(100) + expect(result.healthGrade).toBe('A') + expect(result.costRate).toBe(0) + }) +}) + +// ============================================================================ +// context-budget +// ============================================================================ + +describe('estimateContextBudget', () => { + it('returns only system base when project has no config', async () => { + const root = makeFixtureRoot() + const budget = await estimateContextBudget(root) + expect(budget.total).toBeGreaterThan(0) + expect(budget.mcpTools.count).toBe(0) + expect(budget.skills.count).toBe(0) + }) + + it('includes MCP tools from project .mcp.json', async () => { + const root = makeFixtureRoot() + writeFile(join(root, '.mcp.json'), JSON.stringify({ + mcpServers: { a: { command: 'x' }, b: { command: 'x' } }, + })) + const budget = await estimateContextBudget(root) + expect(budget.mcpTools.count).toBeGreaterThan(0) + }) + + it('includes memory file tokens from CLAUDE.md', async () => { + const root = makeFixtureRoot() + writeFile(join(root, 'CLAUDE.md'), 'Project context for Claude.\n') + const budget = await estimateContextBudget(root) + expect(budget.memory.count).toBeGreaterThan(0) + expect(budget.memory.tokens).toBeGreaterThan(0) + }) +}) + +describe('discoverProjectCwd', () => { + it('returns null for empty directory', async () => { + const root = makeFixtureRoot() + expect(await discoverProjectCwd(root)).toBeNull() + }) + + it('returns null for directory with no jsonl files', async () => { + const root = makeFixtureRoot() + writeFile(join(root, 'readme.txt'), 'hi') + expect(await discoverProjectCwd(root)).toBeNull() + }) + + it('extracts cwd from the first jsonl entry', async () => { + const root = makeFixtureRoot() + const entry = JSON.stringify({ type: 'assistant', cwd: '/Users/test/project', timestamp: new Date().toISOString() }) + writeFile(join(root, 'session.jsonl'), entry + '\n') + expect(await discoverProjectCwd(root)).toBe('/Users/test/project') + }) +}) diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts new file mode 100644 index 0000000..352cf3c --- /dev/null +++ b/tests/optimize.test.ts @@ -0,0 +1,1184 @@ +import { describe, it, expect } from 'vitest' + +import { + detectJunkReads, + detectDuplicateReads, + detectLowReadEditRatio, + detectCacheBloat, + detectBloatedClaudeMd, + detectContextBloat, + detectCapabilityReliability, + detectLowWorthSessions, + detectSessionOutliers, + computeHealth, + computeTrend, + buildOptimizeJsonReport, + type ToolCall, + type ApiCallMeta, + type WasteFinding, + type OptimizeResult, +} from '../src/optimize.js' +import type { ProjectSummary } from '../src/types.js' + +function call(name: string, input: Record<string, unknown>, sessionId = 's1', project = 'p1'): ToolCall { + return { name, input, sessionId, project } +} + +function emptyProjects(): ProjectSummary[] { + return [] +} + +function projectWithSessions(costs: number[], project = 'app'): ProjectSummary { + const sessions = costs.map((cost, i) => { + const tokens = Math.round(cost * 1000) + return { + sessionId: `s${i + 1}`, + project, + firstTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + lastTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:30:00Z`, + totalCostUSD: cost, + totalInputTokens: tokens, + totalOutputTokens: tokens, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as ProjectSummary['sessions'][number]['categoryBreakdown'], + skillBreakdown: {}, + } + }) + + return { + project, + projectPath: `/tmp/${project}`, + sessions, + totalCostUSD: costs.reduce((sum, cost) => sum + cost, 0), + totalApiCalls: sessions.length, + } +} + +type TestSession = ProjectSummary['sessions'][number] + +function contextSession( + i: number, + overrides: Partial<TestSession>, + project = 'app', +): TestSession { + return { + sessionId: `s${i + 1}`, + project, + firstTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + lastTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:30:00Z`, + totalCostUSD: 1, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as TestSession['categoryBreakdown'], + skillBreakdown: {}, + ...overrides, + } +} + +function projectWithContextSessions(sessions: TestSession[], project = 'app'): ProjectSummary { + return { + project, + projectPath: `/tmp/${project}`, + sessions, + totalCostUSD: sessions.reduce((sum, session) => sum + session.totalCostUSD, 0), + totalApiCalls: sessions.reduce((sum, session) => sum + session.apiCalls, 0), + } +} + +describe('detectJunkReads', () => { + it('returns null below minimum threshold', () => { + const calls = [ + call('Read', { file_path: '/x/node_modules/a.js' }), + call('Read', { file_path: '/x/node_modules/b.js' }), + ] + expect(detectJunkReads(calls)).toBeNull() + }) + + it('flags when threshold is met', () => { + const calls = [ + call('Read', { file_path: '/x/node_modules/a.js' }), + call('Read', { file_path: '/x/node_modules/b.js' }), + call('Read', { file_path: '/x/.git/config' }), + ] + const finding = detectJunkReads(calls) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('low') + }) + + it('scales impact with read count', () => { + const make = (n: number) => Array.from({ length: n }, (_, i) => + call('Read', { file_path: `/x/node_modules/file-${i}.js` }) + ) + expect(detectJunkReads(make(25))!.impact).toBe('high') + expect(detectJunkReads(make(10))!.impact).toBe('medium') + }) + + it('ignores non-junk paths', () => { + const calls = [ + call('Read', { file_path: '/x/src/a.ts' }), + call('Read', { file_path: '/x/src/b.ts' }), + call('Read', { file_path: '/x/README.md' }), + ] + expect(detectJunkReads(calls)).toBeNull() + }) + + it('ignores non-read tools', () => { + const calls = [ + call('Edit', { file_path: '/x/node_modules/a.js' }), + call('Bash', { command: 'ls node_modules' }), + call('Grep', { pattern: 'test', path: '/x/node_modules' }), + ] + expect(detectJunkReads(calls)).toBeNull() + }) + + it('handles missing file_path gracefully', () => { + const calls = [ + call('Read', {}), + call('Read', { file_path: null as unknown as string }), + ] + expect(detectJunkReads(calls)).toBeNull() + }) + + it('suggests CLAUDE.md advice listing detected and common junk dirs', () => { + const calls = Array.from({ length: 5 }, () => call('Read', { file_path: '/x/node_modules/a.js' })) + const finding = detectJunkReads(calls)! + expect(finding.fix.type).toBe('paste') + if (finding.fix.type === 'paste') { + expect(finding.fix.text).toContain('node_modules') + // Issue #277: every paste-style fix should declare its destination so + // users can tell a permanent CLAUDE.md rule from a one-time session + // opener at a glance. + expect(finding.fix.destination).toBe('claude-md') + } + expect(finding.fix.label).toContain('CLAUDE.md') + }) +}) + +describe('detectDuplicateReads', () => { + it('counts same file read multiple times in same session', () => { + const calls = [ + ...Array.from({ length: 4 }, () => call('Read', { file_path: '/src/a.ts' }, 's1')), + ...Array.from({ length: 4 }, () => call('Read', { file_path: '/src/b.ts' }, 's1')), + ] + const finding = detectDuplicateReads(calls) + expect(finding).not.toBeNull() + }) + + it('does not count across sessions', () => { + const calls = [ + call('Read', { file_path: '/src/a.ts' }, 's1'), + call('Read', { file_path: '/src/a.ts' }, 's2'), + call('Read', { file_path: '/src/a.ts' }, 's3'), + ] + expect(detectDuplicateReads(calls)).toBeNull() + }) + + it('excludes junk directory reads', () => { + const calls = Array.from({ length: 10 }, () => + call('Read', { file_path: '/x/node_modules/foo.js' }, 's1') + ) + expect(detectDuplicateReads(calls)).toBeNull() + }) + + it('returns null for single reads', () => { + const calls = [ + call('Read', { file_path: '/src/a.ts' }, 's1'), + call('Read', { file_path: '/src/b.ts' }, 's1'), + ] + expect(detectDuplicateReads(calls)).toBeNull() + }) +}) + +describe('detectLowReadEditRatio', () => { + it('returns null below minimum edit count', () => { + const calls = [ + call('Edit', {}), + call('Edit', {}), + call('Read', {}), + ] + expect(detectLowReadEditRatio(calls)).toBeNull() + }) + + it('returns null when ratio is healthy', () => { + const calls = [ + ...Array.from({ length: 40 }, () => call('Read', {})), + ...Array.from({ length: 10 }, () => call('Edit', {})), + ] + expect(detectLowReadEditRatio(calls)).toBeNull() + }) + + it('flags when edits outpace reads', () => { + const calls = [ + ...Array.from({ length: 5 }, () => call('Read', {})), + ...Array.from({ length: 10 }, () => call('Edit', {})), + ] + const finding = detectLowReadEditRatio(calls) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('high') + }) + + it('counts Grep and Glob as reads for ratio', () => { + const calls = [ + ...Array.from({ length: 40 }, () => call('Grep', {})), + ...Array.from({ length: 10 }, () => call('Edit', {})), + ] + expect(detectLowReadEditRatio(calls)).toBeNull() + }) + + it('counts Write as edit', () => { + const calls = [ + ...Array.from({ length: 15 }, () => call('Read', {})), + ...Array.from({ length: 10 }, () => call('Write', {})), + ] + const finding = detectLowReadEditRatio(calls) + expect(finding).not.toBeNull() + }) +}) + +describe('detectCacheBloat', () => { + it('returns null below minimum api calls', () => { + const apiCalls: ApiCallMeta[] = [ + { cacheCreationTokens: 80000, version: '2.1.100' }, + { cacheCreationTokens: 80000, version: '2.1.100' }, + ] + expect(detectCacheBloat(apiCalls, emptyProjects())).toBeNull() + }) + + it('returns null when median is close to baseline', () => { + const apiCalls: ApiCallMeta[] = Array.from({ length: 20 }, () => ({ + cacheCreationTokens: 50000, + version: '2.1.98', + })) + expect(detectCacheBloat(apiCalls, emptyProjects())).toBeNull() + }) + + it('flags when median exceeds 1.4x baseline', () => { + const apiCalls: ApiCallMeta[] = Array.from({ length: 20 }, () => ({ + cacheCreationTokens: 80000, + version: '2.1.100', + })) + const finding = detectCacheBloat(apiCalls, emptyProjects()) + expect(finding).not.toBeNull() + }) +}) + +describe('detectBloatedClaudeMd', () => { + it('returns null when no projects have CLAUDE.md', () => { + const result = detectBloatedClaudeMd(new Set(['/nonexistent/path'])) + expect(result).toBeNull() + }) + + it('returns null for empty project set', () => { + const result = detectBloatedClaudeMd(new Set()) + expect(result).toBeNull() + }) +}) + +describe('detectContextBloat', () => { + it('returns null below the input/context token floor', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 74_999, + totalOutputTokens: 100, + }), + ]) + + expect(detectContextBloat([project])).toBeNull() + }) + + it('returns null when output is proportionate to input/context tokens', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 100_000, + totalOutputTokens: 5_000, + }), + ]) + + expect(detectContextBloat([project])).toBeNull() + }) + + it('discounts cache reads when estimating context pressure', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 5_000, + totalCacheReadTokens: 700_000, + totalOutputTokens: 5_000, + }), + ]) + + expect(detectContextBloat([project])).toBeNull() + }) + + it('weights cache writes when estimating context pressure', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 10_000, + totalCacheWriteTokens: 80_000, + totalOutputTokens: 3_000, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('110.0K effective input/cache') + expect(finding!.tokensSaved).toBe(65_000) + }) + + it('flags sessions where input/cache tokens swamp output', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 90_000, + totalCacheReadTokens: 30_000, + totalOutputTokens: 2_000, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('context-heavy session') + expect(finding!.explanation).toContain('app/s1') + expect(finding!.explanation).toContain('93.0K effective input/cache') + expect(finding!.explanation).toContain('46.5:1') + expect(finding!.impact).toBe('low') + expect(finding!.tokensSaved).toBe(63_000) + }) + + it('uses medium impact between the low and high tiers', () => { + const project = projectWithContextSessions( + Array.from({ length: 4 }, (_, i) => contextSession(i, { + totalInputTokens: 80_000, + totalOutputTokens: 1_000, + })), + ) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('medium') + }) + + it('uses high impact at 10 or more candidates regardless of total size', () => { + const project = projectWithContextSessions( + Array.from({ length: 10 }, (_, i) => contextSession(i, { + totalInputTokens: 80_000, + totalOutputTokens: 1_000, + })), + ) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('high') + }) + + it('includes context growth from the previous session when it is large', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 20_000, + totalOutputTokens: 1_000, + }), + contextSession(1, { + totalInputTokens: 100_000, + totalOutputTokens: 2_000, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('5.0x previous session input') + }) + + it('calculates context growth within each project only', () => { + const finding = detectContextBloat([ + projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 20_000, + totalOutputTokens: 1_000, + }), + contextSession(1, { + totalInputTokens: 100_000, + totalOutputTokens: 2_000, + }), + ], 'app'), + projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 100_000, + totalOutputTokens: 2_000, + }, 'api'), + ], 'api'), + ]) + + expect(finding).not.toBeNull() + expect(finding!.explanation.match(/previous session input/g)).toHaveLength(1) + }) + + it('summarizes additional candidates after the preview limit', () => { + const project = projectWithContextSessions( + Array.from({ length: 6 }, (_, i) => contextSession(i, { + totalInputTokens: 80_000 + i * 10_000, + totalOutputTokens: 1_000, + })), + ) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('app/s6') + expect(finding!.explanation).toContain('; +1 more') + expect(finding!.impact).toBe('high') + }) + + it('uses high impact for one very large context-heavy session', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 600_000, + totalOutputTokens: 10_000, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.impact).toBe('high') + }) + + it('handles zero-output sessions without dividing by zero', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 80_000, + totalOutputTokens: 0, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('1000+:1') + expect(finding!.tokensSaved).toBe(80_000) + }) + + it('caps display ratio at 1000+:1 for non-zero-output sessions too', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 5_000_000, + totalOutputTokens: 100, + }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('1000+:1') + }) + + it('suppresses the growth ratio when the previous session is more than 7 days back', () => { + const project = projectWithContextSessions([ + { + ...contextSession(0, { totalInputTokens: 20_000, totalOutputTokens: 1_000 }), + firstTimestamp: '2026-05-01T10:00:00Z', + lastTimestamp: '2026-05-01T10:30:00Z', + }, + { + ...contextSession(1, { totalInputTokens: 100_000, totalOutputTokens: 2_000 }), + firstTimestamp: '2026-05-15T10:00:00Z', + lastTimestamp: '2026-05-15T10:30:00Z', + }, + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).not.toContain('previous session input') + }) + + it('anchors growth even when the previous session is below the reporting threshold', () => { + const project = projectWithContextSessions([ + contextSession(0, { totalInputTokens: 20_000, totalOutputTokens: 1_000 }), + contextSession(1, { totalInputTokens: 100_000, totalOutputTokens: 2_000 }), + ]) + + const finding = detectContextBloat([project]) + expect(finding).not.toBeNull() + // The first session sits below CONTEXT_BLOAT_MIN_INPUT_TOKENS (75K) and + // is not itself a candidate, but the growth-from-previous comparison for + // the second session must still anchor against it. + expect(finding!.explanation).toContain('5.0x previous session input') + }) + + it('honors excludedSessionIds passed by the orchestrator', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 90_000, + totalCacheReadTokens: 30_000, + totalOutputTokens: 2_000, + }), + ]) + + const finding = detectContextBloat([project], new Set(['s1'])) + expect(finding).toBeNull() + }) +}) + +type LowWorthTurn = TestSession['turns'][number] + +function lowWorthTurn(overrides: Partial<LowWorthTurn> = {}): LowWorthTurn { + return { + userMessage: 'do the work', + assistantCalls: [], + timestamp: '2026-05-01T10:00:00Z', + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: false, + ...overrides, + } +} + +function lowWorthSession(cost: number, i: number, overrides: Partial<TestSession> = {}, project = 'app'): TestSession { + const tokens = Math.round(cost * 1000) + return { + sessionId: `s${i + 1}`, + project, + firstTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + lastTimestamp: `2026-05-${String(i + 1).padStart(2, '0')}T10:30:00Z`, + totalCostUSD: cost, + totalInputTokens: tokens, + totalOutputTokens: tokens, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as TestSession['categoryBreakdown'], + skillBreakdown: {}, + ...overrides, + } +} + +function projectWithLowWorthSessions(sessions: TestSession[], project = 'app'): ProjectSummary { + return { + project, + projectPath: `/tmp/${project}`, + sessions, + totalCostUSD: sessions.reduce((sum, s) => sum + s.totalCostUSD, 0), + totalApiCalls: sessions.reduce((sum, s) => sum + s.apiCalls, 0), + } +} + +describe('detectLowWorthSessions', () => { + it('returns null for cheap sessions', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(1.99, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('does not flag the no-edit cost boundary', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(2.99, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('flags expensive sessions with no edit turns', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('possibly low-worth') + expect(finding!.explanation).toContain('app/s1') + expect(finding!.explanation).toContain('no edit turns') + // sessionTokenTotal = input + output + cache. The lowWorthSession helper + // sets input=output=cost*1000, so the savings ceiling is 2x cost*1000. + expect(finding!.tokensSaved).toBe(8_000) + }) + + it('flags retry-heavy sessions', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(2.5, 0, { + turns: [ + lowWorthTurn({ hasEdits: true, retries: 1 }), + lowWorthTurn({ hasEdits: true, retries: 2 }), + ], + }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('3 retries') + }) + + it('estimates recoverable tokens by retry fraction for sessions with edits', () => { + // 4 turns, 2 retries spread across 2 edits, 0 one-shot edits → trips the + // 'no one-shot edit turns' reason. totalTurns=4, fraction=2/4=0.5, + // sessionTokenTotal=8K, so recoverable savings ceiling is 4K — half the + // session, not the full ceiling that no-edit sessions get. + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { + turns: [ + lowWorthTurn({ hasEdits: true, retries: 1 }), + lowWorthTurn({ hasEdits: true, retries: 1 }), + lowWorthTurn({ hasEdits: false }), + lowWorthTurn({ hasEdits: false }), + ], + }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.tokensSaved).toBe(4_000) + }) + + it('uses full session tokens as the savings ceiling for no-edit sessions', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + const finding = detectLowWorthSessions([project]) + // No edits at all -> entire session is at risk. sessionTokenTotal = 8K. + expect(finding!.tokensSaved).toBe(8_000) + }) + + it('keeps all reasons that apply to the same session', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { + turns: [ + lowWorthTurn({ hasEdits: false, retries: 1 }), + lowWorthTurn({ hasEdits: false, retries: 2 }), + ], + }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('no edit turns') + expect(finding!.explanation).toContain('3 retries') + }) + + it('flags edit sessions with retries but no one-shot edit turns via categoryBreakdown', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(2.25, 0, { + categoryBreakdown: { + coding: { turns: 2, costUSD: 2.25, retries: 2, editTurns: 2, oneShotTurns: 0 }, + } as TestSession['categoryBreakdown'], + }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('no one-shot edit turns') + }) + + it('skips sessions with a git delivery command', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'cd /tmp/app && git commit -m "ship fix"': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('skips sessions with gh pr create', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'gh pr create --fill': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('does not treat read-only git commands as delivery', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'git tag -l': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).not.toBeNull() + }) + + it('does not treat dry-run git commands as delivery', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'git push --dry-run origin main': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).not.toBeNull() + }) + + it('does not treat git commit-tree as a delivery command', () => { + // Regex must match `git commit` only, not `git commit-tree` / + // `git commit-graph`. Without the (?:\s|$|--) lookahead this would be a + // false positive and the session would silently skip detection. + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'git commit-tree HEAD^{tree}': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).not.toBeNull() + }) + + it('still treats `git commit --amend` as a delivery command', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(8, 0, { + turns: [lowWorthTurn({ hasEdits: false })], + bashBreakdown: { 'git commit --amend --no-edit': { calls: 1 } }, + }), + ]) + expect(detectLowWorthSessions([project])).toBeNull() + }) + + it('uses low impact for a single small candidate', () => { + const project = projectWithLowWorthSessions([ + lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] }), + ]) + const finding = detectLowWorthSessions([project]) + expect(finding!.impact).toBe('low') + }) + + it('uses medium impact between low and high tiers', () => { + const project = projectWithLowWorthSessions( + Array.from({ length: 3 }, (_, i) => lowWorthSession(4, i, { + turns: [lowWorthTurn({ hasEdits: false })], + })), + ) + const finding = detectLowWorthSessions([project]) + expect(finding!.impact).toBe('medium') + }) + + it('uses high impact at 10 or more candidates', () => { + const project = projectWithLowWorthSessions( + Array.from({ length: 10 }, (_, i) => lowWorthSession(3, i, { + turns: [lowWorthTurn({ hasEdits: false })], + })), + ) + const finding = detectLowWorthSessions([project]) + expect(finding!.impact).toBe('high') + }) + + it('summarizes additional candidates after the preview limit', () => { + const project = projectWithLowWorthSessions( + Array.from({ length: 6 }, (_, i) => lowWorthSession(4 + i, i, { + turns: [lowWorthTurn({ hasEdits: false })], + })), + ) + const finding = detectLowWorthSessions([project]) + expect(finding!.explanation).toContain('; +1 more') + }) +}) + +type ReliabilityCall = LowWorthTurn['assistantCalls'][number] + +function reliabilityCall(overrides: Partial<ReliabilityCall> = {}): ReliabilityCall { + return { + provider: 'claude', + model: 'claude-sonnet-4-5', + usage: { + inputTokens: 1000, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: 0.01, + tools: ['Edit'], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-05-01T10:00:00Z', + bashCommands: [], + deduplicationKey: 'call', + ...overrides, + } +} + +function reliabilityTurn( + i: number, + overrides: Partial<LowWorthTurn> & { call?: Partial<ReliabilityCall> } = {}, +): LowWorthTurn { + const { call: callOverrides, ...turnOverrides } = overrides + return lowWorthTurn({ + userMessage: `turn ${i}`, + assistantCalls: [reliabilityCall({ + timestamp: `2026-05-01T10:${String(i).padStart(2, '0')}:00Z`, + deduplicationKey: `call-${i}`, + ...callOverrides, + })], + timestamp: `2026-05-01T10:${String(i).padStart(2, '0')}:00Z`, + sessionId: 's1', + hasEdits: true, + retries: 0, + ...turnOverrides, + }) +} + +function projectWithReliabilityTurns(turns: LowWorthTurn[], project = 'app'): ProjectSummary { + return projectWithLowWorthSessions([ + lowWorthSession(1, 0, { + turns, + totalInputTokens: turns.length * 1000, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: turns.length, + }, project), + ], project) +} + +describe('detectCapabilityReliability', () => { + it('flags retry-heavy skills from actual Skill call metadata', () => { + const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, { + retries: i < 3 ? 1 : 0, + call: { tools: ['Edit', 'Skill'], skills: ['reviewer'] }, + })) + + const finding = detectCapabilityReliability([projectWithReliabilityTurns(turns)]) + + expect(finding).not.toBeNull() + expect(finding!.title).toContain('skill') + expect(finding!.explanation).toContain('skill reviewer') + expect(finding!.explanation).toContain('3/5 edit turns retried (60%)') + expect(finding!.explanation).toContain('correlation report') + expect(finding!.tokensSaved).toBe(1500) + expect(finding!.fix.type).toBe('paste') + if (finding!.fix.type === 'paste') expect(finding!.fix.destination).toBe('prompt') + }) + + it('flags retry-heavy MCP servers from invoked MCP tools', () => { + const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, { + retries: i < 3 ? 1 : 0, + call: { + tools: ['Edit', 'mcp__ci__run'], + mcpTools: ['mcp__ci__run'], + }, + })) + + const finding = detectCapabilityReliability([projectWithReliabilityTurns(turns)]) + + expect(finding).not.toBeNull() + expect(finding!.title).toContain('MCP server') + expect(finding!.explanation).toContain('MCP server ci') + expect(finding!.explanation).toContain('3 retries') + }) + + it('does not flag healthy capabilities with mostly one-shot edit turns', () => { + const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, { + retries: i === 0 ? 1 : 0, + call: { tools: ['Edit', 'Skill'], skills: ['healthy'] }, + })) + + expect(detectCapabilityReliability([projectWithReliabilityTurns(turns)])).toBeNull() + }) + + it('does not treat subCategory alone as skill evidence', () => { + const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, { + retries: 1, + subCategory: 'legacy-skill-label', + call: { tools: ['Edit'], skills: [] }, + })) + + expect(detectCapabilityReliability([projectWithReliabilityTurns(turns)])).toBeNull() + }) + + it('does not double-count the same retry-heavy turn across MCP and skill candidates', () => { + const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, { + retries: i < 3 ? 1 : 0, + call: { + tools: ['Edit', 'Skill', 'mcp__ci__run'], + mcpTools: ['mcp__ci__run'], + skills: ['reviewer'], + }, + })) + + const finding = detectCapabilityReliability([projectWithReliabilityTurns(turns)]) + + expect(finding).not.toBeNull() + expect(finding!.title).toContain('2 MCP/skill capabilities') + expect(finding!.explanation).toContain('MCP server ci') + expect(finding!.explanation).toContain('skill reviewer') + // Three retry-heavy turns at 1K effective tokens each, counted once at + // the 50% recoverable ceiling even though two flagged capabilities share + // every turn. + expect(finding!.tokensSaved).toBe(1500) + }) + + it('ignores read-only retry turns for capability reliability', () => { + const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, { + hasEdits: false, + retries: 1, + call: { tools: ['Read', 'Skill'], skills: ['reader'] }, + })) + + expect(detectCapabilityReliability([projectWithReliabilityTurns(turns)])).toBeNull() + }) +}) + +describe('detectSessionOutliers', () => { + it('returns null when there are too few sessions for a project baseline', () => { + expect(detectSessionOutliers([projectWithSessions([0.5, 4])])).toBeNull() + }) + + it('returns null when no session exceeds twice the project average', () => { + expect(detectSessionOutliers([projectWithSessions([1, 1.2, 1.4, 1.6])])).toBeNull() + }) + + it('does not flag the exact 2x boundary', () => { + expect(detectSessionOutliers([projectWithSessions([1, 1, 2])])).toBeNull() + }) + + it('flags sessions costing more than twice their project average', () => { + const finding = detectSessionOutliers([projectWithSessions([1, 1, 1, 10])]) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('high-cost session outlier') + expect(finding!.explanation).toContain('app/s4') + expect(finding!.impact).toBe('medium') + expect(finding!.tokensSaved).toBeGreaterThan(0) + }) + + it('ignores tiny absolute-cost outliers', () => { + expect(detectSessionOutliers([projectWithSessions([0.01, 0.01, 0.01, 0.2])])).toBeNull() + }) + + it('isolates baselines per project', () => { + const finding = detectSessionOutliers([ + projectWithSessions([8, 9, 10], 'web'), + projectWithSessions([1, 1, 1, 12], 'api'), + ]) + + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('api/s4') + expect(finding!.explanation).not.toContain('web/') + }) + + it('excludes sessions already flagged by detectContextBloat', () => { + const project = projectWithSessions([1, 1, 1, 10]) + const excluded = new Set(['s4']) + expect(detectSessionOutliers([project], excluded)).toBeNull() + }) + + it('still flags cost outliers that are not context-bloat candidates', () => { + const project = projectWithSessions([1, 1, 1, 10]) + const excluded = new Set(['some-other-session']) + const finding = detectSessionOutliers([project], excluded) + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('app/s4') + }) +}) + +describe('computeHealth', () => { + it('returns A with 100 for no findings', () => { + const { score, grade } = computeHealth([]) + expect(score).toBe(100) + expect(grade).toBe('A') + }) + + function mockFinding(impact: 'high' | 'medium' | 'low'): WasteFinding { + return { + title: 't', explanation: 'e', impact, tokensSaved: 1000, + fix: { type: 'paste', label: 'l', text: 't' }, + } + } + + it('one low finding stays at A', () => { + const { score, grade } = computeHealth([mockFinding('low')]) + expect(score).toBe(97) + expect(grade).toBe('A') + }) + + it('two high findings drop to C', () => { + const { score, grade } = computeHealth([mockFinding('high'), mockFinding('high')]) + expect(score).toBe(70) + expect(grade).toBe('C') + }) + + it('caps penalty at 80 to prevent score below 20', () => { + const findings = Array.from({ length: 20 }, () => mockFinding('high')) + const { score } = computeHealth(findings) + expect(score).toBe(20) + }) + + it('progresses grades predictably', () => { + expect(computeHealth([mockFinding('low')]).grade).toBe('A') + expect(computeHealth([mockFinding('medium')]).grade).toBe('A') + expect(computeHealth([mockFinding('medium'), mockFinding('medium')]).grade).toBe('B') + expect(computeHealth([mockFinding('high'), mockFinding('high'), mockFinding('high')]).grade).toBe('C') + expect(computeHealth([mockFinding('high'), mockFinding('high'), mockFinding('high'), mockFinding('high'), mockFinding('high')]).grade).toBe('F') + }) +}) + +describe('computeTrend', () => { + const window = 48 * 60 * 60 * 1000 + const baselineWindow = 5 * 24 * 60 * 60 * 1000 + + it('returns active when no recent activity detected', () => { + const trend = computeTrend({ + recentCount: 0, recentWindowMs: window, + baselineCount: 100, baselineWindowMs: baselineWindow, + hasRecentActivity: false, + }) + expect(trend).toBe('active') + }) + + it('returns resolved when recent activity exists but zero waste in it', () => { + const trend = computeTrend({ + recentCount: 0, recentWindowMs: window, + baselineCount: 100, baselineWindowMs: baselineWindow, + hasRecentActivity: true, + }) + expect(trend).toBe('resolved') + }) + + it('returns improving when recent rate is less than half of baseline rate', () => { + const trend = computeTrend({ + recentCount: 5, recentWindowMs: window, + baselineCount: 100, baselineWindowMs: baselineWindow, + hasRecentActivity: true, + }) + expect(trend).toBe('improving') + }) + + it('returns active when recent rate matches baseline rate', () => { + const recentRate = 100 / baselineWindow + const recentCount = Math.ceil(recentRate * window) + const trend = computeTrend({ + recentCount, recentWindowMs: window, + baselineCount: 100, baselineWindowMs: baselineWindow, + hasRecentActivity: true, + }) + expect(trend).toBe('active') + }) + + it('returns active when baseline is empty (new finding)', () => { + const trend = computeTrend({ + recentCount: 10, recentWindowMs: window, + baselineCount: 0, baselineWindowMs: baselineWindow, + hasRecentActivity: true, + }) + expect(trend).toBe('active') + }) +}) + +describe('paste-fix destination tagging (issue #277)', () => { + // Walks every emitted finding's fix and asserts that `paste`-type actions + // declare a destination. Future detectors that ship a paste fix without a + // destination get caught here so users never see an unlabeled "here's a + // suggestion" block again. + function checkAllPasteFixesHaveDestination(findings: WasteFinding[]) { + for (const f of findings) { + if (f.fix.type === 'paste') { + expect( + f.fix.destination, + `finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config` + ).toBeDefined() + expect(['claude-md', 'session-opener', 'prompt', 'shell-config']) + .toContain(f.fix.destination) + } + } + } + + it('detectJunkReads emits a tagged paste fix', () => { + const calls = Array.from({ length: 5 }, () => call('Read', { file_path: '/x/node_modules/a.js' })) + checkAllPasteFixesHaveDestination([detectJunkReads(calls)!]) + }) + + it('detectDuplicateReads emits a tagged paste fix', () => { + const calls = [ + ...Array.from({ length: 6 }, () => call('Read', { file_path: '/src/a.ts' }, 's1')), + ...Array.from({ length: 6 }, () => call('Read', { file_path: '/src/b.ts' }, 's1')), + ...Array.from({ length: 6 }, () => call('Read', { file_path: '/src/c.ts' }, 's1')), + ] + checkAllPasteFixesHaveDestination([detectDuplicateReads(calls)!]) + }) + + it('detectLowReadEditRatio emits a tagged paste fix', () => { + const calls = [ + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/a.ts' })), + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/b.ts' })), + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/c.ts' })), + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/d.ts' })), + ...Array.from({ length: 5 }, () => call('Edit', { file_path: '/src/e.ts' })), + ] + const finding = detectLowReadEditRatio(calls) + if (finding) checkAllPasteFixesHaveDestination([finding]) + }) +}) + +describe('buildOptimizeJsonReport', () => { + it('serializes setup health, savings, and fix details for integrations', () => { + const result: OptimizeResult = { + costRate: 0.00002, + healthScore: 72, + healthGrade: 'C', + findings: [ + { + title: 'Trim stale context', + explanation: 'Old instructions are loaded every turn.', + impact: 'medium', + tokensSaved: 50_000, + trend: 'active', + fix: { + type: 'paste', + label: 'Add guardrail', + text: 'Prefer short context.', + destination: 'claude-md', + }, + }, + ], + } + const range = { + start: new Date('2026-05-01T00:00:00.000Z'), + end: new Date('2026-05-08T00:00:00.000Z'), + } + + const report = buildOptimizeJsonReport( + [projectWithSessions([3, 2])], + '7 Days', + result, + range, + ) + + expect(report.period).toEqual({ + label: '7 Days', + start: '2026-05-01T00:00:00.000Z', + end: '2026-05-08T00:00:00.000Z', + }) + expect(report.summary).toMatchObject({ + healthScore: 72, + healthGrade: 'C', + findingCount: 1, + periodCostUSD: 5, + sessions: 2, + calls: 2, + potentialSavingsTokens: 50_000, + potentialSavingsCostUSD: 1, + potentialSavingsPercent: 20, + costRateUSD: 0.00002, + }) + expect(report.findings[0]).toMatchObject({ + title: 'Trim stale context', + severity: 'medium', + trend: 'active', + tokensSaved: 50_000, + estimatedSavingsUSD: 1, + fix: { + type: 'paste', + destination: 'claude-md', + }, + }) + }) +}) diff --git a/tests/otel-cache-aggregation.test.ts b/tests/otel-cache-aggregation.test.ts new file mode 100644 index 0000000..912263d --- /dev/null +++ b/tests/otel-cache-aggregation.test.ts @@ -0,0 +1,202 @@ +// Regression test for the OTel multi-conversation cache overwrite bug. +// +// Root cause: `parseProviderSources` in parser.ts calls +// `delete section.files[source.path]` +// at the START of every loop iteration over changedSources. When multiple OTel +// conversations from the same agent-traces.db share the same path key, each +// iteration wiped the merged turns accumulated by all previous iterations, so +// only the LAST conversation's data survived — a ~434x cost undercount in +// practice (observed: $0.19 vs ~$85 ground truth from OTel DB). +// +// This test exercises the full `parseAllSessions` pipeline to catch any future +// regression at the aggregation layer, not just the provider-level parser. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' + +import { isSqliteAvailable } from '../src/sqlite.js' +import { clearSessionCache, parseAllSessions } from '../src/parser.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...p: unknown[]): void } + close(): void +} + +function createOtelDb(dbPath: string): void { + const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb } + const db = new DatabaseSync(dbPath) + db.exec(` + CREATE TABLE spans ( + span_id TEXT PRIMARY KEY NOT NULL, + trace_id TEXT NOT NULL, + operation_name TEXT, + start_time_ms INTEGER NOT NULL DEFAULT 0, + response_model TEXT + ); + CREATE TABLE span_attributes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + span_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT + ); + `) + db.close() +} + +interface ConvSpec { + spanId: string + traceId: string + convId: string + model: string + input: number + output: number + cacheRead: number + cacheCreate: number + startTimeMs?: number +} + +function insertConversation(dbPath: string, spec: ConvSpec): void { + const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb } + const db = new DatabaseSync(dbPath) + db.prepare( + `INSERT INTO spans (span_id, trace_id, operation_name, start_time_ms, response_model) + VALUES (?, ?, ?, ?, ?)` + ).run(spec.spanId, spec.traceId, 'chat', spec.startTimeMs ?? Date.now(), spec.model) + + const attrStmt = db.prepare( + `INSERT INTO span_attributes (span_id, key, value) VALUES (?, ?, ?)` + ) + const attrs: Record<string, string | number> = { + 'gen_ai.conversation.id': spec.convId, + 'gen_ai.response.model': spec.model, + 'gen_ai.usage.input_tokens': spec.input, + 'gen_ai.usage.output_tokens': spec.output, + 'gen_ai.usage.cache_read.input_tokens': spec.cacheRead, + 'gen_ai.usage.cache_creation.input_tokens': spec.cacheCreate, + } + for (const [key, value] of Object.entries(attrs)) { + attrStmt.run(spec.spanId, key, String(value)) + } + db.close() +} + +describe.skipIf(!isSqliteAvailable())( + 'OTel multi-conversation cache aggregation (regression for 434x undercount)', + () => { + let tmpHome: string + let tmpCache: string + let dbPath: string + + beforeEach(async () => { + tmpHome = await mkdtemp(join(tmpdir(), 'cb-otel-agg-home-')) + tmpCache = await mkdtemp(join(tmpdir(), 'cb-otel-agg-cache-')) + dbPath = join(tmpHome, 'agent-traces.db') + + process.env['HOME'] = tmpHome + process.env['CODEBURN_CACHE_DIR'] = tmpCache + + vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '') + // Redirect JSONL and transcript dirs to nonexistent paths so real + // developer session files don't contaminate the test results. + vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmpHome, 'no-jsonl')) + vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws')) + }) + + afterEach(async () => { + clearSessionCache() + vi.unstubAllEnvs() + await rm(tmpHome, { recursive: true, force: true }) + await rm(tmpCache, { recursive: true, force: true }) + }) + + it('preserves cache tokens from all conversations, not just the last one', async () => { + // Pricing for claude-haiku-4-5 (per litellm-snapshot.json): + // input: $1.00 / M → 1e-6 + // output: $5.00 / M → 5e-6 + // cache_read: $0.10 / M → 1e-7 + // cache_write: $1.25 / M → 1.25e-6 + createOtelDb(dbPath) + + const conversations: ConvSpec[] = [ + { spanId: 'span-1', traceId: 'trace-1', convId: 'conv-1', model: 'claude-haiku-4-5-20251001', + input: 1_000, output: 100, cacheRead: 50_000, cacheCreate: 500 }, + { spanId: 'span-2', traceId: 'trace-2', convId: 'conv-2', model: 'claude-haiku-4-5-20251001', + input: 2_000, output: 200, cacheRead: 60_000, cacheCreate: 600 }, + { spanId: 'span-3', traceId: 'trace-3', convId: 'conv-3', model: 'claude-haiku-4-5-20251001', + input: 3_000, output: 300, cacheRead: 70_000, cacheCreate: 700 }, + ] + for (const c of conversations) insertConversation(dbPath, c) + + const projects = await parseAllSessions(undefined, 'copilot') + const allCalls = projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) + + // All three conversations must be present — before the fix only 1 survived. + expect(allCalls).toHaveLength(3) + + const totalInput = allCalls.reduce((s, c) => s + c.usage.inputTokens, 0) + const totalOutput = allCalls.reduce((s, c) => s + c.usage.outputTokens, 0) + const totalCacheRead = allCalls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0) + const totalCacheCreate = allCalls.reduce((s, c) => s + c.usage.cacheCreationInputTokens, 0) + + expect(totalInput).toBe(6_000) // 1 000 + 2 000 + 3 000 + expect(totalOutput).toBe(600) // 100 + 200 + 300 + expect(totalCacheRead).toBe(180_000) // 50k + 60k + 70k — all must survive + expect(totalCacheCreate).toBe(1_800) // 500 + 600 + 700 + + // Pre-fix regression check: if only the last conversation survived, + // totalCacheRead would be 70 000 (the last one). Assert it's 180 000. + expect(totalCacheRead).toBeGreaterThan(100_000) + + // Cost sanity: input+output+cacheRead+cacheCreate with haiku-4-5 pricing + // 6000 * 1e-6 = 0.006 + // 600 * 5e-6 = 0.003 + // 180k * 1e-7 = 0.018 + // 1800 * 1.25e-6 = 0.00225 + // total ≈ $0.029 + const totalCostUSD = allCalls.reduce((s, c) => s + c.costUSD, 0) + expect(totalCostUSD).toBeCloseTo(0.029, 2) + }) + + it('second run from disk cache also delivers all conversations', async () => { + // Ensures the merged result written to the session-cache.json survives + // a reload and yields the same aggregated data on repeat invocations. + createOtelDb(dbPath) + + const conversations: ConvSpec[] = [ + { spanId: 'span-a', traceId: 'trace-a', convId: 'conv-a', model: 'claude-haiku-4-5-20251001', + input: 500, output: 50, cacheRead: 25_000, cacheCreate: 250 }, + { spanId: 'span-b', traceId: 'trace-b', convId: 'conv-b', model: 'claude-haiku-4-5-20251001', + input: 500, output: 50, cacheRead: 25_000, cacheCreate: 250 }, + ] + for (const c of conversations) insertConversation(dbPath, c) + + // First run — parses and writes disk cache + await parseAllSessions(undefined, 'copilot') + + // Clear in-memory cache only, leaving disk cache intact + clearSessionCache() + + // Second run — should read from disk cache + const projects = await parseAllSessions(undefined, 'copilot') + const allCalls = projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) + + expect(allCalls).toHaveLength(2) + + const totalCacheRead = allCalls.reduce((s, c) => s + c.usage.cacheReadInputTokens, 0) + expect(totalCacheRead).toBe(50_000) // 25k + 25k from both conversations + }) + } +) diff --git a/tests/overview.test.ts b/tests/overview.test.ts new file mode 100644 index 0000000..89d6d75 --- /dev/null +++ b/tests/overview.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest' + +import { renderOverview } from '../src/overview.js' +import type { ProjectSummary } from '../src/types.js' + +function makeProject(opts: { + project: string + projectPath: string + cost: number + calls: number + model: string + provider: string + tokens: { input: number; output: number; cacheR: number; cacheW: number } +}): ProjectSummary { + const usage = { + inputTokens: opts.tokens.input, + outputTokens: opts.tokens.output, + cacheReadInputTokens: opts.tokens.cacheR, + cacheCreationInputTokens: opts.tokens.cacheW, + } + return { + project: opts.project, + projectPath: opts.projectPath, + totalCostUSD: opts.cost, + totalSavingsUSD: 0, + totalProxiedCostUSD: 0, + totalApiCalls: opts.calls, + sessions: [{ + sessionId: 's1', + project: opts.project, + totalInputTokens: opts.tokens.input, + totalOutputTokens: opts.tokens.output, + totalCacheReadTokens: opts.tokens.cacheR, + totalCacheWriteTokens: opts.tokens.cacheW, + apiCalls: opts.calls, + modelBreakdown: { [opts.model]: { calls: opts.calls, costUSD: opts.cost, savingsUSD: 0, tokens: usage } }, + categoryBreakdown: { coding: { turns: 1, costUSD: opts.cost, savingsUSD: 0, retries: 0, editTurns: 1, oneShotTurns: 1 } }, + toolBreakdown: { Bash: { calls: 5 }, Read: { calls: 2 } }, + mcpBreakdown: {}, + bashBreakdown: {}, + skillBreakdown: {}, + subagentBreakdown: {}, + turns: [{ + userMessage: 'hi', + timestamp: '2026-06-15T10:00:00Z', + sessionId: 's1', + category: 'coding', + retries: 0, + hasEdits: true, + assistantCalls: [{ provider: opts.provider, model: opts.model, costUSD: opts.cost, usage }], + }], + }], + } as unknown as ProjectSummary +} + +describe('renderOverview', () => { + it('renders the detailed sections from real aggregation', () => { + const out = renderOverview([makeProject({ + project: 'myproject', + projectPath: '/Users/test/myproject', + cost: 12.5, + calls: 3, + model: 'claude-opus-4-8', + provider: 'claude', + tokens: { input: 1000, output: 200, cacheR: 5000, cacheW: 100 }, + })], { label: 'June 2026', color: false }) + + for (const section of ['Totals', 'By tool', 'Top models', 'Highest-value days', 'Top projects', 'Daily', 'By activity', 'Tools']) { + expect(out).toContain(section) + } + expect(out).toContain('Opus 4.8') // model display name + expect(out).toContain('claude') // provider in By tool + expect(out).toContain('myproject') // clean project name from path basename + expect(out).toContain('$12.50') + expect(out).toContain('2026-06-15') + expect(out).toContain('Coding') + expect(out).toContain('Bash') + }) + + it('uses thousands separators and a B unit, and strips color in no-color mode', () => { + const out = renderOverview([makeProject({ + project: 'big', + projectPath: '/Users/test/big', + cost: 1234.56, + calls: 10, + model: 'claude-opus-4-8', + provider: 'claude', + tokens: { input: 1_000_000, output: 1_000_000, cacheR: 2_000_000_000, cacheW: 0 }, + })], { label: 'June 2026', color: false }) + + expect(out).toContain('$1,234.56') + // tokens render as full, comma-grouped numbers (not abbreviated) + expect(out).toContain('2,002,000,000') + // no-color mode must not emit ANSI escape codes + // eslint-disable-next-line no-control-regex + expect(out).not.toMatch(/\[/) + }) + + it('reports no usage for an empty range', () => { + const out = renderOverview([], { label: 'June 2026', color: false }) + expect(out).toContain('No usage found for June 2026') + }) + + it('does not split a slug-only Claude project path into fake path segments', () => { + const out = renderOverview([makeProject({ + project: 'Projects-Content-OS', + projectPath: 'Projects/Content/OS', + cost: 3.25, + calls: 1, + model: 'claude-sonnet-4-5', + provider: 'claude', + tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 }, + })], { label: 'June 2026', color: false }) + + expect(out).toContain('Projects-Content-OS') + expect(out).not.toContain(' OS ') + }) +}) + +describe('renderOverview unpriced models', () => { + it('warns when a model with usage has no pricing data', () => { + const out = renderOverview([makeProject({ + project: 'mystery', + projectPath: '/Users/test/mystery', + cost: 0, + calls: 4, + model: 'zz-mystery-paid-model-999', + provider: 'claude', + tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 }, + })], { label: 'June 2026', color: false }) + + expect(out).toContain('Unpriced') + expect(out).toContain('1 model at $0') + expect(out).toContain('zz-mystery-paid-model-999') + expect(out).toContain('codeburn model-alias') + }) + + it('stays silent when every model is priced', () => { + const out = renderOverview([makeProject({ + project: 'priced', + projectPath: '/Users/test/priced', + cost: 5, + calls: 2, + model: 'claude-opus-4-8', + provider: 'claude', + tokens: { input: 100, output: 50, cacheR: 0, cacheW: 0 }, + })], { label: 'June 2026', color: false }) + + expect(out).not.toContain('Unpriced') + }) +}) diff --git a/tests/parser-claude-cwd.test.ts b/tests/parser-claude-cwd.test.ts new file mode 100644 index 0000000..9a379db --- /dev/null +++ b/tests/parser-claude-cwd.test.ts @@ -0,0 +1,577 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises' +import { join, relative } from 'path' +import { tmpdir, homedir } from 'os' + +import { parseAllSessions } from '../src/parser.js' +import type { DateRange } from '../src/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'claude-cwd-test-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + // Point desktop sessions at an empty subdir by default so real sessions + // on the developer's machine do not bleed into the unit tests. + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions') +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function dayRange(day: string): DateRange { + return { + start: new Date(`${day}T00:00:00.000Z`), + end: new Date(`${day}T23:59:59.999Z`), + } +} + +async function writeClaudeSession( + projectSlug: string, + sessionId: string, + cwd: string, + timestamp: string, + usage: Record<string, unknown> = { input_tokens: 100, output_tokens: 50 }, + model = 'claude-sonnet-4-5', +): Promise<void> { + const projectDir = join(tmpDir, 'projects', projectSlug) + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, `${sessionId}.jsonl`) + await writeFile(filePath, JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + cwd, + message: { + id: `msg-${sessionId}`, + type: 'message', + role: 'assistant', + model, + content: [], + usage, + }, + }) + '\n') + + const mtime = new Date(timestamp) + await utimes(filePath, mtime, mtime) +} + +describe('Claude cwd project paths', () => { + it('uses the JSONL cwd as the canonical project path instead of the lossy directory slug', async () => { + await writeClaudeSession( + 'c--AI-LAB-OPENCLAW', + 'windows-session', + 'C:\\AI_LAB\\OPENCLAW', + '2099-05-01T12:00:00.000Z', + ) + + const projects = await parseAllSessions(dayRange('2099-05-01'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.projectPath).toBe('C:\\AI_LAB\\OPENCLAW') + expect(projects[0]!.projectPath).not.toBe('c//AI/LAB/OPENCLAW') + expect(projects[0]!.totalApiCalls).toBe(1) + }) + + it('groups Windows cwd case and slash variants into one project', async () => { + await writeClaudeSession( + 'windows-openclaw-a', + 'upper-backslash', + 'C:\\AI_LAB\\OPENCLAW', + '2099-05-02T10:00:00.000Z', + ) + await writeClaudeSession( + 'windows-openclaw-b', + 'lower-forward-slash', + 'c:/AI_LAB/OPENCLAW/', + '2099-05-02T11:00:00.000Z', + ) + + const projects = await parseAllSessions(dayRange('2099-05-02'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions).toHaveLength(2) + expect(projects[0]!.totalApiCalls).toBe(2) + expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([ + 'lower-forward-slash', + 'upper-backslash', + ]) + }) + + it('prefers the canonical cwd path even when mixed with slug-only sessions in the same directory', async () => { + const slug = 'c--AI-LAB-OPENCLAW' + const projectDir = join(tmpDir, 'projects', slug) + await mkdir(projectDir, { recursive: true }) + const noCwdPath = join(projectDir, 'a-no-cwd.jsonl') + await writeFile(noCwdPath, JSON.stringify({ + type: 'assistant', + sessionId: 'no-cwd', + timestamp: '2099-05-03T10:00:00.000Z', + message: { + id: 'msg-no-cwd', type: 'message', role: 'assistant', + model: 'claude-sonnet-4-5', content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) + '\n') + await utimes(noCwdPath, new Date('2099-05-03T10:00:00.000Z'), new Date('2099-05-03T10:00:00.000Z')) + + await writeClaudeSession(slug, 'b-with-cwd', 'C:\\AI_LAB\\OPENCLAW', '2099-05-03T11:00:00.000Z') + + const projects = await parseAllSessions(dayRange('2099-05-03'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions).toHaveLength(2) + expect(projects[0]!.projectPath).toBe('C:\\AI_LAB\\OPENCLAW') + expect(projects[0]!.projectPath).not.toBe('c//AI/LAB/OPENCLAW') + }) + + it('falls back to the slug-derived path when cwd is null, missing, or empty', async () => { + const slug = 'fallback-slug' + const projectDir = join(tmpDir, 'projects', slug) + await mkdir(projectDir, { recursive: true }) + + async function writeWith(name: string, sessionId: string, cwdField: unknown, ts: string) { + const filePath = join(projectDir, `${name}.jsonl`) + const obj: Record<string, unknown> = { + type: 'assistant', sessionId, timestamp: ts, + message: { + id: `msg-${sessionId}`, type: 'message', role: 'assistant', + model: 'claude-sonnet-4-5', content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + } + if (cwdField !== undefined) obj.cwd = cwdField + await writeFile(filePath, JSON.stringify(obj) + '\n') + await utimes(filePath, new Date(ts), new Date(ts)) + } + + await writeWith('null-cwd', 's-null', null, '2099-05-04T10:00:00.000Z') + await writeWith('empty-cwd', 's-empty', '', '2099-05-04T10:30:00.000Z') + await writeWith('whitespace-cwd', 's-ws', ' ', '2099-05-04T11:00:00.000Z') + await writeWith('missing-cwd', 's-miss', undefined, '2099-05-04T11:30:00.000Z') + + const projects = await parseAllSessions(dayRange('2099-05-04'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions).toHaveLength(4) + expect(projects[0]!.projectPath).toBe('fallback-slug') + }) + + it('keeps a hyphenated slug intact when cwd is absent', async () => { + const slug = 'Projects-Content-OS' + const projectDir = join(tmpDir, 'projects', slug) + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'no-cwd.jsonl') + await writeFile(filePath, JSON.stringify({ + type: 'assistant', + sessionId: 'no-cwd', + timestamp: '2099-05-09T10:00:00.000Z', + message: { + id: 'msg-no-cwd', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) + '\n') + await utimes(filePath, new Date('2099-05-09T10:00:00.000Z'), new Date('2099-05-09T10:00:00.000Z')) + + const projects = await parseAllSessions(dayRange('2099-05-09'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.project).toBe(slug) + expect(projects[0]!.projectPath).toBe(slug) + expect(projects[0]!.projectPath).not.toBe('Projects/Content/OS') + }) + + it('does not group sibling projects under a parent directory that merely contains .git', async () => { + const projectsRoot = join(tmpDir, 'Projects') + const swiftbar = join(projectsRoot, 'Swiftbar') + const contentOs = join(projectsRoot, 'Content-OS') + await mkdir(join(projectsRoot, '.git'), { recursive: true }) + await mkdir(swiftbar, { recursive: true }) + await mkdir(contentOs, { recursive: true }) + + await writeClaudeSession( + 'tmp-Projects-Swiftbar', + 'swiftbar-session', + swiftbar, + '2099-05-10T10:00:00.000Z', + ) + await writeClaudeSession( + 'tmp-Projects-Content-OS', + 'content-os-session', + contentOs, + '2099-05-10T11:00:00.000Z', + ) + + const projects = await parseAllSessions(dayRange('2099-05-10'), 'claude') + const projectPaths = projects.map(project => project.projectPath).sort() + + expect(projects).toHaveLength(2) + expect(projectPaths).toEqual([contentOs, swiftbar].sort()) + expect(projectPaths).not.toContain(projectsRoot) + }) + + it('groups git worktrees under the main repository project', async () => { + const mainRepo = join(tmpDir, 'repos', 'codeburn') + const worktreeA = join(tmpDir, 'worktrees', 'codeburn-feature-a') + const worktreeB = join(tmpDir, 'worktrees', 'codeburn-feature-b') + await mkdir(join(mainRepo, '.git', 'worktrees', 'feature-a'), { recursive: true }) + await mkdir(join(mainRepo, '.git', 'worktrees', 'feature-b'), { recursive: true }) + await mkdir(worktreeA, { recursive: true }) + await mkdir(worktreeB, { recursive: true }) + await writeFile(join(worktreeA, '.git'), `gitdir: ${join(mainRepo, '.git', 'worktrees', 'feature-a')}\n`) + await writeFile(join(worktreeB, '.git'), `gitdir: ${relative(worktreeB, join(mainRepo, '.git', 'worktrees', 'feature-b'))}\n`) + + await writeClaudeSession( + 'tmp-worktrees-codeburn-feature-a', + 'worktree-a-session', + worktreeA, + '2099-05-07T10:00:00.000Z', + ) + await writeClaudeSession( + 'tmp-worktrees-codeburn-feature-b', + 'worktree-b-session', + worktreeB, + '2099-05-07T11:00:00.000Z', + ) + + const projects = await parseAllSessions(dayRange('2099-05-07'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.project).toBe('codeburn') + expect(projects[0]!.projectPath).toBe(mainRepo) + expect(projects[0]!.sessions).toHaveLength(2) + expect(projects[0]!.totalApiCalls).toBe(2) + expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([ + 'worktree-a-session', + 'worktree-b-session', + ]) + }) + + it('does not group separate-git-dir projects that are not git worktrees', async () => { + const externalGitDir = join(tmpDir, 'external-git-dirs', 'project.git') + const projectDir = join(tmpDir, 'standalone', 'separate-git-dir') + await mkdir(externalGitDir, { recursive: true }) + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, '.git'), `gitdir: ${externalGitDir}\n`) + + await writeClaudeSession( + 'tmp-standalone-separate-git-dir', + 'separate-git-dir-session', + projectDir, + '2099-05-08T10:00:00.000Z', + ) + + const projects = await parseAllSessions(dayRange('2099-05-08'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.projectPath).toBe(projectDir) + expect(projects[0]!.project).toBe('tmp-standalone-separate-git-dir') + }) +}) + +describe('Claude cache creation pricing', () => { + it('prices 1-hour cache writes from usage.cache_creation at the 2x input rate', async () => { + await writeClaudeSession( + 'cache-pricing', + 'one-hour-cache', + '/tmp/cache-pricing', + '2099-05-05T10:00:00.000Z', + { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 60_120, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 60_120, + }, + }, + 'claude-opus-4-7', + ) + + const projects = await parseAllSessions(dayRange('2099-05-05'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120) + expect(projects[0]!.totalCostUSD).toBeCloseTo(0.6012, 6) + }) + + it('falls back to the legacy 5-minute cache write rate when split fields are absent', async () => { + await writeClaudeSession( + 'legacy-cache-pricing', + 'legacy-cache', + '/tmp/legacy-cache-pricing', + '2099-05-06T10:00:00.000Z', + { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 60_120, + }, + 'claude-opus-4-7', + ) + + const projects = await parseAllSessions(dayRange('2099-05-06'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120) + expect(projects[0]!.totalCostUSD).toBeCloseTo(0.37575, 6) + }) +}) + +// ── Helpers for Cowork local-agent-mode session fixtures ──────────────── + +async function writeCoworkSession(opts: { + desktopSessionsDir: string + appId: string + workspaceId: string + sessionId: string + spaceName?: string + spaceId?: string + userSelectedFolders?: string[] + title?: string + claudeSessionId: string + timestamp: string + usage?: Record<string, unknown> + model?: string +}): Promise<void> { + const { + desktopSessionsDir, appId, workspaceId, sessionId, + spaceName, spaceId, userSelectedFolders, title, + claudeSessionId, timestamp, + usage = { input_tokens: 100, output_tokens: 50 }, + model = 'claude-sonnet-4-5', + } = opts + + const workspaceDir = join(desktopSessionsDir, appId, workspaceId) + + // spaces.json — written only when a space is defined + await mkdir(workspaceDir, { recursive: true }) + if (spaceId && spaceName) { + await writeFile(join(workspaceDir, 'spaces.json'), JSON.stringify({ + spaces: [{ id: spaceId, name: spaceName, folders: [], projects: [] }], + })) + } + + // local_<sessionId>.json — session metadata + const outputsDir = join(workspaceDir, sessionId, 'outputs') + const sessionMeta: Record<string, unknown> = { + sessionId, + cwd: outputsDir, + title: title ?? (spaceName ? `Test session for ${spaceName}` : 'Untitled session'), + } + if (spaceId) sessionMeta['spaceId'] = spaceId + if (userSelectedFolders) sessionMeta['userSelectedFolders'] = userSelectedFolders + await writeFile(join(workspaceDir, `${sessionId}.json`), JSON.stringify(sessionMeta)) + + // .claude/projects/<slug>/session.jsonl — the actual token-bearing session + const projectSlug = outputsDir.replace(/[/\\]/g, '-').replace(/^-/, '') + const projectDir = join(workspaceDir, sessionId, '.claude', 'projects', projectSlug) + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, `${claudeSessionId}.jsonl`) + await writeFile(filePath, JSON.stringify({ + type: 'assistant', + sessionId: claudeSessionId, + timestamp, + cwd: outputsDir, + message: { + id: `msg-${claudeSessionId}`, + type: 'message', + role: 'assistant', + model, + content: [], + usage, + }, + }) + '\n') + const mtime = new Date(timestamp) + await utimes(filePath, mtime, mtime) +} + +describe('Claude Cowork local-agent-mode session grouping', () => { + it('groups multiple Cowork sessions from the same space under the space name', async () => { + const desktopSessionsDir = join(tmpDir, 'desktop-sessions') + const spaceId = 'space-001' + const spaceName = 'Project1' + + await writeCoworkSession({ + desktopSessionsDir, + appId: 'app-abc', + workspaceId: 'ws-001', + sessionId: 'local_aaaa', + spaceName, + spaceId, + claudeSessionId: 'session-a', + timestamp: '2099-06-01T10:00:00.000Z', + }) + + await writeCoworkSession({ + desktopSessionsDir, + appId: 'app-abc', + workspaceId: 'ws-001', + sessionId: 'local_bbbb', + spaceName, + spaceId, + claudeSessionId: 'session-b', + timestamp: '2099-06-01T11:00:00.000Z', + }) + + const projects = await parseAllSessions(dayRange('2099-06-01'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.project).toBe(spaceName) + expect(projects[0]!.sessions).toHaveLength(2) + expect(projects[0]!.sessions.map(s => s.sessionId).sort()).toEqual([ + 'session-a', + 'session-b', + ]) + }) + + it('keeps sessions from different spaces in separate projects', async () => { + const desktopSessionsDir = join(tmpDir, 'desktop-sessions') + + // Each space gets its own workspace so their spaces.json files don't overwrite each other. + await writeCoworkSession({ + desktopSessionsDir, + appId: 'app-abc', + workspaceId: 'ws-001', + sessionId: 'local_cccc', + spaceName: 'Project1', + spaceId: 'space-001', + claudeSessionId: 'session-c', + timestamp: '2099-06-02T10:00:00.000Z', + }) + + await writeCoworkSession({ + desktopSessionsDir, + appId: 'app-abc', + workspaceId: 'ws-002', + sessionId: 'local_dddd', + spaceName: 'Project2', + spaceId: 'space-002', + claudeSessionId: 'session-d', + timestamp: '2099-06-02T11:00:00.000Z', + }) + + const projects = await parseAllSessions(dayRange('2099-06-02'), 'claude') + + expect(projects).toHaveLength(2) + const names = projects.map(p => p.project).sort() + expect(names).toEqual(['Project1', 'Project2']) + }) + + it('falls back to userSelectedFolders[0] basename when no spaceId is set', async () => { + const desktopSessionsDir = join(tmpDir, 'desktop-sessions') + + await writeCoworkSession({ + desktopSessionsDir, + appId: 'app-abc', + workspaceId: 'ws-003', + sessionId: 'local_eeee', + userSelectedFolders: ['/home/user/projects/ParentFolder/SubFolder'], + title: 'Some session title', + claudeSessionId: 'session-e', + timestamp: '2099-06-03T10:00:00.000Z', + }) + + const projects = await parseAllSessions(dayRange('2099-06-03'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.project).toBe('SubFolder') + expect(projects[0]!.sessions).toHaveLength(1) + }) + + it('falls back to title when no spaceId and no userSelectedFolders', async () => { + const desktopSessionsDir = join(tmpDir, 'desktop-sessions') + + await writeCoworkSession({ + desktopSessionsDir, + appId: 'app-abc', + workspaceId: 'ws-004', + sessionId: 'local_ffff', + title: 'A standalone session task', + claudeSessionId: 'session-f', + timestamp: '2099-06-04T10:00:00.000Z', + }) + + const projects = await parseAllSessions(dayRange('2099-06-04'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.project).toBe('A standalone session task') + expect(projects[0]!.sessions).toHaveLength(1) + }) + + it('groups container-mode sessions (cwd=/sessions/<name>) under the space name', async () => { + const desktopSessionsDir = join(tmpDir, 'desktop-sessions') + const workspaceDir = join(desktopSessionsDir, 'app-abc', 'ws-006') + const sessionId = 'local_hhhh' + + // Set up workspace metadata (spaces.json + session .json with spaceId) + await mkdir(workspaceDir, { recursive: true }) + await writeFile(join(workspaceDir, 'spaces.json'), JSON.stringify({ + spaces: [{ id: 'space-001', name: 'Project1', folders: [], projects: [] }], + })) + const containerCwd = '/sessions/trusting-inspiring-ritchie' + await writeFile(join(workspaceDir, `${sessionId}.json`), JSON.stringify({ + sessionId, spaceId: 'space-001', cwd: containerCwd, title: 'Container session', + })) + + // Container-mode: project slug is derived from the container cwd, not outputs/ + const containerSlug = containerCwd.replace(/\//g, '-').replace(/^-/, '') + const projectDir = join(workspaceDir, sessionId, '.claude', 'projects', containerSlug) + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'container-session.jsonl') + await writeFile(filePath, JSON.stringify({ + type: 'assistant', + sessionId: 'container-session', + timestamp: '2099-06-06T10:00:00.000Z', + cwd: containerCwd, + message: { + id: 'msg-container', type: 'message', role: 'assistant', + model: 'claude-sonnet-4-5', content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) + '\n') + await utimes(filePath, new Date('2099-06-06T10:00:00.000Z'), new Date('2099-06-06T10:00:00.000Z')) + + const projects = await parseAllSessions(dayRange('2099-06-06'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.project).toBe('Project1') + expect(projects[0]!.projectPath).toBe('Project1') + expect(projects[0]!.sessions).toHaveLength(1) + }) + + it('falls back to sanitized directory slug when no session metadata exists', async () => { + const desktopSessionsDir = join(tmpDir, 'desktop-sessions') + const workspaceDir = join(desktopSessionsDir, 'app-abc', 'ws-005') + const sessionId = 'local_gggg' + const outputsDir = join(workspaceDir, sessionId, 'outputs') + const projectSlug = outputsDir.replace(/[/\\]/g, '-').replace(/^-/, '') + const projectDir = join(workspaceDir, sessionId, '.claude', 'projects', projectSlug) + await mkdir(projectDir, { recursive: true }) + + // No spaces.json or session .json at all + const filePath = join(projectDir, 'no-meta-session.jsonl') + await writeFile(filePath, JSON.stringify({ + type: 'assistant', + sessionId: 'no-meta-session', + timestamp: '2099-06-05T10:00:00.000Z', + cwd: outputsDir, + message: { + id: 'msg-no-meta', type: 'message', role: 'assistant', + model: 'claude-sonnet-4-5', content: [], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) + '\n') + await utimes(filePath, new Date('2099-06-05T10:00:00.000Z'), new Date('2099-06-05T10:00:00.000Z')) + + const projects = await parseAllSessions(dayRange('2099-06-05'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.project).toBe(projectSlug) + expect(projects[0]!.sessions).toHaveLength(1) + }) +}) diff --git a/tests/parser-compact-entry.test.ts b/tests/parser-compact-entry.test.ts new file mode 100644 index 0000000..6777d94 --- /dev/null +++ b/tests/parser-compact-entry.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect } from 'vitest' + +import { compactEntry } from '../src/parser.js' +import type { JournalEntry } from '../src/types.js' + +function entry(overrides: Partial<JournalEntry> & Record<string, unknown>): JournalEntry { + return { type: 'user', ...overrides } as JournalEntry +} + +describe('compactEntry', () => { + it('preserves type, timestamp, sessionId, cwd', () => { + const raw = entry({ type: 'user', timestamp: 't1', sessionId: 's1', cwd: '/foo' }) + const c = compactEntry(raw) + expect(c.type).toBe('user') + expect(c.timestamp).toBe('t1') + expect(c.sessionId).toBe('s1') + expect(c.cwd).toBe('/foo') + }) + + it('strips unknown catch-all fields', () => { + const raw = entry({ + type: 'assistant', + toolResult: { type: 'tool_result', content: 'x'.repeat(10_000) }, + someHugeField: 'y'.repeat(10_000), + }) + const c = compactEntry(raw) + expect((c as Record<string, unknown>)['toolResult']).toBeUndefined() + expect((c as Record<string, unknown>)['someHugeField']).toBeUndefined() + }) + + it('preserves deferred_tools_delta attachment with copied names', () => { + const raw = entry({ + type: 'attachment', + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp__svc__t1', 'Bash'], + extraData: 'should be dropped', + }, + }) + const c = compactEntry(raw) + const att = (c as Record<string, unknown>)['attachment'] as Record<string, unknown> + expect(att['type']).toBe('deferred_tools_delta') + expect(att['addedNames']).toEqual(['mcp__svc__t1', 'Bash']) + expect(att['extraData']).toBeUndefined() + }) + + it('copies addedNames into a new array (not by reference)', () => { + const originalNames = ['mcp__a__b', 'Bash'] + const raw = entry({ + type: 'attachment', + attachment: { type: 'deferred_tools_delta', addedNames: originalNames }, + }) + const c = compactEntry(raw) + const att = (c as Record<string, unknown>)['attachment'] as { addedNames: string[] } + expect(att.addedNames).not.toBe(originalNames) + expect(att.addedNames).toEqual(originalNames) + }) + + it('caps addedNames at 1000 entries', () => { + const names = Array.from({ length: 2000 }, (_, i) => `mcp__svc__t${i}`) + const raw = entry({ + type: 'attachment', + attachment: { type: 'deferred_tools_delta', addedNames: names }, + }) + const c = compactEntry(raw) + const att = (c as Record<string, unknown>)['attachment'] as { addedNames: string[] } + expect(att.addedNames).toHaveLength(1000) + }) + + it('filters non-string entries from addedNames', () => { + const raw = entry({ + type: 'attachment', + attachment: { type: 'deferred_tools_delta', addedNames: [42, null, 'mcp__a__b', undefined] }, + }) + const c = compactEntry(raw) + const att = (c as Record<string, unknown>)['attachment'] as { addedNames: string[] } + expect(att.addedNames).toEqual(['mcp__a__b']) + }) + + it('drops non-deferred_tools_delta attachments', () => { + const raw = entry({ + type: 'attachment', + attachment: { type: 'other', data: 'x'.repeat(10_000) }, + }) + const c = compactEntry(raw) + expect((c as Record<string, unknown>)['attachment']).toBeUndefined() + }) + + it('caps user message string content at 2000', () => { + const longText = 'a'.repeat(5000) + const raw = entry({ + type: 'user', + message: { role: 'user' as const, content: longText }, + }) + const c = compactEntry(raw) + expect(c.message!.role).toBe('user') + const content = (c.message as { content: string }).content + expect(content.length).toBe(2000) + }) + + it('caps total user text across all blocks at 2000', () => { + const raw = entry({ + type: 'user', + message: { + role: 'user' as const, + content: [ + { type: 'text' as const, text: 'a'.repeat(1500) }, + { type: 'text' as const, text: 'b'.repeat(1500) }, + { type: 'text' as const, text: 'c'.repeat(1500) }, + { type: 'image' as const, source: 'big data' }, + ], + }, + }) + const c = compactEntry(raw) + const content = (c.message as { content: Array<{ type: string; text: string }> }).content + expect(content).toHaveLength(2) + expect(content[0]!.text.length).toBe(1500) + expect(content[1]!.text.length).toBe(500) + }) + + it('compacts assistant tool_use blocks, dropping text and thinking, preserving id', () => { + const raw = entry({ + type: 'assistant', + timestamp: 't1', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + id: 'msg_123', + usage: { input_tokens: 100, output_tokens: 200 }, + content: [ + { type: 'text', text: 'x'.repeat(50_000) }, + { type: 'thinking', thinking: 'y'.repeat(50_000) }, + { type: 'tool_use', id: 'tu1', name: 'Read', input: { file_path: '/foo', huge: 'z'.repeat(10_000) } }, + { type: 'tool_use', id: 'tu2', name: 'Edit', input: { old_string: 'a'.repeat(5000), new_string: 'b'.repeat(5000) } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ type: string; id?: string; name?: string; input?: Record<string, unknown> }> } + expect(msg.content).toHaveLength(2) + expect(msg.content[0]!.name).toBe('Read') + expect(msg.content[0]!.id).toBe('tu1') + expect(msg.content[0]!.input).toEqual({ file_path: '/foo' }) + expect(msg.content[1]!.name).toBe('Edit') + expect(msg.content[1]!.id).toBe('tu2') + expect(msg.content[1]!.input).toEqual({}) + }) + + it('caps tool_use blocks at 500 per message', () => { + const blocks = Array.from({ length: 600 }, (_, i) => ({ + type: 'tool_use' as const, + id: `tu${i}`, + name: `Tool${i}`, + input: {}, + })) + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: blocks, + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: unknown[] } + expect(msg.content).toHaveLength(500) + }) + + it('preserves model, usage (destructured), and id on assistant messages', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + id: 'msg_abc', + usage: { + input_tokens: 50, + output_tokens: 100, + cache_read_input_tokens: 25, + extraGarbage: 'should not survive', + }, + content: [], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { model: string; id: string; usage: Record<string, unknown> } + expect(msg.model).toBe('claude-opus-4-6') + expect(msg.id).toBe('msg_abc') + expect(msg.usage['input_tokens']).toBe(50) + expect(msg.usage['output_tokens']).toBe(100) + expect(msg.usage['cache_read_input_tokens']).toBe(25) + expect(msg.usage['extraGarbage']).toBeUndefined() + }) + + it('deep-copies usage nested objects, stripping extra keys', () => { + const cacheCreation = { ephemeral_5m_input_tokens: 100, ephemeral_1h_input_tokens: 200, extraJunk: 'big' } + const serverToolUse = { web_search_requests: 3, web_fetch_requests: 1, extraJunk: 'big' } + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { + input_tokens: 10, + output_tokens: 10, + speed: 'fast', + cache_creation: cacheCreation, + server_tool_use: serverToolUse, + }, + content: [], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { usage: Record<string, unknown> } + expect(msg.usage['speed']).toBe('fast') + const cc = msg.usage['cache_creation'] as Record<string, unknown> + expect(cc['ephemeral_5m_input_tokens']).toBe(100) + expect(cc['ephemeral_1h_input_tokens']).toBe(200) + expect(cc['extraJunk']).toBeUndefined() + expect(cc).not.toBe(cacheCreation) + const stu = msg.usage['server_tool_use'] as Record<string, unknown> + expect(stu['web_search_requests']).toBe(3) + expect(stu['web_fetch_requests']).toBe(1) + expect(stu['extraJunk']).toBeUndefined() + expect(stu).not.toBe(serverToolUse) + }) + + it('keeps Skill input.skill and input.name, type-checked and capped', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu', name: 'Skill', input: { skill: 'graphify', args: 'huge arg data' } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record<string, unknown> }> } + expect(msg.content[0]!.input['skill']).toBe('graphify') + expect(msg.content[0]!.input['args']).toBeUndefined() + }) + + it('rejects non-string Skill input.skill and caps long names', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu1', name: 'Skill', input: { skill: { malicious: 'x'.repeat(10_000) } } }, + { type: 'tool_use', id: 'tu2', name: 'Skill', input: { skill: 'a'.repeat(500) } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record<string, unknown> }> } + expect(msg.content[0]!.input['skill']).toBeUndefined() + expect((msg.content[1]!.input['skill'] as string).length).toBe(200) + }) + + it('keeps Bash input.command capped at 2000 for bash command extraction', () => { + const longCmd = 'npm run build && '.repeat(200) + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu', name: 'Bash', input: { command: longCmd, description: 'big desc' } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record<string, unknown> }> } + const cmd = msg.content[0]!.input['command'] as string + expect(cmd.length).toBe(2000) + expect(msg.content[0]!.input['description']).toBeUndefined() + }) + + it('keeps Read file_path capped and drops unrelated input fields', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu', name: 'Read', input: { file_path: '/tmp/' + 'x'.repeat(3000), content: 'big' } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record<string, unknown> }> } + expect((msg.content[0]!.input['file_path'] as string).length).toBe(2000) + expect(msg.content[0]!.input['content']).toBeUndefined() + }) + + it('keeps Agent subagent_type capped and drops prompt text', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [ + { type: 'tool_use', id: 'tu', name: 'Agent', input: { subagent_type: 'reviewer'.repeat(50), prompt: 'big' } }, + ], + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ input: Record<string, unknown> }> } + expect((msg.content[0]!.input['subagent_type'] as string).length).toBe(200) + expect(msg.content[0]!.input['prompt']).toBeUndefined() + }) + + it('handles entry with no message field', () => { + const raw = entry({ type: 'system', timestamp: 't1', cwd: '/x' }) + const c = compactEntry(raw) + expect(c.type).toBe('system') + expect(c.timestamp).toBe('t1') + expect(c.message).toBeUndefined() + }) + + it('handles assistant message with no usage (non-standard)', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + content: [{ type: 'text', text: 'response' }], + }, + }) + const c = compactEntry(raw) + expect(c.message).toBeUndefined() + }) + + it('handles unexpected message role (neither user nor assistant)', () => { + const raw = entry({ + type: 'system', + message: { role: 'system' as never, content: 'sys prompt' }, + }) + const c = compactEntry(raw) + expect(c.message).toBeUndefined() + }) + + it('tolerates null elements in user content array', () => { + const raw = entry({ + type: 'user', + message: { + role: 'user' as const, + content: [null, undefined, { type: 'text', text: 'ok' }, 42, { type: 'text' }] as never, + }, + }) + const c = compactEntry(raw) + const content = (c.message as { content: Array<{ text: string }> }).content + expect(content).toHaveLength(1) + expect(content[0]!.text).toBe('ok') + }) + + it('tolerates assistant content that is not an array', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: 'not an array' as never, + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: unknown[] } + expect(msg.content).toEqual([]) + }) + + it('tolerates null elements in assistant content array', () => { + const raw = entry({ + type: 'assistant', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [null, { type: 'tool_use', id: 'tu1', name: 'Read', input: {} }, undefined] as never, + }, + }) + const c = compactEntry(raw) + const msg = c.message as { content: Array<{ name: string }> } + expect(msg.content).toHaveLength(1) + expect(msg.content[0]!.name).toBe('Read') + }) + + it('memory reduction: compacted entry is much smaller than raw', () => { + const hugeContent = Array.from({ length: 20 }, (_, i) => ({ + type: i % 2 === 0 ? 'text' : 'tool_result', + text: 'x'.repeat(100_000), + content: 'y'.repeat(100_000), + })) + const raw = entry({ + type: 'assistant', + timestamp: '2026-01-01T00:00:00', + message: { + type: 'message' as const, + role: 'assistant' as const, + model: 'claude-opus-4-6', + id: 'msg_1', + usage: { input_tokens: 1000, output_tokens: 500 }, + content: hugeContent as never, + }, + toolResult: { content: 'z'.repeat(500_000) }, + }) + const rawSize = JSON.stringify(raw).length + const compacted = compactEntry(raw) + const compactedSize = JSON.stringify(compacted).length + expect(rawSize).toBeGreaterThan(2_000_000) + expect(compactedSize).toBeLessThan(500) + }) +}) diff --git a/tests/parser-filter.test.ts b/tests/parser-filter.test.ts new file mode 100644 index 0000000..4c34722 --- /dev/null +++ b/tests/parser-filter.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' + +import { filterProjectsByName } from '../src/parser.js' +import type { ProjectSummary } from '../src/types.js' + +function makeProject(project: string, projectPath = project): ProjectSummary { + return { + project, + projectPath, + sessions: [], + totalCostUSD: 0, + totalApiCalls: 0, + } +} + +describe('filterProjectsByName', () => { + const projects = [ + makeProject('codeburn', '/Users/alice/codeburn'), + makeProject('AgentSeal', '/Users/alice/projects/AgentSeal'), + makeProject('dashboard', '/Users/alice/AgentSeal/dashboard'), + makeProject('sandbox', '/tmp/sandbox'), + ] + + it('returns all projects when no filters given', () => { + expect(filterProjectsByName(projects)).toEqual(projects) + expect(filterProjectsByName(projects, [], [])).toEqual(projects) + expect(filterProjectsByName(projects, undefined, undefined)).toEqual(projects) + }) + + it('include matches project name (case-insensitive substring)', () => { + const result = filterProjectsByName(projects, ['codeburn']) + expect(result.map(p => p.project)).toEqual(['codeburn']) + }) + + it('include is case-insensitive', () => { + const result = filterProjectsByName(projects, ['AGENTSEAL']) + expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard']) + }) + + it('include matches substring in path when name does not match', () => { + const result = filterProjectsByName(projects, ['alice/projects']) + expect(result.map(p => p.project)).toEqual(['AgentSeal']) + }) + + it('include uses OR semantics across patterns', () => { + const result = filterProjectsByName(projects, ['codeburn', 'sandbox']) + expect(result.map(p => p.project).sort()).toEqual(['codeburn', 'sandbox']) + }) + + it('exclude removes matching projects (AND-negation across patterns)', () => { + const result = filterProjectsByName(projects, undefined, ['codeburn', 'sandbox']) + expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard']) + }) + + it('exclude matches path substring', () => { + const result = filterProjectsByName(projects, undefined, ['/tmp']) + expect(result.map(p => p.project)).not.toContain('sandbox') + }) + + it('exclude is applied after include', () => { + const result = filterProjectsByName(projects, ['AgentSeal'], ['dashboard']) + expect(result.map(p => p.project)).toEqual(['AgentSeal']) + }) + + it('returns empty array when no project matches include', () => { + expect(filterProjectsByName(projects, ['does-not-exist'])).toEqual([]) + }) + + it('empty-string pattern matches every project', () => { + const resultInclude = filterProjectsByName(projects, ['']) + expect(resultInclude).toHaveLength(projects.length) + const resultExclude = filterProjectsByName(projects, undefined, ['']) + expect(resultExclude).toEqual([]) + }) + + it('does not mutate the input array', () => { + const input = [makeProject('a'), makeProject('b')] + const snapshot = [...input] + filterProjectsByName(input, ['a'], ['b']) + expect(input).toEqual(snapshot) + }) +}) diff --git a/tests/parser-gemini-cache.test.ts b/tests/parser-gemini-cache.test.ts new file mode 100644 index 0000000..c2542b6 --- /dev/null +++ b/tests/parser-gemini-cache.test.ts @@ -0,0 +1,126 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { CACHE_VERSION, computeEnvFingerprint } from '../src/session-cache.js' +import type { DateRange } from '../src/types.js' + +let home: string +let cacheDir: string + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'codeburn-gemini-home-')) + cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-gemini-cache-')) + process.env['HOME'] = home + process.env['CODEBURN_CACHE_DIR'] = cacheDir +}) + +afterEach(async () => { + clearSessionCache() + await rm(home, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) +}) + +describe('Gemini session cache migration', () => { + it('reparses cached legacy aggregate Gemini entries into granular calls', async () => { + const chatsDir = join(home, '.gemini', 'tmp', 'project-a', 'chats') + await mkdir(chatsDir, { recursive: true }) + const sessionPath = join(chatsDir, 'session-2026-05-16.json') + await writeFile(sessionPath, JSON.stringify({ + sessionId: 'gemini-session-1', + startTime: '2026-05-16T10:00:00.000Z', + messages: [ + { id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' }, + { + id: 'g1', + timestamp: '2026-05-16T10:00:05.000Z', + type: 'gemini', + content: 'first', + model: 'gemini-3.1-pro-preview', + tokens: { input: 10, output: 5 }, + }, + { + id: 'g2', + timestamp: '2026-05-16T10:00:10.000Z', + type: 'gemini', + content: 'second', + model: 'gemini-3.1-pro-preview', + tokens: { input: 12, output: 6 }, + }, + ], + })) + + const fileStat = await stat(sessionPath) + await writeFile(join(cacheDir, 'session-cache.json'), JSON.stringify({ + version: CACHE_VERSION, + providers: { + gemini: { + envFingerprint: computeEnvFingerprint('gemini'), + files: { + [sessionPath]: { + fingerprint: { + dev: fileStat.dev, + ino: fileStat.ino, + mtimeMs: fileStat.mtimeMs, + sizeBytes: fileStat.size, + }, + mcpInventory: [], + turns: [{ + timestamp: '2026-05-16T10:00:00.000Z', + sessionId: 'gemini-session-1', + userMessage: 'work', + calls: [{ + provider: 'gemini', + model: 'gemini-3.1-pro-preview', + usage: { + inputTokens: 22, + outputTokens: 11, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + cacheCreationOneHourTokens: 0, + }, + speed: 'standard', + timestamp: '2026-05-16T10:00:00.000Z', + tools: [], + bashCommands: [], + skills: [], + deduplicationKey: 'gemini:gemini-session-1', + }], + }], + }, + }, + }, + }, + })) + + const range: DateRange = { + start: new Date('2026-05-16T00:00:00.000Z'), + end: new Date('2026-05-16T23:59:59.999Z'), + } + + const projects = await parseAllSessions(range, 'gemini') + const keys = projects.flatMap(project => + project.sessions.flatMap(session => + session.turns.flatMap(turn => turn.assistantCalls.map(call => call.deduplicationKey)), + ), + ) + + expect(projects[0]!.totalApiCalls).toBe(2) + expect(keys).toEqual([ + 'gemini:gemini-session-1:g1', + 'gemini:gemini-session-1:g2', + ]) + + const savedCache = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8')) + const savedKeys = savedCache.providers.gemini.files[sessionPath].turns.flatMap((turn: { calls: Array<{ deduplicationKey: string }> }) => + turn.calls.map(call => call.deduplicationKey), + ) + expect(savedKeys).toEqual(keys) + }) +}) diff --git a/tests/parser-large-json-scanner.test.ts b/tests/parser-large-json-scanner.test.ts new file mode 100644 index 0000000..af0668b --- /dev/null +++ b/tests/parser-large-json-scanner.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { parseJsonlLine } from '../src/parser.js' + +function largeUserLine(): string { + return JSON.stringify({ + type: 'user', + sessionId: 's1', + timestamp: '2026-05-01T00:00:00Z', + cwd: '/repo', + message: { + role: 'user', + content: [ + { type: 'image', source: { data: 'x'.repeat(40_000) } }, + { type: 'text', text: 'hello ' + 'a'.repeat(3000) }, + ], + }, + }) +} + +function largeAssistantLine(): string { + return JSON.stringify({ + type: 'assistant', + sessionId: 's1', + timestamp: '2026-05-01T00:00:01Z', + cwd: '/repo', + message: { + id: 'm1', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [ + { type: 'text', text: 'x'.repeat(40_000) }, + { type: 'tool_use', id: 'read1', name: 'Read', input: { file_path: '/tmp/file.ts', content: 'drop me' } }, + { type: 'tool_use', id: 'agent1', name: 'Agent', input: { subagent_type: 'reviewer', prompt: 'drop me' } }, + ], + usage: { + input_tokens: 100, + output_tokens: 20, + cache_read_input_tokens: 300, + }, + }, + }) +} + +describe('large JSONL compact scanner', () => { + it('extracts user text from array content without full JSON.parse', () => { + const parsed = parseJsonlLine(largeUserLine()) + expect(parsed?.type).toBe('user') + const content = parsed?.message?.role === 'user' ? parsed.message.content : '' + expect(content).toBeTypeOf('string') + expect((content as string).startsWith('hello ')).toBe(true) + expect((content as string).length).toBe(2000) + }) + + it('extracts capped tool inputs needed by optimize', () => { + const parsed = parseJsonlLine(Buffer.from(largeAssistantLine())) + const msg = parsed?.message + expect(msg?.role).toBe('assistant') + if (msg?.role !== 'assistant') return + expect(msg.usage.input_tokens).toBe(100) + expect(msg.usage.output_tokens).toBe(20) + expect(msg.usage.cache_read_input_tokens).toBe(300) + expect(msg.content).toEqual([ + { type: 'tool_use', id: 'read1', name: 'Read', input: { file_path: '/tmp/file.ts' } }, + { type: 'tool_use', id: 'agent1', name: 'Agent', input: { subagent_type: 'reviewer' } }, + ]) + }) + + it('extracts deferred MCP inventory from large attachment lines', () => { + const line = JSON.stringify({ + type: 'attachment', + sessionId: 's1', + timestamp: '2026-05-01T00:00:02Z', + padding: 'x'.repeat(40_000), + attachment: { + type: 'deferred_tools_delta', + addedNames: ['Bash', 'mcp__svc__tool'], + }, + }) + const parsed = parseJsonlLine(Buffer.from(line)) as Record<string, unknown> + expect(parsed['attachment']).toEqual({ + type: 'deferred_tools_delta', + addedNames: ['Bash', 'mcp__svc__tool'], + }) + }) +}) diff --git a/tests/parser-large-session.test.ts b/tests/parser-large-session.test.ts new file mode 100644 index 0000000..9ef1b8b --- /dev/null +++ b/tests/parser-large-session.test.ts @@ -0,0 +1,236 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it, beforeEach, afterEach } from 'vitest' + +import { parseAllSessions, clearSessionCache } from '../src/parser.js' +import type { DateRange } from '../src/types.js' + +let home: string + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'codeburn-large-')) + process.env['CLAUDE_CONFIG_DIR'] = join(home, '.claude') +}) + +afterEach(async () => { + clearSessionCache() + await rm(home, { recursive: true, force: true }) +}) + +function userLine(sessionId: string, timestamp: string, textSize = 100): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp, + cwd: '/projects/app', + message: { role: 'user', content: 'x'.repeat(textSize) }, + }) +} + +function assistantLine(sessionId: string, timestamp: string, messageId: string, opts?: { + contentSize?: number + toolCount?: number +}): string { + const contentSize = opts?.contentSize ?? 0 + const toolCount = opts?.toolCount ?? 1 + const content: unknown[] = [] + if (contentSize > 0) { + content.push({ type: 'text', text: 'y'.repeat(contentSize) }) + content.push({ type: 'thinking', thinking: 'z'.repeat(contentSize) }) + } + for (let i = 0; i < toolCount; i++) { + content.push({ + type: 'tool_use', + id: `tu-${i}`, + name: i === 0 ? 'Edit' : 'Read', + input: { file_path: '/tmp/x', big: 'w'.repeat(contentSize) }, + }) + } + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content, + usage: { input_tokens: 1000, output_tokens: 100 }, + }, + }) +} + +function messageFirstLargeAssistantLine(sessionId: string, timestamp: string, messageId: string): string { + const hugeText = 'y'.repeat(3_000_000) + return `{"parentUuid":"u1","isSidechain":false,"message":{"model":"claude-sonnet-4-5","id":"${messageId}","type":"message","role":"assistant","content":[{"type":"text","text":"${hugeText}"},{"type":"tool_use","id":"tu-large","name":"Edit","input":{"file_path":"/tmp/x","old_string":"a","new_string":"b"}}],"usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":5000}},"uuid":"a1","timestamp":"${timestamp}","type":"assistant","sessionId":"${sessionId}","cwd":"/projects/app"}` +} + +function attachmentLine(sessionId: string, timestamp: string): string { + return JSON.stringify({ + type: 'attachment', + sessionId, + timestamp, + attachment: { + type: 'deferred_tools_delta', + addedNames: ['Bash', 'Edit', 'Read', 'mcp__hf__hub_search'], + }, + }) +} + +describe('parseAllSessions with large Claude fixture', () => { + it('correctly parses sessions with bulky text/thinking/tool_result blocks', async () => { + const projectDir = join(home, '.claude', 'projects', 'bigapp') + await mkdir(projectDir, { recursive: true }) + + const lines: string[] = [] + lines.push(attachmentLine('s1', '2026-04-10T09:00:00Z')) + for (let i = 0; i < 50; i++) { + const ts = `2026-04-10T${String(9 + Math.floor(i / 10)).padStart(2, '0')}:${String((i % 10) * 5).padStart(2, '0')}:00Z` + lines.push(userLine('s1', ts, 5000)) + lines.push(assistantLine('s1', ts.replace(':00Z', ':30Z'), `msg-${i}`, { + contentSize: 50_000, + toolCount: 3, + })) + } + + await writeFile(join(projectDir, 'session.jsonl'), lines.join('\n')) + + const range: DateRange = { + start: new Date('2026-04-10T00:00:00Z'), + end: new Date('2026-04-10T23:59:59Z'), + } + + const projects = await parseAllSessions(range, 'claude') + + expect(projects.length).toBeGreaterThan(0) + const proj = projects[0]! + expect(proj.totalApiCalls).toBe(50) + expect(proj.totalCostUSD).toBeGreaterThan(0) + + const sess = proj.sessions[0]! + expect(sess.turns.length).toBe(50) + + for (const turn of sess.turns) { + expect(turn.userMessage.length).toBeLessThanOrEqual(2000) + expect(turn.assistantCalls.length).toBe(1) + const call = turn.assistantCalls[0]! + expect(call.tools).toContain('Edit') + expect(call.tools).toContain('Read') + expect(call.model).toBe('claude-sonnet-4-5') + } + + expect(sess.mcpInventory).toContain('mcp__hf__hub_search') + }) + + it('handles malformed JSONL lines without crashing', async () => { + const projectDir = join(home, '.claude', 'projects', 'baddata') + await mkdir(projectDir, { recursive: true }) + + const lines = [ + 'not json at all', + '{"type": "user", "sessionId": "s1", "timestamp": "2026-04-10T10:00:00Z", "message": {"role": "user", "content": [null, {"type": "text", "text": "hello"}, 42]}}', + '{"type": "assistant", "sessionId": "s1", "timestamp": "2026-04-10T10:01:00Z", "message": {"id": "m1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": "not-an-array", "usage": {"input_tokens": 100, "output_tokens": 50}}}', + '{"type": "assistant", "sessionId": "s1", "timestamp": "2026-04-10T10:02:00Z", "message": {"id": "m2", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": [null, {"type": "tool_use", "id": "t1", "name": "Read", "input": {}}], "usage": {"input_tokens": 100, "output_tokens": 50}}}', + ] + + await writeFile(join(projectDir, 'session.jsonl'), lines.join('\n')) + + const range: DateRange = { + start: new Date('2026-04-10T00:00:00Z'), + end: new Date('2026-04-10T23:59:59Z'), + } + + const projects = await parseAllSessions(range, 'claude') + expect(projects.length).toBeGreaterThan(0) + + const sess = projects[0]!.sessions[0]! + expect(sess.apiCalls).toBeGreaterThanOrEqual(1) + }) + + it('discovers direct Claude subagent JSONL files under a project directory', async () => { + const projectDir = join(home, '.claude', 'projects', 'direct-subagents') + const subagentsDir = join(projectDir, 'subagents') + await mkdir(subagentsDir, { recursive: true }) + + const lines = [ + userLine('subagent-session', '2026-04-10T10:00:00Z', 100), + assistantLine('subagent-session', '2026-04-10T10:01:00Z', 'subagent-msg', { + contentSize: 0, + toolCount: 2, + }), + ] + await writeFile(join(subagentsDir, 'worker.jsonl'), lines.join('\n')) + + const range: DateRange = { + start: new Date('2026-04-10T00:00:00Z'), + end: new Date('2026-04-10T23:59:59Z'), + } + + const projects = await parseAllSessions(range, 'claude') + + expect(projects).toHaveLength(1) + const session = projects[0]!.sessions[0]! + expect(session.sessionId).toBe('worker') + expect(session.apiCalls).toBe(1) + expect(session.toolBreakdown['Edit']?.calls).toBe(1) + expect(session.toolBreakdown['Read']?.calls).toBe(1) + }) + + it('discovers nested Claude subagent JSONL files under a direct subagents directory', async () => { + const projectDir = join(home, '.claude', 'projects', 'nested-subagents') + const nestedSubagentsDir = join(projectDir, 'subagents', 'subagents') + await mkdir(nestedSubagentsDir, { recursive: true }) + + const lines = [ + userLine('nested-subagent-session', '2026-04-10T11:00:00Z', 100), + assistantLine('nested-subagent-session', '2026-04-10T11:01:00Z', 'nested-subagent-msg', { + contentSize: 0, + toolCount: 1, + }), + ] + await writeFile(join(nestedSubagentsDir, 'worker.jsonl'), lines.join('\n')) + + const range: DateRange = { + start: new Date('2026-04-10T00:00:00Z'), + end: new Date('2026-04-10T23:59:59Z'), + } + + const projects = await parseAllSessions(range, 'claude') + + expect(projects).toHaveLength(1) + const session = projects[0]!.sessions[0]! + expect(session.sessionId).toBe('worker') + expect(session.apiCalls).toBe(1) + expect(session.toolBreakdown['Edit']?.calls).toBe(1) + }) + + it('parses huge message-first assistant lines without full JSON.parse expansion', async () => { + const projectDir = join(home, '.claude', 'projects', 'messagefirst') + await mkdir(projectDir, { recursive: true }) + + const lines = [ + userLine('s1', '2026-04-10T10:00:00Z', 100), + messageFirstLargeAssistantLine('s1', '2026-04-10T10:00:01Z', 'msg-large'), + ] + + await writeFile(join(projectDir, 'session.jsonl'), lines.join('\n')) + + const range: DateRange = { + start: new Date('2026-04-10T00:00:00Z'), + end: new Date('2026-04-10T23:59:59Z'), + } + + const projects = await parseAllSessions(range, 'claude') + expect(projects.length).toBeGreaterThan(0) + + const sess = projects[0]!.sessions[0]! + expect(sess.apiCalls).toBe(1) + expect(sess.totalInputTokens).toBe(1000) + expect(sess.totalOutputTokens).toBe(100) + expect(sess.totalCacheReadTokens).toBe(5000) + expect(sess.toolBreakdown['Edit']?.calls).toBe(1) + }) +}) diff --git a/tests/parser-local-savings.test.ts b/tests/parser-local-savings.test.ts new file mode 100644 index 0000000..ae5dd25 --- /dev/null +++ b/tests/parser-local-savings.test.ts @@ -0,0 +1,131 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { + setLocalModelSavings, + setModelAliases, + loadPricing, +} from '../src/models.js' +import { parseAllSessions, clearSessionCache } from '../src/parser.js' +import type { DateRange } from '../src/types.js' + +const FIXTURE_DAY = Date.UTC(2026, 3, 16) +const RANGE_START = new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000) +const RANGE_END = new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000) + +function makeRange(): DateRange { + return { start: RANGE_START, end: RANGE_END } +} + +let tmpDirs: string[] = [] + +beforeAll(async () => { + await loadPricing() +}) + +beforeEach(() => { + setLocalModelSavings({}) + setModelAliases({}) +}) + +afterEach(async () => { + delete (Object.prototype as Record<string, unknown>).calls + clearSessionCache() + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } +}) + +async function setupLocalModelSession(modelName: string): Promise<string> { + const base = await mkdtemp(join(tmpdir(), 'codeburn-savings-')) + tmpDirs.push(base) + const projectDir = join(base, 'projects', 'p') + await mkdir(projectDir, { recursive: true }) + // Use a synthetic local-style model name and a small known token count. + const file = join(projectDir, 's1.jsonl') + const ts = '2026-04-16T10:00:00.000Z' + const line = JSON.stringify({ + type: 'assistant', + timestamp: ts, + sessionId: 's1', + message: { + type: 'message', + role: 'assistant', + model: modelName, + id: 'msg-1', + content: [], + usage: { + input_tokens: 1000, + output_tokens: 200, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + await writeFile(file, line + '\n', 'utf-8') + process.env['CLAUDE_CONFIG_DIR'] = base + return base +} + +describe('local-model savings: end-to-end', () => { + it('keeps an unconfigured local model at $0 with no savings recorded', async () => { + await setupLocalModelSession('llama3.1:8b') + const projects = await parseAllSessions(makeRange(), 'all') + const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls))) + expect(allCalls.length).toBeGreaterThan(0) + for (const c of allCalls) { + expect(c.costUSD).toBe(0) + expect(c.savingsUSD ?? 0).toBe(0) + expect(c.isLocalSavings).toBeFalsy() + } + }) + + it('records savings and forces cost to 0 when a local model has a savings mapping', async () => { + await setupLocalModelSession('llama3.1:8b') + setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' }) + clearSessionCache() + const projects = await parseAllSessions(makeRange(), 'all') + const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls))) + expect(allCalls.length).toBeGreaterThan(0) + for (const c of allCalls) { + expect(c.costUSD).toBe(0) + expect(c.savingsUSD).toBeGreaterThan(0) + expect(c.savingsBaselineModel).toBe('gpt-4o') + expect(c.isLocalSavings).toBe(true) + } + // Session and project rollups surface the savings total. + const totalSavings = projects.reduce((s, p) => s + p.totalSavingsUSD, 0) + expect(totalSavings).toBeGreaterThan(0) + }) + + it('does not apply savings for a model that has no mapping', async () => { + await setupLocalModelSession('qwen2.5:32b') + setLocalModelSavings({ 'unrelated:1b': 'gpt-4o' }) + clearSessionCache() + const projects = await parseAllSessions(makeRange(), 'all') + const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls))) + for (const c of allCalls) { + expect(c.savingsUSD ?? 0).toBe(0) + } + }) + + it('forces a $0 cost even when the same model is also in modelAliases', async () => { + // The local-savings path is meant to win for actual cost: spending + // config semantics say "this is local, track counterfactual", so + // even a stale modelAliases entry must not cause us to charge real + // dollars for a local call. + await setupLocalModelSession('llama3.1:8b') + setModelAliases({ 'llama3.1:8b': 'gpt-4o' }) + setLocalModelSavings({ 'llama3.1:8b': 'gpt-4o' }) + clearSessionCache() + const projects = await parseAllSessions(makeRange(), 'all') + const allCalls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls))) + for (const c of allCalls) { + expect(c.costUSD).toBe(0) + expect(c.savingsUSD).toBeGreaterThan(0) + } + }) +}) diff --git a/tests/parser-mcp-inventory.test.ts b/tests/parser-mcp-inventory.test.ts new file mode 100644 index 0000000..cbbe34c --- /dev/null +++ b/tests/parser-mcp-inventory.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' + +import { extractMcpInventory } from '../src/parser.js' +import type { JournalEntry } from '../src/types.js' + +function entry(overrides: Partial<JournalEntry> & Record<string, unknown>): JournalEntry { + return { type: 'attachment', ...overrides } as JournalEntry +} + +describe('extractMcpInventory', () => { + it('returns empty array when no entries have an attachment', () => { + expect(extractMcpInventory([entry({ type: 'user' })])).toEqual([]) + }) + + it('returns empty array when no deferred_tools_delta is present', () => { + expect(extractMcpInventory([ + entry({ attachment: { type: 'something_else', addedNames: ['mcp__a__b'] } }), + ])).toEqual([]) + }) + + it('extracts mcp__server__tool names from a single delta', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['Bash', 'Edit', 'mcp__hf__hub_repo_search', 'mcp__hf__paper_search'], + }, + }), + ]) + expect(result).toEqual(['mcp__hf__hub_repo_search', 'mcp__hf__paper_search']) + }) + + it('filters out built-in tools (no mcp__ prefix)', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['Bash', 'Edit', 'WebFetch', 'mcp__svc__t1'], + }, + }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('rejects malformed names: empty server segment', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp____tool', 'mcp__svc__t1'], + }, + }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('rejects malformed names: missing tool segment (no second `__`)', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp__server', 'mcp__svc__t1'], + }, + }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('rejects malformed names: empty tool segment (trailing `__`)', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp__server__', 'mcp__svc__t1'], + }, + }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('unions across multiple delta entries (incremental adds)', () => { + const result = extractMcpInventory([ + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t1'] } }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t2', 'mcp__b__t1'] } }), + ]) + expect(result).toEqual(['mcp__a__t1', 'mcp__a__t2', 'mcp__b__t1']) + }) + + it('deduplicates names seen in multiple deltas', () => { + const result = extractMcpInventory([ + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t1', 'mcp__a__t1'] } }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__a__t1'] } }), + ]) + expect(result).toEqual(['mcp__a__t1']) + }) + + it('tolerates missing or non-string addedNames', () => { + const result = extractMcpInventory([ + entry({ attachment: { type: 'deferred_tools_delta' } }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: 'not-an-array' } }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: [42, null, 'mcp__svc__t1', undefined] } }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('tolerates malformed attachment object', () => { + const result = extractMcpInventory([ + entry({ attachment: null }), + entry({ attachment: 'string-not-object' }), + entry({ attachment: { type: 'deferred_tools_delta', addedNames: ['mcp__svc__t1'] } }), + ]) + expect(result).toEqual(['mcp__svc__t1']) + }) + + it('returns names in sorted order', () => { + const result = extractMcpInventory([ + entry({ + attachment: { + type: 'deferred_tools_delta', + addedNames: ['mcp__zzz__a', 'mcp__aaa__z', 'mcp__mmm__m'], + }, + }), + ]) + expect(result).toEqual(['mcp__aaa__z', 'mcp__mmm__m', 'mcp__zzz__a']) + }) +}) diff --git a/tests/parser-number-helpers.test.ts b/tests/parser-number-helpers.test.ts new file mode 100644 index 0000000..53e1c32 --- /dev/null +++ b/tests/parser-number-helpers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { isPositiveNumber, safeNumber } from '../src/parser.js' + +describe('safeNumber', () => { + it('returns positive finite numbers unchanged', () => { + expect(safeNumber(1)).toBe(1) + expect(safeNumber(0.25)).toBe(0.25) + }) + + it('normalizes non-positive numbers to zero', () => { + expect(safeNumber(0)).toBe(0) + expect(safeNumber(-1)).toBe(0) + }) + + it('normalizes non-finite numbers to zero', () => { + expect(safeNumber(Number.NaN)).toBe(0) + expect(safeNumber(Number.POSITIVE_INFINITY)).toBe(0) + expect(safeNumber(Number.NEGATIVE_INFINITY)).toBe(0) + }) + + it('normalizes non-number values to zero', () => { + expect(safeNumber('12')).toBe(0) + expect(safeNumber(null)).toBe(0) + expect(safeNumber(undefined)).toBe(0) + expect(safeNumber({ value: 12 })).toBe(0) + }) +}) + +describe('isPositiveNumber', () => { + it('returns true for positive finite numbers', () => { + expect(isPositiveNumber(1)).toBe(true) + expect(isPositiveNumber(0.25)).toBe(true) + }) + + it('returns false for zero and negative numbers', () => { + expect(isPositiveNumber(0)).toBe(false) + expect(isPositiveNumber(-1)).toBe(false) + }) + + it('returns false for non-finite numbers', () => { + expect(isPositiveNumber(Number.NaN)).toBe(false) + expect(isPositiveNumber(Number.POSITIVE_INFINITY)).toBe(false) + expect(isPositiveNumber(Number.NEGATIVE_INFINITY)).toBe(false) + }) + + it('returns false for non-number values', () => { + expect(isPositiveNumber('12')).toBe(false) + expect(isPositiveNumber(null)).toBe(false) + expect(isPositiveNumber(undefined)).toBe(false) + expect(isPositiveNumber({ value: 12 })).toBe(false) + }) +}) diff --git a/tests/parser-proxy-codex-only.test.ts b/tests/parser-proxy-codex-only.test.ts new file mode 100644 index 0000000..1bb9264 --- /dev/null +++ b/tests/parser-proxy-codex-only.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, expect, it } from 'vitest' + +// A non-Claude provider records its project path with the leading slash stripped +// ("Users/test/x"), while a configured proxy path keeps it ("/Users/test/x"). +// Matching must be leading-slash agnostic, or a Codex-ONLY project under a proxy +// path is silently reported as out-of-pocket even though the SAME path is flagged +// when a Claude session happens to co-exist there (covered by the merge test) — +// i.e. attribution would depend on incidental provider co-location. +// +// This lives in its own file because the codex provider captures CODEX_HOME when +// its module is first evaluated; isolating it gives a fresh module graph that +// reads the env set below before the dynamic import. + +const CWD = '/Users/test/codexonlyproxied' +let tmpDirs: string[] = [] + +afterEach(async () => { + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } +}) + +it('flags a Codex-only project under a proxy path (leading-slash agnostic match)', async () => { + // Codex fixture (the only sessions present). + const home = await mkdtemp(join(tmpdir(), 'codeburn-codexonly-')) + tmpDirs.push(home) + const dir = join(home, 'sessions', '2026', '04', '16') + await mkdir(dir, { recursive: true }) + const meta = JSON.stringify({ + type: 'session_meta', timestamp: '2026-04-16T10:00:00Z', + payload: { cwd: CWD, originator: 'codex-cli', session_id: 'codex-1', model: 'gpt-5.3-codex' }, + }) + const tokens = JSON.stringify({ + type: 'event_msg', timestamp: '2026-04-16T10:01:00Z', + payload: { + type: 'token_count', + info: { + model: 'gpt-5.3-codex', + last_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 }, + total_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 }, + }, + }, + }) + await writeFile(join(dir, 'rollout-codex-1.jsonl'), meta + '\n' + tokens + '\n', 'utf-8') + process.env['CODEX_HOME'] = home + + // Empty Claude dir so the only project is the Codex one. + const claudeEmpty = await mkdtemp(join(tmpdir(), 'codeburn-codexonly-claude-')) + tmpDirs.push(claudeEmpty) + await mkdir(join(claudeEmpty, 'projects'), { recursive: true }) + process.env['CLAUDE_CONFIG_DIR'] = claudeEmpty + + // Import AFTER env is set so the codex provider reads CODEX_HOME. + const { parseAllSessions, clearSessionCache } = await import('../src/parser.js') + const { setProxyPaths, loadPricing } = await import('../src/models.js') + await loadPricing() + setProxyPaths([CWD]) + clearSessionCache() + + const range = { start: new Date(Date.UTC(2026, 3, 15)), end: new Date(Date.UTC(2026, 3, 17)) } + const projects = await parseAllSessions(range, 'all') + + const codex = projects.find(p => p.sessions.some(s => s.turns.some(t => t.assistantCalls.some(c => c.provider === 'codex')))) + expect(codex).toBeDefined() + expect(codex!.totalCostUSD).toBeGreaterThan(0) + // The fix: leading-slash agnostic matching flags the Codex-only project. + expect(codex!.totalProxiedCostUSD).toBeCloseTo(codex!.totalCostUSD, 10) + + setProxyPaths([]) + clearSessionCache() +}) diff --git a/tests/parser-proxy-merge.test.ts b/tests/parser-proxy-merge.test.ts new file mode 100644 index 0000000..a0d8dc9 --- /dev/null +++ b/tests/parser-proxy-merge.test.ts @@ -0,0 +1,104 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' + +// Regression guard for the cross-provider merge in parseAllSessions: when the +// same repo is used with Claude Code AND another tool (Codex), the two +// ProjectSummaries merge by canonical path and totalCostUSD is summed. The +// merge must RE-DERIVE totalProxiedCostUSD from the final path+cost, or only +// the first-seen provider's proxied amount survives and net out-of-pocket is +// overstated — silently reintroducing the exact bug issue #417 fixes. +// +// The codex provider captures its sessions dir (CODEX_HOME) when its module is +// first evaluated, so this file sets the env and creates fixtures BEFORE a +// dynamic import of the parser. A hyphen-free cwd is used so codex's +// sanitize/unsanitize path round-trips to the same merge key as Claude. + +const MERGE_CWD = '/Users/test/proxiedmerge' +let tmpDirs: string[] = [] + +afterEach(async () => { + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } +}) + +async function writeClaudeFixture(cwd: string): Promise<void> { + const base = await mkdtemp(join(tmpdir(), 'codeburn-merge-claude-')) + tmpDirs.push(base) + const dir = join(base, 'projects', 'p') + await mkdir(dir, { recursive: true }) + const line = JSON.stringify({ + type: 'assistant', + timestamp: '2026-04-16T10:00:00.000Z', + sessionId: 's1', + cwd, + message: { + type: 'message', role: 'assistant', model: 'claude-sonnet-4-6', id: 'm1', content: [], + usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }) + await writeFile(join(dir, 's1.jsonl'), line + '\n', 'utf-8') + process.env['CLAUDE_CONFIG_DIR'] = base +} + +async function writeCodexFixture(cwd: string): Promise<void> { + const home = await mkdtemp(join(tmpdir(), 'codeburn-merge-codex-')) + tmpDirs.push(home) + const dir = join(home, 'sessions', '2026', '04', '16') + await mkdir(dir, { recursive: true }) + const meta = JSON.stringify({ + type: 'session_meta', timestamp: '2026-04-16T10:00:00Z', + payload: { cwd, originator: 'codex-cli', session_id: 'codex-1', model: 'gpt-5.3-codex' }, + }) + const tokens = JSON.stringify({ + type: 'event_msg', timestamp: '2026-04-16T10:01:00Z', + payload: { + type: 'token_count', + info: { + model: 'gpt-5.3-codex', + last_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 }, + total_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 }, + }, + }, + }) + await writeFile(join(dir, 'rollout-codex-1.jsonl'), meta + '\n' + tokens + '\n', 'utf-8') + process.env['CODEX_HOME'] = home +} + +describe('proxy pricing: cross-provider merge', () => { + it('re-derives proxied == total when Claude and Codex sessions merge under a proxy path', async () => { + await writeClaudeFixture(MERGE_CWD) + await writeCodexFixture(MERGE_CWD) + + // Import AFTER env is set so the codex provider reads CODEX_HOME. + const { parseAllSessions, clearSessionCache } = await import('../src/parser.js') + const { setProxyPaths, loadPricing } = await import('../src/models.js') + await loadPricing() + setProxyPaths([MERGE_CWD]) + clearSessionCache() + + const range = { start: new Date(Date.UTC(2026, 3, 15)), end: new Date(Date.UTC(2026, 3, 17)) } + const projects = await parseAllSessions(range, 'all') + + // Sanity: both providers landed in a single merged project (proves the + // cross-provider merge path actually ran, not just a lone Claude project). + expect(projects).toHaveLength(1) + const merged = projects[0]! + const providers = new Set( + merged.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls.map(c => c.provider))), + ) + expect(providers.has('claude')).toBe(true) + expect(providers.has('codex')).toBe(true) + + // The fix: the whole merged total is subscription-covered, not just the + // first provider's slice. Without the merge re-derivation this is < total. + expect(merged.totalProxiedCostUSD).toBeCloseTo(merged.totalCostUSD, 10) + expect(merged.totalCostUSD - merged.totalProxiedCostUSD).toBeCloseTo(0, 10) + + setProxyPaths([]) + clearSessionCache() + }) +}) diff --git a/tests/parser-proxy-pricing.test.ts b/tests/parser-proxy-pricing.test.ts new file mode 100644 index 0000000..26df51b --- /dev/null +++ b/tests/parser-proxy-pricing.test.ts @@ -0,0 +1,264 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { setProxyPaths, isProxiedPath, getProxyPathsConfigHash, setLocalModelSavings, setModelAliases, loadPricing } from '../src/models.js' +import { parseAllSessions, clearSessionCache, filterProjectsByDateRange } from '../src/parser.js' +import type { DateRange, ProjectSummary } from '../src/types.js' + +// ── Part A: isProxiedPath matching rule (pure) ───────────────────────────── + +describe('isProxiedPath: path matching rule', () => { + beforeEach(() => setProxyPaths([])) + afterEach(() => setProxyPaths([])) + + it('never matches when no proxy paths are configured', () => { + expect(isProxiedPath('/Users/me/work/acme')).toBe(false) + }) + + it('matches an exact path', () => { + setProxyPaths(['/Users/me/work/acme']) + expect(isProxiedPath('/Users/me/work/acme')).toBe(true) + }) + + it('matches a child directory under the prefix', () => { + setProxyPaths(['/Users/me/work']) + expect(isProxiedPath('/Users/me/work/acme/sub')).toBe(true) + }) + + it('does NOT match across a partial path segment (boundary guard)', () => { + // The single most important negative: a string prefix that is not a + // directory-segment boundary must not silently zero unrelated spend. + setProxyPaths(['/Users/me/proj']) + expect(isProxiedPath('/Users/me/project-unrelated')).toBe(false) + }) + + it('is tolerant of trailing slashes on both config and cwd', () => { + setProxyPaths(['/Users/me/work/']) + expect(isProxiedPath('/Users/me/work')).toBe(true) + expect(isProxiedPath('/Users/me/work/')).toBe(true) + }) + + it('is case-insensitive (macOS/Windows default filesystems)', () => { + setProxyPaths(['/Users/Me/Work']) + expect(isProxiedPath('/users/me/work/acme')).toBe(true) + }) + + it('matches a Windows-style config against a forward-slash cwd', () => { + setProxyPaths(['C:\\Users\\me\\work']) + expect(isProxiedPath('C:/Users/me/work/acme')).toBe(true) + }) + + it('never matches an empty/undefined/null cwd', () => { + setProxyPaths(['/Users/me/work']) + expect(isProxiedPath('')).toBe(false) + expect(isProxiedPath(undefined)).toBe(false) + expect(isProxiedPath(null)).toBe(false) + }) + + it('drops a root "/" entry so it can never match everything', () => { + setProxyPaths(['/']) + expect(isProxiedPath('/Users/me/anything')).toBe(false) + }) + + it('drops blank / non-string entries', () => { + setProxyPaths(['', ' ', undefined as unknown as string, '/Users/me/work']) + expect(isProxiedPath('/Users/me/work/x')).toBe(true) + expect(isProxiedPath('/somewhere/else')).toBe(false) + }) + + it('matches when any one of several configured paths matches', () => { + setProxyPaths(['/Users/me/a', '/Users/me/b']) + expect(isProxiedPath('/Users/me/b/deep')).toBe(true) + expect(isProxiedPath('/Users/me/c')).toBe(false) + }) + + it('is reset by setProxyPaths([])', () => { + setProxyPaths(['/Users/me/work']) + expect(isProxiedPath('/Users/me/work')).toBe(true) + setProxyPaths([]) + expect(isProxiedPath('/Users/me/work')).toBe(false) + }) + + it('matches a leading-slash-stripped cwd (non-Claude provider path form)', () => { + // Codex/unsanitizePath project paths drop the leading slash; the configured + // path keeps it. Matching must be agnostic to that difference. + setProxyPaths(['/Users/me/work']) + expect(isProxiedPath('Users/me/work/acme')).toBe(true) + expect(isProxiedPath('Users/me/work')).toBe(true) + }) +}) + +describe('getProxyPathsConfigHash: cache-key stability', () => { + beforeEach(() => setProxyPaths([])) + afterEach(() => setProxyPaths([])) + + it('is empty when unconfigured', () => { + expect(getProxyPathsConfigHash()).toBe('') + }) + + it('is order-independent', () => { + setProxyPaths(['/a', '/b']) + const h1 = getProxyPathsConfigHash() + setProxyPaths(['/b', '/a']) + expect(getProxyPathsConfigHash()).toBe(h1) + }) + + it('does NOT collide two materially different sets (delimited join)', () => { + // Regression guard: a separator-less join would make {'/a','/b'} and + // {'/a/b'} hash identically and let the session cache serve stale numbers. + setProxyPaths(['/a', '/b']) + const h1 = getProxyPathsConfigHash() + setProxyPaths(['/a/b']) + expect(getProxyPathsConfigHash()).not.toBe(h1) + }) +}) + +// ── Part B: end-to-end attribution through parseAllSessions ──────────────── + +const FIXTURE_DAY = Date.UTC(2026, 3, 16) +const RANGE_START = new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000) +const RANGE_END = new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000) +const makeRange = (): DateRange => ({ start: RANGE_START, end: RANGE_END }) + +// A stable, non-existent absolute path: resolveCanonicalProjectPath finds no +// .git ancestor and returns it unchanged, so projectPath is predictable. +const FIXTURE_CWD = '/private/var/eywa-proxy-fixture/acme' + +let tmpDirs: string[] = [] + +beforeAll(async () => { + await loadPricing() +}) + +beforeEach(() => { + setProxyPaths([]) + setLocalModelSavings({}) + setModelAliases({}) + clearSessionCache() +}) + +afterEach(async () => { + setProxyPaths([]) + clearSessionCache() + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } +}) + +async function setupProxiedSession(cwd: string = FIXTURE_CWD): Promise<void> { + const base = await mkdtemp(join(tmpdir(), 'codeburn-proxy-')) + tmpDirs.push(base) + const projectDir = join(base, 'projects', 'p') + await mkdir(projectDir, { recursive: true }) + const line = JSON.stringify({ + type: 'assistant', + timestamp: '2026-04-16T10:00:00.000Z', + sessionId: 's1', + cwd, + message: { + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + id: 'msg-1', + content: [], + usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }) + await writeFile(join(projectDir, 's1.jsonl'), line + '\n', 'utf-8') + process.env['CLAUDE_CONFIG_DIR'] = base +} + +const allCalls = (projects: ProjectSummary[]) => + projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls))) + +describe('proxy pricing: end-to-end through parseAllSessions', () => { + it('attributes nothing as proxied when no proxy paths are configured', async () => { + await setupProxiedSession() + const projects = await parseAllSessions(makeRange(), 'all') + const total = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0) + expect(total).toBeGreaterThan(0) + expect(proxied).toBe(0) + }) + + it('flags the full cost as proxied when the project is under a proxy path, WITHOUT altering costUSD', async () => { + await setupProxiedSession() + setProxyPaths([FIXTURE_CWD]) + clearSessionCache() + const projects = await parseAllSessions(makeRange(), 'all') + + const total = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0) + expect(total).toBeGreaterThan(0) + // "Full cost, flagged": the billable figure is preserved, the same amount + // is reported as subscription-covered, so net out-of-pocket is 0. + expect(proxied).toBeCloseTo(total, 10) + expect(total - proxied).toBeCloseTo(0, 10) + + // The raw per-call cost is never destroyed — it stays at the full API rate. + const calls = allCalls(projects) + expect(calls.length).toBeGreaterThan(0) + for (const c of calls) expect(c.costUSD).toBeGreaterThan(0) + }) + + it('matches a parent prefix on a segment boundary', async () => { + await setupProxiedSession() + setProxyPaths(['/private/var/eywa-proxy-fixture']) + clearSessionCache() + const projects = await parseAllSessions(makeRange(), 'all') + const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0) + expect(proxied).toBeGreaterThan(0) + }) + + it('does NOT flag a sibling path that is only a string prefix (no spend silently zeroed)', async () => { + await setupProxiedSession() + // '/private/var/eywa-proxy-fixture/ac' is a string prefix of '.../acme' + // but not a directory-segment boundary — must not match. + setProxyPaths(['/private/var/eywa-proxy-fixture/ac']) + clearSessionCache() + const projects = await parseAllSessions(makeRange(), 'all') + const total = projects.reduce((s, p) => s + p.totalCostUSD, 0) + const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0) + expect(total).toBeGreaterThan(0) + expect(proxied).toBe(0) + }) + + it('attributes nothing when a different, unrelated path is configured', async () => { + await setupProxiedSession() + setProxyPaths(['/Users/someone/else']) + clearSessionCache() + const projects = await parseAllSessions(makeRange(), 'all') + const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0) + expect(proxied).toBe(0) + }) + + it('preserves proxy attribution after date-range filtering (filterProjectsByDateRange)', async () => { + await setupProxiedSession() + setProxyPaths([FIXTURE_CWD]) + clearSessionCache() + const projects = await parseAllSessions(makeRange(), 'all') + const filtered = filterProjectsByDateRange(projects, makeRange()) + expect(filtered.length).toBeGreaterThan(0) + const total = filtered.reduce((s, p) => s + p.totalCostUSD, 0) + const proxied = filtered.reduce((s, p) => s + p.totalProxiedCostUSD, 0) + expect(total).toBeGreaterThan(0) + expect(proxied).toBeCloseTo(total, 10) + }) + + it('does not serve stale proxy attribution from the in-memory cache after proxyPaths changes', async () => { + // parseAllSessions caches ProjectSummary[] for 180s keyed partly on the + // proxy-config hash. Toggling proxyPaths must change the key so the second + // call recomputes rather than returning the pre-change (proxied=0) result. + await setupProxiedSession() + const before = await parseAllSessions(makeRange(), 'all') + expect(before.reduce((s, p) => s + p.totalProxiedCostUSD, 0)).toBe(0) + + setProxyPaths([FIXTURE_CWD]) // deliberately NO clearSessionCache() + const after = await parseAllSessions(makeRange(), 'all') + const proxied = after.reduce((s, p) => s + p.totalProxiedCostUSD, 0) + expect(proxied).toBeGreaterThan(0) + }) +}) diff --git a/tests/parser-skip-line.test.ts b/tests/parser-skip-line.test.ts new file mode 100644 index 0000000..f023f46 --- /dev/null +++ b/tests/parser-skip-line.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest' + +import { shouldSkipLine } from '../src/parser.js' + +const threshold = '2026-04-01T00:00:00.000Z' + +function makeLine(type: string, timestamp: string, payloadSize = 0): string { + const payload = payloadSize > 0 ? `,"content":"${'x'.repeat(payloadSize)}"` : '' + return `{"type":"${type}","sessionId":"s1","timestamp":"${timestamp}"${payload}}` +} + +function makeLineWithLongCwd(type: string, timestamp: string, cwdLength: number): string { + const cwd = '/projects/' + 'a'.repeat(cwdLength) + return `{"type":"${type}","sessionId":"s1","cwd":"${cwd}","timestamp":"${timestamp}","message":{"role":"user","content":"hi"}}` +} + +describe('shouldSkipLine', () => { + it('skips old user lines', () => { + expect(shouldSkipLine(makeLine('user', '2026-03-01T10:00:00Z'), threshold)).toBe(true) + }) + + it('skips old assistant lines', () => { + expect(shouldSkipLine(makeLine('assistant', '2026-03-15T10:00:00Z'), threshold)).toBe(true) + }) + + it('does not skip in-range user lines', () => { + expect(shouldSkipLine(makeLine('user', '2026-04-05T10:00:00Z'), threshold)).toBe(false) + }) + + it('does not skip in-range assistant lines', () => { + expect(shouldSkipLine(makeLine('assistant', '2026-04-10T10:00:00Z'), threshold)).toBe(false) + }) + + it('never skips attachment lines regardless of timestamp', () => { + expect(shouldSkipLine(makeLine('attachment', '2026-01-01T00:00:00Z'), threshold)).toBe(false) + }) + + it('never skips system lines regardless of timestamp', () => { + expect(shouldSkipLine(makeLine('system', '2026-01-01T00:00:00Z'), threshold)).toBe(false) + }) + + it('never skips summary lines regardless of timestamp', () => { + expect(shouldSkipLine(makeLine('summary', '2026-01-01T00:00:00Z'), threshold)).toBe(false) + }) + + it('does not skip lines with no timestamp field', () => { + expect(shouldSkipLine('{"type":"user","sessionId":"s1"}', threshold)).toBe(false) + }) + + it('does not skip lines with unparseable timestamp', () => { + expect(shouldSkipLine('{"type":"user","timestamp":"bad"}', threshold)).toBe(false) + }) + + it('does not skip malformed JSON', () => { + expect(shouldSkipLine('not json at all', threshold)).toBe(false) + }) + + it('only reads top-level type and timestamp fields', () => { + const line = '{"message":{"type":"assistant","timestamp":"2026-03-01T10:00:00Z"},"type":"user","timestamp":"2026-04-05T10:00:00Z"}' + expect(shouldSkipLine(line, threshold)).toBe(false) + }) + + it('handles timestamp pushed past 200 chars by long cwd', () => { + const line = makeLineWithLongCwd('user', '2026-03-01T10:00:00Z', 300) + expect(line.indexOf('"timestamp"')).toBeGreaterThan(200) + expect(shouldSkipLine(line, threshold)).toBe(true) + }) + + it('handles timestamp at the edge of the 2048 head window', () => { + const line = makeLineWithLongCwd('user', '2026-03-01T10:00:00Z', 1900) + expect(line.indexOf('"timestamp"')).toBeGreaterThan(1900) + expect(shouldSkipLine(line, threshold)).toBe(true) + }) + + it('returns false when timestamp is beyond the head window', () => { + const line = makeLineWithLongCwd('user', '2026-03-01T10:00:00Z', 2100) + expect(line.indexOf('"timestamp"')).toBeGreaterThan(2048) + expect(shouldSkipLine(line, threshold)).toBe(false) + }) + + it('skips old assistant line with large payload without parsing it', () => { + const line = makeLine('assistant', '2026-02-01T10:00:00Z', 50_000_000) + expect(line.length).toBeGreaterThan(50_000_000) + expect(shouldSkipLine(line, threshold)).toBe(true) + }) +}) diff --git a/tests/parser-subagent-collection.test.ts b/tests/parser-subagent-collection.test.ts new file mode 100644 index 0000000..890809e --- /dev/null +++ b/tests/parser-subagent-collection.test.ts @@ -0,0 +1,61 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join, basename } from 'path' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { collectJsonlFiles, readAgentType } from '../src/parser.js' + +let root: string +beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'codeburn-collect-')) }) +afterEach(async () => { await rm(root, { recursive: true, force: true }) }) + +describe('collectJsonlFiles', () => { + // Regression for #470: workflow/ultracode subagent transcripts live nested at + // `<session>/subagents/workflows/<wf>/agent-*.jsonl`. A flat scan dropped them, + // so usage went uncounted whenever the workflow feature was on. + it('collects nested workflow subagent transcripts, not just top-level subagent files', async () => { + const sessionDir = join(root, 'session-1') + const wfDir = join(sessionDir, 'subagents', 'workflows', 'wf_abc') + await mkdir(wfDir, { recursive: true }) + + await writeFile(join(root, 'session-1.jsonl'), '{}\n') + await writeFile(join(sessionDir, 'subagents', 'agent-direct.jsonl'), '{}\n') + await writeFile(join(wfDir, 'agent-nested.jsonl'), '{}\n') + // Sidecar metadata must never be picked up as a transcript. + await writeFile(join(wfDir, 'agent-nested.meta.json'), '{}\n') + + const found = (await collectJsonlFiles(root)).map(f => basename(f)).sort() + + expect(found).toContain('session-1.jsonl') + expect(found).toContain('agent-direct.jsonl') + expect(found).toContain('agent-nested.jsonl') + expect(found).not.toContain('agent-nested.meta.json') + }) + + it('returns an empty list for a missing directory without throwing', async () => { + await expect(collectJsonlFiles(join(root, 'does-not-exist'))).resolves.toEqual([]) + }) +}) + +describe('readAgentType (Claude-scoped agent-type detection)', () => { + it('reads agentType from a subagent transcript’s sibling .meta.json', async () => { + const dir = join(root, 'session', 'subagents') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'agent-x.jsonl'), '{}\n') + await writeFile(join(dir, 'agent-x.meta.json'), JSON.stringify({ agentType: 'Explore' })) + expect(await readAgentType(join(dir, 'agent-x.jsonl'))).toBe('Explore') + }) + + it('falls back to workflow-subagent for nested workflow agents without a meta', async () => { + const dir = join(root, 'session', 'subagents', 'workflows', 'wf_1') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'agent-y.jsonl'), '{}\n') + expect(await readAgentType(join(dir, 'agent-y.jsonl'))).toBe('workflow-subagent') + }) + + it('returns undefined for an ordinary (non-subagent) session file', async () => { + await writeFile(join(root, 'session.jsonl'), '{}\n') + expect(await readAgentType(join(root, 'session.jsonl'))).toBeUndefined() + }) +}) diff --git a/tests/parser.test.ts b/tests/parser.test.ts new file mode 100644 index 0000000..1a2a4ec --- /dev/null +++ b/tests/parser.test.ts @@ -0,0 +1,414 @@ +// Tests for durable-source monotonic cost behaviour (PR #477 / copilot-otel). +// Five scenarios: +// (a) file-purge monotonic — copilot JSONL file deleted → total unchanged +// (b) OTel-prune monotonic — OTel DB rows pruned → total unchanged +// (c) no double-count — same source parsed twice → counted once +// (d) non-durable evicts — deleted source for non-durable provider IS removed +// (e) 90-day age-out — orphan ≥ 91d old is pruned; ≤ 89d is retained + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm, unlink } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' + +import { isSqliteAvailable } from '../src/sqlite.js' +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { loadCache, saveCache } from '../src/session-cache.js' +import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js' + +// ── Synthetic provider state ─────────────────────────────────────────────── +// Module-level so the vi.mock factory closure captures them by reference and +// tests can mutate them freely without re-creating the mock. +let _synthSources: SessionSource[] = [] +let _synthDurable = false +let _synthYields: ParsedProviderCall[] = [] + +vi.mock('../src/providers/index.js', async (importOriginal) => { + type Mod = typeof import('../src/providers/index.js') + const actual = await importOriginal<Mod>() + return { + ...actual, + async discoverAllSessions(filter?: string) { + // Pass through for specific non-synthetic providers; inject synthetic + // sources only when filter is undefined/'all'/'test-synthetic'. + if (filter && filter !== 'all' && filter !== 'test-synthetic') { + return actual.discoverAllSessions(filter) + } + const base = filter === 'test-synthetic' + ? [] + : await actual.discoverAllSessions(filter) + return [..._synthSources, ...base] + }, + async getProvider(name: string) { + if (name === 'test-synthetic') { + return { + name: 'test-synthetic', + displayName: 'Test Synthetic', + durableSources: _synthDurable, + modelDisplayName: (m: string) => m, + toolDisplayName: (t: string) => t, + async discoverSessions() { return _synthSources }, + createSessionParser(_s: SessionSource, _k: Set<string>): SessionParser { + return { + async *parse(): AsyncGenerator<ParsedProviderCall> { + for (const call of _synthYields) { + // Respect seenKeys so that when multiple sources share the same + // dedup key, only the first source yields it (mirrors real parsers). + if (_k.has(call.deduplicationKey)) continue + _k.add(call.deduplicationKey) + yield call + } + }, + } + }, + } + } + return actual.getProvider(name) + }, + } +}) + +// ── OTel DB helpers ─────────────────────────────────────────────────────── +const requireForTest = createRequire(import.meta.url) +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...p: unknown[]): void } + close(): void +} + +function createOtelDb(dbPath: string): void { + const { DatabaseSync } = requireForTest('node:sqlite') as { + DatabaseSync: new (path: string) => TestDb + } + const db = new DatabaseSync(dbPath) + db.exec(` + CREATE TABLE spans ( + span_id TEXT PRIMARY KEY NOT NULL, + trace_id TEXT NOT NULL, + operation_name TEXT, + start_time_ms INTEGER NOT NULL DEFAULT 0, + response_model TEXT + ); + CREATE TABLE span_attributes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + span_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT + ); + `) + db.close() +} + +interface OtelConvSpec { + spanId: string + traceId: string + convId: string + model: string + input: number + output: number + startTimeMs?: number +} + +function insertOtelConv(dbPath: string, spec: OtelConvSpec): void { + const { DatabaseSync } = requireForTest('node:sqlite') as { + DatabaseSync: new (path: string) => TestDb + } + const db = new DatabaseSync(dbPath) + db.prepare( + `INSERT INTO spans (span_id, trace_id, operation_name, start_time_ms, response_model) + VALUES (?, ?, ?, ?, ?)` + ).run(spec.spanId, spec.traceId, 'chat', spec.startTimeMs ?? Date.now(), spec.model) + const attr = db.prepare( + `INSERT INTO span_attributes (span_id, key, value) VALUES (?, ?, ?)` + ) + const attrs: Record<string, string | number> = { + 'gen_ai.conversation.id': spec.convId, + 'gen_ai.response.model': spec.model, + 'gen_ai.usage.input_tokens': spec.input, + 'gen_ai.usage.output_tokens': spec.output, + 'gen_ai.usage.cache_read.input_tokens': 0, + 'gen_ai.usage.cache_creation.input_tokens': 0, + } + for (const [k, v] of Object.entries(attrs)) attr.run(spec.spanId, k, String(v)) + db.close() +} + +// ── Copilot JSONL helpers ───────────────────────────────────────────────── +async function createJsonlSession( + sessionStateDir: string, + sessionId: string, + outputTokens: number, +): Promise<string> { + const dir = join(sessionStateDir, sessionId) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'workspace.yaml'), `id: ${sessionId}\ncwd: /home/user/testproj\n`) + const lines = [ + JSON.stringify({ type: 'session.model_change', timestamp: '2026-05-01T10:00:00Z', data: { newModel: 'gpt-4.1' } }), + JSON.stringify({ type: 'user.message', timestamp: '2026-05-01T10:00:05Z', data: { content: 'hello', interactionId: 'int-1' } }), + JSON.stringify({ type: 'assistant.message', timestamp: '2026-05-01T10:00:10Z', data: { messageId: 'msg-1', outputTokens, interactionId: 'int-1', toolRequests: [] } }), + ] + await writeFile(join(dir, 'events.jsonl'), lines.join('\n') + '\n') + return join(dir, 'events.jsonl') +} + +// ── Helpers ─────────────────────────────────────────────────────────────── +function totalCost(projects: Awaited<ReturnType<typeof parseAllSessions>>): number { + return projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) + .reduce((s, c) => s + c.costUSD, 0) +} + +function totalOutput(projects: Awaited<ReturnType<typeof parseAllSessions>>): number { + return projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) + .reduce((s, c) => s + c.usage.outputTokens, 0) +} + +// ── Common env setup ────────────────────────────────────────────────────── +let tmpHome: string +let tmpCache: string + +beforeEach(async () => { + tmpHome = await mkdtemp(join(tmpdir(), 'cb-parser-test-home-')) + tmpCache = await mkdtemp(join(tmpdir(), 'cb-parser-test-cache-')) + + process.env['HOME'] = tmpHome + process.env['CODEBURN_CACHE_DIR'] = tmpCache + + // Reset synthetic provider state + _synthSources = [] + _synthDurable = false + _synthYields = [] +}) + +afterEach(async () => { + clearSessionCache() + vi.unstubAllEnvs() + + _synthSources = [] + + await rm(tmpHome, { recursive: true, force: true }) + await rm(tmpCache, { recursive: true, force: true }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// (a) File-purge monotonic: copilot JSONL file deleted → total unchanged +// ═══════════════════════════════════════════════════════════════════════════ +describe('(a) copilot JSONL file-purge monotonic', () => { + it('preserves monthly total after events.jsonl is deleted', async () => { + const sessionStateDir = join(tmpHome, 'session-state') + await mkdir(sessionStateDir, { recursive: true }) + + vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1') + vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws')) + + const eventsPath = await createJsonlSession(sessionStateDir, 'sess-del', 200) + + // First parse: file exists → cached + const proj1 = await parseAllSessions(undefined, 'copilot') + const out1 = totalOutput(proj1) + expect(out1).toBe(200) + + // Delete the source file (simulates VS Code / CLI pruning it) + await unlink(eventsPath) + clearSessionCache() + + // Second parse: file gone but copilot is durable → total must not drop + const proj2 = await parseAllSessions(undefined, 'copilot') + const out2 = totalOutput(proj2) + expect(out2).toBeGreaterThanOrEqual(out1) + expect(out2).toBe(out1) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// (b) OTel-prune monotonic: OTel DB rows pruned → total unchanged +// ═══════════════════════════════════════════════════════════════════════════ +describe.skipIf(!isSqliteAvailable())( + '(b) OTel DB-prune monotonic', + () => { + it('preserves total after one conversation is pruned from the OTel DB', async () => { + const dbPath = join(tmpHome, 'agent-traces.db') + vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '') + vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmpHome, 'no-jsonl')) + vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws')) + + // DB with two conversations + createOtelDb(dbPath) + insertOtelConv(dbPath, { spanId: 's1', traceId: 't1', convId: 'prune-c1', model: 'gpt-4.1', input: 500, output: 50 }) + insertOtelConv(dbPath, { spanId: 's2', traceId: 't2', convId: 'prune-c2', model: 'gpt-4.1', input: 1000, output: 100 }) + + const proj1 = await parseAllSessions(undefined, 'copilot') + const out1 = totalOutput(proj1) + expect(out1).toBe(150) // 50 + 100 + + // Simulate OTel pruning conv-1 from the DB: rebuild DB with only conv-2 + clearSessionCache() + await rm(dbPath) + createOtelDb(dbPath) + insertOtelConv(dbPath, { spanId: 's2', traceId: 't2', convId: 'prune-c2', model: 'gpt-4.1', input: 1000, output: 100 }) + + // Second parse: DB was rebuilt without conv-1. The union-merge in + // parseProviderSources keeps conv-1's turns in the cache (since its + // dedup keys are not re-emitted by the re-parse) → total must not drop. + const proj2 = await parseAllSessions(undefined, 'copilot') + const out2 = totalOutput(proj2) + expect(out2).toBeGreaterThanOrEqual(out1) + expect(out2).toBe(out1) + }) + } +) + +// ═══════════════════════════════════════════════════════════════════════════ +// (c) No double-count: same fully-present source parsed twice → counted once +// ═══════════════════════════════════════════════════════════════════════════ +describe.skipIf(!isSqliteAvailable())( + '(c) OTel source parsed twice is counted once', + () => { + it('second parse of unchanged DB yields same total, not double', async () => { + const dbPath = join(tmpHome, 'agent-traces.db') + vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '') + vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', join(tmpHome, 'no-jsonl')) + vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws')) + + createOtelDb(dbPath) + insertOtelConv(dbPath, { spanId: 'dedup-s1', traceId: 'dedup-t1', convId: 'dedup-c1', model: 'gpt-4.1', input: 300, output: 30 }) + + const proj1 = await parseAllSessions(undefined, 'copilot') + expect(totalOutput(proj1)).toBe(30) + + clearSessionCache() + + // Second parse — disk cache is populated, fingerprint unchanged + const proj2 = await parseAllSessions(undefined, 'copilot') + expect(totalOutput(proj2)).toBe(30) // NOT 60 + }) + } +) + +// ═══════════════════════════════════════════════════════════════════════════ +// (d) Non-durable evicts: deleted source for non-durable provider is removed +// ═══════════════════════════════════════════════════════════════════════════ +describe('(d) non-durable provider evicts deleted sources', () => { + it('removes cache entry for a path that leaves discoverSessions()', async () => { + // Two real temp files as source paths (fingerprintFile needs them to exist) + const fileA = join(tmpHome, 'synth-a.txt') + const fileB = join(tmpHome, 'synth-b.txt') + await writeFile(fileA, 'placeholder-a') + await writeFile(fileB, 'placeholder-b') + + const dedupA = 'synth-dedup-evict-a' + const dedupB = 'synth-dedup-evict-b' + + const makeCall = (deduplicationKey: string): ParsedProviderCall => ({ + provider: 'test-synthetic', model: 'gpt-4o', + inputTokens: 10, outputTokens: 5, + cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0.001, tools: [], bashCommands: [], + timestamp: new Date().toISOString(), + speed: 'standard', + deduplicationKey, + userMessage: 'test', sessionId: 'synth-sess', + }) + + _synthDurable = false + _synthSources = [ + { path: fileA, project: 'test', provider: 'test-synthetic' }, + { path: fileB, project: 'test', provider: 'test-synthetic' }, + ] + _synthYields = [makeCall(dedupA)] + + // First parse: both sources present → data for A cached + const proj1 = await parseAllSessions(undefined, 'test-synthetic') + expect(totalOutput(proj1)).toBeGreaterThan(0) + + clearSessionCache() + + // Remove A from discovered sources (simulates file-gone + discoverSessions skips it). + // B stays so sources.length > 0 → eviction loop fires. + _synthSources = [{ path: fileB, project: 'test', provider: 'test-synthetic' }] + _synthYields = [] // B yields nothing (empty file) + + const proj2 = await parseAllSessions(undefined, 'test-synthetic') + // A's cache entry must be evicted → total should be 0 + expect(totalOutput(proj2)).toBe(0) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// (e) 90-day age-out: orphan ≥ 91d old is pruned; ≤ 89d is retained +// ═══════════════════════════════════════════════════════════════════════════ +describe('(e) 90-day age-out for durable providers', () => { + it('prunes an orphaned cache entry whose newest call is 91 days old', async () => { + const synthFile = join(tmpHome, 'synth-age.txt') + await writeFile(synthFile, 'placeholder') + + const ts91dAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString() + + _synthDurable = true + _synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }] + _synthYields = [{ + provider: 'test-synthetic', model: 'gpt-4o', + inputTokens: 10, outputTokens: 8, + cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0.002, tools: [], bashCommands: [], + timestamp: ts91dAgo, + speed: 'standard', + deduplicationKey: 'synth-age-out-91d', + userMessage: 'old', sessionId: 'synth-old', + }] + + // First parse: cached with 91d-old timestamp → immediately pruned by 90-day check + const proj1 = await parseAllSessions(undefined, 'test-synthetic') + expect(totalOutput(proj1)).toBe(0) // pruned right away + + // Confirm: entry is not in the persistent cache after first parse + clearSessionCache() + _synthSources = [] // no longer discovered + const proj2 = await parseAllSessions(undefined, 'test-synthetic') + expect(totalOutput(proj2)).toBe(0) + }) + + it('retains an orphaned cache entry whose newest call is 89 days old', async () => { + const synthFile = join(tmpHome, 'synth-retain.txt') + await writeFile(synthFile, 'placeholder') + + const ts89dAgo = new Date(Date.now() - 89 * 24 * 60 * 60 * 1000).toISOString() + + _synthDurable = true + _synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }] + _synthYields = [{ + provider: 'test-synthetic', model: 'gpt-4o', + inputTokens: 10, outputTokens: 7, + cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0.002, tools: [], bashCommands: [], + timestamp: ts89dAgo, + speed: 'standard', + deduplicationKey: 'synth-retain-89d', + userMessage: 'recent-ish', sessionId: 'synth-recent', + }] + + // First parse: cached with 89d-old timestamp → NOT pruned (within 90d window) + const proj1 = await parseAllSessions(undefined, 'test-synthetic') + expect(totalOutput(proj1)).toBe(7) + + // Remove source (simulate it being orphaned) + clearSessionCache() + _synthSources = [] // no longer discovered → orphan pass handles it + + // Second parse: orphan with 89d timestamp → retained + counted via orphan pass + const proj2 = await parseAllSessions(undefined, 'test-synthetic') + expect(totalOutput(proj2)).toBe(7) + }) +}) diff --git a/tests/plan-usage.test.ts b/tests/plan-usage.test.ts new file mode 100644 index 0000000..eabfe5a --- /dev/null +++ b/tests/plan-usage.test.ts @@ -0,0 +1,343 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { savePlan } from '../src/config.js' +import { activePlansFromMap, computePeriodFromResetDay, getPlanUsage, getPlanUsageFromProjects, getPlanUsages } from '../src/plan-usage.js' +import type { ProjectSummary } from '../src/types.js' + +const { parseAllSessionsMock } = vi.hoisted(() => ({ + parseAllSessionsMock: vi.fn(), +})) + +vi.mock('../src/parser.js', () => ({ + parseAllSessions: parseAllSessionsMock, +})) + +describe('computePeriodFromResetDay', () => { + it('uses current month when today is on/after reset day', () => { + const { periodStart, periodEnd } = computePeriodFromResetDay(1, new Date('2026-04-17T10:00:00.000Z')) + expect(periodStart.getFullYear()).toBe(2026) + expect(periodStart.getMonth()).toBe(3) + expect(periodStart.getDate()).toBe(1) + expect(periodEnd.getMonth()).toBe(4) + expect(periodEnd.getDate()).toBe(1) + }) + + it('uses previous month when today is before reset day', () => { + const { periodStart, periodEnd } = computePeriodFromResetDay(15, new Date('2026-04-03T10:00:00.000Z')) + expect(periodStart.getMonth()).toBe(2) + expect(periodStart.getDate()).toBe(15) + expect(periodEnd.getMonth()).toBe(3) + expect(periodEnd.getDate()).toBe(15) + }) + + it('clamps reset day into 1..28', () => { + const { periodStart } = computePeriodFromResetDay(99, new Date('2026-04-27T10:00:00.000Z')) + expect(periodStart.getDate()).toBe(28) + }) +}) + +describe('getPlanUsage', () => { + beforeEach(() => { + parseAllSessionsMock.mockReset() + }) + + it('passes provider filter from plan and computes status', async () => { + parseAllSessionsMock.mockResolvedValue([ + { + totalCostUSD: 160, + sessions: [], + }, + ]) + + const usage = await getPlanUsage({ + id: 'claude-max', + monthlyUsd: 200, + provider: 'claude', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }, new Date('2026-04-10T10:00:00.000Z')) + + expect(parseAllSessionsMock).toHaveBeenCalledWith( + expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }), + 'claude', + ) + expect(usage.spentApiEquivalentUsd).toBe(160) + expect(usage.percentUsed).toBe(80) + expect(usage.status).toBe('near') + }) + + it('projects using median daily spend (not mean)', async () => { + const dailyCosts = [1, 100, 1, 100, 1, 100, 1] + const turns = dailyCosts.map((cost, idx) => ({ + timestamp: `2026-04-${String(idx + 1).padStart(2, '0')}T12:00:00.000Z`, + assistantCalls: [{ costUSD: cost }], + })) + + parseAllSessionsMock.mockResolvedValue([ + { + totalCostUSD: dailyCosts.reduce((sum, value) => sum + value, 0), + sessions: [{ turns }], + }, + ]) + + const usage = await getPlanUsage({ + id: 'custom', + monthlyUsd: 500, + provider: 'all', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }, new Date('2026-04-07T12:00:00.000Z')) + + // Median(1,100,1,100,1,100,1) = 1, so remaining 23 days adds 23. + expect(Math.round(usage.projectedMonthUsd)).toBe(327) + expect(parseAllSessionsMock).toHaveBeenCalledWith( + expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }), + 'all', + ) + }) + + it('computes plan usage from pre-fetched projects', () => { + const usage = getPlanUsageFromProjects({ + id: 'custom', + monthlyUsd: 100, + provider: 'all', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }, [ + { + totalCostUSD: 40, + sessions: [ + { + turns: [ + { timestamp: '2026-04-02T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] }, + { timestamp: '2026-04-03T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] }, + ], + }, + ], + }, + ], new Date('2026-04-10T10:00:00.000Z')) + + expect(usage.spentApiEquivalentUsd).toBe(40) + expect(usage.budgetUsd).toBe(100) + expect(usage.status).toBe('under') + }) + + it('projects month-end spend from API call timestamps', () => { + const usage = getPlanUsageFromProjects({ + id: 'custom', + monthlyUsd: 100, + provider: 'all', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }, [ + { + project: 'codeburn', + projectPath: '/tmp/codeburn', + totalCostUSD: 10, + totalApiCalls: 1, + sessions: [ + { + turns: [ + { + timestamp: '2026-03-31T23:59:00.000Z', + assistantCalls: [{ costUSD: 10, timestamp: '2026-04-01T10:00:00.000Z' }], + }, + ], + }, + ], + }, + ] as ProjectSummary[], new Date('2026-04-01T12:00:00.000Z')) + + expect(Math.round(usage.projectedMonthUsd)).toBe(300) + }) + + it('returns active plans in provider display order', () => { + const plans = activePlansFromMap({ + codex: { + id: 'custom', + monthlyUsd: 200, + provider: 'codex', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }, + claude: { + id: 'claude-max', + monthlyUsd: 200, + provider: 'claude', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }, + cursor: { + id: 'none', + monthlyUsd: 0, + provider: 'cursor', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }, + }) + + expect(plans.map(plan => plan.provider)).toEqual(['claude', 'codex']) + }) + + it('keeps the provider-specific parser filter for one active plan', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-usage-test-')) + process.env['HOME'] = dir + + try { + await savePlan({ + id: 'claude-max', + monthlyUsd: 200, + provider: 'claude', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }) + + parseAllSessionsMock.mockResolvedValue([ + { + project: 'codeburn', + projectPath: '/tmp/codeburn', + totalCostUSD: 80, + totalApiCalls: 1, + sessions: [], + }, + ] satisfies ProjectSummary[]) + + const usages = await getPlanUsages(new Date('2026-04-10T12:00:00.000Z')) + + expect(parseAllSessionsMock).toHaveBeenCalledTimes(1) + expect(parseAllSessionsMock).toHaveBeenCalledWith( + expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }), + 'claude', + ) + expect(usages).toHaveLength(1) + expect(usages[0]?.spentApiEquivalentUsd).toBe(80) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('computes multiple active plan usages from one all-provider parse', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-usage-test-')) + process.env['HOME'] = dir + + try { + await savePlan({ + id: 'claude-max', + monthlyUsd: 200, + provider: 'claude', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }) + await savePlan({ + id: 'custom', + monthlyUsd: 100, + provider: 'codex', + resetDay: 1, + setAt: '2026-04-01T00:00:00.000Z', + }) + + parseAllSessionsMock.mockResolvedValue([ + { + project: 'codeburn', + projectPath: '/tmp/codeburn', + totalCostUSD: 150, + totalApiCalls: 2, + sessions: [ + { + sessionId: 'session-1', + project: 'codeburn', + firstTimestamp: '2026-04-03T10:00:00.000Z', + lastTimestamp: '2026-04-03T11:00:00.000Z', + totalCostUSD: 150, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 2, + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {}, + skillBreakdown: {}, + turns: [ + { + userMessage: 'work', + timestamp: '2026-04-03T10:00:00.000Z', + sessionId: 'session-1', + category: 'coding', + retries: 0, + hasEdits: true, + assistantCalls: [ + { + provider: 'claude', + model: 'claude-opus-4-7', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: 100, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-04-03T10:00:00.000Z', + bashCommands: [], + deduplicationKey: 'claude-1', + }, + { + provider: 'codex', + model: 'gpt-5.5', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: 50, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-04-03T11:00:00.000Z', + bashCommands: [], + deduplicationKey: 'codex-1', + }, + ], + }, + ], + }, + ], + }, + ] satisfies ProjectSummary[]) + + const usages = await getPlanUsages(new Date('2026-04-10T12:00:00.000Z')) + + expect(parseAllSessionsMock).toHaveBeenCalledTimes(1) + expect(parseAllSessionsMock).toHaveBeenCalledWith( + expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }), + 'all', + ) + expect(usages.map(usage => usage.plan.provider)).toEqual(['claude', 'codex']) + expect(usages.map(usage => usage.spentApiEquivalentUsd)).toEqual([100, 50]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/plans.test.ts b/tests/plans.test.ts new file mode 100644 index 0000000..ca4553b --- /dev/null +++ b/tests/plans.test.ts @@ -0,0 +1,178 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, it, expect } from 'vitest' + +import { clearPlan, readPlan, readPlans, saveConfig, savePlan } from '../src/config.js' +import { getPresetPlan, isPlanId, isPlanProvider } from '../src/plans.js' + +describe('plan presets', () => { + it('resolves builtin presets', () => { + expect(getPresetPlan('claude-pro')).toMatchObject({ id: 'claude-pro', monthlyUsd: 20, provider: 'claude' }) + expect(getPresetPlan('claude-max')).toMatchObject({ id: 'claude-max', monthlyUsd: 200, provider: 'claude' }) + expect(getPresetPlan('cursor-pro')).toMatchObject({ id: 'cursor-pro', monthlyUsd: 20, provider: 'cursor' }) + expect(getPresetPlan('custom')).toBeNull() + }) + + it('validates ids and providers', () => { + expect(isPlanId('claude-pro')).toBe(true) + expect(isPlanId('none')).toBe(true) + expect(isPlanId('bad-plan')).toBe(false) + + expect(isPlanProvider('all')).toBe(true) + expect(isPlanProvider('claude')).toBe(true) + expect(isPlanProvider('invalid')).toBe(false) + }) +}) + +describe('plan config persistence', () => { + it('round-trips per-provider plans and clears one provider at a time', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-')) + process.env['HOME'] = dir + + try { + await savePlan({ + id: 'claude-max', + monthlyUsd: 200, + provider: 'claude', + resetDay: 12, + setAt: '2026-04-17T12:00:00.000Z', + }) + await savePlan({ + id: 'custom', + monthlyUsd: 200, + provider: 'codex', + resetDay: 1, + setAt: '2026-04-18T12:00:00.000Z', + }) + + const plans = await readPlans() + expect(plans.claude).toMatchObject({ + id: 'claude-max', + monthlyUsd: 200, + provider: 'claude', + resetDay: 12, + }) + expect(plans.codex).toMatchObject({ + id: 'custom', + monthlyUsd: 200, + provider: 'codex', + resetDay: 1, + }) + expect(await readPlan()).toMatchObject({ id: 'claude-max', provider: 'claude' }) + + await clearPlan('codex') + expect((await readPlans()).codex).toBeUndefined() + expect((await readPlans()).claude).toMatchObject({ id: 'claude-max' }) + + await clearPlan('all') + expect((await readPlans()).claude).toMatchObject({ id: 'claude-max' }) + + await clearPlan() + expect(await readPlan()).toBeUndefined() + expect(await readPlans()).toEqual({}) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('reads legacy single-plan config as a provider-keyed plan map', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-')) + process.env['HOME'] = dir + + try { + await saveConfig({ + plan: { + id: 'cursor-pro', + monthlyUsd: 20, + provider: 'cursor', + resetDay: 3, + setAt: '2026-04-17T12:00:00.000Z', + }, + }) + + const plans = await readPlans() + expect(plans.cursor).toMatchObject({ + id: 'cursor-pro', + monthlyUsd: 20, + provider: 'cursor', + resetDay: 3, + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('drops a hand-edited all plan when provider-specific plans are present', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-')) + process.env['HOME'] = dir + + try { + await saveConfig({ + plans: { + all: { + id: 'custom', + monthlyUsd: 300, + resetDay: 1, + setAt: '2026-04-17T12:00:00.000Z', + }, + claude: { + id: 'claude-max', + monthlyUsd: 200, + resetDay: 1, + setAt: '2026-04-18T12:00:00.000Z', + }, + }, + }) + + const plans = await readPlans() + expect(plans.all).toBeUndefined() + expect(plans.claude).toMatchObject({ id: 'claude-max', provider: 'claude' }) + expect(await readPlan()).toMatchObject({ id: 'claude-max', provider: 'claude' }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('does not allow an all-provider plan to overlap provider-specific plans', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-')) + process.env['HOME'] = dir + + try { + await savePlan({ + id: 'custom', + monthlyUsd: 100, + provider: 'all', + resetDay: 1, + setAt: '2026-04-17T12:00:00.000Z', + }) + await savePlan({ + id: 'claude-max', + monthlyUsd: 200, + provider: 'claude', + resetDay: 1, + setAt: '2026-04-18T12:00:00.000Z', + }) + + expect(await readPlans()).toMatchObject({ + claude: { id: 'claude-max' }, + }) + expect((await readPlans()).all).toBeUndefined() + + await savePlan({ + id: 'custom', + monthlyUsd: 300, + provider: 'all', + resetDay: 1, + setAt: '2026-04-19T12:00:00.000Z', + }) + expect(await readPlans()).toMatchObject({ + all: { id: 'custom', monthlyUsd: 300 }, + }) + expect((await readPlans()).claude).toBeUndefined() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/pricing-fallback-data.test.ts b/tests/pricing-fallback-data.test.ts new file mode 100644 index 0000000..6e2ec01 --- /dev/null +++ b/tests/pricing-fallback-data.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' + +import fallback from '../src/data/pricing-fallback.json' assert { type: 'json' } + +// The gap-fill fallback is generated from models.dev / OpenRouter. These assert +// the bundler's hygiene guarantees on the committed artifact, so a future +// rebundle that regresses them fails CI rather than shipping bad pricing. +describe('pricing-fallback.json data hygiene', () => { + const entries = Object.entries(fallback as Record<string, (number | null)[]>) + + it('is non-empty', () => { + expect(entries.length).toBeGreaterThan(50) + }) + + it('has no negative rates (OpenRouter -1 "variable price" sentinels)', () => { + const bad = entries.filter(([, v]) => (v[0] ?? 0) < 0 || (v[1] ?? 0) < 0 || (v[2] ?? 0) < 0 || (v[3] ?? 0) < 0) + expect(bad.map(([k]) => k)).toEqual([]) + }) + + it('has no entry that is free on both input and output', () => { + const bad = entries.filter(([, v]) => v[0] === 0 && v[1] === 0) + expect(bad.map(([k]) => k)).toEqual([]) + }) + + it('has no unreachable @pin or date-suffixed keys', () => { + const bad = entries.filter(([k]) => /@/.test(k) || /\d{8}$/.test(k)) + expect(bad.map(([k]) => k)).toEqual([]) + }) + + it('stores per-token rates (no per-million values leaked through)', () => { + // A per-million value would be >= 1; real per-token rates are tiny. + const bad = entries.filter(([, v]) => (v[0] ?? 0) >= 1 || (v[1] ?? 0) >= 1) + expect(bad.map(([k]) => k)).toEqual([]) + }) +}) diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts new file mode 100644 index 0000000..1c346dd --- /dev/null +++ b/tests/provider-registry.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi } from 'vitest' +import { providers, getAllProviders, getProvider, safeDiscoverSessions, discoverAllSessions } from '../src/providers/index.js' +import type { Provider } from '../src/providers/types.js' + +function fakeProvider(name: string, discover: Provider['discoverSessions']): Provider { + return { + name, + displayName: name, + modelDisplayName: (m: string) => m, + toolDisplayName: (t: string) => t, + discoverSessions: discover, + } as unknown as Provider +} + +describe('provider registry', () => { + it('has core providers registered synchronously', () => { + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack', 'grok']) + }) + + it('codebuff tool display names normalize codebuff-native names to canonical set', () => { + const codebuff = providers.find(p => p.name === 'codebuff')! + expect(codebuff.toolDisplayName('read_files')).toBe('Read') + expect(codebuff.toolDisplayName('code_search')).toBe('Grep') + expect(codebuff.toolDisplayName('str_replace')).toBe('Edit') + expect(codebuff.toolDisplayName('run_terminal_command')).toBe('Bash') + expect(codebuff.toolDisplayName('spawn_agents')).toBe('Agent') + expect(codebuff.toolDisplayName('write_todos')).toBe('TodoWrite') + expect(codebuff.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) + + it('codebuff model display names cover known agent tiers', () => { + const codebuff = providers.find(p => p.name === 'codebuff')! + expect(codebuff.modelDisplayName('codebuff')).toBe('Codebuff') + expect(codebuff.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2') + expect(codebuff.modelDisplayName('some-future-model')).toBe('some-future-model') + }) + + it('includes sqlite providers after async load', async () => { + const all = await getAllProviders() + const names = all.map(p => p.name) + expect(names).toContain('claude') + expect(names).toContain('codex') + expect(names).toContain('forge') + expect(names).toContain('warp') + expect(names.length).toBeGreaterThanOrEqual(2) + }) + + it('forge is available through async provider loading', async () => { + const forge = await getProvider('forge') + expect(forge).toBeDefined() + expect(forge!.name).toBe('forge') + }) + + it('warp model and tool display names are normalized', async () => { + const warp = await getProvider('warp') + expect(warp).toBeDefined() + expect(warp!.modelDisplayName('warp-auto-efficient')).toBe('Warp Auto (efficient)') + expect(warp!.modelDisplayName('gpt-5.3-codex')).toBe('GPT-5.3 Codex') + expect(warp!.toolDisplayName('run_command')).toBe('Bash') + }) + + it('opencode model display names strip provider prefix', async () => { + const all = await getAllProviders() + const oc = all.find(p => p.name === 'opencode') + if (!oc) return + expect(oc.modelDisplayName('anthropic/claude-opus-4-6-20260205')).toBe('Opus 4.6') + expect(oc.modelDisplayName('google/gemini-2.5-pro')).toBe('Gemini 2.5 Pro') + }) + + it('opencode tool display names normalize builtins', async () => { + const all = await getAllProviders() + const oc = all.find(p => p.name === 'opencode') + if (!oc) return + expect(oc.toolDisplayName('bash')).toBe('Bash') + expect(oc.toolDisplayName('edit')).toBe('Edit') + expect(oc.toolDisplayName('task')).toBe('Agent') + expect(oc.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) + + it('claude tool display names are identity', () => { + const claude = providers.find(p => p.name === 'claude')! + expect(claude.toolDisplayName('Bash')).toBe('Bash') + expect(claude.toolDisplayName('Read')).toBe('Read') + }) + + it('codex tool display names are normalized', () => { + const codex = providers.find(p => p.name === 'codex')! + expect(codex.toolDisplayName('exec_command')).toBe('Bash') + expect(codex.toolDisplayName('read_file')).toBe('Read') + expect(codex.toolDisplayName('write_file')).toBe('Edit') + expect(codex.toolDisplayName('spawn_agent')).toBe('Agent') + }) + + it('codex model display names are human-readable', () => { + const codex = providers.find(p => p.name === 'codex')! + expect(codex.modelDisplayName('gpt-5.4')).toBe('GPT-5.4') + expect(codex.modelDisplayName('gpt-5.4-mini')).toBe('GPT-5.4 Mini') + expect(codex.modelDisplayName('gpt-5.3-codex')).toBe('GPT-5.3 Codex') + expect(codex.modelDisplayName('gpt-5.5')).toBe('GPT-5.5') + }) + + it('claude model display names are human-readable', () => { + const claude = providers.find(p => p.name === 'claude')! + expect(claude.modelDisplayName('claude-opus-4-6-20260205')).toBe('Opus 4.6') + expect(claude.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') + }) + + it('kimi model and tool display names are normalized', () => { + const kimi = providers.find(p => p.name === 'kimi')! + expect(kimi.modelDisplayName('kimi-auto')).toBe('Kimi (auto)') + expect(kimi.modelDisplayName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo') + expect(kimi.toolDisplayName('Shell')).toBe('Bash') + expect(kimi.toolDisplayName('WriteFile')).toBe('Write') + }) + + it('lingtai-tui model display names are normalized', () => { + const lingtai = providers.find(p => p.name === 'lingtai-tui')! + expect(lingtai.displayName).toBe('LingTai TUI') + expect(lingtai.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') + expect(lingtai.toolDisplayName('custom_tool')).toBe('custom_tool') + }) + + it('cursor model display names handle auto mode', async () => { + const all = await getAllProviders() + const cursor = all.find(p => p.name === 'cursor')! + expect(cursor.modelDisplayName('cursor-auto')).toBe('Cursor (auto)') + expect(cursor.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking)') + expect(cursor.modelDisplayName('grok-code-fast-1')).toBe('Grok Code Fast') + expect(cursor.modelDisplayName('unknown-model')).toBe('unknown-model') + }) + + describe('provider-discovery isolation', () => { + it('safeDiscoverSessions returns [] and warns once instead of propagating', async () => { + const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const boom = fakeProvider('boom-helper', async () => { throw new Error('crafted file blew up') }) + try { + await expect(safeDiscoverSessions(boom)).resolves.toEqual([]) + expect(warn.mock.calls.length).toBeGreaterThanOrEqual(1) + expect(String(warn.mock.calls[0]![0])).toContain('boom-helper') + // Deduped on repeat within the same run: no additional warning. + const afterFirst = warn.mock.calls.length + await safeDiscoverSessions(boom) + expect(warn.mock.calls.length).toBe(afterFirst) + } finally { + warn.mockRestore() + } + }) + + it('discoverAllSessions drops a throwing provider but keeps the healthy ones', async () => { + const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const boom = fakeProvider('boom-loop', async () => { throw new Error('kaboom') }) + const ok1 = fakeProvider('ok1', async () => [{ path: '/a.jsonl', project: 'p1', provider: 'ok1' }]) + const ok2 = fakeProvider('ok2', async () => [{ path: '/b.jsonl', project: 'p2', provider: 'ok2' }]) + try { + // A throwing provider in the middle must not abort the loop. + const sources = await discoverAllSessions('all', [ok1, boom, ok2]) + expect(sources.map(s => s.path)).toEqual(['/a.jsonl', '/b.jsonl']) + expect(warn.mock.calls.some(c => String(c[0]).includes('boom-loop'))).toBe(true) + } finally { + warn.mockRestore() + } + }) + + it('discoverAllSessions honors the provider filter', async () => { + const ok1 = fakeProvider('keep', async () => [{ path: '/keep.jsonl', project: 'k', provider: 'keep' }]) + const ok2 = fakeProvider('drop', async () => [{ path: '/drop.jsonl', project: 'd', provider: 'drop' }]) + const sources = await discoverAllSessions('keep', [ok1, ok2]) + expect(sources.map(s => s.path)).toEqual(['/keep.jsonl']) + }) + }) +}) diff --git a/tests/provider-turn-grouping.test.ts b/tests/provider-turn-grouping.test.ts new file mode 100644 index 0000000..d9dd0d6 --- /dev/null +++ b/tests/provider-turn-grouping.test.ts @@ -0,0 +1,158 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { DateRange } from '../src/types.js' + +let home: string +let cacheDir: string +let vibeHome: string +let clearParserCache: (() => void) | undefined + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-home-')) + cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-cache-')) + vibeHome = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-vibe-')) + process.env['HOME'] = home + process.env['CODEBURN_CACHE_DIR'] = cacheDir + process.env['VIBE_HOME'] = vibeHome +}) + +afterEach(async () => { + clearParserCache?.() + clearParserCache = undefined + vi.resetModules() + await rm(home, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) + await rm(vibeHome, { recursive: true, force: true }) +}) + +function dayRange(): DateRange { + return { + start: new Date('2026-05-16T00:00:00.000Z'), + end: new Date('2026-05-16T23:59:59.999Z'), + } +} + +async function loadParser() { + vi.resetModules() + const parser = await import('../src/parser.js') + clearParserCache = parser.clearSessionCache + return parser.parseAllSessions +} + +describe('provider turn grouping', () => { + it('groups Gemini assistant messages under their user turn so retries are counted', async () => { + const chatsDir = join(home, '.gemini', 'tmp', 'project-a', 'chats') + await mkdir(chatsDir, { recursive: true }) + await writeFile(join(chatsDir, 'session-gemini.json'), JSON.stringify({ + sessionId: 'gemini-session-1', + startTime: '2026-05-16T10:00:00.000Z', + messages: [ + { id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'implement parser update in src/parser.ts' }, + { + id: 'g1', + timestamp: '2026-05-16T10:00:05.000Z', + type: 'gemini', + content: 'editing', + model: 'gemini-3.1-pro-preview', + tokens: { input: 100, output: 30 }, + toolCalls: [{ id: 't1', name: 'edit_file', args: { path: 'src/parser.ts' } }], + }, + { + id: 'g2', + timestamp: '2026-05-16T10:00:10.000Z', + type: 'gemini', + content: 'testing', + model: 'gemini-3.1-pro-preview', + tokens: { input: 80, output: 20 }, + toolCalls: [{ id: 't2', name: 'run_command', args: { command: 'npm test' } }], + }, + { + id: 'g3', + timestamp: '2026-05-16T10:00:15.000Z', + type: 'gemini', + content: 'fixing after test', + model: 'gemini-3.1-pro-preview', + tokens: { input: 90, output: 25 }, + toolCalls: [{ id: 't3', name: 'edit_file', args: { path: 'src/parser.ts' } }], + }, + ], + })) + + const parseAllSessions = await loadParser() + const projects = await parseAllSessions(dayRange(), 'gemini') + const session = projects[0]!.sessions[0]! + const turn = session.turns[0]! + + expect(session.turns).toHaveLength(1) + expect(turn.assistantCalls.map(call => call.deduplicationKey)).toEqual([ + 'gemini:gemini-session-1:g1', + 'gemini:gemini-session-1:g2', + 'gemini:gemini-session-1:g3', + ]) + expect(turn.hasEdits).toBe(true) + expect(turn.retries).toBe(1) + expect(session.categoryBreakdown[turn.category].editTurns).toBe(1) + expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0) + }) + + it('groups Mistral Vibe assistant messages and uses Vibe session_cost when present', async () => { + const sessionDir = join(vibeHome, 'logs', 'session', 'session_20260516_100000_vibe') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'meta.json'), JSON.stringify({ + session_id: 'vibe-session-1', + start_time: '2026-05-16T10:00:00.000Z', + end_time: '2026-05-16T10:01:00.000Z', + environment: { working_directory: '/Users/test/project-a' }, + stats: { + session_prompt_tokens: 300, + session_completion_tokens: 90, + session_cost: 0.123456, + input_price_per_million: 100, + output_price_per_million: 100, + }, + config: { active_model: 'mistral-medium-3.5', models: [] }, + title: 'vibe parser update', + })) + await writeFile(join(sessionDir, 'messages.jsonl'), [ + { role: 'user', content: 'implement parser update in src/providers/mistral-vibe.ts', message_id: 'u1' }, + { + role: 'assistant', + content: 'editing', + message_id: 'a1', + tool_calls: [{ id: 't1', type: 'function', function: { name: 'search_replace', arguments: '{"file_path":"src/providers/mistral-vibe.ts"}' } }], + }, + { + role: 'assistant', + content: 'testing', + message_id: 'a2', + tool_calls: [{ id: 't2', type: 'function', function: { name: 'bash', arguments: '{"command":"npm test"}' } }], + }, + { + role: 'assistant', + content: 'fixing after test', + message_id: 'a3', + tool_calls: [{ id: 't3', type: 'function', function: { name: 'write_file', arguments: '{"path":"src/providers/mistral-vibe.ts"}' } }], + }, + ].map(message => JSON.stringify(message)).join('\n') + '\n') + + const parseAllSessions = await loadParser() + const projects = await parseAllSessions(dayRange(), 'mistral-vibe') + const session = projects[0]!.sessions[0]! + const turn = session.turns[0]! + + expect(session.turns).toHaveLength(1) + expect(turn.assistantCalls.map(call => call.deduplicationKey)).toEqual([ + 'mistral-vibe:vibe-session-1:a1', + 'mistral-vibe:vibe-session-1:a2', + 'mistral-vibe:vibe-session-1:a3', + ]) + expect(turn.retries).toBe(1) + expect(session.totalCostUSD).toBeCloseTo(0.123456, 8) + expect(session.totalInputTokens).toBe(300) + expect(session.totalOutputTokens).toBe(90) + expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0) + }) +}) diff --git a/tests/providers/antigravity.test.ts b/tests/providers/antigravity.test.ts new file mode 100644 index 0000000..e98fd59 --- /dev/null +++ b/tests/providers/antigravity.test.ts @@ -0,0 +1,608 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +import { isSqliteAvailable } from '../../src/sqlite.js' +import { + antigravityAppDataDirFromSourcePath, + antigravityCascadeIdFromPath, + createAntigravityProvider, + discoverAntigravitySessionSources, + extractAntigravityAppDataDirFromLine, + extractAntigravityGeneratorMetadata, + extractAntigravityModelMap, + getAntigravityStatusLineEventsPath, + parseAntigravityServerInfo, + parseAntigravityServerInfoFromLine, + recordAntigravityStatusLinePayload, + shouldReparseAntigravitySource, +} from '../../src/providers/antigravity.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type CurrentCliFixture = { + conversationId: string + rows: Array<{ idx: number; hex: string }> +} + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +function createCurrentAntigravityCliDb(dbPath: string, fixture: CurrentCliFixture): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) as TestDb + try { + db.exec('CREATE TABLE gen_metadata (idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx))') + db.exec('CREATE TABLE trajectory_metadata_blob (id text DEFAULT "main", data blob, PRIMARY KEY (id))') + db.prepare('INSERT INTO trajectory_metadata_blob (id, data) VALUES (?, ?)').run( + 'main', + Buffer.from('file:///Users/example/private-project'), + ) + for (const row of fixture.rows) { + const data = Buffer.from(row.hex, 'hex') + db.prepare('INSERT INTO gen_metadata (idx, data, size) VALUES (?, ?, ?)').run(row.idx, data, data.length) + } + } finally { + db.close() + } +} + +async function collectAntigravityCalls(source: { path: string; project: string; provider: string }): Promise<ParsedProviderCall[]> { + const parser = createAntigravityProvider().createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + return calls +} + +describe('antigravity provider helpers', () => { + it('parses legacy https server flags from POSIX process args', () => { + const server = parseAntigravityServerInfoFromLine( + '/Applications/Antigravity.app/language_server_macos_arm --app_data_dir antigravity --https_server_port 57101 --csrf_token 01234567-89ab-cdef-0123-456789abcdef', + ) + + expect(server).toEqual({ + port: 57101, + csrfToken: '01234567-89ab-cdef-0123-456789abcdef', + }) + }) + + it('parses Windows extension server flags and equals syntax', () => { + const server = parseAntigravityServerInfoFromLine( + 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Antigravity\\resources\\app\\extensions\\antigravity\\bin\\language_server_windows_x64.exe --extension_server_port=62225 --extension_server_csrf_token=abcdef01-2345-6789-abcd-ef0123456789', + ) + + expect(server).toEqual({ + port: 62225, + csrfToken: 'abcdef01-2345-6789-abcd-ef0123456789', + }) + }) + + it('parses Windows extension server flags and space syntax', () => { + const server = parseAntigravityServerInfo([ + 'node something-unrelated', + 'language_server_windows_x64.exe --app_data_dir C:\\Users\\Admin\\.gemini\\antigravity --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + ]) + + expect(server).toEqual({ + port: 62300, + csrfToken: 'fedcba98-7654-3210-fedc-ba9876543210', + }) + }) + + it('parses quoted flag values', () => { + const server = parseAntigravityServerInfoFromLine( + 'Antigravity language_server_windows_x64.exe --extension_server_port "62301" --extension_server_csrf_token "fedcba98-7654-3210-fedc-ba9876543211"', + ) + + expect(server).toEqual({ + port: 62301, + csrfToken: 'fedcba98-7654-3210-fedc-ba9876543211', + }) + }) + + it('normalizes app_data_dir from app and CLI process args', () => { + expect(extractAntigravityAppDataDirFromLine( + 'language_server --app_data_dir antigravity --https_server_port 0 --csrf_token 01234567-89ab-cdef-0123-456789abcdef', + )).toBe('antigravity') + + expect(extractAntigravityAppDataDirFromLine( + 'language_server --app_data_dir /Users/dev/.gemini/antigravity-cli --https_server_port 0 --csrf_token 01234567-89ab-cdef-0123-456789abcdef', + )).toBe('antigravity-cli') + + expect(extractAntigravityAppDataDirFromLine( + 'language_server.exe --app_data_dir "C:\\Users\\Admin\\.gemini\\antigravity-cli" --extension_server_port 62225 --extension_server_csrf_token abcdef01-2345-6789-abcd-ef0123456789', + )).toBe('antigravity-cli') + + expect(extractAntigravityAppDataDirFromLine( + 'language_server_windows_x64.exe --app_data_dir antigravity-ide --extension_server_port 8720 --extension_server_csrf_token 39800f1b-343a-40b0-8eb5-850702450346', + )).toBe('antigravity-ide') + }) + + it('accepts Antigravity 2 ephemeral port zero', () => { + const server = parseAntigravityServerInfoFromLine( + 'antigravity language_server_macos_arm --https_server_port 0 --csrf_token 01234567-89ab-cdef-0123-456789abcdef', + ) + + expect(server).toEqual({ + port: 0, + csrfToken: '01234567-89ab-cdef-0123-456789abcdef', + }) + }) + + it('matches language-server and antigravity markers case-insensitively', () => { + const server = parseAntigravityServerInfoFromLine( + 'ANTIGRAVITY LANGUAGE_SERVER_WINDOWS_X64.EXE --extension_server_port 62302 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543212', + ) + + expect(server).toEqual({ + port: 62302, + csrfToken: 'fedcba98-7654-3210-fedc-ba9876543212', + }) + }) + + it('ignores process args without an antigravity marker', () => { + expect(parseAntigravityServerInfoFromLine( + 'language_server --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + )).toBeNull() + }) + + it('ignores invalid ports', () => { + expect(parseAntigravityServerInfoFromLine( + 'antigravity language_server --extension_server_port 99999 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + )).toBeNull() + }) + + it('ignores chained flag names as values', () => { + expect(parseAntigravityServerInfoFromLine( + 'antigravity language_server --extension_server_port=--extension_server_csrf_token --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + )).toBeNull() + }) + + it('ignores implausibly short CSRF tokens', () => { + expect(parseAntigravityServerInfoFromLine( + 'antigravity language_server --extension_server_port 62300 --extension_server_csrf_token short', + )).toBeNull() + }) + + it('extracts model maps from wrapped and unwrapped RPC responses', () => { + expect(extractAntigravityModelMap({ + response: { models: { high: { model: 'MODEL_PLACEHOLDER_M7' } } }, + })).toEqual({ MODEL_PLACEHOLDER_M7: 'high' }) + + expect(extractAntigravityModelMap({ + models: { low: { model: 'MODEL_PLACEHOLDER_M8' } }, + })).toEqual({ MODEL_PLACEHOLDER_M8: 'low' }) + expect(extractAntigravityModelMap({ + models: { bad: null, good: { model: 'MODEL_PLACEHOLDER_M9' } }, + })).toEqual({ MODEL_PLACEHOLDER_M9: 'good' }) + expect(extractAntigravityModelMap({ + models: { 'gemini-3-flash-agent': { model: 'MODEL_PLACEHOLDER_M133', displayName: 'Gemini 3.5 Flash (High)' } }, + })).toEqual({ MODEL_PLACEHOLDER_M133: 'gemini-3.5-flash-high' }) + expect(extractAntigravityModelMap(null)).toEqual({}) + }) + + it('extracts generator metadata from wrapped and unwrapped RPC responses', () => { + const metadata = [{ + chatModel: { + model: 'gemini-3-pro', + usage: { + model: 'gemini-3-pro', + inputTokens: '10', + outputTokens: '4', + apiProvider: 'google', + }, + }, + }] + + expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: metadata } })).toEqual(metadata) + expect(extractAntigravityGeneratorMetadata({ generatorMetadata: metadata })).toEqual(metadata) + expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: null } })).toEqual([]) + expect(extractAntigravityGeneratorMetadata(null)).toEqual([]) + }) + + it('derives cascade ids from legacy .pb and Antigravity 2 .db files', () => { + expect(antigravityCascadeIdFromPath('/tmp/123.pb')).toBe('123') + expect(antigravityCascadeIdFromPath('/tmp/456.db')).toBe('456') + expect(antigravityCascadeIdFromPath('/tmp/789.db-wal')).toBe('789.db-wal') + }) + + it('routes app and CLI source paths to matching Antigravity app data dirs', () => { + expect(antigravityAppDataDirFromSourcePath( + '/Users/dev/.gemini/antigravity/conversations/session.db', + )).toBe('antigravity') + + expect(antigravityAppDataDirFromSourcePath( + '/Users/dev/.gemini/antigravity-cli/conversations/session.pb', + )).toBe('antigravity-cli') + + expect(antigravityAppDataDirFromSourcePath( + 'C:\\Users\\Admin\\.gemini\\antigravity-cli\\implicit\\session.pb', + )).toBe('antigravity-cli') + + expect(antigravityAppDataDirFromSourcePath( + '/Users/dev/.gemini/antigravity-ide/conversations/session.db', + )).toBe('antigravity-ide') + + expect(antigravityAppDataDirFromSourcePath( + 'C:\\Users\\Admin\\.gemini\\antigravity-ide\\implicit\\session.pb', + )).toBe('antigravity-ide') + }) + + it('discovers legacy .pb files and Antigravity 2 .db files only', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-')) + + try { + await writeFile(join(dir, 'legacy.pb'), '') + await writeFile(join(dir, 'antigravity-2.db'), '') + await writeFile(join(dir, 'uppercase.DB'), '') + await writeFile(join(dir, 'antigravity-2.db-wal'), '') + await mkdir(join(dir, 'directory.pb')) + + const sources = await discoverAntigravitySessionSources([{ + dir, + project: 'test-project', + extensions: ['.pb', '.db'], + }]) + + expect(sources).toEqual([ + { path: join(dir, 'antigravity-2.db'), project: 'test-project', provider: 'antigravity' }, + { path: join(dir, 'legacy.pb'), project: 'test-project', provider: 'antigravity' }, + { path: join(dir, 'uppercase.DB'), project: 'test-project', provider: 'antigravity' }, + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('discovers antigravity-ide conversation and implicit files', async () => { + const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-home-')) + const conversationsDir = join(tempHome, '.gemini', 'antigravity-ide', 'conversations') + const implicitDir = join(tempHome, '.gemini', 'antigravity-ide', 'implicit') + + await mkdir(conversationsDir, { recursive: true }) + await mkdir(implicitDir, { recursive: true }) + + await writeFile(join(conversationsDir, 'session1.db'), '') + await writeFile(join(implicitDir, 'session2.pb'), '') + + const roots = [ + { + dir: conversationsDir, + project: 'antigravity-ide', + extensions: ['.pb', '.db'] as const, + }, + { + dir: implicitDir, + project: 'antigravity-ide', + extensions: ['.pb'] as const, + }, + ] + + const sources = await discoverAntigravitySessionSources(roots) + expect(sources).toEqual([ + { path: join(conversationsDir, 'session1.db'), project: 'antigravity-ide', provider: 'antigravity' }, + { path: join(implicitDir, 'session2.pb'), project: 'antigravity-ide', provider: 'antigravity' }, + ]) + + await rm(tempHome, { recursive: true, force: true }) + }) + + it('displays Gemini 3.5 Flash thinking variants as the base model', () => { + const provider = createAntigravityProvider() + + expect(provider.modelDisplayName('gemini-3.5-flash')).toBe('Gemini 3.5 Flash') + expect(provider.modelDisplayName('gemini-3.5-flash-high')).toBe('Gemini 3.5 Flash') + expect(provider.modelDisplayName('gemini-3.5-flash-medium')).toBe('Gemini 3.5 Flash') + expect(provider.modelDisplayName('gemini-3.5-flash-low')).toBe('Gemini 3.5 Flash') + expect(provider.modelDisplayName('Gemini 3.5 Flash (High)')).toBe('Gemini 3.5 Flash') + }) + + it('captures exact Antigravity CLI statusLine usage as fallback calls', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-')) + process.env['CODEBURN_CACHE_DIR'] = dir + + try { + const payload = { + conversation_id: 'ce061468-2e2b-4c6f-bf4f-e072bd5fa986', + session_id: 'session-1', + cwd: '/workspace/project', + model: { + id: 'Gemini 3.5 Flash (High)', + display_name: 'Gemini 3.5 Flash (High)', + }, + context_window: { + current_usage: { + input_tokens: 28407, + output_tokens: 137, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } + + expect(await recordAntigravityStatusLinePayload(payload)).toBe(true) + expect(await recordAntigravityStatusLinePayload(payload)).toBe(true) + + const recorded = await readFile(getAntigravityStatusLineEventsPath(), 'utf-8') + expect(recorded).not.toContain('/workspace/project') + expect(JSON.parse(recorded.split(/\r?\n/)[0]!)).not.toHaveProperty('cwd') + + const source = { + path: getAntigravityStatusLineEventsPath(), + project: 'antigravity-cli', + provider: 'antigravity', + } + + const parser = createAntigravityProvider().createSessionParser(source, new Set()) + const calls = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + provider: 'antigravity', + model: 'Gemini 3.5 Flash (High)', + inputTokens: 28407, + outputTokens: 137, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + sessionId: 'ce061468-2e2b-4c6f-bf4f-e072bd5fa986', + project: 'antigravity-cli', + }) + expect(calls[0]!.projectPath).toBeUndefined() + expect(calls[0]!.costUSD).toBeGreaterThan(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('skips statusLine fallback calls when RPC cache already covered the conversation', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-rpc-dedup-')) + process.env['CODEBURN_CACHE_DIR'] = dir + + try { + expect(await recordAntigravityStatusLinePayload({ + conversation_id: 'rpc-covered-conversation', + session_id: 'session-1', + model: 'Gemini 3.5 Flash (High)', + context_window: { + current_usage: { + input_tokens: 1000, + output_tokens: 100, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + })).toBe(true) + + const parser = createAntigravityProvider().createSessionParser({ + path: getAntigravityStatusLineEventsPath(), + project: 'antigravity-cli', + provider: 'antigravity', + }, new Set(['antigravity:rpc-covered-conversation:0'])) + + const calls = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toEqual([]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('skips singleton statusLine snapshots and deltas monotonic usage', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-runs-')) + process.env['CODEBURN_CACHE_DIR'] = dir + + const basePayload = { + conversation_id: 'statusline-runs', + session_id: 'session-1', + model: 'Gemini 3.5 Flash (High)', + } + + const withUsage = ( + input_tokens: number, + output_tokens: number, + cache_read_input_tokens = 0, + ) => ({ + ...basePayload, + context_window: { + current_usage: { + input_tokens, + output_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens, + }, + }, + }) + + try { + expect(await recordAntigravityStatusLinePayload(withUsage(100, 10))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(200, 20))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(200, 20))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(300, 30, 50))).toBe(true) + + const parser = createAntigravityProvider().createSessionParser({ + path: getAntigravityStatusLineEventsPath(), + project: 'antigravity-cli', + provider: 'antigravity', + }, new Set()) + + const calls = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toHaveLength(2) + expect(calls.map(call => [call.inputTokens, call.outputTokens, call.cacheReadInputTokens])).toEqual([ + [200, 20, 0], + [100, 10, 50], + ]) + expect(calls.map(call => call.cachedInputTokens)).toEqual([0, 0]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('treats non-monotonic statusLine usage as a new request snapshot', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-reset-')) + process.env['CODEBURN_CACHE_DIR'] = dir + + const payload = ( + input_tokens: number, + output_tokens: number, + cache_read_input_tokens = 0, + ) => ({ + conversation_id: 'statusline-reset', + session_id: 'session-1', + model: 'Gemini 3.5 Flash (High)', + context_window: { + current_usage: { + input_tokens, + output_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens, + }, + }, + }) + + try { + expect(await recordAntigravityStatusLinePayload(payload(1000, 100))).toBe(true) + expect(await recordAntigravityStatusLinePayload(payload(1000, 100))).toBe(true) + expect(await recordAntigravityStatusLinePayload(payload(200, 30, 500))).toBe(true) + + const parser = createAntigravityProvider().createSessionParser({ + path: getAntigravityStatusLineEventsPath(), + project: 'antigravity-cli', + provider: 'antigravity', + }, new Set()) + + const calls = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toHaveLength(2) + expect(calls.map(call => [call.inputTokens, call.outputTokens, call.cacheReadInputTokens])).toEqual([ + [1000, 100, 0], + [200, 30, 500], + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('always reparses append-only statusLine sources but not unchanged cached cascades', () => { + const statusLinePath = getAntigravityStatusLineEventsPath() + + expect(shouldReparseAntigravitySource(statusLinePath, 1)).toBe(true) + expect(shouldReparseAntigravitySource('/tmp/antigravity/conversation.pb', 0)).toBe(true) + expect(shouldReparseAntigravitySource('/tmp/antigravity/conversation.pb', 1)).toBe(false) + }) + + it('parses current Antigravity CLI SQLite conversations with non-zero token usage', async () => { + if (!isSqliteAvailable()) return + + const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-current-cli-')) + const cacheDir = join(tempHome, 'cache') + const previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + process.env['CODEBURN_CACHE_DIR'] = cacheDir + + try { + const fixture = JSON.parse(await readFile( + new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url), + 'utf-8', + )) as CurrentCliFixture + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + const logsDir = join( + tempHome, + '.gemini', + 'antigravity-cli', + 'brain', + fixture.conversationId, + '.system_generated', + 'logs', + ) + + await mkdir(conversationsDir, { recursive: true }) + await mkdir(logsDir, { recursive: true }) + await writeFile( + join(logsDir, 'transcript.jsonl'), + await readFile( + new URL( + '../fixtures/antigravity-cli-current/brain/fixture-current-cli/.system_generated/logs/transcript.jsonl', + import.meta.url, + ), + 'utf-8', + ), + ) + + const dbPath = join(conversationsDir, `${fixture.conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, fixture) + + const sources = await discoverAntigravitySessionSources([{ + dir: conversationsDir, + project: 'antigravity-cli', + extensions: ['.pb', '.db'], + }]) + expect(sources).toEqual([{ path: dbPath, project: 'antigravity-cli', provider: 'antigravity' }]) + + const calls = await collectAntigravityCalls(sources[0]!) + + expect(calls.length).toBeGreaterThanOrEqual(1) + expect(calls[0]).toMatchObject({ + provider: 'antigravity', + model: 'gemini-3.1-pro-high', + inputTokens: 30265, + outputTokens: 659, + reasoningTokens: 71, + sessionId: fixture.conversationId, + project: 'antigravity-cli', + }) + expect(calls[0]!.projectPath).toBeUndefined() + expect(calls[0]!.costUSD).toBeGreaterThan(0) + } finally { + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir + await rm(tempHome, { recursive: true, force: true }) + } + }) + + it('deduplicates current SQLite rows against RPC response ids with hyphens', async () => { + if (!isSqliteAvailable()) return + + const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-current-cli-dedup-')) + const cacheDir = join(tempHome, 'cache') + const previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + process.env['CODEBURN_CACHE_DIR'] = cacheDir + + try { + const fixture = JSON.parse(await readFile( + new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url), + 'utf-8', + )) as CurrentCliFixture + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + + await mkdir(conversationsDir, { recursive: true }) + + const dbPath = join(conversationsDir, `${fixture.conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, fixture) + + const parser = createAntigravityProvider().createSessionParser({ + path: dbPath, + project: 'antigravity-cli', + provider: 'antigravity', + }, new Set([`antigravity:${fixture.conversationId}:fixture-response-1`])) + const calls = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toEqual([]) + } finally { + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir + await rm(tempHome, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/providers/claude-config-dirs.test.ts b/tests/providers/claude-config-dirs.test.ts new file mode 100644 index 0000000..b6abcb5 --- /dev/null +++ b/tests/providers/claude-config-dirs.test.ts @@ -0,0 +1,377 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { delimiter as pathDelimiter, join } from 'path' +import { tmpdir, homedir } from 'os' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { claude } from '../../src/providers/claude.js' +import { clearSessionCache, filterProjectsByClaudeConfigSource, parseAllSessions } from '../../src/parser.js' + +let tmpRoot: string +const savedEnv = { + CLAUDE_CONFIG_DIR: process.env['CLAUDE_CONFIG_DIR'], + CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'], + HOME: process.env['HOME'], +} + +beforeEach(async () => { + clearSessionCache() + tmpRoot = await mkdtemp(join(tmpdir(), 'codeburn-claude-multi-')) + // Point HOME at a scratch dir so the default `~/.claude` fallback resolves + // somewhere we control. Without this, a stray `~/.claude` on the test + // machine could leak into discovery. + process.env['HOME'] = join(tmpRoot, 'home') + await mkdir(process.env['HOME'], { recursive: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CLAUDE_CONFIG_DIRS'] +}) + +afterEach(async () => { + clearSessionCache() + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + await rm(tmpRoot, { recursive: true, force: true }) +}) + +async function makeConfigDir(name: string, projectSlugs: string[]): Promise<string> { + const dir = join(tmpRoot, name) + for (const slug of projectSlugs) { + const projectDir = join(dir, 'projects', slug) + await mkdir(projectDir, { recursive: true }) + // Discovery only checks for the project subdirectory. A real session + // file is not required; the parser is exercised separately below. + } + return dir +} + +async function writeSession(configDir: string, slug: string, sessionId: string, lines: string[]): Promise<void> { + const dir = join(configDir, 'projects', slug) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, `${sessionId}.jsonl`), lines.join('\n')) +} + +function summaryLine(sessionId: string, cwd: string): string { + return JSON.stringify({ + type: 'summary', + summary: 'test', + leafUuid: 'l', + sessionId, + cwd, + timestamp: '2026-05-09T00:00:00.000Z', + }) +} + +function userLine(uuid: string, sessionId: string, cwd: string, text: string): string { + return JSON.stringify({ + type: 'user', + uuid, + sessionId, + cwd, + timestamp: '2026-05-09T00:00:01.000Z', + message: { role: 'user', content: text }, + }) +} + +function assistantLine(uuid: string, parentUuid: string, sessionId: string, cwd: string): string { + return JSON.stringify({ + type: 'assistant', + uuid, + parentUuid, + sessionId, + cwd, + timestamp: '2026-05-09T00:00:02.000Z', + message: { + id: `msg_${uuid}`, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'reply' }], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) +} + +describe('claude provider — CLAUDE_CONFIG_DIRS discovery', () => { + it('falls back to ~/.claude when no env var is set', async () => { + const homeDir = process.env['HOME']! + await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true }) + + const sources = await claude.discoverSessions() + const projectDirs = sources.map(s => s.path) + expect(projectDirs).toContain(join(homeDir, '.claude', 'projects', '-Users-you-app')) + }) + + it('honors CLAUDE_CONFIG_DIR for a single override', async () => { + const dir = await makeConfigDir('claude-work', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIR'] = dir + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true) + // The default `~/.claude` should NOT also be scanned when the override is set. + expect(sources.every(s => !s.path.startsWith(join(process.env['HOME']!, '.claude')))).toBe(true) + }) + + it('CLAUDE_CONFIG_DIRS overrides CLAUDE_CONFIG_DIR and walks every dir in the list', async () => { + const work = await makeConfigDir('claude-work', ['-Users-you-app']) + const personal = await makeConfigDir('claude-personal', ['-Users-you-app']) + const single = await makeConfigDir('claude-other', ['-Users-you-other']) + + process.env['CLAUDE_CONFIG_DIR'] = single + process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter) + + const sources = await claude.discoverSessions() + const paths = sources.map(s => s.path) + expect(paths).toContain(join(work, 'projects', '-Users-you-app')) + expect(paths).toContain(join(personal, 'projects', '-Users-you-app')) + // CLAUDE_CONFIG_DIR should be ignored once CLAUDE_CONFIG_DIRS is non-empty. + expect(paths.some(p => p.startsWith(single))).toBe(false) + }) + + it('emits the same project name for the same slug across dirs (so parser merges)', async () => { + const work = await makeConfigDir('claude-work', ['-Users-you-app']) + const personal = await makeConfigDir('claude-personal', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter) + + const sources = await claude.discoverSessions() + const ourSources = sources.filter(s => + s.path === join(work, 'projects', '-Users-you-app') || + s.path === join(personal, 'projects', '-Users-you-app'), + ) + expect(ourSources).toHaveLength(2) + expect(new Set(ourSources.map(s => s.project))).toEqual(new Set(['-Users-you-app'])) + expect(new Set(ourSources.map(s => s.sourceKind))).toEqual(new Set(['claude-config'])) + expect(new Set(ourSources.map(s => s.sourceLabel))).toEqual(new Set(['claude-work', 'claude-personal'])) + expect(ourSources.every(s => typeof s.sourceId === 'string' && s.sourceId.startsWith('claude-config:'))).toBe(true) + }) + + it('tolerates a non-existent dir in the list without dropping the real ones', async () => { + const real = await makeConfigDir('claude-real', ['-Users-you-app']) + const fake = join(tmpRoot, 'does-not-exist') + process.env['CLAUDE_CONFIG_DIRS'] = [real, fake].join(pathDelimiter) + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true) + }) + + it('dedupes when the same dir appears twice in CLAUDE_CONFIG_DIRS', async () => { + const dir = await makeConfigDir('claude-once', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIRS'] = [dir, dir].join(pathDelimiter) + + const sources = await claude.discoverSessions() + const ourSources = sources.filter(s => s.path === join(dir, 'projects', '-Users-you-app')) + expect(ourSources).toHaveLength(1) + }) + + it('skips empty entries (leading, trailing, doubled delimiters)', async () => { + const dir = await makeConfigDir('claude-only', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIRS'] = `${pathDelimiter}${dir}${pathDelimiter}${pathDelimiter}` + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true) + }) + + it('expands ~ in CLAUDE_CONFIG_DIR', async () => { + const homeDir = process.env['HOME']! + await mkdir(join(homeDir, 'custom-claude', 'projects', '-Users-you-app'), { recursive: true }) + process.env['CLAUDE_CONFIG_DIR'] = '~/custom-claude' + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(homeDir, 'custom-claude', 'projects', '-Users-you-app'))).toBe(true) + }) + + it('falls back to CLAUDE_CONFIG_DIR when CLAUDE_CONFIG_DIRS is set but empty', async () => { + const single = await makeConfigDir('claude-fallback', ['-Users-you-app']) + process.env['CLAUDE_CONFIG_DIR'] = single + process.env['CLAUDE_CONFIG_DIRS'] = '' + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(single, 'projects', '-Users-you-app'))).toBe(true) + }) + + it('skips entries that point at a file rather than a directory', async () => { + const real = await makeConfigDir('claude-real', ['-Users-you-app']) + const filePath = join(tmpRoot, 'not-a-dir.txt') + await writeFile(filePath, 'this is not a config dir') + process.env['CLAUDE_CONFIG_DIRS'] = [real, filePath].join(pathDelimiter) + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true) + expect(sources.every(s => !s.path.startsWith(filePath))).toBe(true) + }) +}) + +describe('claude provider — config.json claudeConfigDirs (menubar-driven)', () => { + async function writeConfigJson(value: unknown): Promise<void> { + const dir = join(process.env['HOME']!, '.config', 'codeburn') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'config.json'), JSON.stringify({ claudeConfigDirs: value })) + } + + it('honors claudeConfigDirs from config.json when no env var is set', async () => { + const work = await makeConfigDir('claude-work', ['-Users-you-app']) + const personal = await makeConfigDir('claude-personal', ['-Users-you-app']) + await writeConfigJson([work, personal]) + + const sources = await claude.discoverSessions() + const paths = sources.map(s => s.path) + expect(paths).toContain(join(work, 'projects', '-Users-you-app')) + expect(paths).toContain(join(personal, 'projects', '-Users-you-app')) + }) + + it('lets env CLAUDE_CONFIG_DIRS override config.json', async () => { + const fromEnv = await makeConfigDir('claude-env', ['-Users-you-app']) + const fromFile = await makeConfigDir('claude-file', ['-Users-you-app']) + await writeConfigJson([fromFile]) + process.env['CLAUDE_CONFIG_DIRS'] = fromEnv + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(fromEnv, 'projects', '-Users-you-app'))).toBe(true) + expect(sources.every(s => !s.path.startsWith(fromFile))).toBe(true) + }) + + it('lets env CLAUDE_CONFIG_DIR override config.json', async () => { + const fromEnv = await makeConfigDir('claude-env', ['-Users-you-app']) + const fromFile = await makeConfigDir('claude-file', ['-Users-you-app']) + await writeConfigJson([fromFile]) + process.env['CLAUDE_CONFIG_DIR'] = fromEnv + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(fromEnv, 'projects', '-Users-you-app'))).toBe(true) + expect(sources.every(s => !s.path.startsWith(fromFile))).toBe(true) + }) + + it('falls back to ~/.claude when config.json claudeConfigDirs is empty', async () => { + const homeDir = process.env['HOME']! + await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true }) + await writeConfigJson([]) + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(homeDir, '.claude', 'projects', '-Users-you-app'))).toBe(true) + }) + + it('expands ~ in config.json entries', async () => { + const homeDir = process.env['HOME']! + await mkdir(join(homeDir, 'cfg-claude', 'projects', '-Users-you-app'), { recursive: true }) + await writeConfigJson(['~/cfg-claude']) + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(homeDir, 'cfg-claude', 'projects', '-Users-you-app'))).toBe(true) + }) + + it('ignores non-string and blank entries in config.json', async () => { + const real = await makeConfigDir('claude-real', ['-Users-you-app']) + await writeConfigJson([real, 42, '', ' ', null]) + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true) + }) + + it('falls back to ~/.claude when claudeConfigDirs is not an array', async () => { + const homeDir = process.env['HOME']! + await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true }) + await writeConfigJson('not-an-array') + + const sources = await claude.discoverSessions() + expect(sources.some(s => s.path === join(homeDir, '.claude', 'projects', '-Users-you-app'))).toBe(true) + }) +}) + +describe('claude parser — multi-dir aggregation (issue #208 option 1)', () => { + it('merges sessions from two config dirs into a single ProjectSummary when the canonical cwd matches', async () => { + const work = await makeConfigDir('claude-work', []) + const personal = await makeConfigDir('claude-personal', []) + process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter) + + // Both accounts touch the same real project path. Same cwd -> same merge key. + const slug = '-Users-you-shared-app' + const cwd = '/Users/you/shared-app' + await writeSession(work, slug, 'sess-work', [ + summaryLine('sess-work', cwd), + userLine('u1', 'sess-work', cwd, 'hi from work'), + assistantLine('a1', 'u1', 'sess-work', cwd), + ]) + await writeSession(personal, slug, 'sess-personal', [ + summaryLine('sess-personal', cwd), + userLine('u2', 'sess-personal', cwd, 'hi from personal'), + assistantLine('a2', 'u2', 'sess-personal', cwd), + ]) + + const projects = await parseAllSessions(undefined, 'claude') + const matches = projects.filter(p => p.project === slug) + expect(matches).toHaveLength(1) + expect(matches[0]!.totalApiCalls).toBe(2) + // Two sessions, one from each dir, both rolled up. + expect(matches[0]!.sessions.map(s => s.sessionId).sort()).toEqual(['sess-personal', 'sess-work']) + // No `account` or `accountPath` field should appear on the ProjectSummary + // — option 1 explicitly avoids attribution. + expect((matches[0]! as Record<string, unknown>)['account']).toBeUndefined() + expect((matches[0]! as Record<string, unknown>)['accountPath']).toBeUndefined() + }) + + it('keeps source metadata on merged sessions so one Claude config can be selected', async () => { + const work = await makeConfigDir('claude-work', []) + const personal = await makeConfigDir('claude-personal', []) + process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter) + + const slug = '-Users-you-shared-app' + const cwd = '/Users/you/shared-app' + await writeSession(work, slug, 'sess-work', [ + summaryLine('sess-work', cwd), + userLine('u1', 'sess-work', cwd, 'hi from work'), + assistantLine('a1', 'u1', 'sess-work', cwd), + ]) + await writeSession(personal, slug, 'sess-personal', [ + summaryLine('sess-personal', cwd), + userLine('u2', 'sess-personal', cwd, 'hi from personal'), + assistantLine('a2', 'u2', 'sess-personal', cwd), + ]) + + const projects = await parseAllSessions(undefined, 'claude') + const merged = projects.find(p => p.project === slug) + expect(merged).toBeDefined() + expect(merged!.sessions).toHaveLength(2) + + const sourceIds = new Map(merged!.sessions.map(s => [s.source?.label, s.source?.id])) + const workSourceId = sourceIds.get('claude-work') + expect(workSourceId).toBeDefined() + + const workOnly = filterProjectsByClaudeConfigSource(projects, workSourceId!) + expect(workOnly).toHaveLength(1) + expect(workOnly[0]!.project).toBe(slug) + expect(workOnly[0]!.totalApiCalls).toBe(1) + expect(workOnly[0]!.sessions.map(s => s.sessionId)).toEqual(['sess-work']) + expect(workOnly[0]!.sessions[0]!.source?.label).toBe('claude-work') + }) + + // Documents the path-aware merge behavior: the mergedMap in parseAllSessions + // now keys by normalized cwd path (crossProviderKey), not by slug. Two dirs + // can share the same slug but have different underlying cwds — those stay + // separate because they represent genuinely different repositories. In real + // Claude usage different cwds always produce different slugs anyway, so this + // scenario is contrived, but the test pins the new behavior explicitly. + it('keeps sessions with the same slug but different cwds as separate projects', async () => { + const work = await makeConfigDir('claude-work', []) + const personal = await makeConfigDir('claude-personal', []) + process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter) + + const slug = '-Users-you-app' + await writeSession(work, slug, 'sess-work', [ + summaryLine('sess-work', '/Users/you/work-app'), + userLine('u1', 'sess-work', '/Users/you/work-app', 'work'), + assistantLine('a1', 'u1', 'sess-work', '/Users/you/work-app'), + ]) + await writeSession(personal, slug, 'sess-personal', [ + summaryLine('sess-personal', '/Users/you/personal-app'), + userLine('u2', 'sess-personal', '/Users/you/personal-app', 'personal'), + assistantLine('a2', 'u2', 'sess-personal', '/Users/you/personal-app'), + ]) + + const projects = await parseAllSessions(undefined, 'claude') + const matches = projects.filter(p => p.project === slug) + // Different cwds → different crossProviderKey → two separate project rows. + expect(matches).toHaveLength(2) + expect(matches.every(m => m.totalApiCalls === 1)).toBe(true) + }) +}) diff --git a/tests/providers/cline.test.ts b/tests/providers/cline.test.ts new file mode 100644 index 0000000..d739b96 --- /dev/null +++ b/tests/providers/cline.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { cline, createClineProvider } from '../../src/providers/cline.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +async function writeTask(baseDir: string, taskId: string, opts?: { + tokensIn?: number + tokensOut?: number + model?: string + userMessage?: string + cost?: number +}): Promise<string> { + const taskDir = join(baseDir, 'tasks', taskId) + await mkdir(taskDir, { recursive: true }) + + const messages: unknown[] = [] + if (opts?.userMessage) { + messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1700000000000 }) + } + const usage: Record<string, unknown> = { + tokensIn: opts?.tokensIn ?? 100, + tokensOut: opts?.tokensOut ?? 50, + } + if (opts?.cost !== undefined) usage.cost = opts.cost + messages.push({ type: 'say', say: 'api_req_started', text: JSON.stringify(usage), ts: 1700000001000 }) + + const modelTag = opts?.model ? `<model>${opts.model}</model>` : '' + const history = [ + { role: 'user', content: [{ type: 'text', text: `hello\n<environment_details>\n${modelTag}\n</environment_details>` }] }, + ] + + await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify(messages)) + await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify(history)) + + return taskDir +} + +describe('cline provider - discovery', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers Cline tasks from VS Code globalStorage and home data roots', async () => { + const vscodeDir = join(tmpDir, 'globalStorage') + const homeDataDir = join(tmpDir, 'cline-data') + await writeTask(vscodeDir, 'task-vscode') + await writeTask(homeDataDir, 'task-home') + + const provider = createClineProvider([vscodeDir, homeDataDir]) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.map(s => s.provider)).toEqual(['cline', 'cline']) + expect(sessions.map(s => s.project)).toEqual(['Cline', 'Cline']) + expect(sessions.map(s => s.path).sort()).toEqual([ + join(homeDataDir, 'tasks', 'task-home'), + join(vscodeDir, 'tasks', 'task-vscode'), + ].sort()) + }) + + it('deduplicates the same task id across roots by keeping the newest task directory', async () => { + const vscodeDir = join(tmpDir, 'globalStorage') + const homeDataDir = join(tmpDir, 'cline-data') + const oldTask = await writeTask(vscodeDir, 'task-same') + const newTask = await writeTask(homeDataDir, 'task-same') + await utimes(join(oldTask, 'ui_messages.json'), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z')) + await utimes(join(newTask, 'ui_messages.json'), new Date('2026-02-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z')) + + const provider = createClineProvider([vscodeDir, homeDataDir]) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toBe(newTask) + }) + + it('skips task directories without ui_messages.json', async () => { + const vscodeDir = join(tmpDir, 'globalStorage') + await mkdir(join(vscodeDir, 'tasks', 'task-no-ui'), { recursive: true }) + + const provider = createClineProvider(vscodeDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(0) + }) +}) + +describe('cline provider - parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses Cline usage with cline provider identity', async () => { + const taskDir = await writeTask(tmpDir, 'task-parse', { + tokensIn: 200, + tokensOut: 100, + model: 'anthropic/claude-sonnet-4-5', + userMessage: 'build the feature', + cost: 0.07, + }) + + const source = { path: taskDir, project: 'Cline', provider: 'cline' } + const calls: ParsedProviderCall[] = [] + for await (const call of cline.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.provider).toBe('cline') + expect(calls[0]!.model).toBe('claude-sonnet-4-5') + expect(calls[0]!.inputTokens).toBe(200) + expect(calls[0]!.outputTokens).toBe(100) + expect(calls[0]!.costUSD).toBe(0.07) + expect(calls[0]!.userMessage).toBe('build the feature') + expect(calls[0]!.deduplicationKey).toMatch(/^cline:task-parse:/) + }) +}) + +describe('cline provider - metadata', () => { + it('has correct name and displayName', () => { + expect(cline.name).toBe('cline') + expect(cline.displayName).toBe('Cline') + }) + + it('passes through model and tool display names', () => { + expect(cline.modelDisplayName('claude-sonnet-4-5')).toBe('claude-sonnet-4-5') + expect(cline.toolDisplayName('read_file')).toBe('read_file') + }) +}) diff --git a/tests/providers/codebuff.test.ts b/tests/providers/codebuff.test.ts new file mode 100644 index 0000000..eb6b234 --- /dev/null +++ b/tests/providers/codebuff.test.ts @@ -0,0 +1,480 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createCodebuffProvider } from '../../src/providers/codebuff.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codebuff-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +type ToolBlock = { + type: 'tool' + toolName: string + input?: Record<string, unknown> +} + +type TextBlock = { type: 'text'; content: string } + +type Block = ToolBlock | TextBlock + +type AiOpts = { + id?: string + credits?: number + timestamp?: string + blocks?: Block[] + metadata?: Record<string, unknown> +} + +function aiMessage(opts: AiOpts = {}) { + const m: Record<string, unknown> = { + id: opts.id ?? 'msg-ai-1', + variant: 'ai', + content: '', + timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z', + } + if (opts.blocks !== undefined) m['blocks'] = opts.blocks + if (opts.credits !== undefined) m['credits'] = opts.credits + if (opts.metadata !== undefined) m['metadata'] = opts.metadata + return m +} + +function userMessage(content: string, timestamp?: string) { + return { + id: 'msg-user-1', + variant: 'user', + content, + timestamp: timestamp ?? '2026-04-14T10:00:10.000Z', + } +} + +async function writeChat( + baseDir: string, + projectName: string, + chatId: string, + messages: unknown[], + runState?: unknown, +): Promise<string> { + const chatDir = join(baseDir, 'projects', projectName, 'chats', chatId) + await mkdir(chatDir, { recursive: true }) + await writeFile(join(chatDir, 'chat-messages.json'), JSON.stringify(messages)) + if (runState !== undefined) { + await writeFile(join(chatDir, 'run-state.json'), JSON.stringify(runState)) + } + return chatDir +} + +describe('codebuff provider - session discovery', () => { + it('discovers sessions under projects/<name>/chats/<chatId>/', async () => { + await writeChat( + tmpDir, + 'myproject', + '2026-04-14T10-00-00.000Z', + [userMessage('hi'), aiMessage({ credits: 10 })], + { sessionState: { projectContext: { cwd: '/Users/test/myproject' } } }, + ) + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('codebuff') + expect(sessions[0]!.project).toBe('myproject') + expect(sessions[0]!.path).toContain('2026-04-14T10-00-00.000Z') + }) + + it('uses the cwd basename from run-state.json when present', async () => { + await writeChat( + tmpDir, + 'sanitized-folder', + '2026-04-14T11-00-00.000Z', + [aiMessage({ credits: 5 })], + { sessionState: { projectContext: { cwd: '/Users/test/real-project' } } }, + ) + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('real-project') + }) + + it('falls back to the folder name when run-state.json is missing', async () => { + await writeChat(tmpDir, 'fallback-project', '2026-04-14T12-00-00.000Z', [ + aiMessage({ credits: 3 }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('fallback-project') + }) + + it('discovers sessions across multiple projects', async () => { + await writeChat(tmpDir, 'proj-a', '2026-04-14T10-00-00.000Z', [aiMessage({ credits: 1 })]) + await writeChat(tmpDir, 'proj-b', '2026-04-14T10-30-00.000Z', [aiMessage({ credits: 2 })]) + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + const projects = sessions.map(s => s.project).sort() + expect(projects).toEqual(['proj-a', 'proj-b']) + }) + + it('returns empty for a non-existent directory', async () => { + const provider = createCodebuffProvider('/nonexistent/codebuff-path') + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('skips chat folders without chat-messages.json', async () => { + const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z') + await mkdir(chatDir, { recursive: true }) + // No chat-messages.json created. + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) +}) + +describe('codebuff provider - JSONL parsing', () => { + it('yields one call per assistant message with credits, mapping codebuff tools to canonical names', async () => { + const chatDir = await writeChat( + tmpDir, + 'proj', + '2026-04-14T10-00-00.000Z', + [ + userMessage('implement the feature'), + aiMessage({ + credits: 42, + metadata: { + runState: { sessionState: { mainAgentState: { agentType: 'base2' } } }, + }, + blocks: [ + { type: 'tool', toolName: 'read_files', input: {} }, + { type: 'tool', toolName: 'str_replace', input: {} }, + { type: 'tool', toolName: 'run_terminal_command', input: { command: 'npm test' } }, + { type: 'tool', toolName: 'suggest_followups', input: {} }, + ], + }), + ], + ) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('codebuff') + expect(call.model).toBe('codebuff-base2') + expect(call.userMessage).toBe('implement the feature') + // `suggest_followups` is intentionally dropped from the tool breakdown. + expect(call.tools).toEqual(['Read', 'Edit', 'Bash']) + expect(call.bashCommands).toContain('npm') + // Credits × $0.01 = $0.42 when token counts are absent. + expect(call.costUSD).toBeCloseTo(0.42, 6) + expect(call.inputTokens).toBe(0) + expect(call.outputTokens).toBe(0) + }) + + it('prefers direct metadata.usage tokens when available and still records credits', async () => { + const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + aiMessage({ + credits: 10, + metadata: { + model: 'claude-haiku-4-5-20251001', + usage: { + inputTokens: 5000, + outputTokens: 2000, + cacheCreationInputTokens: 1000, + cacheReadInputTokens: 500, + }, + }, + }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.model).toBe('claude-haiku-4-5-20251001') + expect(call.inputTokens).toBe(5000) + expect(call.outputTokens).toBe(2000) + expect(call.cacheCreationInputTokens).toBe(1000) + expect(call.cacheReadInputTokens).toBe(500) + expect(call.cachedInputTokens).toBe(500) + // With real token counts the calculated cost takes precedence over credits. + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('falls back to providerOptions.codebuff.usage in the stashed RunState history', async () => { + const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + aiMessage({ + credits: 7, + metadata: { + runState: { + sessionState: { + mainAgentState: { + messageHistory: [ + { role: 'user' }, + { + role: 'assistant', + providerOptions: { + codebuff: { + model: 'openai/gpt-4o', + usage: { + prompt_tokens: 2000, + completion_tokens: 800, + prompt_tokens_details: { cached_tokens: 400 }, + }, + }, + }, + }, + ], + }, + }, + }, + }, + }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('openai/gpt-4o') + expect(calls[0]!.inputTokens).toBe(2000) + expect(calls[0]!.outputTokens).toBe(800) + expect(calls[0]!.cacheReadInputTokens).toBe(400) + }) + + it('skips assistant messages with no credits and no tokens', async () => { + const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + aiMessage({ blocks: [{ type: 'text', content: 'mode-divider' }] }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(0) + }) + + it('deduplicates calls seen across multiple parses', async () => { + const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + aiMessage({ id: 'msg-dup', credits: 3 }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const seenKeys = new Set<string>() + + const firstRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + firstRun.push(call) + } + + const secondRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + secondRun.push(call) + } + + expect(firstRun).toHaveLength(1) + expect(secondRun).toHaveLength(0) + }) + + it('yields one call per assistant message in a multi-turn chat, preserving user messages', async () => { + const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + userMessage('first question'), + aiMessage({ id: 'a1', credits: 5, timestamp: '2026-04-14T10:00:30.000Z' }), + userMessage('second question', '2026-04-14T10:01:00.000Z'), + aiMessage({ id: 'a2', credits: 8, timestamp: '2026-04-14T10:01:30.000Z' }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[0]!.costUSD).toBeCloseTo(0.05, 6) + expect(calls[1]!.userMessage).toBe('second question') + expect(calls[1]!.costUSD).toBeCloseTo(0.08, 6) + }) + + it('handles a missing chat-messages.json gracefully', async () => { + const provider = createCodebuffProvider(tmpDir) + const source = { + path: join(tmpDir, 'projects', 'missing', 'chats', 'nope'), + project: 'missing', + provider: 'codebuff', + } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(0) + }) + + it('skips a malformed chat-messages.json without throwing', async () => { + const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z') + await mkdir(chatDir, { recursive: true }) + await writeFile(join(chatDir, 'chat-messages.json'), 'not-valid-json') + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(0) + }) +}) + +describe('codebuff provider - sessionId channel scoping', () => { + it('produces distinct sessionIds for the same chatId across different channel roots', async () => { + const chatId = '2026-04-14T10-00-00.000Z' + const channelA = join(tmpDir, 'manicode') + const channelB = join(tmpDir, 'manicode-dev') + const cwd = '/Users/test/shared-project' + const runState = { sessionState: { projectContext: { cwd } } } + + const chatDirA = await writeChat( + channelA, + 'shared-project', + chatId, + [userMessage('hi'), aiMessage({ credits: 5 })], + runState, + ) + const chatDirB = await writeChat( + channelB, + 'shared-project', + chatId, + [userMessage('hi'), aiMessage({ credits: 5 })], + runState, + ) + + const providerA = createCodebuffProvider(channelA) + const providerB = createCodebuffProvider(channelB) + + const sourceA = { path: chatDirA, project: 'shared-project', provider: 'codebuff' } + const sourceB = { path: chatDirB, project: 'shared-project', provider: 'codebuff' } + + const callsA: ParsedProviderCall[] = [] + for await (const call of providerA.createSessionParser(sourceA, new Set()).parse()) { + callsA.push(call) + } + const callsB: ParsedProviderCall[] = [] + for await (const call of providerB.createSessionParser(sourceB, new Set()).parse()) { + callsB.push(call) + } + + expect(callsA).toHaveLength(1) + expect(callsB).toHaveLength(1) + // The whole point of the fix: same chatId + same project should NOT + // collapse into a single session when the chats live under different + // channel roots. + expect(callsA[0]!.sessionId).not.toBe(callsB[0]!.sessionId) + expect(callsA[0]!.sessionId).toBe(`manicode/${chatId}`) + expect(callsB[0]!.sessionId).toBe(`manicode-dev/${chatId}`) + // The sessionId must not contain ':' -- src/parser.ts keys sessions as + // `${provider}:${sessionId}:${project}` and reconstructs the session via + // `key.split(':')[1]`, so a colon would truncate the id downstream. + expect(callsA[0]!.sessionId).not.toContain(':') + expect(callsB[0]!.sessionId).not.toContain(':') + }) + + it('includes the channel name in the sessionId', async () => { + const chatId = '2026-04-14T10-00-00.000Z' + const channelRoot = join(tmpDir, 'manicode-staging') + const chatDir = await writeChat(channelRoot, 'proj', chatId, [aiMessage({ credits: 3 })]) + + const provider = createCodebuffProvider(channelRoot) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe(`manicode-staging/${chatId}`) + expect(calls[0]!.sessionId).not.toContain(':') + }) + + it('falls back to the chatId when the path does not match the expected structure', async () => { + const chatId = '2026-04-14T10-00-00.000Z' + // Not the canonical <channel>/projects/<proj>/chats/<chatId> layout. + const chatDir = join(tmpDir, 'oddly-shaped', chatId) + await mkdir(chatDir, { recursive: true }) + await writeFile( + join(chatDir, 'chat-messages.json'), + JSON.stringify([aiMessage({ credits: 2 })]), + ) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe(chatId) + }) +}) + +describe('codebuff provider - display names', () => { + const provider = createCodebuffProvider('/tmp') + + it('has the correct identifiers', () => { + expect(provider.name).toBe('codebuff') + expect(provider.displayName).toBe('Codebuff') + }) + + it('maps known Codebuff tiers to readable names', () => { + expect(provider.modelDisplayName('codebuff')).toBe('Codebuff') + expect(provider.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2') + expect(provider.modelDisplayName('codebuff-lite')).toBe('Codebuff Lite') + }) + + it('returns the raw name for unknown models', () => { + expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6') + }) + + it('normalizes tool names to the canonical set', () => { + expect(provider.toolDisplayName('read_files')).toBe('Read') + expect(provider.toolDisplayName('str_replace')).toBe('Edit') + expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash') + expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) +}) diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts new file mode 100644 index 0000000..86f1176 --- /dev/null +++ b/tests/providers/codex.test.ts @@ -0,0 +1,593 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createCodexProvider } from '../../src/providers/codex.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codex-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function sessionMeta(opts: { cwd?: string; originator?: string; session_id?: string; model?: string; forked_from_id?: string; timestamp?: string } = {}) { + return JSON.stringify({ + type: 'session_meta', + timestamp: opts.timestamp ?? '2026-04-14T10:00:00Z', + payload: { + cwd: opts.cwd ?? '/Users/test/myproject', + originator: opts.originator ?? 'codex-cli', + session_id: opts.session_id ?? 'sess-001', + model: opts.model ?? 'gpt-5.3-codex', + ...(opts.forked_from_id ? { forked_from_id: opts.forked_from_id } : {}), + }, + }) +} + +function tokenCount(opts: { + timestamp?: string + last?: { input?: number; cached?: number; output?: number; reasoning?: number } + total?: { input?: number; cached?: number; output?: number; reasoning?: number; total?: number } + model?: string +}) { + return JSON.stringify({ + type: 'event_msg', + timestamp: opts.timestamp ?? '2026-04-14T10:01:00Z', + payload: { + type: 'token_count', + info: { + model: opts.model, + last_token_usage: opts.last ? { + input_tokens: opts.last.input ?? 0, + cached_input_tokens: opts.last.cached ?? 0, + output_tokens: opts.last.output ?? 0, + reasoning_output_tokens: opts.last.reasoning ?? 0, + total_tokens: (opts.last.input ?? 0) + (opts.last.cached ?? 0) + (opts.last.output ?? 0) + (opts.last.reasoning ?? 0), + } : undefined, + total_token_usage: opts.total ? { + input_tokens: opts.total.input ?? 0, + cached_input_tokens: opts.total.cached ?? 0, + output_tokens: opts.total.output ?? 0, + reasoning_output_tokens: opts.total.reasoning ?? 0, + total_tokens: opts.total.total ?? ((opts.total.input ?? 0) + (opts.total.cached ?? 0) + (opts.total.output ?? 0) + (opts.total.reasoning ?? 0)), + } : undefined, + }, + }, + }) +} + +function functionCall(name: string, timestamp?: string) { + return JSON.stringify({ + type: 'response_item', + timestamp: timestamp ?? '2026-04-14T10:00:30Z', + payload: { type: 'function_call', name }, + }) +} + +function mcpToolCallEnd(server: string, tool: string, timestamp?: string) { + return JSON.stringify({ + type: 'event_msg', + timestamp: timestamp ?? '2026-04-14T10:00:30Z', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call-1', + invocation: { server, tool, arguments: {} }, + duration: '1.2s', + result: { Ok: { content: [] } }, + }, + }) +} + +function userMessage(text: string, timestamp?: string) { + return JSON.stringify({ + type: 'response_item', + timestamp: timestamp ?? '2026-04-14T10:00:00Z', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text }], + }, + }) +} + +async function writeSession(dir: string, date: string, filename: string, lines: string[]) { + const [year, month, day] = date.split('-') + const sessionDir = join(dir, 'sessions', year!, month!, day!) + await mkdir(sessionDir, { recursive: true }) + const filePath = join(sessionDir, filename) + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +describe('codex provider - model display names', () => { + it('maps gpt-5.3-codex-spark to its own label', () => { + const provider = createCodexProvider(tmpDir) + const name = provider.modelDisplayName('gpt-5.3-codex-spark') + expect(name).not.toBe('GPT-5.3 Codex') + expect(name).toBe('GPT-5.3 Codex Spark') + }) + + it('maps gpt-5.3-codex reasoning suffixes to the base label', () => { + const provider = createCodexProvider(tmpDir) + expect(provider.modelDisplayName('gpt-5.3-codex-high')).toBe('GPT-5.3 Codex') + expect(provider.modelDisplayName('gpt-5.3-codex-low')).toBe('GPT-5.3 Codex') + }) +}) + +describe('codex provider - session discovery', () => { + it('discovers sessions in YYYY/MM/DD structure', async () => { + await writeSession(tmpDir, '2026-04-14', 'rollout-abc123.jsonl', [ + sessionMeta({ cwd: '/Users/test/myproject' }), + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('codex') + expect(sessions[0]!.project).toBe('Users-test-myproject') + expect(sessions[0]!.path).toContain('rollout-abc123.jsonl') + }) + + it('returns empty for non-existent directory', async () => { + const provider = createCodexProvider('/nonexistent/path/that/does/not/exist') + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('accepts case-insensitive originator (Codex Desktop)', async () => { + await writeSession(tmpDir, '2026-04-14', 'rollout-desktop.jsonl', [ + sessionMeta({ originator: 'Codex Desktop' }), + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + }) + + it('accepts session_meta lines larger than 16 KB (Codex CLI 0.128+)', async () => { + // Codex CLI 0.128+ embeds the full base_instructions / system prompt in the + // first session_meta line, often pushing it past 20 KB. Regression guard + // against a fixed-size buffer in readFirstLine. + const bigPayload = JSON.stringify({ + type: 'session_meta', + timestamp: '2026-05-02T00:00:00Z', + payload: { + cwd: '/Users/test/big', + originator: 'codex-tui', + session_id: 'sess-big', + model: 'gpt-5.5', + base_instructions: { text: 'x'.repeat(40_000) }, + }, + }) + await writeSession(tmpDir, '2026-05-02', 'rollout-big.jsonl', [ + bigPayload, + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toContain('rollout-big.jsonl') + // Confirm the large meta line was actually parsed (cwd extracted), + // not just that some path was registered. + expect(sessions[0]!.project).toBe('Users-test-big') + }) + + it('handles a session_meta line without trailing newline', async () => { + const [year, month, day] = '2026-05-02'.split('-') + const sessionDir = join(tmpDir, 'sessions', year!, month!, day!) + await mkdir(sessionDir, { recursive: true }) + // Write a single session_meta line, deliberately without a trailing \n. + await writeFile( + join(sessionDir, 'rollout-no-nl.jsonl'), + JSON.stringify({ + type: 'session_meta', + timestamp: '2026-05-02T00:00:00Z', + payload: { + cwd: '/Users/test/nonl', + originator: 'codex-tui', + session_id: 'sess-nonl', + model: 'gpt-5.5', + }, + }), + ) + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('Users-test-nonl') + }) + + it('handles a session_meta line that spans multiple stream chunks', async () => { + // createReadStream defaults to a 64 KiB highWaterMark, so a >64 KiB first + // line forces readline to assemble the line across chunk boundaries. + const bigPayload = JSON.stringify({ + type: 'session_meta', + timestamp: '2026-05-02T00:00:00Z', + payload: { + cwd: '/Users/test/multichunk', + originator: 'codex-tui', + session_id: 'sess-multichunk', + model: 'gpt-5.5', + base_instructions: { text: 'y'.repeat(120_000) }, + }, + }) + await writeSession(tmpDir, '2026-05-02', 'rollout-multichunk.jsonl', [ + bigPayload, + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('Users-test-multichunk') + }) + + it('rejects truncated/torn first-line writes without throwing', async () => { + // Simulate a partial write where Codex started the session_meta object + // but hasn't flushed the rest yet (no closing brace, no newline). + const [year, month, day] = '2026-05-02'.split('-') + const sessionDir = join(tmpDir, 'sessions', year!, month!, day!) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, 'rollout-torn.jsonl'), + '{"type":"session_meta","timestamp":"2026-05-02T00:00:00Z","payload":{"cwd":"/x","originator":"codex-tui","session_id":"s","model":"gpt', + ) + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('returns no sessions for an empty rollout file', async () => { + const [year, month, day] = '2026-05-02'.split('-') + const sessionDir = join(tmpDir, 'sessions', year!, month!, day!) + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'rollout-empty.jsonl'), '') + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('skips files without codex session_meta', async () => { + const [year, month, day] = '2026-04-14'.split('-') + const sessionDir = join(tmpDir, 'sessions', year!, month!, day!) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, 'rollout-bad.jsonl'), + JSON.stringify({ type: 'other', payload: {} }) + '\n', + ) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) +}) + +describe('codex provider - JSONL parsing', () => { + it('extracts token usage from last_token_usage', async () => { + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-parse.jsonl', [ + sessionMeta({ session_id: 'sess-parse', model: 'gpt-5.3-codex' }), + userMessage('fix the bug'), + functionCall('exec_command'), + functionCall('read_file'), + tokenCount({ + timestamp: '2026-04-14T10:01:00Z', + last: { input: 500, cached: 100, output: 200, reasoning: 50 }, + total: { total: 850 }, + }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('codex') + expect(call.model).toBe('gpt-5.3-codex') + expect(call.inputTokens).toBe(400) + expect(call.cachedInputTokens).toBe(100) + expect(call.cacheReadInputTokens).toBe(100) + expect(call.outputTokens).toBe(200) + expect(call.reasoningTokens).toBe(50) + expect(call.tools).toEqual(['Bash', 'Read']) + expect(call.userMessage).toBe('fix the bug') + expect(call.sessionId).toBe('sess-parse') + expect(call.costUSD).toBeGreaterThan(0) + expect(call.deduplicationKey).toContain('codex:') + }) + + it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => { + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [ + sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }), + userMessage('look up the issue'), + mcpToolCallEnd('github', 'get_issue'), + tokenCount({ + timestamp: '2026-04-14T10:01:00Z', + last: { input: 300, output: 100 }, + total: { total: 400 }, + }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['mcp__github__get_issue']) + }) + + it('attributes CLI-wrapped MCP calls (mcp-cli call server tool) to MCP + Bash', async () => { + const execStr = (command: string) => JSON.stringify({ + type: 'response_item', + timestamp: '2026-04-14T10:00:30Z', + payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command }) }, + }) + // command as an array (Codex sometimes logs argv form). + const execArr = (command: string[]) => JSON.stringify({ + type: 'response_item', + timestamp: '2026-04-14T10:00:30Z', + payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command }) }, + }) + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcpcli.jsonl', [ + sessionMeta({ session_id: 'sess-mcpcli', model: 'gpt-5.5' }), + userMessage('look up an issue via the MCP CLI'), + // Real invocation forms that MUST attribute to MCP: + execStr("bash -lc \"mcp-cli call github get_issue '{\\\"id\\\": 5}'\""), // bash -lc wrapper + execStr('mcp-cli -c ./mcp.json call linear list_issues'), // flags before subcommand + execArr(['mcp-cli', 'call', 'slack', 'post_message', '{}']), // argv array form + // Lookups and unrelated commands that must NOT attribute: + execStr('mcp-cli info github'), + execStr('mcp-cli grep "*issue*"'), + execStr('my-mcp-cli-wrapper call github get_issue'), // not the mcp-cli binary + execStr('ls -la'), + tokenCount({ timestamp: '2026-04-14T10:01:00Z', last: { input: 300, output: 100 }, total: { total: 400 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const tools = calls[0]!.tools + // Every exec still counts as Bash (7 exec_commands total). + expect(tools.filter(t => t === 'Bash')).toHaveLength(7) + // Exactly the three `call` invocations attribute to MCP; info/grep/wrapper/ls do not. + expect(tools.filter(t => t.startsWith('mcp__')).sort()).toEqual([ + 'mcp__github__get_issue', + 'mcp__linear__list_issues', + 'mcp__slack__post_message', + ]) + }) + + it('normalizes Codex subagent tool calls to Agent', async () => { + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-agent.jsonl', [ + sessionMeta({ session_id: 'sess-agent', model: 'gpt-5.5' }), + userMessage('delegate the review'), + functionCall('spawn_agent'), + functionCall('wait_agent'), + functionCall('close_agent'), + tokenCount({ + timestamp: '2026-04-14T10:01:00Z', + last: { input: 300, output: 100 }, + total: { total: 400 }, + }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Agent', 'Agent', 'Agent']) + }) + + it('skips duplicate token_count events', async () => { + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-dedup.jsonl', [ + sessionMeta(), + tokenCount({ + timestamp: '2026-04-14T10:01:00Z', + last: { input: 500, output: 200 }, + total: { total: 700 }, + }), + tokenCount({ + timestamp: '2026-04-14T10:01:01Z', + last: { input: 500, output: 200 }, + total: { total: 700 }, + }), + tokenCount({ + timestamp: '2026-04-14T10:02:00Z', + last: { input: 300, output: 100 }, + total: { total: 1100 }, + }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(2) + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[1]!.inputTokens).toBe(300) + }) + + it('does not drop the first event when total_token_usage is omitted (cumulativeTotal=0)', async () => { + // Regression for the prevCumulativeTotal-initialized-to-0 bug. Sessions + // that emit only last_token_usage (no total_token_usage) report + // cumulativeTotal=0 on every event. With a 0-initialized prev, the first + // event matched the dedup guard and was silently dropped, losing the + // session's opening turn. The null sentinel fixes this. + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-zero-total.jsonl', [ + sessionMeta(), + tokenCount({ + timestamp: '2026-04-14T10:01:00Z', + last: { input: 500, output: 200 }, + // No `total` — info.total_token_usage will be undefined. + }), + tokenCount({ + timestamp: '2026-04-14T10:01:01Z', + last: { input: 100, output: 50 }, + }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + + // Both events should produce calls — the first with input=500, second + // with input=100. With the buggy 0-init, only the second would survive + // (or neither, depending on equality timing). + expect(calls.length).toBeGreaterThanOrEqual(1) + expect(calls[0]!.inputTokens).toBe(500) + }) + + it('still dedups consecutive zero-cumulative duplicates', async () => { + // The other half of the regression: two consecutive events with the + // same cumulativeTotal (here both 0 because total_token_usage is + // omitted) and identical last_token_usage must NOT both ingest. The + // second is a duplicate. + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-zero-dup.jsonl', [ + sessionMeta(), + tokenCount({ + timestamp: '2026-04-14T10:01:00Z', + last: { input: 500, output: 200 }, + }), + tokenCount({ + timestamp: '2026-04-14T10:01:01Z', + last: { input: 500, output: 200 }, + }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const parser = provider.createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + expect(calls).toHaveLength(1) + }) +}) + +describe('codex provider - forked session dedupe', () => { + // Aggregate every discovered session through ONE shared seenKeys, exactly as + // the real provider report does, then sum the global token total. + async function aggregateTokens(dir: string): Promise<{ tokens: number; calls: number }> { + const provider = createCodexProvider(dir) + const sessions = (await provider.discoverSessions()).sort((a, b) => (a.path < b.path ? -1 : 1)) + const seenKeys = new Set<string>() + let tokens = 0 + let calls = 0 + for (const s of sessions) { + for await (const c of provider.createSessionParser(s, seenKeys).parse()) { + calls++ + tokens += c.inputTokens + c.outputTokens + c.cachedInputTokens + c.reasoningTokens + } + } + return { tokens, calls } + } + + it('does not double-count a fork that replays the parent past the 5s cutoff', async () => { + // Parent does 1100 tokens of real work. The fork replays both events with + // timestamps well beyond the 5s fork cutoff, then adds one genuine event + // (+400). The replays must collide with the parent and drop, so the global + // total is 1500 -- not 2600 (which keying on the fork's own session id would + // produce by double-counting the replayed history). + await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [ + sessionMeta({ session_id: 'sess-parent' }), + tokenCount({ timestamp: '2026-04-14T10:00:01Z', last: { input: 700 }, total: { total: 700 } }), + tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 400 }, total: { total: 1100 } }), + ]) + await writeSession(tmpDir, '2026-04-14', 'rollout-2-fork.jsonl', [ + sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent' }), + tokenCount({ timestamp: '2026-04-14T10:00:10Z', last: { input: 700 }, total: { total: 700 } }), + tokenCount({ timestamp: '2026-04-14T10:00:11Z', last: { input: 400 }, total: { total: 1100 } }), + tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { input: 400 }, total: { total: 1500 } }), + ]) + + const { tokens } = await aggregateTokens(tmpDir) + expect(tokens).toBe(1500) + }) + + it('keeps a genuine divergent fork event that shares a cumulative total with the parent', async () => { + // Parent reaches cumulative 1600 via input (last input 500). The fork replays + // 700 and 1100, then does genuinely different work that also reaches + // cumulative 1600 but via OUTPUT (last output 500). Keying on cumulativeTotal + // alone would collide the fork's 1600 with the parent's 1600 and drop it + // (undercount, losing 500). The content-addressed key keeps both. + await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [ + sessionMeta({ session_id: 'sess-parent' }), + tokenCount({ timestamp: '2026-04-14T10:00:01Z', last: { input: 700 }, total: { total: 700 } }), + tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 400 }, total: { total: 1100 } }), + tokenCount({ timestamp: '2026-04-14T10:00:03Z', last: { input: 500 }, total: { input: 1600, total: 1600 } }), + ]) + await writeSession(tmpDir, '2026-04-14', 'rollout-2-fork.jsonl', [ + sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent' }), + tokenCount({ timestamp: '2026-04-14T10:00:10Z', last: { input: 700 }, total: { total: 700 } }), + tokenCount({ timestamp: '2026-04-14T10:00:11Z', last: { input: 400 }, total: { total: 1100 } }), + tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { output: 500 }, total: { input: 1100, output: 500, total: 1600 } }), + ]) + + const { tokens } = await aggregateTokens(tmpDir) + // parent 1600 + fork's genuine +500 = 2100; replays (700, 1100) dropped. + expect(tokens).toBe(2100) + }) + + it('does not overcount a total-only fork whose replay straddles the 5s cutoff', async () => { + // The dedupe key must be derived from the cumulative token breakdown, not + // per-event deltas. In the fallback branch (events with total_token_usage + // but no last_token_usage), the delta is computed against a running `prev`. + // A fork skips replays within 5s of the fork (prev NOT advanced), so a + // replay kept just past the cutoff would compute a different delta than the + // parent did and, with a delta-based key, fail to dedupe -> double-count. + // The cumulative totals are copied verbatim, so a cumulative-based key + // collides regardless of the cutoff. Parent does 300 tokens; the fork is a + // pure replay (no new work), so the global total must stay 300. + await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [ + sessionMeta({ session_id: 'sess-parent' }), + tokenCount({ timestamp: '2026-04-14T10:00:01Z', total: { input: 100, total: 100 } }), + tokenCount({ timestamp: '2026-04-14T10:00:02Z', total: { input: 200, total: 200 } }), + tokenCount({ timestamp: '2026-04-14T10:00:03Z', total: { input: 300, total: 300 } }), + ]) + await writeSession(tmpDir, '2026-04-14', 'rollout-2-fork.jsonl', [ + sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent' }), + // 10:00:01 is within the 5s cutoff -> skipped (prev not advanced). + tokenCount({ timestamp: '2026-04-14T10:00:01Z', total: { input: 100, total: 100 } }), + // These land past the cutoff and replay the parent's cumulative totals. + tokenCount({ timestamp: '2026-04-14T10:00:08Z', total: { input: 200, total: 200 } }), + tokenCount({ timestamp: '2026-04-14T10:00:09Z', total: { input: 300, total: 300 } }), + ]) + + const { tokens } = await aggregateTokens(tmpDir) + expect(tokens).toBe(300) + }) +}) diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts new file mode 100644 index 0000000..89586a5 --- /dev/null +++ b/tests/providers/copilot.test.ts @@ -0,0 +1,1825 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join, posix, win32 } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { copilot, createCopilotProvider, getVSCodeGlobalStorageDirs, getVSCodeWorkspaceStorageDirs } from '../../src/providers/copilot.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +async function createSessionDir(sessionId: string, lines: string[], cwd = '/home/user/myproject') { + const sessionDir = join(tmpDir, sessionId) + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'workspace.yaml'), `id: ${sessionId}\ncwd: ${cwd}\n`) + await writeFile(join(sessionDir, 'events.jsonl'), lines.join('\n') + '\n') + return join(sessionDir, 'events.jsonl') +} + +function modelChange(newModel: string, previousModel?: string) { + return JSON.stringify({ type: 'session.model_change', timestamp: '2026-04-15T10:00:01Z', data: { newModel, previousModel } }) +} + +function userMessage(content: string) { + return JSON.stringify({ type: 'user.message', timestamp: '2026-04-15T10:00:10Z', data: { content, interactionId: 'int-1' } }) +} + +function assistantMessage(opts: { messageId: string; outputTokens: number; tools?: string[]; timestamp?: string }) { + return JSON.stringify({ + type: 'assistant.message', + timestamp: opts.timestamp ?? '2026-04-15T10:00:15Z', + data: { + messageId: opts.messageId, + outputTokens: opts.outputTokens, + interactionId: 'int-1', + toolRequests: (opts.tools ?? []).map(name => ({ name, toolCallId: `call-${name}`, type: 'function' })), + }, + }) +} + +function transcriptSessionStart(sessionId: string) { + return JSON.stringify({ type: 'session.start', data: { sessionId, producer: 'copilot-agent' } }) +} + +function transcriptUserMessage(content: string) { + return JSON.stringify({ type: 'user.message', data: { content, attachments: [] } }) +} + +function transcriptAssistantMessage(opts: { messageId: string; content?: string; reasoningText?: string; toolCallIds?: string[]; toolNames?: string[] }) { + return JSON.stringify({ + type: 'assistant.message', + data: { + messageId: opts.messageId, + content: opts.content ?? '', + reasoningText: opts.reasoningText ?? '', + toolRequests: (opts.toolCallIds ?? []).map((id, i) => ({ + toolCallId: id, + name: opts.toolNames?.[i] ?? (i === 0 ? 'read_file' : 'run_in_terminal'), + type: 'function', + })), + }, + }) +} + +function chatSessionSampleRequest(overrides: Record<string, unknown> = {}) { + return { + requestId: 'request_8c8ce017-6e3f-460a-9931-5a16825d231a', + modelId: 'copilot/claude-sonnet-4.6', + completionTokens: 490, + result: { + metadata: { + promptTokens: 32543, + outputTokens: 60, + resolvedModel: 'claude-sonnet-4-6', + toolCallRounds: [{ thinking: { tokens: 0 }, modelId: 'claude-sonnet-4.6' }], + agentId: 'github.copilot.editsAgent', + }, + }, + ...overrides, + } +} + +async function createChatSessionFile(filePath: string, entries: unknown[]) { + await writeFile(filePath, entries.map(entry => JSON.stringify(entry)).join('\n') + '\n') +} + +async function collectCalls(source: { path: string; project: string; provider: string; sourceType?: string }, seenKeys = new Set<string>()) { + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls.push(call) + return calls +} + +describe('copilot provider - JSONL parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses a basic assistant message', async () => { + const eventsPath = await createSessionDir('sess-001', [ + modelChange('gpt-4.1'), + userMessage('write a function'), + assistantMessage({ messageId: 'msg-1', outputTokens: 150 }), + ]) + + const source = { path: eventsPath, project: 'myproject', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('copilot') + expect(call.model).toBe('gpt-4.1') + expect(call.outputTokens).toBe(150) + expect(call.inputTokens).toBe(0) + expect(call.userMessage).toBe('write a function') + expect(call.sessionId).toBe('sess-001') + expect(call.bashCommands).toEqual([]) + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('tracks model changes mid-session', async () => { + const eventsPath = await createSessionDir('sess-002', [ + modelChange('gpt-5-mini'), + userMessage('first'), + assistantMessage({ messageId: 'msg-1', outputTokens: 50, timestamp: '2026-04-15T10:00:10Z' }), + modelChange('gpt-4.1', 'gpt-5-mini'), + userMessage('second'), + assistantMessage({ messageId: 'msg-2', outputTokens: 80, timestamp: '2026-04-15T10:01:00Z' }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(2) + expect(calls[0]!.model).toBe('gpt-5-mini') + expect(calls[1]!.model).toBe('gpt-4.1') + }) + + it('extracts tool names from toolRequests', async () => { + const eventsPath = await createSessionDir('sess-003', [ + modelChange('gpt-4.1'), + userMessage('run tests'), + assistantMessage({ messageId: 'msg-1', outputTokens: 60, tools: ['bash', 'read_file', 'write_file'] }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Edit']) + }) + + it('normalizes Copilot MCP tool names from toolRequests', async () => { + const eventsPath = await createSessionDir('sess-mcp-tools', [ + modelChange('gpt-4.1'), + userMessage('list MCP-backed tasks and issues'), + assistantMessage({ + messageId: 'msg-1', + outputTokens: 60, + tools: ['github-mcp-server-list_issues', 'cyberday-get_tasks', 'mempalace-mempalace_search', 'bash'], + }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls[0]!.tools).toEqual([ + 'mcp__github_mcp_server__list_issues', + 'mcp__cyberday__get_tasks', + 'mcp__mempalace__mempalace_search', + 'Bash', + ]) + }) + + it('does not crash on malformed toolRequests (string / null / missing)', async () => { + // Regression guard: a corrupt session previously aborted the whole file's + // parse loop because .map was called on a non-array. The fix coerces any + // non-array shape (string, null, missing) to []. We mix one corrupt event + // between two healthy events and assert both healthy events still parse. + const corruptToolRequestsString = JSON.stringify({ + type: 'assistant.message', + timestamp: '2026-04-15T10:00:15Z', + data: { messageId: 'corrupt-string', outputTokens: 50, toolRequests: 'not an array' }, + }) + const corruptToolRequestsNull = JSON.stringify({ + type: 'assistant.message', + timestamp: '2026-04-15T10:00:16Z', + data: { messageId: 'corrupt-null', outputTokens: 50, toolRequests: null }, + }) + const eventsPath = await createSessionDir('sess-corrupt', [ + modelChange('gpt-4.1'), + assistantMessage({ messageId: 'msg-before', outputTokens: 100 }), + corruptToolRequestsString, + corruptToolRequestsNull, + assistantMessage({ messageId: 'msg-after', outputTokens: 200 }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + // The healthy messages BEFORE and AFTER the corrupt events both parse — + // proving that the corrupt event no longer aborts the per-file parse loop. + // Pre-fix, .map on a non-array threw and we'd see < 4 calls. + expect(calls).toHaveLength(4) + expect(calls.find(c => c.outputTokens === 100)).toBeDefined() // msg-before + expect(calls.find(c => c.outputTokens === 200)).toBeDefined() // msg-after + // Corrupt events produce calls with empty tools, not crashes. + const corruptCalls = calls.filter(c => c.outputTokens === 50) + expect(corruptCalls.length).toBe(2) + for (const c of corruptCalls) { + expect(c.tools).toEqual([]) + } + }) + + it('ignores malformed non-string tool names', async () => { + const malformedToolName = JSON.stringify({ + type: 'assistant.message', + timestamp: '2026-04-15T10:00:15Z', + data: { + messageId: 'malformed-tool-name', + outputTokens: 50, + toolRequests: [null, { name: 123, toolCallId: 'call-bad', type: 'function' }], + }, + }) + const eventsPath = await createSessionDir('sess-malformed-tool-name', [ + modelChange('gpt-4.1'), + malformedToolName, + assistantMessage({ messageId: 'msg-after', outputTokens: 100, tools: ['github-mcp-server-list_issues'] }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(2) + expect(calls[0]!.tools).toEqual([]) + expect(calls[1]!.tools).toEqual(['mcp__github_mcp_server__list_issues']) + }) + + it('skips assistant messages with zero outputTokens', async () => { + const eventsPath = await createSessionDir('sess-004', [ + modelChange('gpt-4.1'), + assistantMessage({ messageId: 'msg-empty', outputTokens: 0 }), + assistantMessage({ messageId: 'msg-real', outputTokens: 42 }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(42) + }) + + it('deduplicates messages across parser runs', async () => { + const eventsPath = await createSessionDir('sess-005', [ + modelChange('gpt-4.1'), + assistantMessage({ messageId: 'msg-dup', outputTokens: 100 }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const seenKeys = new Set<string>() + + const calls1: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls1.push(call) + + const calls2: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls2.push(call) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) + + it('returns empty for missing file', async () => { + const source = { path: '/nonexistent/events.jsonl', project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) + + it('skips assistant messages before the first model_change event', async () => { + const eventsPath = await createSessionDir('sess-no-model', [ + assistantMessage({ messageId: 'msg-early', outputTokens: 50 }), + modelChange('gpt-4.1'), + assistantMessage({ messageId: 'msg-after', outputTokens: 80 }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(80) + expect(calls[0]!.model).toBe('gpt-4.1') + }) + + it('infers OpenAI auto bucket for transcript toolCallId prefix call_', async () => { + const eventsPath = await createSessionDir('sess-tr-call', [ + transcriptSessionStart('sess-tr-call'), + transcriptUserMessage('check model inference'), + transcriptAssistantMessage({ + messageId: 'msg-1', + content: 'done', + toolCallIds: ['call_abc123'], + }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('copilot-openai-auto') + }) + + it('infers Anthropic auto bucket for transcript toolCallId prefixes tooluse_/toolu_vrtx_', async () => { + const eventsPath = await createSessionDir('sess-tr-claude', [ + transcriptSessionStart('sess-tr-claude'), + transcriptUserMessage('check model inference'), + transcriptAssistantMessage({ + messageId: 'msg-1', + content: 'done', + toolCallIds: ['tooluse_XY', 'toolu_vrtx_01ABC'], + }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('copilot-anthropic-auto') + }) + + it('chooses the dominant inferred transcript model when prefixes are mixed', async () => { + const eventsPath = await createSessionDir('sess-tr-mixed', [ + transcriptSessionStart('sess-tr-mixed'), + transcriptUserMessage('mixed'), + transcriptAssistantMessage({ + messageId: 'msg-1', + content: 'one', + toolCallIds: ['toolu_bdrk_123'], + }), + transcriptAssistantMessage({ + messageId: 'msg-2', + content: 'two', + toolCallIds: ['call_1'], + }), + transcriptAssistantMessage({ + messageId: 'msg-3', + content: 'three', + toolCallIds: ['call_2'], + }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(3) + expect(calls.every(c => c.model === 'copilot-openai-auto')).toBe(true) + }) + + it('normalizes Copilot MCP tool names from VS Code transcripts', async () => { + const eventsPath = await createSessionDir('sess-tr-mcp-tools', [ + transcriptSessionStart('sess-tr-mcp-tools'), + transcriptUserMessage('use GitHub MCP'), + transcriptAssistantMessage({ + messageId: 'msg-1', + content: 'done', + toolCallIds: ['call_abc123', 'call_def456'], + toolNames: ['github-mcp-server-list_issues', 'read_file'], + }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['mcp__github_mcp_server__list_issues', 'Read']) + }) +}) + +describe('copilot provider - chatSessions parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-chatsessions-test-')) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1') + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('parses sample journal token counts and cost', async () => { + const filePath = join(tmpDir, 'sample.jsonl') + await createChatSessionFile(filePath, [ + { kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-session-1', requests: [] } }, + { kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] }, + ]) + + const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(32543) + expect(calls[0]!.outputTokens).toBe(60) + expect(calls[0]!.model).toBe('claude-sonnet-4-6') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('returns no calls for an empty reconstructed requests array', async () => { + const filePath = join(tmpDir, 'empty.jsonl') + await createChatSessionFile(filePath, [ + { kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-empty', requests: [] } }, + ]) + + const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' }) + + expect(calls).toHaveLength(0) + }) + + it('discovers and parses emptyWindowChatSessions from globalStorage', async () => { + const globalDir = join(tmpDir, 'globalStorage') + const emptyWindowDir = join(globalDir, 'emptyWindowChatSessions') + await mkdir(emptyWindowDir, { recursive: true }) + const filePath = join(emptyWindowDir, 'empty-window.jsonl') + await createChatSessionFile(filePath, [ + { kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'empty-window-session', requests: [] } }, + { kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] }, + ]) + + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', globalDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('copilot-chat') + expect((sessions[0] as { sourceType?: string }).sourceType).toBe('chatsession') + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(32543) + }) + + it('skips chatSessions discovery when an OTel source is present', async () => { + if (!isSqliteAvailable()) return + + vi.unstubAllEnvs() + const dbPath = join(tmpDir, 'agent-traces.db') + vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '') + createOtelDb(dbPath) + insertSpan(dbPath, { + spanId: 'span-chatsession-skip', + traceId: 'trace-chatsession-skip', + operationName: 'chat', + startTimeMs: 1000, + attrs: { + 'gen_ai.conversation.id': 'conv-chatsession-skip', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 100, + 'gen_ai.usage.output_tokens': 10, + }, + }) + + const wsDir = join(tmpDir, 'vscode-ws') + const hashDir = join(wsDir, 'abc123') + const workspaceChatSessionsDir = join(hashDir, 'chatSessions') + const globalDir = join(tmpDir, 'globalStorage') + const emptyWindowDir = join(globalDir, 'emptyWindowChatSessions') + await mkdir(workspaceChatSessionsDir, { recursive: true }) + await mkdir(emptyWindowDir, { recursive: true }) + await writeFile(join(hashDir, 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' })) + await createChatSessionFile(join(workspaceChatSessionsDir, 'workspace.jsonl'), [ + { kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-workspace', requests: [] } }, + { kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] }, + ]) + await createChatSessionFile(join(emptyWindowDir, 'empty-window.jsonl'), [ + { kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-empty-window', requests: [] } }, + { kind: 2, k: ['requests'], v: [chatSessionSampleRequest({ requestId: 'request-empty-window' })] }, + ]) + + const provider = createCopilotProvider('/nonexistent/legacy', wsDir, globalDir) + const sources = await provider.discoverSessions() + + expect(sources.filter(s => (s as { sourceType?: string }).sourceType === 'otel')).toHaveLength(1) + expect(sources.filter(s => (s as { sourceType?: string }).sourceType === 'chatsession')).toHaveLength(0) + }) + + it('applies append-then-edit journal updates', async () => { + const filePath = join(tmpDir, 'append-edit.jsonl') + await createChatSessionFile(filePath, [ + { kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-edit', requests: [] } }, + { kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] }, + { kind: 1, k: ['requests', 0, 'result', 'metadata', 'outputTokens'], v: 88 }, + ]) + + const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(88) + }) + + it('deduplicates by requestId across parser runs', async () => { + const filePath = join(tmpDir, 'dedupe.jsonl') + await createChatSessionFile(filePath, [ + { kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-dedupe', requests: [] } }, + { kind: 2, v: [chatSessionSampleRequest()] }, + ]) + const source = { path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' } + const seenKeys = new Set<string>() + + const calls1 = await collectCalls(source, seenKeys) + const calls2 = await collectCalls(source, seenKeys) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) + + it('ignores prototype-pollution journal paths without crashing', async () => { + const filePath = join(tmpDir, 'proto.jsonl') + await createChatSessionFile(filePath, [ + { kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-proto', requests: [] } }, + { kind: 1, k: ['__proto__', 'polluted'], v: true }, + { kind: 1, k: ['constructor', 'prototype', 'polluted'], v: true }, + { kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] }, + ]) + + expect(({} as { polluted?: unknown }).polluted).toBeUndefined() + const calls = await collectCalls({ path: filePath, project: 'myproject', provider: 'copilot', sourceType: 'chatsession' }) + + expect(calls).toHaveLength(1) + expect(({} as { polluted?: unknown }).polluted).toBeUndefined() + }) + + it('skips legacy transcripts for a workspace hash that has chatSessions', async () => { + const wsDir = join(tmpDir, 'vscode-ws') + const hashDir = join(wsDir, 'abc123') + const chatSessionsDir = join(hashDir, 'chatSessions') + const transcriptsDir = join(hashDir, 'GitHub.copilot-chat', 'transcripts') + await mkdir(chatSessionsDir, { recursive: true }) + await mkdir(transcriptsDir, { recursive: true }) + await writeFile(join(hashDir, 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' })) + await createChatSessionFile(join(chatSessionsDir, 'chat.jsonl'), [ + { kind: 0, v: { version: 3, creationDate: 1780157113020, sessionId: 'chat-modern', requests: [] } }, + { kind: 2, k: ['requests'], v: [chatSessionSampleRequest()] }, + ]) + await writeFile(join(transcriptsDir, 'legacy.jsonl'), transcriptSessionStart('legacy') + '\n') + + const provider = createCopilotProvider('/nonexistent/legacy', wsDir, '/nonexistent/global') + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect((sessions[0] as { sourceType?: string }).sourceType).toBe('chatsession') + expect(sessions[0]!.path).toContain(`${join('abc123', 'chatSessions')}`) + }) +}) + +describe('copilot provider - discoverSessions', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-')) + // Disable OTel discovery so tests aren't contaminated by real sessions + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1') + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('discovers sessions from directory', async () => { + await createSessionDir('sess-disc-001', [modelChange('gpt-4.1')]) + await createSessionDir('sess-disc-002', [modelChange('gpt-4.1')]) + + const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode') + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.every(s => s.provider === 'copilot')).toBe(true) + expect(sessions.every(s => s.path.endsWith('events.jsonl'))).toBe(true) + }) + + it('reads project name from workspace.yaml cwd', async () => { + await createSessionDir('sess-disc-003', [modelChange('gpt-4.1')], '/home/user/myapp') + + const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode') + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('myapp') + }) + + it('strips quotes and trailing comments from workspace.yaml cwd', async () => { + const sessionDir = join(tmpDir, 'sess-quoted') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'workspace.yaml'), 'cwd: "/home/user/myapp" # project root\n') + await writeFile(join(sessionDir, 'events.jsonl'), '\n') + + const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode') + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('myapp') + }) + + it('returns empty when directory does not exist', async () => { + const provider = createCopilotProvider('/nonexistent/path', '/nonexistent/vscode') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('skips entries without events.jsonl', async () => { + const emptyDir = join(tmpDir, 'empty-session') + await mkdir(emptyDir, { recursive: true }) + + const provider = createCopilotProvider(tmpDir, '/nonexistent/vscode') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('discovers VS Code workspace transcripts', async () => { + const wsDir = join(tmpDir, 'vscode-ws') + const transcriptsDir = join(wsDir, 'abc123', 'GitHub.copilot-chat', 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + await writeFile(join(wsDir, 'abc123', 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' })) + await writeFile(join(transcriptsDir, 'session-1.jsonl'), JSON.stringify({ type: 'session.start', data: { sessionId: 's1', producer: 'copilot-agent' } }) + '\n') + + const provider = createCopilotProvider('/nonexistent/legacy', wsDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('myapp') + expect(sessions[0]!.path).toContain('session-1.jsonl') + }) + + it('includes VSCodium workspaceStorage paths on all supported platforms', () => { + expect(getVSCodeWorkspaceStorageDirs('/Users/test', 'darwin')).toContain( + posix.join('/Users/test', 'Library', 'Application Support', 'VSCodium', 'User', 'workspaceStorage'), + ) + expect(getVSCodeWorkspaceStorageDirs('C:\\Users\\test', 'win32')).toContain( + win32.join('C:\\Users\\test', 'AppData', 'Roaming', 'VSCodium', 'User', 'workspaceStorage'), + ) + expect(getVSCodeWorkspaceStorageDirs('/home/test', 'linux')).toContain( + posix.join('/home/test', '.config', 'VSCodium', 'User', 'workspaceStorage'), + ) + }) + + it('includes VSCodium globalStorage paths on all supported platforms', () => { + expect(getVSCodeGlobalStorageDirs('/Users/test', 'darwin')).toContain( + posix.join('/Users/test', 'Library', 'Application Support', 'VSCodium', 'User', 'globalStorage'), + ) + expect(getVSCodeGlobalStorageDirs('C:\\Users\\test', 'win32')).toContain( + win32.join('C:\\Users\\test', 'AppData', 'Roaming', 'VSCodium', 'User', 'globalStorage'), + ) + expect(getVSCodeGlobalStorageDirs('/home/test', 'linux')).toContain( + posix.join('/home/test', '.config', 'VSCodium', 'User', 'globalStorage'), + ) + }) +}) + +describe('copilot provider - metadata', () => { + it('has correct name and displayName', () => { + expect(copilot.name).toBe('copilot') + expect(copilot.displayName).toBe('Copilot') + }) + + it('normalizes tool display names', () => { + expect(copilot.toolDisplayName('bash')).toBe('Bash') + expect(copilot.toolDisplayName('read_file')).toBe('Read') + expect(copilot.toolDisplayName('write_file')).toBe('Edit') + expect(copilot.toolDisplayName('web_search')).toBe('WebSearch') + expect(copilot.toolDisplayName('github-mcp-server-list_issues')).toBe('mcp__github_mcp_server__list_issues') + expect(copilot.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) + + it('normalizes model display names', () => { + expect(copilot.modelDisplayName('gpt-4.1')).toBe('GPT-4.1') + expect(copilot.modelDisplayName('gpt-4.1-mini')).toBe('GPT-4.1 Mini') + expect(copilot.modelDisplayName('gpt-4.1-nano')).toBe('GPT-4.1 Nano') + expect(copilot.modelDisplayName('gpt-5-mini')).toBe('GPT-5 Mini') + expect(copilot.modelDisplayName('o3')).toBe('o3') + expect(copilot.modelDisplayName('o4-mini')).toBe('o4-mini') + expect(copilot.modelDisplayName('copilot-openai-auto')).toBe('Copilot (OpenAI auto)') + expect(copilot.modelDisplayName('copilot-anthropic-auto')).toBe('Copilot (Anthropic auto)') + expect(copilot.modelDisplayName('unknown-model-xyz')).toBe('unknown-model-xyz') + }) + + it('longest-prefix match wins for versioned model IDs', () => { + // gpt-5-mini-2026-01-01 must match gpt-5-mini, not gpt-5 + expect(copilot.modelDisplayName('gpt-5-mini-2026-01-01')).toBe('GPT-5 Mini') + expect(copilot.modelDisplayName('gpt-4.1-mini-2026-01-01')).toBe('GPT-4.1 Mini') + }) +}) + +// --------------------------------------------------------------------------- +// OTel cache token tests +// +// These tests verify that the OTel SQLite parser correctly extracts +// cacheReadInputTokens and cacheCreationInputTokens from the agent-traces.db +// schema, and that multiple conversations from the same DB file are each +// parsed independently with their full cache token data intact. +// +// This is the regression guard for the bug documented in DEBUG_HANDOFF.md: +// cache tokens were extracted during parsing but lost in aggregation because +// all conversations shared the same file path key in the session cache. +// --------------------------------------------------------------------------- + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +/** Creates a minimal agent-traces.db schema matching the VS Code Copilot Chat OTel store. */ +function createOtelDb(dbPath: string): void { + const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb } + const db = new DatabaseSync(dbPath) + db.exec(` + CREATE TABLE spans ( + span_id TEXT PRIMARY KEY NOT NULL, + trace_id TEXT NOT NULL, + operation_name TEXT, + start_time_ms INTEGER NOT NULL DEFAULT 0, + response_model TEXT + ); + CREATE TABLE span_attributes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + span_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT + ); + `) + db.close() +} + +interface SpanDef { + spanId: string + traceId: string + operationName: string + startTimeMs?: number + responseModel?: string + attrs: Record<string, string | number> +} + +function insertSpan(dbPath: string, span: SpanDef): void { + const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (path: string) => TestDb } + const db = new DatabaseSync(dbPath) + db.prepare( + `INSERT INTO spans (span_id, trace_id, operation_name, start_time_ms, response_model) + VALUES (?, ?, ?, ?, ?)` + ).run(span.spanId, span.traceId, span.operationName, span.startTimeMs ?? 0, span.responseModel ?? null) + const attrStmt = db.prepare( + `INSERT INTO span_attributes (span_id, key, value) VALUES (?, ?, ?)` + ) + for (const [key, value] of Object.entries(span.attrs)) { + attrStmt.run(span.spanId, key, String(value)) + } + db.close() +} + +describe('copilot provider - OTel cache token parsing', () => { + let tmpDir: string + let dbPath: string + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-otel-test-')) + dbPath = join(tmpDir, 'agent-traces.db') + vi.stubEnv('CODEBURN_COPILOT_OTEL_DB', dbPath) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '') + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('skips tests when node:sqlite is unavailable', () => { + if (!isSqliteAvailable()) return + // Placeholder — subsequent tests use isSqliteAvailable guard + }) + + it('extracts cache tokens from a single OTel conversation', async () => { + if (!isSqliteAvailable()) return + + createOtelDb(dbPath) + insertSpan(dbPath, { + spanId: 'span-001', + traceId: 'trace-001', + operationName: 'chat', + startTimeMs: 1000, + responseModel: 'gpt-4.1', + attrs: { + 'gen_ai.conversation.id': 'conv-001', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 1000, + 'gen_ai.usage.output_tokens': 200, + 'gen_ai.usage.cache_read.input_tokens': 50000, + 'gen_ai.usage.cache_creation.input_tokens': 500, + }, + }) + + const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws') + const sources = await provider.discoverSessions() + + const otelSources = sources.filter(s => s.path.startsWith(dbPath)) + expect(otelSources).toHaveLength(1) + expect(otelSources[0]!.provider).toBe('copilot') + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(otelSources[0]!, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.model).toBe('gpt-4.1') + expect(call.inputTokens).toBe(1000) + expect(call.outputTokens).toBe(200) + expect(call.cacheReadInputTokens).toBe(50000) + expect(call.cacheCreationInputTokens).toBe(500) + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('discovers one source per OTel DB file (not per conversation)', async () => { + if (!isSqliteAvailable()) return + + createOtelDb(dbPath) + + // Two independent conversations in the same DB + insertSpan(dbPath, { + spanId: 'span-a1', traceId: 'trace-a', operationName: 'chat', startTimeMs: 1000, + attrs: { + 'gen_ai.conversation.id': 'conv-alpha', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 800, + 'gen_ai.usage.output_tokens': 100, + 'gen_ai.usage.cache_read.input_tokens': 40000, + 'gen_ai.usage.cache_creation.input_tokens': 400, + }, + }) + insertSpan(dbPath, { + spanId: 'span-b1', traceId: 'trace-b', operationName: 'chat', startTimeMs: 2000, + attrs: { + 'gen_ai.conversation.id': 'conv-beta', + 'gen_ai.response.model': 'claude-sonnet-4', + 'gen_ai.usage.input_tokens': 600, + 'gen_ai.usage.output_tokens': 80, + 'gen_ai.usage.cache_read.input_tokens': 30000, + 'gen_ai.usage.cache_creation.input_tokens': 300, + }, + }) + + const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws') + const sources = await provider.discoverSessions() + + // One source per DB file (not per conversation) + const otelSources = sources.filter(s => s.path === dbPath) + expect(otelSources).toHaveLength(1) + expect(otelSources[0]!.path).toBe(dbPath) + + // But the parser still yields calls from BOTH conversations + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(otelSources[0]!, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(2) + const sessionIds = new Set(calls.map(c => c.sessionId)) + expect(sessionIds.size).toBe(2) + }) + + it('preserves cache tokens when parsing multiple conversations from one DB', async () => { + if (!isSqliteAvailable()) return + + createOtelDb(dbPath) + + insertSpan(dbPath, { + spanId: 'span-c1', traceId: 'trace-c', operationName: 'chat', startTimeMs: 1000, + attrs: { + 'gen_ai.conversation.id': 'conv-c', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 500, + 'gen_ai.usage.output_tokens': 100, + 'gen_ai.usage.cache_read.input_tokens': 20000, + 'gen_ai.usage.cache_creation.input_tokens': 200, + }, + }) + insertSpan(dbPath, { + spanId: 'span-d1', traceId: 'trace-d', operationName: 'chat', startTimeMs: 2000, + attrs: { + 'gen_ai.conversation.id': 'conv-d', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 700, + 'gen_ai.usage.output_tokens': 150, + 'gen_ai.usage.cache_read.input_tokens': 35000, + 'gen_ai.usage.cache_creation.input_tokens': 350, + }, + }) + + const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws') + const sources = await provider.discoverSessions() + // One source per DB file — the parser iterates all conversations internally + const otelSource = sources.find(s => s.path === dbPath) + expect(otelSource).toBeDefined() + const allCalls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(otelSource!, new Set()).parse()) { + allCalls.push(call) + } + + expect(allCalls).toHaveLength(2) + + const totalCacheRead = allCalls.reduce((sum, c) => sum + c.cacheReadInputTokens, 0) + const totalCacheCreate = allCalls.reduce((sum, c) => sum + c.cacheCreationInputTokens, 0) + + // Both conversations' cache tokens must survive end-to-end + expect(totalCacheRead).toBe(55000) // 20000 + 35000 + expect(totalCacheCreate).toBe(550) // 200 + 350 + }) + + it('includes tool names from execute_tool spans in the same trace', async () => { + if (!isSqliteAvailable()) return + + createOtelDb(dbPath) + // chat span + insertSpan(dbPath, { + spanId: 'span-e1', traceId: 'trace-e', operationName: 'chat', startTimeMs: 1000, + attrs: { + 'gen_ai.conversation.id': 'conv-e', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 300, + 'gen_ai.usage.output_tokens': 50, + 'gen_ai.usage.cache_read.input_tokens': 10000, + 'gen_ai.usage.cache_creation.input_tokens': 100, + }, + }) + // execute_tool span in the same trace + insertSpan(dbPath, { + spanId: 'span-e2', traceId: 'trace-e', operationName: 'execute_tool', startTimeMs: 1500, + attrs: { + 'gen_ai.tool.name': 'readFile', + }, + }) + + const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws') + const sources = await provider.discoverSessions() + const src = sources.find(s => s.path.startsWith(dbPath)) + expect(src).toBeDefined() + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(src!, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toContain('Read') + expect(calls[0]!.cacheReadInputTokens).toBe(10000) + }) + + it('skips OTel spans with zero input and output tokens', async () => { + if (!isSqliteAvailable()) return + + createOtelDb(dbPath) + insertSpan(dbPath, { + spanId: 'span-f1', traceId: 'trace-f', operationName: 'chat', startTimeMs: 1000, + attrs: { + 'gen_ai.conversation.id': 'conv-f', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 0, + 'gen_ai.usage.output_tokens': 0, + 'gen_ai.usage.cache_read.input_tokens': 50000, + 'gen_ai.usage.cache_creation.input_tokens': 500, + }, + }) + + const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws') + const sources = await provider.discoverSessions() + const src = sources.find(s => s.path.startsWith(dbPath)) + expect(src).toBeDefined() + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(src!, new Set()).parse()) { + calls.push(call) + } + // Span with zero input AND output tokens is skipped + expect(calls).toHaveLength(0) + }) + + it('OTel source path equals the plain DB file path and durableSources is true', async () => { + if (!isSqliteAvailable()) return + + createOtelDb(dbPath) + insertSpan(dbPath, { + spanId: 'span-g1', traceId: 'trace-g', operationName: 'chat', startTimeMs: 1000, + attrs: { + 'gen_ai.conversation.id': 'conv-g', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 100, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.cache_read.input_tokens': 0, + 'gen_ai.usage.cache_creation.input_tokens': 0, + }, + }) + + const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws') + + // durableSources must be true on the copilot provider + expect(provider.durableSources).toBe(true) + + const sources = await provider.discoverSessions() + const otelSrc = sources.find(s => s.path.startsWith(dbPath)) + expect(otelSrc).toBeDefined() + + // Path is the plain DB file path (no #otel-conv= compound suffix) + expect(otelSrc!.path).toBe(dbPath) + + // Parser must open the DB and produce results for all conversations + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(otelSrc!, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('attributes genuine subagents but excludes the root agent', async () => { + if (!isSqliteAvailable()) return + + createOtelDb(dbPath) + + // Root agent turn: chat span + invoke_agent WITHOUT a parent session. + insertSpan(dbPath, { + spanId: 'span-root-chat', traceId: 'trace-root', operationName: 'chat', startTimeMs: 1000, + attrs: { + 'gen_ai.conversation.id': 'conv-h', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 400, + 'gen_ai.usage.output_tokens': 60, + 'gen_ai.usage.cache_read.input_tokens': 0, + 'gen_ai.usage.cache_creation.input_tokens': 0, + }, + }) + insertSpan(dbPath, { + spanId: 'span-root-agent', traceId: 'trace-root', operationName: 'invoke_agent', startTimeMs: 1010, + attrs: { + 'gen_ai.conversation.id': 'conv-h', + 'gen_ai.agent.name': 'GitHub Copilot Chat', + }, + }) + + // Genuine subagent: its own trace holds the subagent's chat span plus an + // invoke_agent span carrying copilot_chat.parent_chat_session_id. + insertSpan(dbPath, { + spanId: 'span-sub-chat', traceId: 'trace-sub', operationName: 'chat', startTimeMs: 2000, + attrs: { + 'gen_ai.conversation.id': 'conv-h', + 'gen_ai.response.model': 'claude-haiku-4.5', + 'gen_ai.usage.input_tokens': 250, + 'gen_ai.usage.output_tokens': 30, + 'gen_ai.usage.cache_read.input_tokens': 0, + 'gen_ai.usage.cache_creation.input_tokens': 0, + }, + }) + insertSpan(dbPath, { + spanId: 'span-sub-agent', traceId: 'trace-sub', operationName: 'invoke_agent', startTimeMs: 2010, + attrs: { + 'gen_ai.conversation.id': 'conv-h', + 'gen_ai.agent.name': 'Explore', + 'copilot_chat.parent_chat_session_id': 'conv-h', + }, + }) + + const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws') + const sources = await provider.discoverSessions() + const src = sources.find(s => s.path.startsWith(dbPath)) + expect(src).toBeDefined() + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(src!, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(2) + const rootCall = calls.find(c => c.model === 'gpt-4.1')! + const subCall = calls.find(c => c.model === 'claude-haiku-4.5')! + + // Root agent must NOT surface as a subagent + expect(rootCall.subagentTypes ?? []).not.toContain('GitHub Copilot Chat') + expect(rootCall.subagentTypes ?? []).toHaveLength(0) + + // Genuine subagent is attributed to its own call + expect(subCall.subagentTypes).toEqual(['Explore']) + }) + + it('normalises multi-line OTel shell scripts, dropping control-flow keywords', async () => { + if (!isSqliteAvailable()) return + + createOtelDb(dbPath) + insertSpan(dbPath, { + spanId: 'span-sh-chat', traceId: 'trace-sh', operationName: 'chat', startTimeMs: 1000, + attrs: { + 'gen_ai.conversation.id': 'conv-sh', + 'gen_ai.response.model': 'gpt-4.1', + 'gen_ai.usage.input_tokens': 100, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.cache_read.input_tokens': 0, + 'gen_ai.usage.cache_creation.input_tokens': 0, + }, + }) + // A full multi-line script with control flow and newline-separated commands, + // exactly as the OTel store records it. + insertSpan(dbPath, { + spanId: 'span-sh-tool', traceId: 'trace-sh', operationName: 'execute_tool', startTimeMs: 1500, + attrs: { + 'gen_ai.tool.name': 'run_in_terminal', + 'gen_ai.tool.call.arguments': JSON.stringify({ + command: 'for f in *.ts; do\n echo "$f"\ndone\ngit status\nnpm test', + }), + }, + }) + + const provider = createCopilotProvider('/nonexistent/jsonl', '/nonexistent/ws') + const sources = await provider.discoverSessions() + const src = sources.find(s => s.path.startsWith(dbPath)) + expect(src).toBeDefined() + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(src!, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const bash = calls[0]!.bashCommands + // Real commands separated by newlines/`;` are captured + expect(bash).toEqual(expect.arrayContaining(['echo', 'git', 'npm'])) + // Control-flow keywords are NOT reported as commands + for (const kw of ['for', 'do', 'done']) { + expect(bash).not.toContain(kw) + } + }) +}) + +// --------------------------------------------------------------------------- +// JetBrains (IntelliJ / PyCharm / …) session parsing +// --------------------------------------------------------------------------- +// +// The JetBrains Copilot plugin persists sessions to a Nitrite (H2 MVStore) .db +// (~/.config/github-copilot/<ide>/<kind>/<storeId>/copilot-*-nitrite.db) of +// Java-serialized documents. Assistant replies are nested-escaped +// {"__first__":{"type":"Subgraph",…}} blobs; the model and projectName are +// separate serialized fields. These helpers reproduce that on-disk shape so +// tests exercise the real regex/scan extraction path. + +describe('copilot provider - JetBrains parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-jetbrains-test-')) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1') + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + // A JetBrains source: session content lives in the Nitrite .db. + function jbDbSource(path: string, sessionId: string, mtime = '2026-07-03T12:00:00.000Z') { + return { + path, project: 'copilot-jetbrains', provider: 'copilot', sourceType: 'jetbrains', + sessionId, storeId: sessionId, dbPath: path, mtime, + } as unknown as { path: string; project: string; provider: string; sourceType?: string } + } + + // ---- Nitrite .db parsing ---- + + // Build an assistant response blob in the real nested-escaped shape: + // {"__first__":{"type":"Subgraph","value":"{\"<uuid>\":{\"type\":\"Value\", + // \"value\":\"{\\\"type\\\":\\\"Markdown\\\",\\\"data\\\":\\\"{\\\\\\\"text\\\\\\\":...}\"}"}} + function jbAssistantBlob(text: string, opts: { model?: string; errored?: boolean; files?: string[] } = {}) { + const innerMd = { type: 'Markdown', data: JSON.stringify({ text, annotations: [] }) } + const valueMap: Record<string, unknown> = { + 'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) }, + } + if (opts.model) valueMap['__model__'] = { type: 'Value', value: `{"model":"${opts.model}"}` } + // Files the turn referenced — project is derived from these file:// paths. + if (opts.files) { + valueMap['__refs__'] = { + type: 'Value', + value: JSON.stringify({ type: 'References', data: opts.files.map((f) => `file://${f}`).join(' ') }), + } + } + const outer: Record<string, unknown> = { + __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) }, + } + if (opts.errored) { + // Real failed turns store the error under a type:"Error" record with a + // `message` field (NOT a Markdown `text`), so it is not billable output. + outer['__err__'] = { + type: 'Value', + value: JSON.stringify({ type: 'Error', message: 'Sorry, an error occurred while generating a response' }), + } + } + return JSON.stringify(outer) + } + + // An AGENT-MODE assistant blob: the reply lives in an AgentRound record, and + // (as in real agent sessions) the Markdown record holds the USER's prompt, + // which must NOT be counted as the reply. `rounds` is a list of AgentRound + // replies (a single blob can carry several); a pure tool-call round has ''. + function jbAgentBlob(rounds: string[], opts: { model?: string; userPrompt?: string; errored?: boolean } = {}) { + const valueMap: Record<string, unknown> = {} + let n = 0 + // The user prompt as a Markdown record — a decoy the reply extractor must + // skip in agent mode (real stores put the prompt here, not the answer). + if (opts.userPrompt !== undefined) { + const md = { type: 'Markdown', data: JSON.stringify({ text: opts.userPrompt, annotations: [] }) } + valueMap[`u0000000-0000-0000-0000-00000000000${n++}`] = { type: 'Value', value: JSON.stringify(md) } + } + for (const reply of rounds) { + const ar = { type: 'AgentRound', data: JSON.stringify({ roundId: n, reply, toolCalls: [] }) } + valueMap[`a0000000-0000-0000-0000-00000000000${n++}`] = { type: 'Value', value: JSON.stringify(ar) } + } + if (opts.model) valueMap['__model__'] = { type: 'Value', value: `{"model":"${opts.model}"}` } + const outer: Record<string, unknown> = { __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) } } + if (opts.errored) { + outer['__err__'] = { + type: 'Value', + value: JSON.stringify({ type: 'Error', message: 'Sorry, an error occurred while generating a response' }), + } + } + return JSON.stringify(outer) + } + + // A conversation title record in the real framing: `$<GUID>…name…value<TITLE>t\x00\x06source`. + function jbConversationRecord(guid: string, title: string) { + return `$${guid}t\x00\x04namesq\x00\x01?@\x00\x00w\x00\x00t\x00value t\x00${title}t\x00\x06sourcet\x00copilotx` + } + + // Assemble a minimal Nitrite-.db-shaped buffer: MVStore header + entity-class + // anchor + optional conversation records + assistant blobs. When a blob is + // preceded by a conversation record, turns attribute to that conversation. + function jbDbContent(blobs: string[], conversations: string[] = []) { + return ( + 'H:2,block:9,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + conversations.join('\n') + '\n' + + blobs.join('\nt\x00\x00model\n') + + '\n' + ) + } + + async function createJetBrainsDb(root: string, ide: string, kind: string, storeId: string, content: string) { + const dir = join(root, ide, kind, storeId) + await mkdir(dir, { recursive: true }) + const dbName = + kind === 'chat-agent-sessions' + ? 'copilot-agent-sessions-nitrite.db' + : kind === 'chat-edit-sessions' + ? 'copilot-edit-sessions-nitrite.db' + : 'copilot-chat-nitrite.db' + await writeFile(join(dir, dbName), content) + return join(dir, dbName) + } + + // The plugin-recorded project label, in the real Java-serialized framing: + // the field key `projectName` followed by TC_STRING `0x74 <u16 len> <value>`, + // then the sibling `user` field. This is what extractJetBrainsProjectName reads. + function jbProjectNameField(name: string) { + // TC_STRING length is the UTF-8 BYTE count (the .db is written UTF-8 and + // read back as latin1), not the JS UTF-16 code-unit count. + const len = Buffer.byteLength(name, 'utf8') + const hi = String.fromCharCode((len >> 8) & 0xff) + const lo = String.fromCharCode(len & 0xff) + return `t\x00\x0bprojectName\x74${hi}${lo}${name}t\x00\x04usert\x00\x08dev-user` + } + + it('parses assistant turns from a Nitrite .db and estimates cost', async () => { + const content = jbDbContent([ + jbAssistantBlob('Hello! How can I help you today?'), + jbAssistantBlob('Here is a longer architecture overview with plenty of detail.', { model: 'claude-opus-4.5' }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-1', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-1')) + expect(calls).toHaveLength(2) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + expect(calls[0]!.costIsEstimated).toBe(true) + expect(calls[0]!.inputTokens).toBe(0) + // Per-turn model recovered from inside the blob, normalised dots→dashes. + expect(calls[1]!.model).toBe('claude-opus-4-5') + expect(calls[1]!.costUSD).toBeGreaterThan(0) + // Dedup keys are conversation-scoped, content-derived, and distinct. + expect(calls[0]!.deduplicationKey).toMatch(/^copilot:jb:conv-1:[0-9a-f]{12}:1$/) + expect(calls[1]!.deduplicationKey).toMatch(/^copilot:jb:conv-1:[0-9a-f]{12}:1$/) + expect(calls[0]!.deduplicationKey).not.toBe(calls[1]!.deduplicationKey) + }) + + it('recovers a reply containing quotes without garbling or duplicating it', async () => { + // Regression: the unescape loop must run extraction ONLY on the final, + // fully-unescaped form. Accumulating matches at every depth would union a + // half-unescaped (quote-truncated) capture with the full one, producing a + // garbled duplicate and inflating the token/cost estimate. + const reply = 'Use `printf "%s"` to print, then check "status" here.' + const content = jbDbContent([jbAssistantBlob(reply, { model: 'claude-opus-4.5' })]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-quote', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-quote')) + expect(calls).toHaveLength(1) + // Token estimate reflects the true reply length (CHARS_PER_TOKEN = 4), not + // an inflated garbled copy. + expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4)) + }) + + it('counts a multibyte UTF-8 reply by codepoints, not latin1 bytes', async () => { + // The .db is read as latin1; the parser must re-decode to UTF-8 so a + // multibyte char counts as one codepoint for the token estimate. + const reply = 'café ☕ déjà vu — naïve façade' // several multibyte chars + const content = jbDbContent([jbAssistantBlob(reply, { model: 'claude-opus-4.5' })]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-utf8', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-utf8')) + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4)) + }) + + it('extracts agent-mode replies from AgentRound (not the user prompt Markdown)', async () => { + // Agent-mode sessions (e.g. PyCharm) store the reply in an AgentRound record; + // the Markdown record holds the USER prompt. The reply extractor must read + // the AgentRound reply and ignore the prompt — otherwise the turn bills $0 + // (reply never found) or bills the user's words as output. + const reply = "Here's a quick summary of this repo: it does X, Y, and Z." + const content = jbDbContent([ + jbAgentBlob([reply], { model: 'claude-opus-4.5', userPrompt: 'summarise this repo' }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-agent', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-agent')) + expect(calls).toHaveLength(1) + // Priced from the AgentRound reply, not the (shorter) user prompt. + expect(calls[0]!.outputTokens).toBe(Math.ceil(reply.length / 4)) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + expect(calls[0]!.model).toBe('claude-opus-4-5') + }) + + it('skips pure tool-call agent rounds (empty reply → no billable output)', async () => { + // A round that only issued tool calls has reply:'' — it contributes nothing, + // exactly like a Steps-only ask-mode blob. + const content = jbDbContent([jbAgentBlob([''], { model: 'claude-opus-4.5' })]) + const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-toolonly', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-toolonly')) + expect(calls).toHaveLength(0) + }) + + it('a failed agent turn bills $0 and never counts the user prompt as the reply', async () => { + // Failed agent turn: empty AgentRound reply + an error marker + a user-prompt + // Markdown record. The parser must NOT fall back to the Markdown (that would + // bill the user's words); an agent blob is agent mode regardless of whether + // its reply is empty, so this is an errored turn → $0. + const content = jbDbContent([ + jbAgentBlob([''], { model: 'claude-opus-4.5', userPrompt: 'do the thing', errored: true }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-agenterr', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-agenterr')) + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(0) + expect(calls[0]!.costUSD).toBe(0) + }) + + it('collects multiple AgentRound replies within one blob', async () => { + // A multi-round agent turn: the first round explores (tool call, empty + // reply), the second answers. Both non-empty replies are joined. + const content = jbDbContent([ + jbAgentBlob(['Let me explore the project.', '', 'Done — here is what it does.'], { + model: 'claude-opus-4.5', + }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'py', 'chat-agent-sessions', 'conv-multiround', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-multiround')) + expect(calls).toHaveLength(1) + const joined = 'Let me explore the project.\nDone — here is what it does.' + expect(calls[0]!.outputTokens).toBe(Math.ceil(joined.length / 4)) + }) + + it('treats errored turns as $0 (failed generation, no billable output)', async () => { + const content = jbDbContent([ + jbAssistantBlob('', { errored: true }), + jbAssistantBlob('A real successful reply.', { model: 'claude-opus-4.5' }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-err', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-err')) + expect(calls).toHaveLength(2) + const errored = calls.find((c) => c.outputTokens === 0) + const good = calls.find((c) => c.outputTokens > 0) + expect(errored).toBeDefined() + expect(errored!.costUSD).toBe(0) + expect(good).toBeDefined() + expect(good!.costUSD).toBeGreaterThan(0) + }) + + it('de-duplicates repeated byte-copies of the same reply within a .db', async () => { + const content = jbDbContent([ + jbAssistantBlob('identical reply text stored twice'), + jbAssistantBlob('identical reply text stored twice'), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-dup', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-dup')) + expect(calls).toHaveLength(1) + }) + + it('skips Steps/progress-only assistant blobs (no billable text)', async () => { + const stepsBlob = JSON.stringify({ + __first__: { + type: 'Subgraph', + value: JSON.stringify({ x: { type: 'Value', value: JSON.stringify({ type: 'Steps', data: '[]' }) } }), + }, + }) + const content = jbDbContent([stepsBlob, jbAssistantBlob('The only real answer.')]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-steps', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-steps')) + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) + + it('per-turn model differences within one .db (opus vs gpt) are priced separately', async () => { + const content = jbDbContent([ + jbAssistantBlob('Opus answer with enough words to score tokens.', { model: 'claude-opus-4.5' }), + jbAssistantBlob('GPT answer with enough words to score tokens.', { model: 'gpt-5.3' }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-multi', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-multi')) + expect(calls).toHaveLength(2) + expect(calls.map((c) => c.model).sort()).toEqual(['claude-opus-4-5', 'gpt-5.3']) + }) + + it('splits one .db into sessions by conversation; project = repo, title = session label', async () => { + const guidA = '6acf5299-f9f7-404f-812d-dbe8300e1e5b' + const guidB = '485825c0-3331-46a7-acb2-c71875ad6640' + // Conversation A references a file in a real git repo; B touches no files. + const repoDir = join(tmpDir, 'container', 'web-api') + await mkdir(join(repoDir, '.git'), { recursive: true }) + await mkdir(join(repoDir, 'src'), { recursive: true }) + const fileA = join(repoDir, 'src', 'Main.java') + // Interleave each conversation record before its own turns (turns attribute + // to the nearest preceding conversation GUID). Title evolves default→final. + const content = + 'H:2,block:9,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + jbConversationRecord(guidA, 'New Agent Session') + '\n' + + jbConversationRecord(guidA, 'Understanding the API Architecture') + '\n' + + jbAssistantBlob('Answer about the web API.', { model: 'claude-opus-4.5', files: [fileA] }) + '\n' + + jbConversationRecord(guidB, 'Exploring the Controller Layer in Spring Boot') + '\n' + + jbAssistantBlob('Answer about the controller layer breakdown.', { model: 'gpt-5.3' }) + '\n' + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'multi-conv', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'multi-conv')) + expect(calls).toHaveLength(2) + const bySession = new Map(calls.map((c) => [c.sessionId, c])) + // Sessions are split by conversation GUID. + expect(bySession.has(guidA)).toBe(true) + expect(bySession.has(guidB)).toBe(true) + // Project = the git repo root of the referenced file; else the generic + // bucket when the chat touched no files. + expect(bySession.get(guidA)!.project).toBe('web-api') + expect(bySession.get(guidB)!.project).toBe('copilot-jetbrains') + // The conversation TITLE is the session label (userMessage), NOT the project. + expect(bySession.get(guidA)!.userMessage).toBe('Understanding the API Architecture') + expect(bySession.get(guidB)!.userMessage).toBe('Exploring the Controller Layer in Spring Boot') + // Titles must never appear as project names (they are chat threads). + expect(calls.map((c) => c.project)).not.toContain('Understanding the API Architecture') + }) + + it('is idempotent across re-parses of the same .db (shared seenKeys)', async () => { + const content = jbDbContent([jbAssistantBlob('first reply'), jbAssistantBlob('second reply')]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-idem', content) + + const seen = new Set<string>() + const first = await collectCalls(jbDbSource(dbPath, 'conv-idem'), seen) + const second = await collectCalls(jbDbSource(dbPath, 'conv-idem'), seen) + expect(first).toHaveLength(2) + expect(second).toHaveLength(0) + }) + + it('discovers a store dir with a Nitrite .db', async () => { + const content = jbDbContent([jbAssistantBlob('hi there')]) + await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'db-only', content) + + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const jb = sessions.filter((s) => (s as { sourceType?: string }).sourceType === 'jetbrains') + expect(jb).toHaveLength(1) + expect((jb[0] as { dbPath?: string }).dbPath).toContain('copilot-agent-sessions-nitrite.db') + }) + + it('infers project as the git repo root of a referenced file (deep subdir → repo root)', async () => { + // Create a real git repo on disk so the .git walk-up can resolve it. + const repoDir = join(tmpDir, 'container', 'myapp') + await mkdir(join(repoDir, '.git'), { recursive: true }) + await mkdir(join(repoDir, 'src', 'a'), { recursive: true }) + const fileA = join(repoDir, 'src', 'a', 'One.ts') + const content = jbDbContent([ + jbAssistantBlob('Editing files in a real repo.', { model: 'gpt-4.1', files: [fileA] }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-gitwalk', content) + + const calls = await collectCalls(jbDbSource(dbPath, 'conv-gitwalk')) + expect(calls).toHaveLength(1) + // Project = basename of the nearest ancestor with .git (the repo root + // 'myapp'), NOT the deep subdir 'a'/'src' or the container dir. + expect(calls[0]!.project).toBe('myapp') + expect(calls[0]!.model).toBe('gpt-4.1') + }) + + it('falls back to copilot-jetbrains when no referenced file resolves to a git repo', async () => { + const content = jbDbContent([ + jbAssistantBlob('Editing a file outside any repo.', { + model: 'gpt-4.1', + files: ['/nonexistent/no-repo-here/src/One.ts'], + }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-norepo', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-norepo')) + expect(calls).toHaveLength(1) + expect(calls[0]!.project).toBe('copilot-jetbrains') + }) + + it('resolves a git repo whose name contains a space', async () => { + const repoDir = join(tmpDir, 'My Project') + await mkdir(join(repoDir, '.git'), { recursive: true }) + await mkdir(join(repoDir, 'src'), { recursive: true }) + const file = join(repoDir, 'src', 'One.ts') + const content = jbDbContent([ + jbAssistantBlob('Reading a file in a spaced repo.', { model: 'gpt-4.1', files: [file] }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-space', content) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-space')) + expect(calls).toHaveLength(1) + expect(calls[0]!.project).toBe('My Project') + }) + + it('discovers JetBrains sessions across IDE dirs and session kinds', async () => { + const content = jbDbContent([jbAssistantBlob('Hello from agent mode.', { model: 'claude-opus-4.5' })]) + await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'a1', content) + await createJetBrainsDb(tmpDir, 'intellij', 'chat-agent-sessions', 'b1', content) + + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const jb = sessions.filter((s) => (s as { sourceType?: string }).sourceType === 'jetbrains') + expect(jb.map((s) => (s as { sessionId?: string }).sessionId).sort()).toEqual(['a1', 'b1']) + }) + + it('does not crash on a corrupt/truncated .db', async () => { + const dbPath = await createJetBrainsDb( + tmpDir, + 'iu', + 'chat-agent-sessions', + 'conv-corrupt', + 'H:2,block:9\ncom.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n{"__first__":{"type":"Subgraph"' // truncated, unbalanced + ) + const calls = await collectCalls(jbDbSource(dbPath, 'conv-corrupt')) + expect(Array.isArray(calls)).toBe(true) // no throw; may be empty + }) + + // ---- projectName field (JetBrains Copilot 1.12+) ---- + + it('uses the plugin-recorded projectName over the file-path git-walk', async () => { + // Same store carries both a projectName AND a file ref; projectName wins. + const repoDir = join(tmpDir, 'container', 'walkable-repo') + await mkdir(join(repoDir, '.git'), { recursive: true }) + const file = join(repoDir, 'Main.java') + const content = jbDbContent([ + jbProjectNameField('shared-utils'), + jbAssistantBlob('An answer referencing a file in a real git repo.', { + model: 'claude-opus-4.5', + files: [file], + }), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-pn', content) + // discoverSessions populates source.projectName; feed the resolved source. + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-pn')! + expect((src as { projectName?: string }).projectName).toBe('shared-utils') + const calls = await collectCalls(src as never) + expect(calls.length).toBeGreaterThan(0) + // projectName beats the git-walk result (`walkable-repo`). + expect(calls.every((c) => c.project === 'shared-utils')).toBe(true) + }) + + it('joins projectName across kind dirs by store id (turns in agent, name in edit)', async () => { + // The billable turns live in chat-agent-sessions but carry NO projectName; + // the sibling chat-edit-sessions store (same id) records it. Discovery must + // join them so the agent session is labelled with the real repo. + const storeId = 'store-xyz-123' + const agentContent = jbDbContent([ + jbAssistantBlob('Architecture overview of the repo, no file refs at all.', { model: 'claude-opus-4.5' }), + ]) + await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', storeId, agentContent) + // Edit-kind store: has the projectName, but no billable turns. + const editContent = jbDbContent([], []) + jbProjectNameField('web-api') + await createJetBrainsDb(tmpDir, 'iu', 'chat-edit-sessions', storeId, editContent) + + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const jb = sessions.filter((s) => (s as { sourceType?: string }).sourceType === 'jetbrains') + // Every source for this store id inherits the sibling-recorded name. + for (const s of jb) { + expect((s as { projectName?: string }).projectName).toBe('web-api') + } + const agentSrc = jb.find((s) => ((s as { dbPath?: string }).dbPath ?? '').includes('chat-agent-sessions'))! + const calls = await collectCalls(agentSrc as never) + expect(calls.length).toBeGreaterThan(0) + expect(calls.every((c) => c.project === 'web-api')).toBe(true) + }) + + it('falls back to git-walk then bucket when no projectName is recorded', async () => { + // No projectName, no file refs → the honest generic bucket (older plugins). + const content = jbDbContent([jbAssistantBlob('A reply with no project signal at all.')]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'conv-nopn', content) + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-nopn')! + expect((src as { projectName?: string }).projectName).toBeUndefined() + const calls = await collectCalls(src as never) + expect(calls.every((c) => c.project === 'copilot-jetbrains')).toBe(true) + }) + + it('extractJetBrainsProjectName reads the length-prefixed value, immune to embedded quotes', async () => { + // A value containing a quote/newline must not truncate: length-prefixed read. + const tricky = 'weird"name' + const raw = jbDbContent([jbAssistantBlob('x')]) + jbProjectNameField(tricky) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-sessions', 'conv-tricky', raw) + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-tricky')! + expect((src as { projectName?: string }).projectName).toBe(tricky) + }) + + it('reads a non-ASCII (multibyte UTF-8) projectName', async () => { + // The value is length-delimited in UTF-8 bytes and re-decoded latin1→utf8, + // so a repo name with multibyte characters must round-trip intact. + const name = 'проект-café' + const raw = jbDbContent([jbAssistantBlob('x')]) + jbProjectNameField(name) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-sessions', 'conv-utf8name', raw) + const provider = createCopilotProvider('/nonexistent/legacy', '/nonexistent/ws', '/nonexistent/global', tmpDir) + const sessions = await provider.discoverSessions() + const src = sessions.find((s) => (s as { storeId?: string }).storeId === 'conv-utf8name')! + expect((src as { projectName?: string }).projectName).toBe(name) + }) + + // --------------------------------------------------------------------------- + // Old plugin format (≤1.5.x, e.g. 1.5.59-243) + // --------------------------------------------------------------------------- + // In the old plugin all session turns live inside ONE large binary-framed + // outer Nitrite document. Each turn's response is stored as a UUID-keyed + // Value entry containing an AgentRound record (one escaping level deeper than + // the __first__/Subgraph format used by plugins ≥1.12.x). + + /** + * Build an outer Nitrite document in the old plugin format. + * The document is preceded by a single binary byte (0x81) and starts with a + * UUID-keyed Value entry. Each AgentRound is stored as a Value whose value + * field is a JSON string containing {\"type\":\"AgentRound\",\"data\":\"...\"} + * (one level of JSON-string escaping from the document root). + */ + function jbOldFormatDoc(rounds: Array<{ reply: string; model?: string }>, opts: { upperUuid?: boolean } = {}) { + const cased = (u: string) => (opts.upperUuid ? u.toUpperCase() : u) + const entries: Record<string, unknown> = {} + // Lead entry (mimics the References record always present in real DBs) + entries[cased('0f383f5c-f169-4fee-9115-c06d4dd8985f')] = { + type: 'Value', + value: JSON.stringify({ type: 'References', data: '[]' }), + } + rounds.forEach((r, i) => { + const uuid = cased(`ccadf30b-fa34-4387-9f14-0a5f63457d${String(i).padStart(2, '0')}`) + const agentRoundData = JSON.stringify({ roundId: i + 1, reply: r.reply, toolCalls: [] }) + const agentRoundValue = JSON.stringify({ type: 'AgentRound', data: agentRoundData }) + entries[uuid] = { type: 'Value', value: agentRoundValue } + if (r.model) { + const modelUuid = cased(`bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbb${String(i).padStart(4, '0')}`) + entries[modelUuid] = { type: 'Value', value: `{"model":"${r.model}"}` } + } + }) + // Binary framing byte (0x81) followed by the JSON document + return '\x81' + JSON.stringify(entries) + } + + it('parses agent turns from old plugin format (≤1.5.x, no __first__ blobs)', async () => { + // The old plugin stores all turns in one big outer Nitrite document with a + // binary framing byte. The fallback path must find and parse it. + const convGuid = '17a5d71b-27f7-4937-8803-7fc2cbb705cb' + const convRecord = jbConversationRecord(convGuid, 'Understanding HBase Architecture') + const oldFormatContent = + 'H:2,block:8,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + convRecord + '\n' + + jbOldFormatDoc([ + { reply: "I'll scan the repository to find the top-level project structure.", model: 'gpt-4.1' }, + { reply: "Now I'll open the README to explain architecture." }, + { reply: '' }, // empty reply (pure tool-call round) — must not produce a call + ]) + + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'old-fmt-1', oldFormatContent) + const calls = await collectCalls(jbDbSource(dbPath, 'old-fmt-1')) + + // The fallback emits one call per outer document (all replies joined). + expect(calls).toHaveLength(1) + expect(calls[0]!.costIsEstimated).toBe(true) + // The two NON-EMPTY rounds are captured and joined; the empty (tool-call) + // round contributes nothing. Assert the exact combined token count so the + // test fails if either reply is dropped or the empty round leaks in. + const joined = + "I'll scan the repository to find the top-level project structure.\n" + + "Now I'll open the README to explain architecture." + expect(calls[0]!.outputTokens).toBe(Math.ceil(joined.length / 4)) + // The session label is the conversation TITLE, not the reply text. + expect(calls[0]!.userMessage).toBe('Understanding HBase Architecture') + }) + + it('parses old plugin format when the outer-doc UUIDs are uppercase hex', async () => { + // The outer-doc detection must be case-insensitive: an uppercase UUID must + // not make the whole session fall through to $0. + const convRecord = jbConversationRecord('27b6e82c-38f8-4048-9914-8fd3dcc816dc', 'Conv Upper') + const content = + 'H:2,block:8,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + convRecord + '\n' + + jbOldFormatDoc([{ reply: 'An uppercase-UUID reply with enough words to score.' }], { upperUuid: true }) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'old-fmt-upper', content) + const calls = await collectCalls(jbDbSource(dbPath, 'old-fmt-upper')) + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) + + it('old plugin format: does not parse when __first__ blobs already yield turns (no double-count)', async () => { + // When the newer __first__/Subgraph path finds turns, the old-format fallback + // must not run (turns.length > 0 prevents it). + const content = jbDbContent([ + jbAgentBlob(['A reply from the new format.']), + ]) + const dbPath = await createJetBrainsDb(tmpDir, 'iu', 'chat-agent-sessions', 'new-fmt-guard', content) + const calls = await collectCalls(jbDbSource(dbPath, 'new-fmt-guard')) + // Only the one Subgraph-format turn — no old-format duplicates + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) +}) + +describe('copilot provider - JetBrains dedup key stability across store rewrites', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'copilot-jetbrains-dedup-')) + vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1') + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + function jbDedupSource(path: string, sessionId: string) { + return { + path, project: 'copilot-jetbrains', provider: 'copilot', sourceType: 'jetbrains', + sessionId, storeId: sessionId, dbPath: path, mtime: '2026-07-03T12:00:00.000Z', + } as unknown as { path: string; project: string; provider: string; sourceType?: string } + } + + function blobFor(text: string) { + const innerMd = { type: 'Markdown', data: JSON.stringify({ text, annotations: [] }) } + const valueMap = { 'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) } } + return JSON.stringify({ __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) } }) + } + + function dbContent(blobs: string[]) { + return ( + 'H:2,block:9,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + '\n' + blobs.join('\nt\x00\x00model\n') + '\n' + ) + } + + it('a compaction that moves a new blob ahead of an old one must not re-bill the old turn', async () => { + // copilot is a durable provider: cached turns are never deleted, and a + // re-parse appends any dedup key it has not seen. MVStore compaction can + // rewrite the file with blobs in a different byte order. If dedup keys were + // positional (conversation + scan index), a rewrite that puts a NEW turn + // before an OLD one would hand the new turn the old turn's key (skipped as + // already-seen) and re-emit the old turn under a fresh index — billing it + // twice and never billing the new turn. Content-derived keys are immune. + const oldReply = 'The original answer, long enough to carry a token estimate.' + const newReply = 'A fresh answer written after the compaction happened.' + + const dir = join(tmpDir, 'iu', 'chat-agent-sessions', 'conv-rewrite') + await mkdir(dir, { recursive: true }) + const dbPath = join(dir, 'copilot-agent-sessions-nitrite.db') + + const seen = new Set<string>() + + // Scan 1: the store holds only the old turn. + await writeFile(dbPath, dbContent([blobFor(oldReply)])) + const first = await collectCalls(jbDedupSource(dbPath, 'conv-rewrite'), seen) + expect(first).toHaveLength(1) + expect(first[0]!.outputTokens).toBe(Math.ceil(oldReply.length / 4)) + + // Scan 2: compaction rewrote the file — the new turn now sits BEFORE the + // old one in byte order. + await writeFile(dbPath, dbContent([blobFor(newReply), blobFor(oldReply)])) + const second = await collectCalls(jbDedupSource(dbPath, 'conv-rewrite'), seen) + + // Exactly the new turn must be billed — once, at its own length. The old + // turn is already cached and must not re-enter under a different key. + expect(second).toHaveLength(1) + expect(second[0]!.outputTokens).toBe(Math.ceil(newReply.length / 4)) + }) +}) diff --git a/tests/providers/crush.test.ts b/tests/providers/crush.test.ts new file mode 100644 index 0000000..b86f535 --- /dev/null +++ b/tests/providers/crush.test.ts @@ -0,0 +1,317 @@ +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createCrushProvider } from '../../src/providers/crush.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await mkdtemp(join(tmpdir(), 'crush-test-')) +}) + +afterEach(async () => { + await rm(tmpRoot, { recursive: true, force: true }) +}) + +// CREATE TABLE statements taken verbatim from charmbracelet/crush@v0.66.1 +// internal/db/migrations/20250424200609_initial.sql, with subsequent ALTERs +// folded in (summary_message_id, provider on messages, is_summary_message, +// todos on sessions). Keeping the literal upstream column ordering and +// constraints makes drift easy to spot. +function createCrushDb(dir: string): string { + mkdirSync(dir, { recursive: true }) + const dbPath = join(dir, 'crush.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + parent_session_id TEXT, + title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0), + prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0), + completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0), + cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0), + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + summary_message_id TEXT, + todos TEXT + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', + model TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER, + provider TEXT, + is_summary_message INTEGER DEFAULT 0 NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +type SessionFixture = { + id: string + parentId?: string | null + promptTokens?: number + completionTokens?: number + cost?: number + createdAt?: number + updatedAt?: number + messageCount?: number +} + +function insertSession(db: TestDb, s: SessionFixture): void { + db.prepare(` + INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + s.id, + s.parentId ?? null, + 'test session', + s.messageCount ?? 0, + s.promptTokens ?? 0, + s.completionTokens ?? 0, + s.cost ?? 0, + s.createdAt ?? 1_700_000_000, + s.updatedAt ?? s.createdAt ?? 1_700_000_000, + ) +} + +function insertMessage(db: TestDb, sessionId: string, role: string, model: string | null, id: string): void { + db.prepare(` + INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at) + VALUES (?, ?, ?, '[]', ?, ?, ?) + `).run(id, sessionId, role, model, 1_700_000_000, 1_700_000_000) +} + +async function writeRegistry(globalDataDir: string, entries: Record<string, { path: string; data_dir: string }>): Promise<void> { + await mkdir(globalDataDir, { recursive: true }) + await writeFile(join(globalDataDir, 'projects.json'), JSON.stringify(entries)) +} + +async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> { + const out: ParsedProviderCall[] = [] + for await (const call of parser.parse()) out.push(call) + return out +} + +describe('crush provider', () => { + it('reports correct identity', () => { + const p = createCrushProvider() + expect(p.name).toBe('crush') + expect(p.displayName).toBe('Crush') + expect(p.modelDisplayName('gpt-5')).toBe('gpt-5') + }) + + it('returns no sessions when registry is missing', async () => { + const globalData = join(tmpRoot, 'crush-global') + process.env['CRUSH_GLOBAL_DATA'] = globalData + const p = createCrushProvider() + const sessions = await p.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('returns no sessions when registry is malformed JSON', async () => { + const globalData = join(tmpRoot, 'crush-global') + await mkdir(globalData, { recursive: true }) + await writeFile(join(globalData, 'projects.json'), '{ not json') + process.env['CRUSH_GLOBAL_DATA'] = globalData + const p = createCrushProvider() + const sessions = await p.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('discovers root sessions with cost or tokens, skipping zero rows and child sessions', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-a') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'root-with-cost', cost: 0.42, promptTokens: 100, completionTokens: 50, createdAt: 1_700_000_001 }) + insertSession(db, { id: 'root-no-spend', cost: 0, promptTokens: 0, completionTokens: 0, createdAt: 1_700_000_002 }) + insertSession(db, { id: 'child', parentId: 'root-with-cost', cost: 0.01, createdAt: 1_700_000_003 }) + insertSession(db, { id: 'root-tokens-only', cost: 0, promptTokens: 5, completionTokens: 5, createdAt: 1_700_000_004 }) + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'proj-a': { path: projectDir, data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sessions = await p.discoverSessions() + const ids = sessions.map(s => s.path.split(':').pop()).sort() + expect(ids).toEqual(['root-tokens-only', 'root-with-cost']) + expect(sessions.every(s => s.provider === 'crush')).toBe(true) + }) + + it('parses a session into a ParsedProviderCall with real tokens, cost, and dominant model', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-b') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { + id: 'sess-1', + promptTokens: 1234, + completionTokens: 567, + cost: 0.0789, + createdAt: 1_700_000_010, + updatedAt: 1_700_000_999, + }) + // Most-used model wins. + insertMessage(db, 'sess-1', 'assistant', 'claude-sonnet-4-6', 'm1') + insertMessage(db, 'sess-1', 'assistant', 'claude-sonnet-4-6', 'm2') + insertMessage(db, 'sess-1', 'assistant', 'gpt-5', 'm3') + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'proj-b': { path: projectDir, data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + expect(sources).toHaveLength(1) + + const calls = await collect(p.createSessionParser(sources[0]!, new Set())) + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('crush') + expect(call.model).toBe('claude-sonnet-4-6') + expect(call.inputTokens).toBe(1234) + expect(call.outputTokens).toBe(567) + expect(call.costUSD).toBeCloseTo(0.0789, 6) + expect(call.sessionId).toBe('sess-1') + expect(call.deduplicationKey).toBe('crush:sess-1') + // Crush stores epoch seconds; 1_700_000_999 sec → 2023-11-14T22:29:59.000Z. + expect(call.timestamp).toBe(new Date(1_700_000_999 * 1000).toISOString()) + }) + + it('falls back to "unknown" when no message has a model', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-c') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-no-model', cost: 0.05, promptTokens: 10, completionTokens: 5, createdAt: 1_700_000_500 }) + insertMessage(db, 'sess-no-model', 'user', null, 'm1') + insertMessage(db, 'sess-no-model', 'assistant', null, 'm2') + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'proj-c': { path: projectDir, data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + const calls = await collect(p.createSessionParser(sources[0]!, new Set())) + expect(calls[0]!.model).toBe('unknown') + }) + + it('respects seenKeys for deduplication', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-d') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-dup', cost: 0.10, promptTokens: 100, completionTokens: 50, createdAt: 1_700_000_700 }) + }) + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'proj-d': { path: projectDir, data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + const seen = new Set<string>() + const first = await collect(p.createSessionParser(sources[0]!, seen)) + expect(first).toHaveLength(1) + + const second = await collect(p.createSessionParser(sources[0]!, seen)) + expect(second).toHaveLength(0) + }) + + it('accepts an array-shaped projects.json (legacy format)', async () => { + if (!isSqliteAvailable()) return + + const projectDir = join(tmpRoot, 'project-e') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withTestDb(dbPath, db => { + insertSession(db, { id: 'sess-arr', cost: 0.01, promptTokens: 1, completionTokens: 1, createdAt: 1_700_000_800 }) + }) + + const globalData = join(tmpRoot, 'crush-global') + await mkdir(globalData, { recursive: true }) + await writeFile( + join(globalData, 'projects.json'), + JSON.stringify([{ path: projectDir, data_dir: '.crush' }]), + ) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + expect(sources).toHaveLength(1) + }) + + it('ignores registry entries whose db is missing', async () => { + if (!isSqliteAvailable()) return + + const globalData = join(tmpRoot, 'crush-global') + await writeRegistry(globalData, { + 'ghost': { path: join(tmpRoot, 'does-not-exist'), data_dir: '.crush' }, + }) + process.env['CRUSH_GLOBAL_DATA'] = globalData + + const p = createCrushProvider() + const sources = await p.discoverSessions() + expect(sources).toEqual([]) + }) + + it('is registered via getAllProviders', async () => { + if (!isSqliteAvailable()) return + const { getAllProviders } = await import('../../src/providers/index.js') + const providers = await getAllProviders() + const found = providers.find(p => p.name === 'crush') + expect(found).toBeDefined() + expect(found!.displayName).toBe('Crush') + }) +}) diff --git a/tests/providers/cursor-agent.test.ts b/tests/providers/cursor-agent.test.ts new file mode 100644 index 0000000..78b7282 --- /dev/null +++ b/tests/providers/cursor-agent.test.ts @@ -0,0 +1,329 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +import { getAllProviders } from '../../src/providers/index.js' +import { createCursorAgentProvider } from '../../src/providers/cursor-agent.js' +import type { ParsedProviderCall, Provider, SessionSource } from '../../src/providers/types.js' +import { isSqliteAvailable } from '../../src/sqlite.js' + +const CHARS_PER_TOKEN = 4 +const CURSOR_AGENT_DEFAULT_MODEL = 'cursor-agent-auto' +const FIXED_UUID = '123e4567-e89b-12d3-a456-426614174000' + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tempRoots: string[] = [] + +beforeEach(() => { + tempRoots = [] +}) + +afterEach(async () => { + await Promise.all(tempRoots.filter(existsSync).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function makeBaseDir(): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), 'cursor-agent-test-')) + tempRoots.push(dir) + return dir +} + +async function collectCalls(provider: Provider, source: SessionSource): Promise<ParsedProviderCall[]> { + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + return calls +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(dbPath) + fn(db) + db.close() +} + +describe('cursor-agent provider', () => { + it('is registered', async () => { + const all = await getAllProviders() + const provider = all.find((p) => p.name === 'cursor-agent') + + expect(provider).toBeDefined() + expect(provider?.displayName).toBe('Cursor Agent') + }) + + it('maps default model to Cursor (auto) label', () => { + const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture') + expect(provider.modelDisplayName('cursor-agent-auto')).toBe('Cursor (auto)') + }) + + it('maps known models and appends estimation label', () => { + const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture') + + expect(provider.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking) (est.)') + expect(provider.modelDisplayName('claude-4.6-sonnet')).toBe('Sonnet 4.6 (est.)') + expect(provider.modelDisplayName('composer-1')).toBe('Composer 1 (est.)') + }) + + it('falls through to raw model name for unknown models with single est. suffix', () => { + const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture') + + expect(provider.modelDisplayName('claude-5-future-model')).toBe('claude-5-future-model (est.)') + expect(provider.modelDisplayName('gpt-9')).toBe('gpt-9 (est.)') + }) + + it('returns identity for tool display name', () => { + const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture') + expect(provider.toolDisplayName('cursor:edit')).toBe('cursor:edit') + }) + + it('returns empty discovery when projects dir is missing', async () => { + const baseDir = await makeBaseDir() + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + expect(sources).toEqual([]) + }) + + it('discovers a single transcript', async () => { + const baseDir = await makeBaseDir() + const transcriptDir = join(baseDir, 'projects', 'test-proj', 'agent-transcripts') + await mkdir(transcriptDir, { recursive: true }) + const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`) + await writeFile(transcriptPath, 'user:\n<user_query>hello</user_query>\nA:\nworld\n') + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + expect(sources).toHaveLength(1) + expect(sources[0]!.provider).toBe('cursor-agent') + expect(sources[0]!.path).toBe(transcriptPath) + }) + + it('discovers transcripts across multiple projects', async () => { + const baseDir = await makeBaseDir() + const transcriptA = join(baseDir, 'projects', 'proj-one', 'agent-transcripts') + const transcriptB = join(baseDir, 'projects', 'proj-two', 'agent-transcripts') + await mkdir(transcriptA, { recursive: true }) + await mkdir(transcriptB, { recursive: true }) + await writeFile(join(transcriptA, `${FIXED_UUID}.txt`), 'user:\n<user_query>a</user_query>\nA:\na\n') + await writeFile(join(transcriptB, `${FIXED_UUID}.txt`), 'user:\n<user_query>b</user_query>\nA:\nb\n') + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + expect(sources).toHaveLength(2) + expect(sources.every((s) => s.provider === 'cursor-agent')).toBe(true) + }) + + it('does not scan a workspace root when agent-transcripts is missing', async () => { + const baseDir = await makeBaseDir() + const workspaceRoot = join(baseDir, 'projects', 'workspace-without-transcripts') + await mkdir(workspaceRoot, { recursive: true }) + await writeFile( + join(workspaceRoot, 'extension-state.txt'), + 'user:\n<user_query>not a transcript</user_query>\nA:\nnot a cursor-agent answer\n', + ) + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + expect(sources).toEqual([]) + }) + + it('prefers jsonl over same-session txt inside UUID transcript dirs', async () => { + const baseDir = await makeBaseDir() + const sessionDir = join(baseDir, 'projects', 'proj-with-duplicates', 'agent-transcripts', FIXED_UUID) + const jsonlPath = join(sessionDir, `${FIXED_UUID}.jsonl`) + const txtPath = join(sessionDir, `${FIXED_UUID}.txt`) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + jsonlPath, + '{"role":"user","message":{"content":[{"type":"text","text":"<user_query>jsonl wins</user_query>"}]}}\n{"role":"assistant","message":{"content":[{"type":"text","text":"jsonl answer"}]}}\n', + ) + await writeFile(txtPath, 'user:\n<user_query>txt duplicate</user_query>\nA:\ntxt answer\n') + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + expect(sources).toHaveLength(1) + expect(sources[0]!.path).toBe(jsonlPath) + }) + + it('parses one user/assistant pair with estimated token counts', async () => { + const baseDir = await makeBaseDir() + const transcriptDir = join(baseDir, 'projects', 'my-proj', 'agent-transcripts') + await mkdir(transcriptDir, { recursive: true }) + + const userText = 'explain parser output' + const assistantText = 'first line\nsecond line' + const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`) + + await writeFile( + transcriptPath, + `user:\n<user_query>${userText}</user_query>\nA:\n${assistantText}\n` + ) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const calls = await collectCalls(provider, source) + + expect(calls).toHaveLength(1) + expect(calls[0]!.provider).toBe('cursor-agent') + expect(calls[0]!.model).toBe(CURSOR_AGENT_DEFAULT_MODEL) + expect(calls[0]!.inputTokens).toBe(Math.ceil(userText.length / CHARS_PER_TOKEN)) + expect(calls[0]!.outputTokens).toBe(Math.ceil(assistantText.length / CHARS_PER_TOKEN)) + expect(calls[0]!.reasoningTokens).toBe(0) + expect(calls[0]!.deduplicationKey).toBe(`cursor-agent:${FIXED_UUID}:0`) + }) + + it('parses without sqlite db and defaults model', async () => { + const baseDir = await makeBaseDir() + const transcriptDir = join(baseDir, 'projects', 'fallback-proj', 'agent-transcripts') + await mkdir(transcriptDir, { recursive: true }) + const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`) + + await writeFile(transcriptPath, 'user:\n<user_query>hello world</user_query>\nA:\n[Thinking]private\nvisible\n') + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const calls = await collectCalls(provider, source) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe(CURSOR_AGENT_DEFAULT_MODEL) + expect(calls[0]!.reasoningTokens).toBe(2) + expect(calls[0]!.outputTokens).toBe(2) + }) + + it('skips unrecognized transcript format and writes stderr message', async () => { + const baseDir = await makeBaseDir() + const transcriptDir = join(baseDir, 'projects', 'bad-proj', 'agent-transcripts') + await mkdir(transcriptDir, { recursive: true }) + const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`) + await writeFile(transcriptPath, 'no markers in this transcript') + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const calls = await collectCalls(provider, source) + + expect(calls).toHaveLength(0) + expect(stderrSpy).toHaveBeenCalled() + expect(String(stderrSpy.mock.calls[0]?.[0] ?? '')).toContain('unrecognized cursor-agent transcript format') + + stderrSpy.mockRestore() + }) + + it('warns only once for the same unrecognized transcript', async () => { + const baseDir = await makeBaseDir() + const transcriptDir = join(baseDir, 'projects', 'bad-proj-repeat', 'agent-transcripts') + await mkdir(transcriptDir, { recursive: true }) + const transcriptPath = join(transcriptDir, 'repeat-bad.txt') + await writeFile(transcriptPath, 'no cursor-agent markers here') + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await collectCalls(provider, source) + await collectCalls(provider, source) + + const warnings = stderrSpy.mock.calls + .map(call => String(call[0] ?? '')) + .filter(message => message.includes('unrecognized cursor-agent transcript format')) + expect(warnings).toHaveLength(1) + + stderrSpy.mockRestore() + }) + + it('discovers jsonl transcripts stored directly under project dir (workspace-less layout)', async () => { + const baseDir = await makeBaseDir() + const fixtureRoot = join(import.meta.dirname, '../fixtures/cursor-agent/workspace-less') + const sessionDir = join(baseDir, 'projects', 'agent-transcripts', '1031d227-0c67-4e17-8954-0b6e2b3322f0') + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, '1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl'), + await readFile( + join( + fixtureRoot, + 'projects/agent-transcripts/1031d227-0c67-4e17-8954-0b6e2b3322f0/1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl', + ), + 'utf-8', + ), + ) + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + expect(sources).toHaveLength(1) + expect(sources[0]!.project).toBe('transcripts') + expect(sources[0]!.path.endsWith('.jsonl')).toBe(true) + + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe('1031d227-0c67-4e17-8954-0b6e2b3322f0') + expect(calls[0]!.userMessage).toBe('Run a quick smoke test') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('falls back to stable sha1 conversation id for non-uuid filenames', async () => { + const baseDir = await makeBaseDir() + const transcriptDir = join(baseDir, 'projects', 'sha-proj', 'agent-transcripts') + await mkdir(transcriptDir, { recursive: true }) + const transcriptPath = join(transcriptDir, 'not-a-uuid.txt') + await writeFile(transcriptPath, 'user:\n<user_query>test</user_query>\nA:\nresult\n') + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + + const callsFirst = await collectCalls(provider, source) + const callsSecond = await collectCalls(provider, source) + + expect(callsFirst).toHaveLength(1) + expect(callsSecond).toHaveLength(1) + expect(callsFirst[0]!.sessionId).toHaveLength(16) + expect(callsFirst[0]!.deduplicationKey.startsWith('cursor-agent:')).toBe(true) + expect(callsFirst[0]!.sessionId).toBe(callsSecond[0]!.sessionId) + expect(callsFirst[0]!.deduplicationKey).toBe(callsSecond[0]!.deduplicationKey) + }) +}) + +skipUnlessSqlite('cursor-agent sqlite metadata', () => { + it('uses model metadata from ai-code-tracking db when present', async () => { + const baseDir = await makeBaseDir() + const transcriptDir = join(baseDir, 'projects', 'proj-with-db', 'agent-transcripts') + const aiTrackingDir = join(baseDir, 'ai-tracking') + await mkdir(transcriptDir, { recursive: true }) + await mkdir(aiTrackingDir, { recursive: true }) + + await writeFile( + join(transcriptDir, `${FIXED_UUID}.txt`), + 'user:\n<user_query>estimate cost</user_query>\nA:\nanswer\n' + ) + + const dbPath = join(aiTrackingDir, 'ai-code-tracking.db') + withTestDb(dbPath, (db) => { + db.exec('CREATE TABLE conversation_summaries (conversationId TEXT, title TEXT, tldr TEXT, model TEXT, mode TEXT, updatedAt INTEGER)') + db.prepare('INSERT INTO conversation_summaries (conversationId, title, tldr, model, mode, updatedAt) VALUES (?, ?, ?, ?, ?, ?)') + .run(FIXED_UUID, 'Demo title', '', 'claude-4.6-sonnet', 'agent', 1735689600000) + }) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const calls = await collectCalls(provider, source) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('claude-4.6-sonnet') + expect(calls[0]!.timestamp).toBe('2025-01-01T00:00:00.000Z') + }) +}) diff --git a/tests/providers/cursor-bubble-dedup.test.ts b/tests/providers/cursor-bubble-dedup.test.ts new file mode 100644 index 0000000..a164eeb --- /dev/null +++ b/tests/providers/cursor-bubble-dedup.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { isSqliteAvailable, openDatabase } from '../../src/sqlite.js' +import { getAllProviders } from '../../src/providers/index.js' +import type { Provider, ParsedProviderCall } from '../../src/providers/types.js' + +/// Pinned regression for the v3 bubble-dedup fix. The previous (v2) code used +/// the bubble row's mutable token counts as part of the deduplication key, so +/// the same bubble was counted twice once Cursor wrote the streaming-complete +/// final token totals on top of the streaming-in-progress row. v3 switched to +/// the SQLite primary `key` column (which is the stable bubbleId:<id>:<id> +/// path) so re-parsing the same DB after token updates produces zero new +/// calls. This test: +/// 1. Builds a tmp SQLite DB with the cursorDiskKV schema and one bubble row +/// with low token counts (the streaming-in-progress shape). +/// 2. Parses it through the cursor provider. Asserts one call. +/// 3. Mutates the row in place to higher token counts (the streaming-complete +/// shape) without changing the SQLite key. +/// 4. Re-parses with the SAME seenKeys set. Asserts zero new calls. +/// If a future refactor brings back token-count-based dedup, the second parse +/// will produce a duplicate call and this test will fail. + +const skipReason = isSqliteAvailable() + ? null + : 'node:sqlite not available — needs Node 22+; skipping' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cursor-dedup-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function buildBubbleValue(opts: { + conversationId: string + text: string + inputTokens: number + outputTokens: number + type: 1 | 2 + createdAt?: string +}): string { + return JSON.stringify({ + type: opts.type, + conversationId: opts.conversationId, + text: opts.text, + tokenCount: { + inputTokens: opts.inputTokens, + outputTokens: opts.outputTokens, + }, + createdAt: opts.createdAt ?? new Date().toISOString(), + modelId: 'gpt-5', + capabilityType: 'composer', + }) +} + +async function createCursorTestDb(): Promise<string> { + // Cursor uses a non-extension state DB filename (state.vscdb in the real app); + // any path works for openDatabase as long as we set up the schema and the + // directory layout the parser expects. The parser only checks the DB + // contents — discovery is bypassed because we hand it the path directly. + const dbPath = join(tmpDir, 'state.vscdb') + await writeFile(dbPath, '') + // Use the underlying node:sqlite to create the schema. + // We need cursorDiskKV with key + value columns. + const Module = await import('node:module') + const requireForSqlite = Module.createRequire(import.meta.url) + const { DatabaseSync } = requireForSqlite('node:sqlite') as { + DatabaseSync: new (path: string) => { + exec(sql: string): void + prepare(sql: string): { run(...p: unknown[]): unknown } + close(): void + } + } + const db = new DatabaseSync(dbPath) + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)') + + // Single assistant bubble (type=2). The parser yields one ParsedProviderCall + // per bubbleId:% row, so a multi-row fixture would muddy the dedup count; + // we keep the test surface minimal — one bubble through one parse, then + // the same bubble again after token mutation. + const bubbleKey = 'bubbleId:abc-123:bubble-xyz' + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run( + bubbleKey, + buildBubbleValue({ + conversationId: 'abc-123', + text: 'def hello(): pass', + inputTokens: 100, + outputTokens: 20, + type: 2, + }) + ) + + db.close() + return dbPath +} + +async function updateAssistantBubbleTokens(dbPath: string, inputTokens: number, outputTokens: number): Promise<void> { + const Module = await import('node:module') + const requireForSqlite = Module.createRequire(import.meta.url) + const { DatabaseSync } = requireForSqlite('node:sqlite') as { + DatabaseSync: new (path: string) => { + prepare(sql: string): { run(...p: unknown[]): unknown } + close(): void + } + } + const db = new DatabaseSync(dbPath) + db.prepare('UPDATE cursorDiskKV SET value = ? WHERE key = ?').run( + buildBubbleValue({ + conversationId: 'abc-123', + text: 'def hello(): pass', + inputTokens, + outputTokens, + type: 2, + }), + 'bubbleId:abc-123:bubble-xyz' + ) + db.close() +} + +async function getCursorProvider(): Promise<Provider> { + const all = await getAllProviders() + const p = all.find(p => p.name === 'cursor') + if (!p) throw new Error('cursor provider not registered') + return p +} + +describe.skipIf(skipReason !== null)('cursor bubble dedup (regression for v3 fix)', () => { + it('does not double-count when bubble token counts mutate between parses', async () => { + const dbPath = await createCursorTestDb() + const provider = await getCursorProvider() + + // First parse: streaming-in-progress shape. + const seenKeys = new Set<string>() + const source = { path: dbPath, project: 'test-project', provider: 'cursor' } + const firstRunCalls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + firstRunCalls.push(call) + } + expect(firstRunCalls.length).toBe(1) + + // Cursor mutates the same bubble row to its final token totals when the + // stream completes. Simulate by updating in place. The SQLite primary + // key stays the same. + await updateAssistantBubbleTokens(dbPath, 250, 80) + + // Second parse with the SAME seenKeys: must yield zero new calls. If the + // dedup key were derived from token counts (the v2 bug), this would + // produce a duplicate. + const secondRunCalls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + secondRunCalls.push(call) + } + expect(secondRunCalls.length).toBe(0) + }) + + it('does not yield the same bubble twice within a single parser run', async () => { + const dbPath = await createCursorTestDb() + const provider = await getCursorProvider() + const seenKeys = new Set<string>() + const source = { path: dbPath, project: 'test-project', provider: 'cursor' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + calls.push(call) + } + // One bubble in the DB → one call. (The user message row at type=1 is + // not surfaced as a separate ParsedProviderCall; it's threaded into the + // assistant call's userMessage field.) + expect(calls.length).toBe(1) + }) +}) diff --git a/tests/providers/cursor-large-db-cap.test.ts b/tests/providers/cursor-large-db-cap.test.ts new file mode 100644 index 0000000..7b60c3d --- /dev/null +++ b/tests/providers/cursor-large-db-cap.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { isSqliteAvailable } from '../../src/sqlite.js' +import { getAllProviders } from '../../src/providers/index.js' +import type { Provider, ParsedProviderCall } from '../../src/providers/types.js' +import type { DateRange } from '../../src/types.js' + +/// Regression for #482: the Cursor scan must not drop in-range sessions just +/// because the DB has more bubbles than the scan budget. The old code kept the +/// most-recent MAX_BUBBLES rows *by ROWID* and warned unconditionally; the new +/// code pages the requested time window and only truncates (with a warning) +/// when the in-range scan genuinely exceeds the budget. We shrink the budget +/// via CODEBURN_CURSOR_MAX_BUBBLES so a tiny fixture exercises the capped path. + +const skipReason = isSqliteAvailable() ? null : 'node:sqlite not available — needs Node 22+; skipping' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cursor-cap-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +type Bubble = { conversationId: string; createdAt: string; model: string; tokens: number } + +/// Inserts assistant bubbles in array order, so ROWID follows array index. +async function createDb(bubbles: Bubble[]): Promise<string> { + const dbPath = join(tmpDir, 'state.vscdb') + await writeFile(dbPath, '') + const Module = await import('node:module') + const requireForSqlite = Module.createRequire(import.meta.url) + const { DatabaseSync } = requireForSqlite('node:sqlite') as { + DatabaseSync: new (path: string) => { + exec(sql: string): void + prepare(sql: string): { run(...p: unknown[]): unknown } + close(): void + } + } + const db = new DatabaseSync(dbPath) + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)') + const stmt = db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)') + bubbles.forEach((b, i) => { + stmt.run( + `bubbleId:${b.conversationId}:bubble-${i}`, + JSON.stringify({ + type: 2, + conversationId: b.conversationId, + text: 'def hello(): pass', + tokenCount: { inputTokens: b.tokens, outputTokens: b.tokens }, + createdAt: b.createdAt, + modelInfo: { modelName: b.model }, + }), + ) + }) + db.close() + return dbPath +} + +async function getCursorProvider(): Promise<Provider> { + const p = (await getAllProviders()).find(p => p.name === 'cursor') + if (!p) throw new Error('cursor provider not registered') + return p +} + +async function parse(dbPath: string, range: DateRange): Promise<ParsedProviderCall[]> { + const provider = await getCursorProvider() + const source = { path: dbPath, project: 'test', provider: 'cursor' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set<string>(), range).parse()) { + calls.push(call) + } + return calls +} + +const iso = (daysAgo: number) => new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString() +const last30Days = (): DateRange => ({ start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), end: new Date() }) +const last120Days = (): DateRange => ({ start: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), end: new Date() }) + +describe.skipIf(skipReason !== null)('cursor large-DB scan cap (#482)', () => { + it('keeps in-range sessions even when they have low ROWIDs and the DB is over budget', async () => { + // In-range bubbles inserted FIRST (low ROWID); out-of-range bubbles inserted + // LATER (high ROWID). The old "most-recent N by ROWID" cap would scan only + // the high-ROWID out-of-range rows and drop the in-range ones entirely. + const dbPath = await createDb([ + { conversationId: 'recent-A', createdAt: iso(1), model: 'gpt-5', tokens: 100 }, + { conversationId: 'recent-B', createdAt: iso(2), model: 'gpt-5', tokens: 100 }, + { conversationId: 'old-C', createdAt: iso(300), model: 'gpt-5', tokens: 100 }, + { conversationId: 'old-D', createdAt: iso(300), model: 'gpt-5', tokens: 100 }, + ]) + process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '2' // total 4 > budget 2 -> capped path + + const calls = await parse(dbPath, last30Days()) + // Both in-range sessions are present (the old ROWID cap returned 0 here). + expect(calls.length).toBe(2) + }) + + it('returns the whole window when in-range bubbles fit the budget (over-budget DB)', async () => { + const dbPath = await createDb([ + { conversationId: 'A', createdAt: iso(1), model: 'gpt-5', tokens: 100 }, + { conversationId: 'B', createdAt: iso(2), model: 'gpt-5', tokens: 100 }, + { conversationId: 'old', createdAt: iso(300), model: 'gpt-5', tokens: 100 }, + { conversationId: 'older', createdAt: iso(301), model: 'gpt-5', tokens: 100 }, + ]) + process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '3' // total 4 > budget 3, but in-range 2 <= 3 + const calls = await parse(dbPath, last30Days()) + expect(calls.length).toBe(2) // both in-range, none truncated + }) + + it('truncates to the budget and keeps the newest in-range bubbles when over budget', async () => { + // Four in-range bubbles, oldest->newest by ROWID; budget 2 keeps the two newest. + const dbPath = await createDb([ + { conversationId: 'd1', createdAt: iso(40), model: 'old-model', tokens: 100 }, + { conversationId: 'd2', createdAt: iso(30), model: 'old-model', tokens: 100 }, + { conversationId: 'd3', createdAt: iso(2), model: 'new-model', tokens: 100 }, + { conversationId: 'd4', createdAt: iso(1), model: 'new-model', tokens: 100 }, + ]) + process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '2' // total 4 > budget 2 + const calls = await parse(dbPath, last120Days()) + expect(calls.length).toBe(2) + // Budget keeps the highest-ROWID (newest-inserted) bubbles. + expect(calls.every(c => c.model === 'new-model')).toBe(true) + }) +}) diff --git a/tests/providers/cursor-real-tokens.test.ts b/tests/providers/cursor-real-tokens.test.ts new file mode 100644 index 0000000..7870745 --- /dev/null +++ b/tests/providers/cursor-real-tokens.test.ts @@ -0,0 +1,412 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' + +import { + createCursorProvider, + clearCursorWorkspaceMapCache, +} from '../../src/providers/cursor.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +const skipReason = isSqliteAvailable() + ? null + : 'node:sqlite not available — needs Node 22+; skipping' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cursor-tokens-test-')) + clearCursorWorkspaceMapCache() +}) + +afterEach(async () => { + clearCursorWorkspaceMapCache() + await rm(tmpDir, { recursive: true, force: true }) +}) + +function buildDb(fn: (db: { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +}) => void): string { + const dbPath = join(tmpDir, 'state.vscdb') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)') + db.exec('CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)') + fn(db) + db.close() + return dbPath +} + +function insertBubble(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + composerId: string + bubbleUuid: string + type: 1 | 2 + text: string + model?: string + inputTokens?: number + outputTokens?: number + createdAt?: string + requestId?: string + codeBlocks?: string +}): void { + const key = `bubbleId:${opts.composerId}:${opts.bubbleUuid}` + const value = JSON.stringify({ + type: opts.type, + conversationId: '', + createdAt: opts.createdAt ?? new Date().toISOString(), + tokenCount: { + inputTokens: opts.inputTokens ?? 0, + outputTokens: opts.outputTokens ?? 0, + }, + modelInfo: opts.model ? { modelName: opts.model } : undefined, + text: opts.text, + codeBlocks: opts.codeBlocks ?? '[]', + requestId: opts.requestId, + }) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value) +} + +function insertComposerData(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + composerId: string + totalUsedTokens?: number | null + contextTokensUsed?: number | null + createdAt?: number +}): void { + const key = `composerData:${opts.composerId}` + const breakdown = opts.totalUsedTokens !== undefined + ? { totalUsedTokens: opts.totalUsedTokens } + : {} + const value = JSON.stringify({ + promptTokenBreakdown: breakdown, + contextTokensUsed: opts.contextTokensUsed ?? undefined, + createdAt: opts.createdAt ?? undefined, + }) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value) +} + +function insertAgentKv(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + blobId: string + role: string + content: unknown + requestId?: string +}): void { + const key = `agentKv:blob:${opts.blobId}` + const value = JSON.stringify({ + role: opts.role, + content: opts.content, + providerOptions: opts.requestId + ? { cursor: { requestId: opts.requestId } } + : undefined, + }) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value) +} + +async function collectCalls(provider: ReturnType<typeof createCursorProvider>, dbPath: string): Promise<ParsedProviderCall[]> { + const source = { path: dbPath, project: 'test', provider: 'cursor' as const } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + return calls +} + +describe.skipIf(skipReason !== null)('cursor real context tokens (#575)', () => { + it('credits composerData.promptTokenBreakdown.totalUsedTokens as input', async () => { + const composerId = 'aaaa1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 50000 }) + insertBubble(db, { + composerId, bubbleUuid: 'b1', type: 1, text: 'user prompt', + inputTokens: 0, outputTokens: 0, + }) + insertBubble(db, { + composerId, bubbleUuid: 'b2', type: 2, text: 'assistant reply', + model: 'claude-4.6-sonnet', inputTokens: 0, outputTokens: 0, + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const credited = calls.find(c => c.inputTokens === 50000) + expect(credited).toBeDefined() + expect(credited!.deduplicationKey).toBe(`cursor:composer-input:${composerId}`) + expect(credited!.costIsEstimated).toBe(true) + }) + + it('credits real input tokens once per conversation, not per bubble', async () => { + const composerId = 'bbbb1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 30000 }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'turn 1' }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply 1', model: 'gpt-5' }) + insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'turn 2' }) + insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'reply 2', model: 'gpt-5' }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const credited = calls.filter(c => c.inputTokens === 30000) + expect(credited.length).toBe(1) + // The metered conversation's user-bubble text must not be counted on top. + const inputTotal = calls.reduce((s, c) => s + c.inputTokens, 0) + expect(inputTotal).toBe(30000) + }) + + it('anchors the conversation record to composerData.createdAt, independent of the parse window', async () => { + const composerId = 'ab121111-2222-3333-4444-555566667777' + const startMs = Date.parse('2026-06-01T10:00:00.000Z') + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 20000, createdAt: startMs }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'later turn' }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const credited = calls.find(c => c.inputTokens === 20000) + expect(credited).toBeDefined() + expect(credited!.timestamp).toBe(new Date(startMs).toISOString()) + }) + + it('falls back to text estimation when no composerData exists', async () => { + const composerId = 'cccc1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertBubble(db, { + composerId, bubbleUuid: 'b1', type: 1, text: 'hello world this is a test', + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const userCall = calls.find(c => c.inputTokens > 0) + expect(userCall).toBeDefined() + expect(userCall!.inputTokens).toBe(Math.ceil('hello world this is a test'.length / 4)) + }) + + it('uses contextTokensUsed when totalUsedTokens is null', async () => { + const composerId = 'dddd1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: null, contextTokensUsed: 42000 }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply', model: 'gpt-5' }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const credited = calls.find(c => c.inputTokens === 42000) + expect(credited).toBeDefined() + }) + + it('uses contextTokensUsed when totalUsedTokens is present but zero', async () => { + const composerId = 'de001111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 0, contextTokensUsed: 42000 }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const credited = calls.find(c => c.inputTokens === 42000) + expect(credited).toBeDefined() + }) + + it('skips the meter when any bubble carries real tokenCounts', async () => { + const composerId = 'ef001111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 80000 }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt', inputTokens: 6000 }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply', model: 'gpt-5', outputTokens: 900 }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + // Real per-bubble counts are authoritative; the snapshot must not stack. + expect(calls.find(c => c.inputTokens === 80000)).toBeUndefined() + const inputTotal = calls.reduce((s, c) => s + c.inputTokens, 0) + expect(inputTotal).toBe(6000) + }) + + it('attributes aggregated agentKv tools once, with canonical Bash names', async () => { + const composerId = 'eeee1111-2222-3333-4444-555566667777' + const requestId = 'req-001' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 10000 }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'do stuff', requestId }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'doing stuff', model: 'gpt-5' }) + insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'do more stuff', requestId: 'req-002' }) + insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'doing more stuff', model: 'gpt-5' }) + insertAgentKv(db, { + blobId: 'akv-1', role: 'user', + content: [{ type: 'text', text: 'do stuff' }], + requestId, + }) + insertAgentKv(db, { + blobId: 'akv-2', role: 'assistant', + content: [ + { type: 'tool-call', toolName: 'Read', args: {} }, + { type: 'tool-call', toolName: 'Shell', args: { command: 'npm test' } }, + ], + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const callWithTools = calls.find(c => c.tools.length > 0) + expect(callWithTools).toBeDefined() + expect(callWithTools!.tools).toContain('Read') + expect(callWithTools!.tools).toContain('Bash') + expect(callWithTools!.bashCommands).toContain('npm') + + const allTools = calls.flatMap(c => c.tools) + const allBashCommands = calls.flatMap(c => c.bashCommands) + expect(allTools.filter(t => t === 'Read').length).toBe(1) + expect(allTools.filter(t => t === 'Bash').length).toBe(1) + expect(allBashCommands.filter(cmd => cmd === 'npm').length).toBe(1) + }) + + it('uses conversation model for pricing the conversation record', async () => { + const composerId = 'ffff1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 100000 }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' }) + insertBubble(db, { + composerId, bubbleUuid: 'b2', type: 2, text: 'reply', + model: 'claude-4.5-opus-high-thinking', + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const creditedCall = calls.find(c => c.inputTokens === 100000) + expect(creditedCall).toBeDefined() + expect(creditedCall!.model).toBe('claude-4.5-opus-high-thinking') + }) + + it('estimates input from the agent stream when a non-Composer turn has empty bubble text', async () => { + const composerId = '99990000-1111-2222-3333-444455556666' + const requestId = 'req-gpt-1' + const prompt = '<user_info>OS: darwin</user_info> refactor the auth module and add tests' + const dbPath = buildDb((db) => { + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: '', requestId }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'done', model: 'gpt-5' }) + insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: prompt, requestId }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const credited = calls.find(c => c.inputTokens > 0) + expect(credited).toBeDefined() + expect(credited!.inputTokens).toBe(Math.ceil(prompt.length / 4)) + }) + + it('counts tool and system stream rows as context for meterless sessions', async () => { + const composerId = '77770000-1111-2222-3333-444455556666' + const requestId = 'req-gpt-2' + const prompt = 'summarize the repo' + const toolResult = 'x'.repeat(4000) + const dbPath = buildDb((db) => { + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: '', requestId }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'done', model: 'gpt-5' }) + insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: prompt, requestId }) + insertAgentKv(db, { blobId: 'akv-2', role: 'tool', content: [{ type: 'text', text: toolResult }] }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const credited = calls.find(c => c.inputTokens > 0) + expect(credited).toBeDefined() + expect(credited!.inputTokens).toBe(Math.ceil((prompt.length + toolResult.length) / 4)) + }) + + it('does not double count turns that also have bubble text in stream-estimated conversations', async () => { + const composerId = '66660000-1111-2222-3333-444455556666' + const requestId = 'req-gpt-3' + const streamPrompt = 'the full prompt with injected context' + const dbPath = buildDb((db) => { + // Turn 1 has visible bubble text; turn 2's lives only in the stream. + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'visible text', requestId }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 1, text: '' }) + insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: streamPrompt, requestId }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const inputTotal = calls.reduce((s, c) => s + c.inputTokens, 0) + expect(inputTotal).toBe(Math.ceil(streamPrompt.length / 4)) + }) + + it('emits sessions recorded only in the agent stream', async () => { + const requestId = 'req-headless-1' + const prompt = 'run the nightly data export' + const reply = 'export completed with 3 warnings' + const dbPath = buildDb((db) => { + insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: prompt, requestId }) + insertAgentKv(db, { blobId: 'akv-2', role: 'assistant', content: [{ type: 'text', text: reply }] }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const session = calls.find(c => c.deduplicationKey === `cursor:agentKv:${requestId}`) + expect(session).toBeDefined() + expect(session!.inputTokens).toBe(Math.ceil(prompt.length / 4)) + expect(session!.outputTokens).toBe(Math.ceil(reply.length / 4)) + }) + + it('pairs each assistant reply with its own turn\'s user question', async () => { + const composerId = '55550000-1111-2222-3333-444455556666' + const dbPath = buildDb((db) => { + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'first question' }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'first reply', model: 'gpt-5' }) + insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'second question' }) + insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'second reply', model: 'gpt-5' }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const firstReply = calls.find(c => c.userMessage.includes('first reply')) + const secondReply = calls.find(c => c.userMessage.includes('second reply')) + expect(firstReply).toBeDefined() + expect(secondReply).toBeDefined() + expect(firstReply!.userMessage).toContain('first question') + expect(secondReply!.userMessage).toContain('second question') + }) + + it('does not fabricate input when an empty-text turn has no agent stream', async () => { + const composerId = '88880000-1111-2222-3333-444455556666' + const dbPath = buildDb((db) => { + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: '' }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'done', model: 'gpt-5' }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + expect(calls.find(c => c.inputTokens > 0)).toBeUndefined() + }) +}) diff --git a/tests/providers/cursor-workspace-breakdown.test.ts b/tests/providers/cursor-workspace-breakdown.test.ts new file mode 100644 index 0000000..8e666b4 --- /dev/null +++ b/tests/providers/cursor-workspace-breakdown.test.ts @@ -0,0 +1,330 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { + createCursorProvider, + clearCursorWorkspaceMapCache, +} from '../../src/providers/cursor.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +let userDir: string + +beforeEach(async () => { + userDir = await mkdtemp(join(tmpdir(), 'cursor-ws-test-')) + // Layout matches Cursor's: <userDir>/{globalStorage,workspaceStorage}/. + await mkdir(join(userDir, 'globalStorage'), { recursive: true }) + await mkdir(join(userDir, 'workspaceStorage'), { recursive: true }) + clearCursorWorkspaceMapCache() +}) + +afterEach(async () => { + clearCursorWorkspaceMapCache() + await rm(userDir, { recursive: true, force: true }) +}) + +function globalDbPath(): string { + return join(userDir, 'globalStorage', 'state.vscdb') +} + +/// Builds a global state.vscdb with the cursorDiskKV table and a small set of +/// bubbles for the requested composer ids. Each bubble carries enough fields +/// to satisfy parseBubbles() — created_at, tokenCount, conversationId, type. +function createGlobalDb(composerIds: string[]): string { + const dbPath = globalDbPath() + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(`CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)`) + // ItemTable is unused by the global parser but creating it mirrors the + // real schema so a stray query against it does not error. + db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`) + + const insert = db.prepare(`INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)`) + const baseTime = Date.now() - 24 * 3600 * 1000 + + for (const composerId of composerIds) { + // Exactly one assistant bubble per composer so the test math is + // "one composer == one call". User bubbles also produce calls in the + // real parser (text-length token estimation), but they are not + // necessary to exercise the workspace routing logic. + const bubbleId = `bubbleId:${composerId}:bubble-${composerId.slice(0, 6)}` + const bubble = { + type: 2, // assistant + conversationId: composerId, + createdAt: new Date(baseTime).toISOString(), + tokenCount: { inputTokens: 100, outputTokens: 50 }, + modelInfo: { modelName: 'claude-4.6-sonnet' }, + text: 'assistant reply for ' + composerId, + codeBlocks: '[]', + } + insert.run(bubbleId, JSON.stringify(bubble)) + } + + db.close() + return dbPath +} + +/// Creates one workspaceStorage/<hash>/ subdir with workspace.json (folder URI) +/// and state.vscdb (composer.composerData listing the supplied composerIds). +function createWorkspaceDir(hash: string, folderUri: string, composerIds: string[]): void { + const dir = join(userDir, 'workspaceStorage', hash) + mkdirSync(dir, { recursive: true }) + + const wsJsonPath = join(dir, 'workspace.json') + // We cannot do a top-level await in a sync helper; the caller writes via + // mkdirSync above and the JSON via Node's sync writeFile shim through the + // require'd 'fs'. Using readFileSync-friendly imports to keep this test + // helper sync. + const fs = requireForTest('fs') as typeof import('fs') + fs.writeFileSync(wsJsonPath, JSON.stringify({ folder: folderUri })) + + const wsDbPath = join(dir, 'state.vscdb') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(wsDbPath) + db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`) + const composerData = { + allComposers: composerIds.map(id => ({ + composerId: id, + name: 'session-' + id.slice(0, 6), + unifiedMode: 'agent', + })), + } + db.prepare(`INSERT INTO ItemTable (key, value) VALUES (?, ?)`).run( + 'composer.composerData', + JSON.stringify(composerData), + ) + db.close() +} + +async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> { + const out: ParsedProviderCall[] = [] + for await (const call of parser.parse()) out.push(call) + return out +} + +describe('cursor provider — per-project breakdown (#196)', () => { + it('emits one source per workspace plus an orphan source', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb([ + 'composer-work-1', + 'composer-work-2', + 'composer-personal-1', + 'composer-orphan-1', + ]) + createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1', 'composer-work-2']) + createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + + const projects = sources.map(s => s.project).sort() + expect(projects).toContain('-Users-me-work-app') + expect(projects).toContain('-Users-me-personal-app') + // Orphan source is labeled 'cursor' so a user with no workspaces + // sees the same project name as before the breakdown change. + expect(projects).toContain('cursor') + }) + + it('routes calls to the right workspace and excludes others', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb([ + 'composer-work-1', + 'composer-work-2', + 'composer-personal-1', + ]) + createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1', 'composer-work-2']) + createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + const workSource = sources.find(s => s.project === '-Users-me-work-app')! + const personalSource = sources.find(s => s.project === '-Users-me-personal-app')! + + const workCalls = await collect(provider.createSessionParser(workSource, new Set())) + const personalCalls = await collect(provider.createSessionParser(personalSource, new Set())) + + const workComposerIds = new Set(workCalls.map(c => c.sessionId)) + expect(workComposerIds).toEqual(new Set(['composer-work-1', 'composer-work-2'])) + const personalComposerIds = new Set(personalCalls.map(c => c.sessionId)) + expect(personalComposerIds).toEqual(new Set(['composer-personal-1'])) + }) + + it('orphan source captures composers not registered in any workspace', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb([ + 'composer-mapped', + 'composer-orphan-a', + 'composer-orphan-b', + ]) + createWorkspaceDir('hash-only', 'file:///Users/me/only-app', ['composer-mapped']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + const orphanSource = sources.find(s => s.project === 'cursor')! + + const orphanCalls = await collect(provider.createSessionParser(orphanSource, new Set())) + const ids = new Set(orphanCalls.map(c => c.sessionId)) + expect(ids).toEqual(new Set(['composer-orphan-a', 'composer-orphan-b'])) + }) + + it('totals across all sources equal totals from the legacy single-source behavior', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb([ + 'composer-work-1', + 'composer-personal-1', + 'composer-orphan-1', + ]) + createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1']) + createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + + const seen = new Set<string>() + let totalCalls = 0 + let totalCost = 0 + for (const source of sources) { + const calls = await collect(provider.createSessionParser(source, seen)) + totalCalls += calls.length + for (const call of calls) totalCost += call.costUSD + } + // Three composers, one assistant call each => three calls overall. + expect(totalCalls).toBe(3) + expect(totalCost).toBeGreaterThan(0) + }) + + it('emits a single `cursor` source (legacy-equivalent) when no workspace mapping exists', async () => { + if (!isSqliteAvailable()) return + + // No createWorkspaceDir calls -> workspaceStorage exists but is empty. + const dbPath = createGlobalDb(['composer-1', 'composer-2']) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + expect(sources).toHaveLength(1) + expect(sources[0]!.project).toBe('cursor') + + const calls = await collect(provider.createSessionParser(sources[0]!, new Set())) + // All composers fall through to the orphan/catch-all source, matching + // the pre-PR behavior where every Cursor session showed under one row. + const ids = new Set(calls.map(c => c.sessionId)) + expect(ids).toEqual(new Set(['composer-1', 'composer-2'])) + }) + + it('handles multi-root workspaces (workspace.json without folder) by skipping them', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb(['composer-multi']) + // Multi-root workspace: workspace.json carries `configuration` not `folder`. + const dir = join(userDir, 'workspaceStorage', 'hash-multi') + mkdirSync(dir, { recursive: true }) + await writeFile( + join(dir, 'workspace.json'), + JSON.stringify({ configuration: 'file:///path/to/.code-workspace' }), + ) + // No state.vscdb either — multi-root composer never registers. + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + // Multi-root produces no workspace mapping; only the orphan source + // (labeled 'cursor') remains, and it captures the multi-root composer. + const projects = sources.map(s => s.project) + expect(projects).toEqual(['cursor']) + const calls = await collect(provider.createSessionParser(sources[0]!, new Set())) + expect(calls.map(c => c.sessionId)).toEqual(['composer-multi']) + }) + + it('sanitizes vscode-remote URIs into a slug', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb(['composer-remote']) + createWorkspaceDir( + 'hash-remote', + 'vscode-remote://wsl+Ubuntu/home/me/proj', + ['composer-remote'], + ) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + const project = sources.find(s => s.project !== 'cursor')!.project + // file:// would yield "-Users-me-proj"; remote URIs get the scheme rewritten. + expect(project).toMatch(/wsl-Ubuntu/) + expect(project).toContain('home') + expect(project).toContain('proj') + }) + + it('drops sub-composer rows whose composer id is not a UUID', async () => { + if (!isSqliteAvailable()) return + + const dbPath = globalDbPath() + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(`CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)`) + db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`) + const insert = db.prepare(`INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)`) + + // One real composer with one bubble. Real composer ids are UUIDs. + const realComposerId = 'cccc1111-2222-3333-4444-555566667777' + insert.run(`bubbleId:${realComposerId}:bubble-real`, JSON.stringify({ + type: 2, + conversationId: realComposerId, + createdAt: new Date().toISOString(), + tokenCount: { inputTokens: 100, outputTokens: 50 }, + modelInfo: { modelName: 'claude-4.6-sonnet' }, + text: 'real', + codeBlocks: '[]', + })) + // A sub-composer row mirroring the real Cursor shape: the composer + // segment has an embedded newline and is not UUID-shaped. Must be + // dropped, not surfaced as its own session. + insert.run(`bubbleId:task-call_xxx\nfc_yyy:bubble-sub`, JSON.stringify({ + type: 2, + conversationId: '', + createdAt: new Date().toISOString(), + tokenCount: { inputTokens: 10, outputTokens: 5 }, + modelInfo: { modelName: 'claude-4.6-sonnet' }, + text: 'sub', + codeBlocks: '[]', + })) + db.close() + + createWorkspaceDir('hash-only', 'file:///Users/me/only', [realComposerId]) + + const provider = createCursorProvider(dbPath) + const sources = await provider.discoverSessions() + const seen = new Set<string>() + let allCalls = 0 + for (const source of sources) { + const calls = await collect(provider.createSessionParser(source, seen)) + allCalls += calls.length + } + // One real composer -> one call. Sub-composer dropped. Total: 1. + expect(allCalls).toBe(1) + }) + + it('remains backwards-compatible when given a legacy bare DB path', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createGlobalDb(['composer-legacy-1', 'composer-legacy-2']) + createWorkspaceDir('hash-legacy', 'file:///Users/me/legacy', ['composer-legacy-1']) + + const provider = createCursorProvider(dbPath) + // Hand-construct a legacy SessionSource (no workspace tag) and verify + // it still yields every call regardless of workspace mapping. + const legacySource = { path: dbPath, project: 'cursor', provider: 'cursor' } + const calls = await collect(provider.createSessionParser(legacySource, new Set())) + const ids = new Set(calls.map(c => c.sessionId)) + expect(ids).toEqual(new Set(['composer-legacy-1', 'composer-legacy-2'])) + }) +}) diff --git a/tests/providers/cursor.test.ts b/tests/providers/cursor.test.ts new file mode 100644 index 0000000..61151a6 --- /dev/null +++ b/tests/providers/cursor.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createRequire } from 'node:module' +import { getAllProviders } from '../../src/providers/index.js' +import { getCursorTimeFloor, createCursorProvider, clearCursorWorkspaceMapCache } from '../../src/providers/cursor.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { Provider } from '../../src/providers/types.js' + +describe('cursor provider', () => { + let cursorProvider: Provider + + beforeEach(async () => { + const all = await getAllProviders() + cursorProvider = all.find(p => p.name === 'cursor')! + }) + it('is registered', () => { + expect(cursorProvider).toBeDefined() + expect(cursorProvider.name).toBe('cursor') + expect(cursorProvider.displayName).toBe('Cursor') + }) + + describe('model display names', () => { + it('maps cursor-auto to Cursor (auto) label', () => { + expect(cursorProvider.modelDisplayName('cursor-auto')).toBe('Cursor (auto)') + }) + + it('maps known models to readable names', () => { + expect(cursorProvider.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking)') + expect(cursorProvider.modelDisplayName('claude-4-sonnet-thinking')).toBe('Sonnet 4 (Thinking)') + expect(cursorProvider.modelDisplayName('grok-code-fast-1')).toBe('Grok Code Fast') + expect(cursorProvider.modelDisplayName('gemini-3-pro')).toBe('Gemini 3 Pro') + expect(cursorProvider.modelDisplayName('gpt-5')).toBe('GPT-5') + expect(cursorProvider.modelDisplayName('composer-1')).toBe('Composer 1') + }) + + it('returns raw name for unknown models', () => { + expect(cursorProvider.modelDisplayName('some-future-model')).toBe('some-future-model') + }) + }) + + describe('tool display names', () => { + it('returns raw tool name as identity', () => { + expect(cursorProvider.toolDisplayName('some_tool')).toBe('some_tool') + }) + }) + + describe('time floor', () => { + it('uses dateRange.start when within the six-month cap', () => { + const start = new Date(2026, 3, 1) + expect(getCursorTimeFloor({ start, end: new Date(2026, 5, 2) })).toBe(start.toISOString()) + }) + }) + + describe('session discovery', () => { + it('returns empty when sqlite is not available', async () => { + const sessions = await cursorProvider.discoverSessions() + expect(Array.isArray(sessions)).toBe(true) + }) + + it('returns empty when db does not exist', async () => { + const sessions = await cursorProvider.discoverSessions() + expect(sessions.every(s => s.provider === 'cursor')).toBe(true) + }) + }) +}) + +describe('cursor sqlite adapter', () => { + it('reports availability', async () => { + const { isSqliteAvailable } = await import('../../src/sqlite.js') + const available = isSqliteAvailable() + expect(typeof available).toBe('boolean') + }) + + it('provides error message when not available', async () => { + const { getSqliteLoadError } = await import('../../src/sqlite.js') + const error = getSqliteLoadError() + expect(typeof error).toBe('string') + expect(error.length).toBeGreaterThan(0) + }) +}) + +describe('cursor cache', () => { + it('returns null when no cache exists', async () => { + const { readCachedResults } = await import('../../src/cursor-cache.js') + const result = await readCachedResults('/nonexistent/path.db', new Date(0).toISOString()) + expect(result).toBeNull() + }) +}) + +// Regression: Cursor renamed the per-workspace composer list key from +// 'composer.composerData' to 'composer.composerHeaders'. loadWorkspaceMap must +// read both, otherwise every composer orphans into the 'cursor' catch-all and +// per-project attribution is lost. +describe('cursor workspace mapping (composer.composerHeaders regression)', () => { + const requireForTest = createRequire(import.meta.url) + type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void + } + let root: string + + function writeItemTableDb(dbPath: string, key: string, composerIds: string[]): void { + const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (p: string) => TestDb } + const db = new DatabaseSync(dbPath) + db.exec('CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)') + if (key) { + const value = JSON.stringify({ allComposers: composerIds.map(composerId => ({ composerId })) }) + db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run(key, value) + } + db.close() + } + + async function makeWorkspace(hash: string, folderUri: string, key: string, composerIds: string[]): Promise<void> { + const wsDir = join(root, 'User', 'workspaceStorage', hash) + await mkdir(wsDir, { recursive: true }) + await writeFile(join(wsDir, 'workspace.json'), JSON.stringify({ folder: folderUri })) + writeItemTableDb(join(wsDir, 'state.vscdb'), key, composerIds) + } + + async function makeGlobalDb(): Promise<string> { + const gsDir = join(root, 'User', 'globalStorage') + await mkdir(gsDir, { recursive: true }) + const dbPath = join(gsDir, 'state.vscdb') + // discoverSessions only needs the global DB to exist; the workspace map is + // built from the sibling workspaceStorage dir. + writeItemTableDb(dbPath, '', []) + return dbPath + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'cursor-ws-test-')) + clearCursorWorkspaceMapCache() + }) + + afterEach(async () => { + clearCursorWorkspaceMapCache() + await rm(root, { recursive: true, force: true }) + }) + + it.skipIf(!isSqliteAvailable())( + 'maps composers to their workspace via composer.composerHeaders (new Cursor key)', + async () => { + await makeWorkspace('ws-headers', 'file:///home/user/myapp', 'composer.composerHeaders', ['comp-1', 'comp-2']) + const dbPath = await makeGlobalDb() + + const sources = await createCursorProvider(dbPath).discoverSessions() + const projects = sources.map(s => s.project) + + // Before the fix these composers orphaned to the 'cursor' catch-all. + expect(projects).toContain('-home-user-myapp') + }, + ) + + it.skipIf(!isSqliteAvailable())( + 'still maps composers via the legacy composer.composerData key', + async () => { + await makeWorkspace('ws-legacy', 'file:///home/user/legacy', 'composer.composerData', ['old-1']) + const dbPath = await makeGlobalDb() + + const sources = await createCursorProvider(dbPath).discoverSessions() + expect(sources.map(s => s.project)).toContain('-home-user-legacy') + }, + ) +}) diff --git a/tests/providers/devin.test.ts b/tests/providers/devin.test.ts new file mode 100644 index 0000000..0ceec22 --- /dev/null +++ b/tests/providers/devin.test.ts @@ -0,0 +1,816 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createDevinProvider } from '../../src/providers/devin.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'devin-provider-')) + process.env['HOME'] = tmpDir +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function configureDevinRate(rate = 1): Promise<void> { + await mkdir(join(tmpDir, '.config', 'codeburn'), { recursive: true }) + await writeFile(join(tmpDir, '.config', 'codeburn', 'config.json'), JSON.stringify({ + devin: { acuUsdRate: rate }, + })) +} + +async function writeTranscript(name: string, transcript: unknown): Promise<string> { + const transcriptsDir = join(tmpDir, 'transcripts') + await mkdir(transcriptsDir, { recursive: true }) + const filePath = join(transcriptsDir, name) + await writeFile(filePath, JSON.stringify(transcript)) + return filePath +} + +async function parseTranscript(filePath: string, project = 'devin'): Promise<ParsedProviderCall[]> { + const provider = createDevinProvider(tmpDir) + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser({ path: filePath, project, provider: 'devin' }, new Set()).parse()) { + calls.push(call) + } + return calls +} + +function createSessionsDb(): void { + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(join(tmpDir, 'sessions.db')) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + working_directory TEXT, + backend_type TEXT, + model TEXT, + agent_mode TEXT, + created_at INTEGER, + last_activity_at INTEGER, + title TEXT, + hidden INTEGER NOT NULL DEFAULT 0 + ) + `) + db.prepare(` + INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('db-session', '/Users/example/work/codeburn', 'claude-sonnet-4-6', 1_800_000_000, 1_800_000_010, 'CodeBurn', 0) + db.prepare(` + INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('hidden-session', '/Users/example/work/hidden', 'claude-opus-4-6', 1_800_000_000, 1_800_000_010, 'Hidden', 1) + db.close() +} + +describe('devin provider', () => { + it('discovers Devin CLI transcript json files', async () => { + await configureDevinRate() + const filePath = await writeTranscript('glimmer-platinum.json', { steps: [] }) + await writeFile(join(tmpDir, 'transcripts', 'ignore.txt'), '{}') + + const provider = createDevinProvider(tmpDir) + const sources = await provider.discoverSessions() + + expect(sources).toEqual([ + { path: filePath, project: 'devin', provider: 'devin' }, + ]) + }) + + it('stays disabled until the Devin ACU rate is configured', async () => { + await writeTranscript('glimmer-platinum.json', { + session_id: 'session-123', + steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }], + }) + + const provider = createDevinProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + expect(await parseTranscript(join(tmpDir, 'transcripts', 'glimmer-platinum.json'))).toEqual([]) + }) + + it('parses per-step ACUs, tokens, tools, and model resolution', async () => { + await configureDevinRate() + const filePath = await writeTranscript('glimmer-platinum.json', { + schema_version: '1', + session_id: 'session-123', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + message: 'please inspect the repo', + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + model_name: 'step-model', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.02076149918138981, + generation_model: 'claude-opus-4-6', + metrics: { + input_tokens: 100, + output_tokens: 20, + cache_creation_tokens: 10, + cache_read_tokens: 5, + }, + }, + tool_calls: [{ function_name: 'read_file' }], + }, + { + step_id: 3, + model_name: 'claude-sonnet-4-6', + metadata: { + created_at: '2027-01-15T08:00:02.000Z', + committed_acu_cost: 0.005421000067144632, + metrics: { input_tokens: 1 }, + }, + tool_calls: [{ function_name: 'str_replace' }], + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(2) + expect(calls.reduce((sum, call) => sum + call.costUSD, 0)).toBeCloseTo(0.026182499248534442, 15) + expect(calls[0]).toMatchObject({ + provider: 'devin', + model: 'Opus 4.6', + inputTokens: 100, + outputTokens: 20, + cacheCreationInputTokens: 10, + cacheReadInputTokens: 5, + cachedInputTokens: 5, + costUSD: 0.02076149918138981, + tools: ['read_file'], + timestamp: '2027-01-15T08:00:01.000Z', + deduplicationKey: 'devin:session-123:2', + userMessage: 'please inspect the repo', + sessionId: 'session-123', + }) + expect(calls[1]).toMatchObject({ + model: 'Sonnet 4.6', + timestamp: '2027-01-15T08:00:02.000Z', + tools: ['str_replace'], + deduplicationKey: 'devin:session-123:3', + }) + }) + + it('renders Devin generation_model variants as friendly display names with effort tiers', async () => { + await configureDevinRate() + const cases = [ + { + schema: '1.4', + modelName: 'GPT-5.4', + location: 'metadata', + generationModel: 'gpt-5-3-codex-xhigh', + expected: 'GPT-5.3 Codex (xhigh)', + }, + { + schema: '1.4', + modelName: 'GPT-5.5', + location: 'metadata', + generationModel: 'gpt-5-4-low', + expected: 'GPT-5.4 (low)', + }, + { + schema: '1.4', + modelName: 'GPT-5.5', + location: 'metadata', + generationModel: 'gpt-5-5-medium', + expected: 'GPT-5.5 (medium)', + }, + { + schema: '1.4', + modelName: 'Gemini 3 Flash', + location: 'metadata', + generationModel: 'MODEL_PRIVATE_11', + expected: 'Gemini 3 Flash', + }, + { + schema: '1.4', + modelName: 'Gemini 3 Flash', + location: 'metadata', + generationModel: 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL', + expected: 'Gemini 3 Flash', + }, + { + schema: '1.7', + modelName: 'GPT-5.3-Codex', + location: 'extra', + generationModel: 'gpt-5-3-codex-xhigh', + expected: 'GPT-5.3 Codex (xhigh)', + }, + { + schema: '1.4', + modelName: 'GPT-5', + location: 'metadata', + generationModel: 'gpt-5', + expected: 'GPT-5', + }, + { + schema: '1.4', + modelName: 'GPT-5.6', + location: 'metadata', + generationModel: 'gpt-5-6-medium', + expected: 'GPT-5.6 (medium)', + }, + { + schema: '1.4', + modelName: 'GPT-5', + location: 'metadata', + generationModel: 'gpt-5-codex-xhigh', + expected: 'GPT-5 Codex (xhigh)', + }, + { + schema: '1.4', + modelName: 'GPT-5', + location: 'metadata', + generationModel: 'gpt-5-codex', + expected: 'GPT-5 Codex', + }, + { + schema: '1.4', + modelName: 'GPT-4', + location: 'metadata', + generationModel: 'gpt-4-1106-preview', + expected: 'gpt-4-1106-preview', + }, + ] as const + + for (let index = 0; index < cases.length; index++) { + const row = cases[index]! + const metadata: Record<string, unknown> = { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 1 }, + } + const extra: Record<string, unknown> = {} + if (row.location === 'metadata') { + metadata['generation_model'] = row.generationModel + } else { + extra['generation_model'] = row.generationModel + } + + const step: Record<string, unknown> = { + step_id: index + 1, + source: 'assistant', + message: 'working', + metadata, + } + if (row.location === 'extra') step['extra'] = extra + + const filePath = await writeTranscript(`model-variant-${index}.json`, { + schema_version: row.schema, + session_id: `model-variant-${index}`, + agent: { model_name: row.modelName }, + steps: [step], + }) + + const calls = await parseTranscript(filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe(row.expected) + } + }) + + it('leaves already-friendly Devin model display names unchanged', () => { + const provider = createDevinProvider(tmpDir) + + expect(provider.modelDisplayName('GPT-5.3 Codex (xhigh)')).toBe('GPT-5.3 Codex (xhigh)') + }) + + it('includes token-only steps and skips user-input or empty steps', async () => { + await configureDevinRate() + const filePath = await writeTranscript('token-only.json', { + session_id: 'token-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 'user-cost', + metadata: { + is_user_input: true, + committed_acu_cost: 99, + metrics: { input_tokens: 99 }, + }, + }, + { step_id: 'empty', metadata: { created_at: '2026-06-05T10:00:00.000Z' } }, + { + step_id: 'tokens', + metadata: { + created_at: '2026-06-05T10:00:01.000Z', + metrics: { output_tokens: 42 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('agent-model') + expect(calls[0]!.outputTokens).toBe(42) + expect(calls[0]!.costUSD).toBe(0) + }) + + it('converts ACUs to costUSD using the configured Devin rate', async () => { + await configureDevinRate(2.5) + const filePath = await writeTranscript('configured-rate.json', { + session_id: 'configured-rate', + agent: { model_name: 'agent-model' }, + steps: [ + { step_id: 's1', metadata: { committed_acu_cost: 0.4 } }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(1, 12) + }) + + it('falls back to filename session id and deduplicates by step id', async () => { + await configureDevinRate() + const filePath = await writeTranscript('fallback-session.json', { + steps: [ + { + step_id: 1, + metadata: { + request_id: 'req-1', + committed_acu_cost: 0.1, + }, + }, + { + step_id: 2, + metadata: { + created_at: '2026-06-05T10:00:00.000Z', + committed_acu_cost: 0.2, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls.map(c => c.sessionId)).toEqual(['fallback-session', 'fallback-session']) + expect(calls.map(c => c.model)).toEqual(['devin', 'devin']) + expect(calls.map(c => c.deduplicationKey)).toEqual([ + 'devin:fallback-session:1', + 'devin:fallback-session:2', + ]) + }) + + it('extracts user message from ContentPart[] messages (ATIF v1.7 multimodal)', async () => { + await configureDevinRate() + const filePath = await writeTranscript('content-parts.json', { + session_id: 'cp-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + message: [ + { type: 'text', text: 'look at this screenshot' }, + { type: 'image', source: { media_type: 'image/png', path: '/tmp/screenshot.png' } }, + ], + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 50 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe('look at this screenshot /tmp/screenshot.png') + }) + + it('parses ATIF v1.7 transcripts with agent.extra, final_metrics, and observations', async () => { + await configureDevinRate() + const filePath = await writeTranscript('atif-v17.json', { + schema_version: '1.7', + session_id: 'v17-session', + agent: { + name: 'devin', + version: '2.0', + model_name: 'claude-sonnet-4-6', + extra: { backend: 'cloud', permission_mode: 'auto' }, + }, + final_metrics: { + total_prompt_tokens: 500, + total_completion_tokens: 200, + total_cached_tokens: 50, + total_steps: 2, + }, + steps: [ + { + step_id: 1, + message: 'fix the bug', + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + source: 'assistant', + model_name: 'claude-sonnet-4-6', + message: 'I will read the file first', + tool_calls: [{ tool_call_id: 'tc1', function_name: 'read_file', arguments: { path: 'src/main.ts' } }], + observation: { + results: [{ source_call_id: 'tc1', content: 'file contents here' }], + }, + extra: { + committed_acu_cost: 0.15, + generation_model: 'claude-sonnet-4-6', + telemetry: { source: 'devin-cli', operation: 'generate' }, + }, + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.15, + metrics: { input_tokens: 200, output_tokens: 50, cache_creation_tokens: 20, cache_read_tokens: 10 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + provider: 'devin', + model: 'Sonnet 4.6', + inputTokens: 200, + outputTokens: 50, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + costUSD: 0.15, + tools: ['read_file'], + userMessage: 'fix the bug', + sessionId: 'v17-session', + }) + }) + + it('handles plain string user messages alongside ContentPart[] messages', async () => { + await configureDevinRate() + const filePath = await writeTranscript('mixed-messages.json', { + session_id: 'mixed-msg-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + message: 'plain text user message', + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 50 }, + }, + }, + { + step_id: 3, + message: [{ type: 'text', text: 'multimodal user message' }], + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:02.000Z' }, + }, + { + step_id: 4, + metadata: { + created_at: '2027-01-15T08:00:03.000Z', + committed_acu_cost: 0.2, + metrics: { input_tokens: 100 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('plain text user message') + expect(calls[1]!.userMessage).toBe('multimodal user message') + }) + + it('reads ACU cost from step.extra when metadata.committed_acu_cost is absent', async () => { + await configureDevinRate() + const filePath = await writeTranscript('extra-acu.json', { + session_id: 'extra-acu-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + extra: { committed_acu_cost: 0.3 }, + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + metrics: { input_tokens: 10 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(0.3, 12) + }) + + it('prefers metadata.committed_acu_cost over extra.committed_acu_cost', async () => { + await configureDevinRate() + const filePath = await writeTranscript('acu-priority.json', { + session_id: 'acu-priority-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + extra: { committed_acu_cost: 0.99 }, + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.11, + metrics: { input_tokens: 10 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(0.11, 12) + }) + + it('reads tokens from step.metrics when metadata.metrics is absent', async () => { + await configureDevinRate() + const filePath = await writeTranscript('step-metrics.json', { + session_id: 'step-metrics-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + metrics: { + prompt_tokens: 300, + completion_tokens: 75, + cached_tokens: 15, + extra: { cache_creation_input_tokens: 25 }, + }, + extra: { committed_acu_cost: 0.2 }, + metadata: { created_at: '2027-01-15T08:00:00.000Z' }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + inputTokens: 300, + outputTokens: 75, + cacheCreationInputTokens: 25, + cacheReadInputTokens: 15, + cachedInputTokens: 15, + costUSD: 0.2, + }) + }) + + it('prefers step.metrics over metadata.metrics when both are present', async () => { + await configureDevinRate() + const filePath = await writeTranscript('metrics-priority.json', { + session_id: 'metrics-priority-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + metrics: { + prompt_tokens: 500, + completion_tokens: 100, + cached_tokens: 20, + extra: { cache_creation_input_tokens: 30 }, + }, + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.1, + metrics: { + input_tokens: 1, + output_tokens: 1, + cache_creation_tokens: 1, + cache_read_tokens: 1, + }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + inputTokens: 500, + outputTokens: 100, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 20, + cachedInputTokens: 20, + }) + }) + + it('handles observation results with ContentPart[] content', async () => { + await configureDevinRate() + const filePath = await writeTranscript('observation-content-parts.json', { + session_id: 'obs-cp-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + message: 'check the image', + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + source: 'assistant', + message: 'reading file', + tool_calls: [{ tool_call_id: 'tc1', function_name: 'read_file', arguments: {} }], + observation: { + results: [{ + source_call_id: 'tc1', + content: [ + { type: 'text', text: 'file output here' }, + { type: 'image', source: { media_type: 'image/png', path: '/tmp/output.png' } }, + ], + }], + }, + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 100, output_tokens: 30 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + tools: ['read_file'], + costUSD: 0.1, + inputTokens: 100, + outputTokens: 30, + }) + }) + + it('falls back to metadata.metrics when step.metrics is present but empty', async () => { + await configureDevinRate() + const filePath = await writeTranscript('empty-step-metrics.json', { + session_id: 'empty-metrics-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + metrics: {}, + metadata: { + created_at: '2027-01-15T08:00:00.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 80, output_tokens: 20, cache_read_tokens: 5 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + inputTokens: 80, + outputTokens: 20, + cacheReadInputTokens: 5, + costUSD: 0.1, + }) + }) + + it('normalizes an image-only ContentPart[] user message to its path', async () => { + await configureDevinRate() + const filePath = await writeTranscript('image-only.json', { + session_id: 'image-only-session', + agent: { model_name: 'agent-model' }, + steps: [ + { + step_id: 1, + message: [ + { type: 'image', source: { media_type: 'image/png', path: '/tmp/only.png' } }, + ], + metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' }, + }, + { + step_id: 2, + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 40 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe('/tmp/only.png') + }) + + it('ignores array-root and malformed transcripts', async () => { + await configureDevinRate() + const arrayPath = await writeTranscript('array.json', []) + const malformedPath = join(tmpDir, 'transcripts', 'bad.json') + await writeFile(malformedPath, '{') + + expect(await parseTranscript(arrayPath)).toEqual([]) + expect(await parseTranscript(malformedPath)).toEqual([]) + }) + + it('deduplicates calls with a shared seen key set', async () => { + await configureDevinRate() + const filePath = await writeTranscript('dupe.json', { + session_id: 'dupe-session', + steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }], + }) + const provider = createDevinProvider(tmpDir) + const seenKeys = new Set<string>() + const source = { path: filePath, project: 'devin', provider: 'devin' } + + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) second.push(call) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) +}) + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('devin provider sessions.db enrichment', () => { + it('uses sessions.db to enrich project, projectPath, model, and timestamp fallbacks', async () => { + await configureDevinRate() + createSessionsDb() + const filePath = await writeTranscript('db-session.json', { + session_id: 'db-session', + steps: [ + { + step_id: 's1', + metadata: { + committed_acu_cost: 0.25, + metrics: { input_tokens: 10 }, + }, + }, + ], + }) + + const calls = await parseTranscript(filePath, 'fallback-project') + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + model: 'Sonnet 4.6', + project: 'codeburn', + projectPath: '/Users/example/work/codeburn', + timestamp: '2027-01-15T08:00:10.000Z', + costUSD: 0.25, + }) + }) + + it('uses sessions.db project labels during discovery when transcript filename matches the session id', async () => { + await configureDevinRate() + createSessionsDb() + const filePath = await writeTranscript('db-session.json', { session_id: 'db-session', steps: [] }) + + const provider = createDevinProvider(tmpDir) + const sources = await provider.discoverSessions() + + expect(sources).toEqual([ + { path: filePath, project: 'codeburn', provider: 'devin' }, + ]) + }) + + it('skips sessions hidden in sessions.db', async () => { + await configureDevinRate() + createSessionsDb() + await writeTranscript('hidden-session.json', { + session_id: 'hidden-session', + steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.25 } }], + }) + + const provider = createDevinProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + + const calls = await parseTranscript(join(tmpDir, 'transcripts', 'hidden-session.json')) + expect(calls).toEqual([]) + }) +}) diff --git a/tests/providers/droid.test.ts b/tests/providers/droid.test.ts new file mode 100644 index 0000000..c312b45 --- /dev/null +++ b/tests/providers/droid.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createDroidProvider } from '../../src/providers/droid.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let factoryDir: string + +async function writeSession(opts: { + projectDir?: string + sessionId?: string + lines?: unknown[] + settings?: unknown + subdir?: string +}): Promise<string> { + const sessionId = opts.sessionId ?? 'session-1' + const projectDir = opts.projectDir ?? '/tmp/my-project' + const subdir = opts.subdir ?? '-tmp-my-project' + const dir = join(factoryDir, 'sessions', subdir) + await mkdir(dir, { recursive: true }) + const jsonlPath = join(dir, `${sessionId}.jsonl`) + const lines = opts.lines ?? [ + { type: 'session_start', id: sessionId, cwd: projectDir, title: 'Test session' }, + { type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'build this' }] } }, + { type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } }, + ] + await writeFile(jsonlPath, lines.map(line => JSON.stringify(line)).join('\n')) + + if (opts.settings !== undefined) { + await writeFile(join(dir, `${sessionId}.settings.json`), JSON.stringify(opts.settings)) + } + + return jsonlPath +} + +async function parseAll(filePath: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> { + const provider = createDroidProvider(factoryDir) + const parser = provider.createSessionParser({ path: filePath, project: 'proj', provider: 'droid' }, seen) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + return calls +} + +describe('droid provider', () => { + beforeEach(async () => { + factoryDir = await mkdtemp(join(tmpdir(), 'codeburn-droid-test-')) + }) + + afterEach(async () => { + await rm(factoryDir, { recursive: true, force: true }) + }) + + it('discovers Droid JSONL sessions', async () => { + await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } }) + + const provider = createDroidProvider(factoryDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('droid') + expect(sessions[0]!.path.endsWith('session-1.jsonl')).toBe(true) + }) + + it('parses calls and distributes session-level token usage', async () => { + const path = await writeSession({ + lines: [ + { type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' }, + { type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: '<system-reminder>x</system-reminder>' }, { type: 'text', text: 'build this' }] } }, + { type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'first' }] } }, + { type: 'message', id: 'a2', timestamp: '2026-04-20T10:00:02.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'second' }] } }, + ], + settings: { model: 'custom:gpt-5-[Proxy]-0', tokenUsage: { inputTokens: 101, outputTokens: 51, cacheCreationTokens: 7, cacheReadTokens: 11, thinkingTokens: 5 } }, + }) + + const calls = await parseAll(path) + + expect(calls).toHaveLength(2) + expect(calls[0]!.provider).toBe('droid') + expect(calls[0]!.model).toBe('gpt-5') + expect(calls[0]!.inputTokens).toBe(50) + expect(calls[1]!.inputTokens).toBe(51) + expect(calls[0]!.outputTokens).toBe(25) + expect(calls[1]!.outputTokens).toBe(26) + expect(calls[0]!.cacheReadInputTokens).toBe(5) + expect(calls[1]!.cacheReadInputTokens).toBe(6) + expect(calls[0]!.userMessage).toBe('build this') + expect(calls[0]!.sessionId).toBe('session-1') + }) + + it('extracts tools and meaningful bash command names', async () => { + const path = await writeSession({ + lines: [ + { type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' }, + { type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [ + { type: 'tool_use', name: 'Execute', input: { command: "python3 - <<'PY'\nimport os\n}\nPY" } }, + { type: 'tool_use', name: 'Read', input: { file_path: '/tmp/a' } }, + { type: 'tool_use', name: 'Task', input: { prompt: 'do work' } }, + ] } }, + ], + settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } }, + }) + + const calls = await parseAll(path) + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Agent']) + expect(calls[0]!.bashCommands).toContain('python3') + expect(calls[0]!.bashCommands).not.toContain('import') + expect(calls[0]!.bashCommands).not.toContain('}') + }) + + it('deduplicates calls by session and message id', async () => { + const path = await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } }) + const seen = new Set<string>() + + expect(await parseAll(path, seen)).toHaveLength(1) + expect(await parseAll(path, seen)).toHaveLength(0) + }) + + it('strips Droid model wrappers for display', () => { + const provider = createDroidProvider(factoryDir) + expect(provider.modelDisplayName('custom:GLM-5.1-[Proxy]-0')).toBe('GLM-5.1') + expect(provider.modelDisplayName('custom:claude-sonnet-4-6-1')).toBe('Sonnet 4.6') + }) + + it('returns no calls when settings are missing', async () => { + const path = await writeSession({}) + expect(await parseAll(path)).toHaveLength(0) + }) + + it('skips internal .factory sessions during discovery', async () => { + await writeSession({ projectDir: factoryDir, subdir: '-internal', settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } }) + + const provider = createDroidProvider(factoryDir) + expect(await provider.discoverSessions()).toHaveLength(0) + }) + + it('returns no calls for empty sessions', async () => { + const path = await writeSession({ + lines: [{ type: 'session_start', id: 'empty', cwd: '/tmp/my-project' }], + settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } }, + }) + + expect(await parseAll(path)).toHaveLength(0) + }) +}) diff --git a/tests/providers/forge.test.ts b/tests/providers/forge.test.ts new file mode 100644 index 0000000..cd2b66b --- /dev/null +++ b/tests/providers/forge.test.ts @@ -0,0 +1,275 @@ +import { mkdtemp, readFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createForgeProvider } from '../../src/providers/forge.js' +import { getProvider } from '../../src/providers/index.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await mkdtemp(join(tmpdir(), 'forge-test-')) +}) + +afterEach(async () => { + await rm(tmpRoot, { recursive: true, force: true }) +}) + +function createForgeDb(): string { + const dbPath = join(tmpRoot, 'forge.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE conversations( + conversation_id TEXT PRIMARY KEY NOT NULL, + title TEXT, + workspace_id BIGINT NOT NULL, + context TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + metrics TEXT + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +function insertConversationRow(db: TestDb, overrides: { + conversationId?: string + title?: string | null + workspaceId?: number | string + context?: string | null + createdAt?: string + updatedAt?: string | null +} = {}): void { + db.prepare(` + INSERT INTO conversations (conversation_id, title, workspace_id, context, created_at, updated_at, metrics) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + overrides.conversationId ?? 'conv-1', + 'title' in overrides ? overrides.title : 'Forge Project', + overrides.workspaceId ?? 123, + overrides.context ?? null, + overrides.createdAt ?? '2026-05-06 15:00:00', + 'updatedAt' in overrides ? overrides.updatedAt : '2026-05-06 15:20:41.379094', + null, + ) +} + +function insertConversation(db: TestDb): void { + const context = { + conversation_id: 'conv-1', + messages: [ + { message: { text: { role: 'User', content: 'implement forge' } } }, + { + message: { + text: { + role: 'Assistant', + content: '', + model: 'claude-opus-4-6', + tool_calls: [ + { name: 'shell', call_id: 'call-1', arguments: { command: 'git status && npm test' } }, + { name: 'Read', call_id: 'call-2', arguments: { file_path: '/tmp/a' } }, + ], + }, + }, + usage: { + prompt_tokens: { actual: 1200 }, + completion_tokens: { actual: 300 }, + total_tokens: { actual: 1500 }, + cached_tokens: { actual: 200 }, + }, + }, + ], + } + + insertConversationRow(db, { context: JSON.stringify(context) }) +} + +async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> { + const out: ParsedProviderCall[] = [] + for await (const call of parser.parse()) out.push(call) + return out +} + +describe('forge provider', () => { + it('discovers conversations with context and parses assistant usage/tool calls', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createForgeDb() + withTestDb(dbPath, insertConversation) + + const provider = createForgeProvider(dbPath) + const sources = await provider.discoverSessions() + + expect(sources).toEqual([ + { + path: `${dbPath}:conv-1`, + project: 'Forge Project', + provider: 'forge', + }, + ]) + + const seenKeys = new Set<string>() + const calls = await collect(provider.createSessionParser(sources[0]!, seenKeys)) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + provider: 'forge', + model: 'claude-opus-4-6', + inputTokens: 1000, + outputTokens: 300, + cacheReadInputTokens: 200, + cachedInputTokens: 200, + cacheCreationInputTokens: 0, + tools: ['Bash', 'Read'], + bashCommands: ['git', 'npm'], + userMessage: 'implement forge', + sessionId: 'conv-1', + timestamp: '2026-05-06T15:20:41.379Z', + deduplicationKey: 'forge:conv-1:call-1', + }) + + const duplicates = await collect(provider.createSessionParser(sources[0]!, seenKeys)) + expect(duplicates).toEqual([]) + }) + + it('does not select conversation context while discovering sessions', async () => { + const source = await readFile(new URL('../../src/providers/forge.ts', import.meta.url), 'utf8') + const discoverySql = source.match(/async function discoverFromDb[\s\S]*?db\.query<[^>]+>\(\s*`([\s\S]*?)`/)?.[1] + const selectedColumns = discoverySql?.split('FROM conversations')[0] ?? '' + + expect(discoverySql).toContain('WHERE context IS NOT NULL') + expect(selectedColumns).not.toMatch(/\bcontext\b/) + expect(selectedColumns).not.toMatch(/\bcreated_at\b|\bupdated_at\b/) + }) + + it('returns no sessions when the database is missing', async () => { + const provider = createForgeProvider(join(tmpRoot, 'missing.db')) + + await expect(provider.discoverSessions()).resolves.toEqual([]) + }) + + it('skips zero-token assistant usage', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createForgeDb() + const context = { + messages: [ + { message: { text: { role: 'User', content: 'zero tokens' } } }, + { + message: { text: { role: 'Assistant', model: 'claude-opus-4-6' } }, + usage: { + prompt_tokens: { actual: 0 }, + completion_tokens: { actual: 0 }, + cached_tokens: { actual: 0 }, + }, + }, + ], + } + withTestDb(dbPath, db => insertConversationRow(db, { context: JSON.stringify(context) })) + + const provider = createForgeProvider(dbPath) + const sources = await provider.discoverSessions() + const calls = await collect(provider.createSessionParser(sources[0]!, new Set())) + + expect(calls).toEqual([]) + }) + + it('parses multiple assistant messages with the nearest previous user prompt', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createForgeDb() + const context = { + messages: [ + { message: { text: { role: 'User', content: 'first request' } } }, + { + message: { text: { role: 'Assistant', model: 'claude-opus-4-6', tool_calls: [{ name: 'shell', call_id: 'call-1', arguments: { command: 'npm test' } }] } }, + usage: { prompt_tokens: { actual: 100 }, completion_tokens: { actual: 20 } }, + }, + { message: { text: { role: 'User', content: 'second request' } } }, + { + message: { text: { role: 'Assistant', model: 'claude-sonnet-4-6', tool_calls: [{ name: 'Read', call_id: 'call-2', arguments: { file_path: '/tmp/a' } }] } }, + usage: { prompt_tokens: { actual: 200 }, completion_tokens: { actual: 30 } }, + }, + ], + } + withTestDb(dbPath, db => insertConversationRow(db, { context: JSON.stringify(context) })) + + const provider = createForgeProvider(dbPath) + const sources = await provider.discoverSessions() + const calls = await collect(provider.createSessionParser(sources[0]!, new Set())) + + expect(calls).toHaveLength(2) + expect(calls.map(call => call.userMessage)).toEqual(['first request', 'second request']) + expect(calls.map(call => call.deduplicationKey)).toEqual(['forge:conv-1:call-1', 'forge:conv-1:call-2']) + }) + + it('uses workspace_id as project when title is null', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createForgeDb() + withTestDb(dbPath, db => insertConversationRow(db, { title: null, workspaceId: 'workspace-1', context: '{}' })) + + const provider = createForgeProvider(dbPath) + const sources = await provider.discoverSessions() + + expect(sources[0]?.project).toBe('workspace-1') + }) + + it('uses large integer workspace_id values as strings when title is null', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createForgeDb() + withTestDb(dbPath, db => { + db.exec(` + INSERT INTO conversations (conversation_id, title, workspace_id, context, created_at, updated_at, metrics) + VALUES ('conv-1', NULL, 8549909960051246556, '{}', '2026-05-06 15:00:00', '2026-05-06 15:20:41.379094', NULL) + `) + }) + + const provider = createForgeProvider(dbPath) + const sources = await provider.discoverSessions() + + expect(sources[0]?.project).toBe('8549909960051246556') + }) + + it('does not throw and yields no calls for invalid JSON context', async () => { + if (!isSqliteAvailable()) return + + const dbPath = createForgeDb() + withTestDb(dbPath, db => insertConversationRow(db, { context: '{invalid' })) + + const provider = createForgeProvider(dbPath) + const sources = await provider.discoverSessions() + + await expect(collect(provider.createSessionParser(sources[0]!, new Set()))).resolves.toEqual([]) + }) + + it('is available through the provider registry', async () => { + const provider = await getProvider('forge') + expect(provider?.name).toBe('forge') + }) +}) diff --git a/tests/providers/gemini.test.ts b/tests/providers/gemini.test.ts new file mode 100644 index 0000000..5dee848 --- /dev/null +++ b/tests/providers/gemini.test.ts @@ -0,0 +1,193 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { createGeminiProvider } from '../../src/providers/gemini.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gemini-provider-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function parseFixture(messages: unknown[]): Promise<ParsedProviderCall[]> { + const filePath = join(tmpDir, 'session-gemini.json') + await writeFile(filePath, JSON.stringify({ + sessionId: 'gemini-session-1', + startTime: '2026-05-16T10:00:00.000Z', + messages, + })) + + const provider = createGeminiProvider() + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser({ path: filePath, project: 'gemini-project', provider: 'gemini' }, new Set()).parse()) { + calls.push(call) + } + return calls +} + +describe('gemini provider', () => { + it('emits one provider call per Gemini message with token usage', async () => { + const calls = await parseFixture([ + { + id: 'u1', + timestamp: '2026-05-16T10:00:00.000Z', + type: 'user', + content: 'inspect the repo', + }, + { + id: 'g1', + timestamp: '2026-05-16T10:00:05.000Z', + type: 'gemini', + content: 'reading files', + model: 'gemini-3.1-pro-preview', + tokens: { input: 120, cached: 20, output: 30, thoughts: 5 }, + toolCalls: [{ id: 't1', name: 'read_file', args: { path: 'src/index.ts' } }], + }, + { + id: 'u2', + timestamp: '2026-05-16T10:01:00.000Z', + type: 'user', + content: [{ text: 'run tests' }], + }, + { + id: 'g2', + timestamp: '2026-05-16T10:01:10.000Z', + type: 'gemini', + content: 'running tests', + model: 'gemini-3.1-pro-preview', + tokens: { input: 80, cached: 10, output: 25 }, + toolCalls: [{ id: 't2', name: 'run_command', args: { command: 'npm test' } }], + }, + ]) + + expect(calls).toHaveLength(2) + expect(calls.map(c => c.deduplicationKey)).toEqual([ + 'gemini:gemini-session-1:g1', + 'gemini:gemini-session-1:g2', + ]) + expect(calls.map(c => c.timestamp)).toEqual([ + '2026-05-16T10:00:05.000Z', + '2026-05-16T10:01:10.000Z', + ]) + expect(calls.map(c => c.userMessage)).toEqual(['inspect the repo', 'run tests']) + expect(calls[0]!.inputTokens).toBe(100) + expect(calls[0]!.cacheReadInputTokens).toBe(20) + expect(calls[0]!.reasoningTokens).toBe(5) + expect(calls[0]!.tools).toEqual(['Read']) + expect(calls[1]!.inputTokens).toBe(70) + expect(calls[1]!.cacheReadInputTokens).toBe(10) + expect(calls[1]!.tools).toEqual(['Bash']) + expect(calls[1]!.bashCommands).toEqual(['npm']) + }) + + it('keeps aggregate token totals when splitting a Gemini session into calls', async () => { + const calls = await parseFixture([ + { id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' }, + { + id: 'g1', + timestamp: '2026-05-16T10:00:05.000Z', + type: 'gemini', + content: 'first', + model: 'gemini-3.1-pro-preview', + tokens: { input: 120, cached: 20, output: 30, thoughts: 5 }, + }, + { + id: 'g2', + timestamp: '2026-05-16T10:00:10.000Z', + type: 'gemini', + content: 'second', + model: 'gemini-3.1-pro-preview', + tokens: { input: 80, cached: 10, output: 25, thoughts: 0 }, + }, + ]) + + expect(calls).toHaveLength(2) + expect(calls.reduce((sum, call) => sum + call.inputTokens, 0)).toBe(170) + expect(calls.reduce((sum, call) => sum + call.cacheReadInputTokens, 0)).toBe(30) + expect(calls.reduce((sum, call) => sum + call.outputTokens, 0)).toBe(55) + expect(calls.reduce((sum, call) => sum + call.reasoningTokens, 0)).toBe(5) + }) + + it('skips Gemini messages without token usage', async () => { + const calls = await parseFixture([ + { id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' }, + { + id: 'info', + timestamp: '2026-05-16T10:00:05.000Z', + type: 'gemini', + content: 'tool-only notice', + model: 'gemini-3.1-pro-preview', + }, + ]) + + expect(calls).toEqual([]) + }) + + it('uses a deterministic ordinal key when Gemini message ids are missing', async () => { + const messages = [ + { id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' }, + { + timestamp: '2026-05-16T10:00:05.000Z', + type: 'gemini', + content: 'first', + model: 'gemini-3.1-pro-preview', + tokens: { input: 10, output: 5 }, + }, + { + timestamp: '2026-05-16T10:00:10.000Z', + type: 'gemini', + content: 'second', + model: 'gemini-3.1-pro-preview', + tokens: { input: 12, output: 6 }, + }, + ] + + const first = await parseFixture(messages) + const second = await parseFixture(messages) + + expect(first.map(c => c.deduplicationKey)).toEqual([ + 'gemini:gemini-session-1:idx-0', + 'gemini:gemini-session-1:idx-1', + ]) + expect(second.map(c => c.deduplicationKey)).toEqual(first.map(c => c.deduplicationKey)) + }) + + it('does not poison seenKeys when a Gemini message timestamp is invalid', async () => { + const filePath = join(tmpDir, 'session-gemini.json') + await writeFile(filePath, JSON.stringify({ + sessionId: 'gemini-session-1', + startTime: '2026-05-16T10:00:00.000Z', + messages: [ + { id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' }, + { + id: 'g1', + timestamp: 'not-a-date', + type: 'gemini', + content: 'first', + model: 'gemini-3.1-pro-preview', + tokens: { input: 10, output: 5 }, + }, + ], + })) + + const provider = createGeminiProvider() + const seenKeys = new Set<string>() + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser( + { path: filePath, project: 'gemini-project', provider: 'gemini' }, + seenKeys, + ).parse()) { + calls.push(call) + } + + expect(calls).toEqual([]) + expect(seenKeys.has('gemini:gemini-session-1:g1')).toBe(false) + }) +}) diff --git a/tests/providers/grok.test.ts b/tests/providers/grok.test.ts new file mode 100644 index 0000000..4fcac7a --- /dev/null +++ b/tests/providers/grok.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createGrokProvider } from '../../src/providers/grok.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'grok-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +// Mirrors the real on-disk layout: +// <sessionsDir>/<url-encoded-cwd>/<uuid>/{summary.json, signals.json, updates.jsonl} +async function writeSession(opts: { + cwdEncoded?: string + uuid?: string + cwd?: string + model?: string + turns?: Array<{ promptId: string; totals: number[] }> + toolCalls?: Array<{ title: string; rawInput: Record<string, unknown> }> + toolsUsed?: string[] +} = {}) { + const cwdEncoded = opts.cwdEncoded ?? '%2FUsers%2Ftest' + const uuid = opts.uuid ?? '019edf9c-0000-7000-8000-000000000001' + const cwd = opts.cwd ?? '/Users/test/myproject' + const model = opts.model ?? 'grok-build' + const dir = join(tmpDir, cwdEncoded, uuid) + await mkdir(dir, { recursive: true }) + + await writeFile(join(dir, 'summary.json'), JSON.stringify({ + info: { id: uuid, cwd }, + created_at: '2026-06-19T11:20:40.686261Z', + updated_at: '2026-06-19T11:31:12.282793Z', + last_active_at: '2026-06-19T11:31:12.222328Z', + num_messages: 42, + current_model_id: model, + session_summary: 'User asks about the repo', + generated_title: 'User asks about the repo', + })) + + await writeFile(join(dir, 'signals.json'), JSON.stringify({ + primaryModelId: model, + modelsUsed: [model], + toolsUsed: opts.toolsUsed ?? ['read_file', 'run_terminal_command', 'grep'], + contextTokensUsed: 40000, + contextWindowTokens: 512000, + })) + + const turns = opts.turns ?? [ + { promptId: 'p1', totals: [20000, 25000] }, + { promptId: 'p2', totals: [30000, 35000] }, + { promptId: 'p3', totals: [40000, 45000] }, + ] + const lines: string[] = [] + for (const turn of turns) { + for (const total of turn.totals) { + lines.push(JSON.stringify({ + timestamp: '2026-06-19T11:30:00.000Z', + method: 'session/update', + params: { + sessionId: uuid, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }, + _meta: { totalTokens: total, promptId: turn.promptId, updateType: 'AgentMessageChunk', modelId: model }, + }, + })) + } + } + for (const tc of opts.toolCalls ?? [ + { title: 'read_file', rawInput: { target_directory: '.' } }, + { title: 'grep', rawInput: { pattern: 'x' } }, + { title: 'run_terminal_command', rawInput: { command: 'git status' } }, + { title: 'spawn_subagent', rawInput: { subagent_type: 'general-purpose', prompt: 'x' } }, + ]) { + lines.push(JSON.stringify({ + timestamp: '2026-06-19T11:30:05.000Z', + method: 'session/update', + params: { sessionId: uuid, update: { sessionUpdate: 'tool_call', toolCallId: 'c1', title: tc.title, rawInput: tc.rawInput } }, + })) + } + await writeFile(join(dir, 'updates.jsonl'), lines.join('\n') + '\n') + + return { dir, uuid } +} + +describe('grok provider - discovery', () => { + it('discovers each session dir and derives project from cwd', async () => { + await writeSession({ cwd: '/Users/test/myproject' }) + const sessions = await createGrokProvider(tmpDir).discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('grok') + expect(sessions[0]!.project).toBe('myproject') + expect(sessions[0]!.path).toMatch(/updates\.jsonl$/) + }) + + it('returns empty for a non-existent sessions dir', async () => { + const sessions = await createGrokProvider('/nope/does/not/exist').discoverSessions() + expect(sessions).toEqual([]) + }) + + it('skips directories without a summary.json', async () => { + await mkdir(join(tmpDir, '%2Ftmp', 'not-a-session'), { recursive: true }) + const sessions = await createGrokProvider(tmpDir).discoverSessions() + expect(sessions).toEqual([]) + }) +}) + +describe('grok provider - parsing', () => { + async function parse(seen = new Set<string>()) { + const provider = createGrokProvider(tmpDir) + const [source] = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + if (!source) return calls + for await (const call of provider.createSessionParser(source, seen).parse()) { + calls.push(call) + } + return calls + } + + it('emits one estimated call per session from the totalTokens curve', async () => { + await writeSession() + const calls = await parse() + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.model).toBe('grok-build') + // input = peak context (max totalTokens across the session) + expect(call.inputTokens).toBe(45000) + // cache reads = re-sent context (sum of per-turn starts 90000 minus peak 45000) + expect(call.cacheReadInputTokens).toBe(45000) + // output = sum of per-turn growth (3 turns x 5000) + expect(call.outputTokens).toBe(15000) + expect(call.costIsEstimated).toBe(true) + expect(call.costUSD).toBeGreaterThan(0) + expect(call.tools).toEqual(['Read', 'Grep', 'Bash', 'Agent']) + expect(call.bashCommands).toContain('git') + expect(call.subagentTypes).toEqual(['general-purpose']) + expect(call.project).toBe('myproject') + expect(call.deduplicationKey).toContain('grok:') + }) + + it('skips a session with no token growth', async () => { + await writeSession({ turns: [{ promptId: 'p1', totals: [0, 0] }] }) + expect(await parse()).toHaveLength(0) + }) + + it('deduplicates across repeated parses', async () => { + await writeSession() + const seen = new Set<string>() + expect(await parse(seen)).toHaveLength(1) + expect(await parse(seen)).toHaveLength(0) + }) + + it('sums fresh input across a compaction instead of only the last peak', async () => { + await writeSession({ turns: [ + { promptId: 'p1', totals: [100000, 400000] }, + { promptId: 'p2', totals: [20000, 50000] }, + ] }) + const calls = await parse() + expect(calls).toHaveLength(1) + // 400k (segment 1 peak) + 50k (post-compaction segment), not just the 400k global peak + expect(calls[0]!.inputTokens).toBe(450000) + }) +}) + +describe('grok provider - display names', () => { + const provider = createGrokProvider('/tmp') + + it('has the right name and displayName', () => { + expect(provider.name).toBe('grok') + expect(provider.displayName).toBe('Grok Build') + }) + + it('labels grok-build', () => { + expect(provider.modelDisplayName('grok-build')).toBe('Grok Build') + }) + + it('normalizes tool names', () => { + expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash') + expect(provider.toolDisplayName('mystery_tool')).toBe('mystery_tool') + }) +}) diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts new file mode 100644 index 0000000..e475bf0 --- /dev/null +++ b/tests/providers/hermes.test.ts @@ -0,0 +1,546 @@ +import { mkdir, mkdtemp, rm } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { calculateCost } from '../../src/models.js' +import { createHermesProvider } from '../../src/providers/hermes.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' +import type { DateRange } from '../../src/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpDir: string +let cacheDir: string +let originalHermesHome: string | undefined +let originalCodeburnCacheDir: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'hermes-provider-test-')) + cacheDir = await mkdtemp(join(tmpdir(), 'hermes-provider-cache-')) + originalHermesHome = process.env['HERMES_HOME'] + originalCodeburnCacheDir = process.env['CODEBURN_CACHE_DIR'] +}) + +afterEach(async () => { + if (originalHermesHome === undefined) delete process.env['HERMES_HOME'] + else process.env['HERMES_HOME'] = originalHermesHome + if (originalCodeburnCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = originalCodeburnCacheDir + await rm(tmpDir, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) +}) + +function createHermesDb(homeDir: string): string { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const dbPath = join(homeDir, 'state.db') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + model TEXT, + cwd TEXT, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + api_call_count INTEGER DEFAULT 0, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + started_at REAL, + ended_at REAL, + title TEXT + ) + `) + db.exec(` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL + ) + `) + db.close() + return dbPath +} + +function createLegacyHermesDb(homeDir: string): string { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const dbPath = join(homeDir, 'state.db') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + model TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + started_at REAL + ) + `) + db.exec(` + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_calls TEXT, + timestamp REAL NOT NULL + ) + `) + db.close() + return dbPath +} + +async function createProfileHermesDb(hermesHome: string, profile: string): Promise<string> { + const profileDir = join(hermesHome, 'profiles', profile) + await mkdir(profileDir, { recursive: true }) + return createHermesDb(profileDir) +} + +function insertSession(db: TestDb, values: { + id: string + source?: string + model?: string + cwd?: string | null + billingProvider?: string + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + reasoningTokens: number + estimatedCost?: number | null + actualCost?: number | null + apiCalls?: number + toolCalls?: number + startedAt: number + title?: string +}): void { + db.prepare( + `INSERT INTO sessions ( + id, source, model, cwd, billing_provider, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, estimated_cost_usd, + actual_cost_usd, api_call_count, tool_call_count, started_at, title + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + values.id, + values.source ?? 'cli', + values.model ?? 'gpt-5.5', + values.cwd ?? null, + values.billingProvider ?? 'openai-codex', + values.inputTokens, + values.outputTokens, + values.cacheReadTokens, + values.cacheWriteTokens, + values.reasoningTokens, + values.estimatedCost ?? null, + values.actualCost ?? null, + values.apiCalls ?? 1, + values.toolCalls ?? 0, + values.startedAt, + values.title ?? values.id, + ) +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +function dayRange(): DateRange { + return { + start: new Date('2026-05-23T00:00:00.000Z'), + end: new Date('2026-05-23T23:59:59.999Z'), + } +} + +async function loadParserWithHermesHome(hermesHome: string, codeburnCacheDir: string) { + process.env['HERMES_HOME'] = hermesHome + process.env['CODEBURN_CACHE_DIR'] = codeburnCacheDir + vi.resetModules() + const parser = await import('../../src/parser.js') + return parser +} + +async function collectCalls(hermesHome: string, sourcePath: string): Promise<ParsedProviderCall[]> { + const provider = createHermesProvider(hermesHome) + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser({ path: sourcePath, project: 'hermes', provider: 'hermes' }, new Set()).parse()) { + calls.push(call) + } + return calls +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('hermes provider', () => { + it('discovers state.db sessions with token usage', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'session-1', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 50, + cacheWriteTokens: 0, + reasoningTokens: 5, + startedAt: 1779549200, + title: 'Test Project', + }) + db.prepare( + `INSERT INTO sessions (id, source, model, input_tokens, output_tokens, started_at, title) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run('empty', 'cli', 'gpt-5.5', 0, 0, 1779549300, 'Empty') + }) + + const provider = createHermesProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('hermes') + expect(sessions[0]!.path).toBe(`${dbPath}#hermes-session=session-1`) + expect(sessions[0]!.project).toBe('default') + }) + + it('parses session-level token usage and tool calls from messages', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'session-1', + source: 'tui', + inputTokens: 1000, + outputTokens: 200, + cacheReadTokens: 300, + cacheWriteTokens: 40, + reasoningTokens: 25, + estimatedCost: 0.12, + apiCalls: 3, + toolCalls: 2, + startedAt: 1779549200, + title: 'Provider Work', + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('session-1', 'user', 'Add Hermes support', 1779549201) + db.prepare('INSERT INTO messages (session_id, role, content, tool_calls, timestamp) VALUES (?, ?, ?, ?, ?)') + .run( + 'session-1', + 'assistant', + '', + JSON.stringify([ + { function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/file.ts' }) } }, + { function: { name: 'terminal', arguments: JSON.stringify({ command: 'npm test' }) } }, + ]), + 1779549202, + ) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=session-1`) + expect(calls).toHaveLength(1) + expect(calls[0]!).toMatchObject({ + provider: 'hermes', + model: 'gpt-5.5', + inputTokens: 1000, + outputTokens: 200, + cacheReadInputTokens: 300, + cacheCreationInputTokens: 40, + cachedInputTokens: 300, + reasoningTokens: 25, + costUSD: 0.12, + userMessage: 'Add Hermes support', + sessionId: 'session-1', + deduplicationKey: 'hermes:default:session-1', + }) + expect(calls[0]!.tools).toEqual(['Read', 'Bash']) + expect(calls[0]!.bashCommands).toEqual(['npm test']) + expect(calls[0]!.toolSequence).toEqual([ + [{ tool: 'Read', file: '/tmp/file.ts' }, { tool: 'Bash', command: 'npm test' }], + ]) + }) + + + it('maps composio MCP tools before generic MCP prefixes', () => { + const provider = createHermesProvider(tmpDir) + expect(provider.toolDisplayName('mcp_composio_GMAIL_SEND_EMAIL')).toBe('MCP') + expect(provider.toolDisplayName('mcp__github__create_issue')).toBe('mcp__github__create_issue') + }) + + it('falls back to calculateCost when no actual or estimated cost is recorded', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'no-cost-session', + model: 'claude-sonnet-4-20250514', + inputTokens: 1000, + outputTokens: 200, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 50, + estimatedCost: null, + actualCost: null, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('no-cost-session', 'user', 'Test calculateCost fallback', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=no-cost-session`) + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBe(calculateCost('claude-sonnet-4-20250514', 1000, 250, 0, 0, 0)) + expect(calls[0]!.reasoningTokens).toBe(50) + }) + + it('does not split multibyte characters when truncating the first user message', async () => { + const dbPath = createHermesDb(tmpDir) + const message = `${'a'.repeat(499)}😀truncated tail` + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'emoji-session', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + estimatedCost: 0.01, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('emoji-session', 'user', message, 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=emoji-session`) + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe(`${'a'.repeat(499)}😀`) + }) + + it('parses legacy databases that predate optional accounting columns', async () => { + const dbPath = createLegacyHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + db.prepare( + `INSERT INTO sessions (id, model, input_tokens, output_tokens, started_at) + VALUES (?, ?, ?, ?, ?)`, + ).run('legacy-session', 'gpt-5.5', 12, 34, 1779549200) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('legacy-session', 'user', 'Legacy Hermes DB', 1779549201) + }) + + const provider = createHermesProvider(tmpDir) + const discovered = await provider.discoverSessions() + expect(discovered.map(s => s.path)).toEqual([`${dbPath}#hermes-session=legacy-session`]) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=legacy-session`) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + inputTokens: 12, + outputTokens: 34, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + reasoningTokens: 0, + userMessage: 'Legacy Hermes DB', + }) + }) + + it('discovers root and profile databases and preserves Hermes DB accounting through parser aggregation', async () => { + const rootDbPath = createHermesDb(tmpDir) + const profileDbPath = await createProfileHermesDb(tmpDir, 'coder') + + withTestDb(rootDbPath, (db) => { + insertSession(db, { + id: 'root-session', + model: 'gpt-5.5', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 40, + reasoningTokens: 5, + estimatedCost: 0.25, + actualCost: 0.30, + startedAt: 1779494400, + title: 'Root session', + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('root-session', 'user', 'Current working directory: /tmp/root-project\nImplement root support', 1779494401) + }) + withTestDb(profileDbPath, (db) => { + insertSession(db, { + id: 'profile-session', + model: 'gpt-5.5', + inputTokens: 200, + outputTokens: 70, + cacheReadTokens: 11, + cacheWriteTokens: 13, + reasoningTokens: 17, + estimatedCost: 0.42, + actualCost: null, + startedAt: 1779501600, + title: 'Profile session', + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('profile-session', 'user', 'Current working directory: /tmp/profile-project\nImplement profile support', 1779501601) + }) + + const provider = createHermesProvider(tmpDir) + const discovered = await provider.discoverSessions() + expect(discovered.map(s => s.path).sort()).toEqual([ + `${profileDbPath}#hermes-session=profile-session`, + `${rootDbPath}#hermes-session=root-session`, + ].sort()) + expect(discovered.map(s => s.project).sort()).toEqual(['coder', 'default']) + + const rootCalls = await collectCalls(tmpDir, `${rootDbPath}#hermes-session=root-session`) + const profileCalls = await collectCalls(tmpDir, `${profileDbPath}#hermes-session=profile-session`) + expect(rootCalls[0]).toMatchObject({ inputTokens: 100, outputTokens: 20, cacheReadInputTokens: 30, cacheCreationInputTokens: 40, reasoningTokens: 5, costUSD: 0.30 }) + expect(profileCalls[0]).toMatchObject({ inputTokens: 200, outputTokens: 70, cacheReadInputTokens: 11, cacheCreationInputTokens: 13, reasoningTokens: 17, costUSD: 0.42 }) + + const { clearSessionCache, parseAllSessions } = await loadParserWithHermesHome(tmpDir, cacheDir) + clearSessionCache() + const projects = await parseAllSessions(dayRange(), 'hermes') + const sessions = projects.flatMap(project => project.sessions) + expect(sessions).toHaveLength(2) + expect(sessions.reduce((sum, session) => sum + session.totalInputTokens, 0)).toBe(300) + expect(sessions.reduce((sum, session) => sum + session.totalOutputTokens, 0)).toBe(90) + expect(sessions.reduce((sum, session) => sum + session.totalReasoningTokens, 0)).toBe(22) + expect(sessions.reduce((sum, session) => sum + session.totalCacheReadTokens, 0)).toBe(41) + expect(sessions.reduce((sum, session) => sum + session.totalCacheWriteTokens, 0)).toBe(53) + expect(sessions.reduce((sum, session) => sum + session.totalCostUSD, 0)).toBeCloseTo(0.72) + expect(projects.map(project => project.project).sort()).toEqual(['tmp-profile-project', 'tmp-root-project']) + + const modelTokens = sessions.flatMap(session => Object.values(session.modelBreakdown).map(model => model.tokens)) + expect(modelTokens.reduce((sum, tokens) => sum + tokens.outputTokens, 0)).toBe(90) + expect(modelTokens.reduce((sum, tokens) => sum + tokens.reasoningTokens, 0)).toBe(22) + }) + + it('treats sibling profile-like directories as default sessions', async () => { + const profileLikeDir = join(dirname(tmpDir), `${basename(tmpDir)}-profiles_backup`, 'coder') + await mkdir(profileLikeDir, { recursive: true }) + const dbPath = createHermesDb(profileLikeDir) + + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'sibling-session', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('sibling-session', 'user', 'Sibling profile-like directory', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=sibling-session`) + expect(calls[0]).toMatchObject({ + deduplicationKey: 'hermes:default:sibling-session', + project: 'default', + }) + }) + + it('infers projects from Windows current working directory messages', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'windows-cwd-session', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('windows-cwd-session', 'user', 'Current working directory: C:\\AI_LAB\\OPENCLAW\nAdd Windows path support', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=windows-cwd-session`) + expect(calls[0]).toMatchObject({ + project: 'C--AI_LAB-OPENCLAW', + projectPath: 'C:\\AI_LAB\\OPENCLAW', + }) + }) + + it('groups by the sessions.cwd column when present, ahead of message scraping', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'cwd-session', + cwd: '/Users/me/projects/codeburn', + inputTokens: 30, + outputTokens: 10, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('cwd-session', 'user', 'Current working directory: /tmp/decoy\nbuild it', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=cwd-session`) + expect(calls[0]).toMatchObject({ + project: 'Users-me-projects-codeburn', + projectPath: '/Users/me/projects/codeburn', + }) + }) + + it('flags estimated cost only when Hermes recorded none', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'no-cost', + inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, + startedAt: 1779549200, + }) + insertSession(db, { + id: 'recorded-cost', + actualCost: 1.23, + inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, + startedAt: 1779549300, + }) + }) + + const noCost = await collectCalls(tmpDir, `${dbPath}#hermes-session=no-cost`) + expect(noCost[0]!.costIsEstimated).toBe(true) + + const recorded = await collectCalls(tmpDir, `${dbPath}#hermes-session=recorded-cost`) + expect(recorded[0]).toMatchObject({ costUSD: 1.23, costIsEstimated: false }) + }) + + it('counts tool-result messages by their tool_name', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'tool-result-session', + inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, tool_name, timestamp) VALUES (?, ?, ?, ?, ?)') + .run('tool-result-session', 'tool', null, 'read_file', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=tool-result-session`) + expect(calls[0]!.tools).toContain('Read') + }) +}) diff --git a/tests/providers/ibm-bob.test.ts b/tests/providers/ibm-bob.test.ts new file mode 100644 index 0000000..d61f92e --- /dev/null +++ b/tests/providers/ibm-bob.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { ibmBob, createIBMBobProvider } from '../../src/providers/ibm-bob.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +function makeUiMessages(opts: { + tokensIn?: number + tokensOut?: number + cacheReads?: number + cacheWrites?: number + cost?: number + userMessage?: string + ts?: number +}): string { + const messages: unknown[] = [] + + if (opts.userMessage) { + messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1_700_000_000_000 }) + } + + const apiData: Record<string, unknown> = { + tokensIn: opts.tokensIn ?? 100, + tokensOut: opts.tokensOut ?? 50, + cacheReads: opts.cacheReads ?? 0, + cacheWrites: opts.cacheWrites ?? 0, + } + if (opts.cost !== undefined) apiData.cost = opts.cost + + messages.push({ + type: 'say', + say: 'api_req_started', + text: JSON.stringify(apiData), + ts: opts.ts ?? 1_700_000_001_000, + }) + + return JSON.stringify(messages) +} + +function makeApiHistory(model?: string): string { + const modelTag = model ? `<model>${model}</model>` : '' + return JSON.stringify([ + { role: 'user', content: [{ type: 'text', text: `hello\n<environment_details>\n${modelTag}\n</environment_details>` }] }, + { role: 'assistant', content: [{ type: 'text', text: 'response' }] }, + ]) +} + +describe('ibm-bob provider - discovery and parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'ibm-bob-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers IBM Bob task directories with ui_messages.json', async () => { + const task1 = join(tmpDir, 'tasks', 'task-a') + const task2 = join(tmpDir, 'tasks', 'task-b') + await mkdir(task1, { recursive: true }) + await mkdir(task2, { recursive: true }) + await writeFile(join(task1, 'ui_messages.json'), '[]') + await writeFile(join(task2, 'ui_messages.json'), '[]') + + const provider = createIBMBobProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.every(s => s.provider === 'ibm-bob')).toBe(true) + expect(sessions.every(s => s.project === 'IBM Bob')).toBe(true) + }) + + it('skips tasks without ui_messages.json', async () => { + const task = join(tmpDir, 'tasks', 'task-no-ui') + await mkdir(task, { recursive: true }) + await writeFile(join(task, 'api_conversation_history.json'), '[]') + + const provider = createIBMBobProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(0) + }) + + it('parses token usage and provider cost from Bob ui messages', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-001') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ + tokensIn: 250, + tokensOut: 125, + cacheReads: 60, + cacheWrites: 30, + cost: 0.08, + userMessage: 'modernize this class', + })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory('anthropic/claude-sonnet-4-6')) + + const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' } + const calls: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!).toMatchObject({ + provider: 'ibm-bob', + model: 'claude-sonnet-4-6', + inputTokens: 250, + outputTokens: 125, + cacheReadInputTokens: 60, + cacheCreationInputTokens: 30, + costUSD: 0.08, + userMessage: 'modernize this class', + sessionId: 'task-001', + }) + expect(calls[0]!.deduplicationKey).toBe('ibm-bob:task-001:0') + }) + + it('falls back to IBM Bob auto model when history has no model tag', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-002') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory()) + + const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' } + const calls: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('ibm-bob-auto') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('deduplicates across parser runs', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-003') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + + const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' } + const seenKeys = new Set<string>() + + const calls1: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls1.push(call) + + const calls2: ParsedProviderCall[] = [] + for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls2.push(call) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) +}) + +describe('ibm-bob provider - metadata', () => { + it('has correct name and displayName', () => { + expect(ibmBob.name).toBe('ibm-bob') + expect(ibmBob.displayName).toBe('IBM Bob') + }) + + it('uses shared short model display names', () => { + expect(ibmBob.modelDisplayName('ibm-bob-auto')).toBe('IBM Bob (auto)') + expect(ibmBob.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') + }) +}) diff --git a/tests/providers/kilo-code.test.ts b/tests/providers/kilo-code.test.ts new file mode 100644 index 0000000..136afc9 --- /dev/null +++ b/tests/providers/kilo-code.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { kiloCode, createKiloCodeProvider } from '../../src/providers/kilo-code.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +describe('kilo-code provider - discovery path differentiation', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kilo-code-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers tasks using kilo-code extension path', async () => { + const task = join(tmpDir, 'tasks', 'task-kilo-1') + await mkdir(task, { recursive: true }) + await writeFile(join(task, 'ui_messages.json'), JSON.stringify([ + { type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 100, tokensOut: 50 }), ts: 1700000000000 }, + ])) + + const provider = createKiloCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + const fromOverride = sessions.filter(s => s.path.startsWith(tmpDir)) + + expect(fromOverride).toHaveLength(1) + expect(fromOverride[0]!.provider).toBe('kilo-code') + }) + + it('parses with kilo-code provider name in dedup key', async () => { + const task = join(tmpDir, 'tasks', 'task-kilo-2') + await mkdir(task, { recursive: true }) + await writeFile(join(task, 'ui_messages.json'), JSON.stringify([ + { type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 200, tokensOut: 100 }), ts: 1700000000000 }, + ])) + + const source = { path: task, project: 'task-kilo-2', provider: 'kilo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiloCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.provider).toBe('kilo-code') + expect(calls[0]!.deduplicationKey).toMatch(/^kilo-code:/) + }) +}) + +describe('kilo-code provider - metadata', () => { + it('has correct name and displayName', () => { + expect(kiloCode.name).toBe('kilo-code') + expect(kiloCode.displayName).toBe('KiloCode') + }) + + it('uses different extension ID than roo-code', () => { + expect(kiloCode.name).toBe('kilo-code') + expect(kiloCode.name).not.toBe('roo-code') + }) +}) diff --git a/tests/providers/kimi.test.ts b/tests/providers/kimi.test.ts new file mode 100644 index 0000000..486a03e --- /dev/null +++ b/tests/providers/kimi.test.ts @@ -0,0 +1,192 @@ +import { createHash } from 'crypto' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createKimiProvider } from '../../src/providers/kimi.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kimi-test-')) +}) + +afterEach(async () => { + delete process.env.KIMI_MODEL_NAME + await rm(tmpDir, { recursive: true, force: true }) +}) + +function md5(value: string): string { + return createHash('md5').update(value, 'utf-8').digest('hex') +} + +function record(timestamp: number, type: string, payload: Record<string, unknown>): string { + return JSON.stringify({ + timestamp, + message: { type, payload }, + }) +} + +async function writeSession(workDir: string, sessionId: string, lines: string[]): Promise<string> { + const hash = md5(workDir) + const sessionDir = join(tmpDir, 'sessions', hash, sessionId) + await mkdir(sessionDir, { recursive: true }) + const wirePath = join(sessionDir, 'wire.jsonl') + await writeFile(wirePath, [ + JSON.stringify({ type: 'metadata', protocol_version: '2' }), + ...lines, + ].join('\n') + '\n') + return wirePath +} + +async function collect(provider: ReturnType<typeof createKimiProvider>, path: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> { + const parser = provider.createSessionParser({ path, project: 'app', provider: 'kimi' }, seen) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + return calls +} + +describe('Kimi provider', () => { + it('discovers session and subagent wire logs under KIMI_SHARE_DIR layout', async () => { + const workDir = '/Users/test/work/app' + const hash = md5(workDir) + await writeFile(join(tmpDir, 'kimi.json'), JSON.stringify({ + work_dirs: [{ path: workDir, kaos: 'local', last_session_id: 'sess-1' }], + })) + + const sessionDir = join(tmpDir, 'sessions', hash, 'sess-1') + const subagentDir = join(sessionDir, 'subagents', 'agent-1') + await mkdir(subagentDir, { recursive: true }) + await writeFile(join(sessionDir, 'wire.jsonl'), '\n') + await writeFile(join(subagentDir, 'wire.jsonl'), '\n') + + const sources = await createKimiProvider(tmpDir).discoverSessions() + + expect(sources).toHaveLength(2) + expect(sources.map(s => s.project)).toEqual(['app', 'app']) + expect(sources.map(s => s.provider)).toEqual(['kimi', 'kimi']) + expect(sources.map(s => s.path).sort()).toEqual([ + join(sessionDir, 'subagents', 'agent-1', 'wire.jsonl'), + join(sessionDir, 'wire.jsonl'), + ].sort()) + }) + + it('parses Kimi wire StatusUpdate usage, tools, bash commands, and configured model', async () => { + await writeFile(join(tmpDir, 'config.toml'), [ + 'default_model = "kimi-code/k2"', + '', + '[models."kimi-code/k2"]', + 'model = "kimi-k2-thinking-turbo"', + ].join('\n')) + + const wirePath = await writeSession('/Users/test/work/app', 'sess-1', [ + record(1776162400, 'TurnBegin', { user_input: 'add status endpoint' }), + record(1776162401, 'ToolCall', { + type: 'function', + id: 'call-shell', + function: { name: 'Shell', arguments: JSON.stringify({ command: 'git status && npm test' }) }, + }), + record(1776162402, 'ToolCall', { + type: 'function', + id: 'call-read', + function: { name: 'ReadFile', arguments: JSON.stringify({ path: 'src/index.ts' }) }, + }), + record(1776162403, 'StatusUpdate', { + message_id: 'msg-1', + token_usage: { + input_other: 100, + input_cache_read: 25, + input_cache_creation: 10, + output: 40, + }, + }), + ]) + + const calls = await collect(createKimiProvider(tmpDir), wirePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + provider: 'kimi', + model: 'kimi-k2-thinking-turbo', + inputTokens: 100, + outputTokens: 40, + cacheReadInputTokens: 25, + cacheCreationInputTokens: 10, + cachedInputTokens: 25, + tools: ['Bash', 'Read'], + bashCommands: ['git', 'npm'], + timestamp: '2026-04-14T10:26:43.000Z', + deduplicationKey: 'kimi:sess-1:msg-1', + userMessage: 'add status endpoint', + sessionId: 'sess-1', + }) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('uses content parts, model payload overrides, and message-id deduplication', async () => { + process.env.KIMI_MODEL_NAME = 'kimi-k2-thinking' + const wirePath = await writeSession('/Users/test/work/app', 'sess-2', [ + record(1776023300, 'TurnBegin', { + user_input: [ + { type: 'text', text: 'refactor parser' }, + { type: 'image_url', image_url: { url: 'file://diagram.png' } }, + { type: 'text', text: 'carefully' }, + ], + }), + record(1776023301, 'ToolCallRequest', { + id: 'call-write', + name: 'WriteFile', + arguments: JSON.stringify({ path: 'src/parser.ts', content: 'x' }), + }), + record(1776023302, 'StatusUpdate', { + message_id: 'msg-2', + model_name: 'kimi-k2.6', + token_usage: { input_other: 5, output: 7 }, + }), + record(1776023303, 'StatusUpdate', { + message_id: 'msg-2', + model_name: 'kimi-k2.6', + token_usage: { input_other: 5, output: 7 }, + }), + ]) + + const calls = await collect(createKimiProvider(tmpDir), wirePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + model: 'kimi-k2.6', + userMessage: 'refactor parser carefully', + tools: ['Write'], + deduplicationKey: 'kimi:sess-2:msg-2', + }) + }) + + it('skips non-usage updates and supports legacy input total fields defensively', async () => { + const wirePath = await writeSession('/Users/test/work/app', 'sess-3', [ + record(1776023400, 'TurnBegin', { user_input: 'summarize' }), + record(1776023401, 'StatusUpdate', { context_usage: 0.5 }), + record(1776023402, 'StatusUpdate', { + message_id: 'msg-3', + token_usage: { + input: 120, + input_cache_read: 30, + input_cache_creation: 10, + output_tokens: 20, + }, + }), + ]) + + const calls = await collect(createKimiProvider(tmpDir), wirePath) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + inputTokens: 80, + cacheReadInputTokens: 30, + cacheCreationInputTokens: 10, + outputTokens: 20, + model: 'kimi-auto', + }) + }) +}) diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts new file mode 100644 index 0000000..5928670 --- /dev/null +++ b/tests/providers/kiro.test.ts @@ -0,0 +1,948 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { kiro, createKiroProvider } from '../../src/providers/kiro.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +function makeChatFile(opts: { + executionId?: string + modelId?: string + workflowId?: string + startTime?: number + endTime?: number + userPrompt?: string + botResponses?: string[] +}) { + const chat = [ + { role: 'human', content: '<identity>\nYou are Kiro.\n</identity>' }, + { role: 'bot', content: '' }, + { role: 'tool', content: 'workspace tree...' }, + { role: 'bot', content: 'I will follow these instructions.' }, + ] + + if (opts.userPrompt) { + chat.push({ role: 'human', content: opts.userPrompt }) + } + + for (const resp of opts.botResponses ?? ['Done.']) { + chat.push({ role: 'bot', content: resp }) + } + + return JSON.stringify({ + executionId: opts.executionId ?? 'exec-001', + actionId: 'act', + context: [], + validations: {}, + chat, + metadata: { + modelId: opts.modelId ?? 'claude-haiku-4-5', + modelProvider: 'qdev', + workflow: 'act', + workflowId: opts.workflowId ?? 'wf-001', + startTime: opts.startTime ?? 1777333000000, + endTime: opts.endTime ?? 1777333010000, + }, + }) +} + +function makeModernExecutionFile(opts: { + executionId?: string + sessionId?: string + modelId?: string + startTime?: number | string + userPrompt?: string + assistantResponse?: string +}) { + const startTime = opts.startTime ?? 1777333000000 + return JSON.stringify({ + executionId: opts.executionId ?? 'exec-modern-001', + sessionId: opts.sessionId ?? 'session-modern-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime, + endTime: typeof startTime === 'number' ? startTime + 10000 : 1777333010000, + modelId: opts.modelId ?? 'claude-sonnet-4.5', + messages: [ + { role: 'user', content: opts.userPrompt ?? 'explain the new kiro storage layout' }, + { + role: 'assistant', + content: opts.assistantResponse ?? 'Done. <tool_use><name>runCommand</name></tool_use>', + toolCalls: [{ name: 'readFile' }], + }, + ], + }) +} + +describe('kiro provider - chat file parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses a basic chat file', async () => { + const wsHash = 'a'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'abc123.chat') + await writeFile(chatPath, makeChatFile({ + modelId: 'claude-haiku-4-5', + userPrompt: 'explain the code', + botResponses: ['Here is an explanation of the code structure.'], + })) + + const source = { path: chatPath, project: 'myproject', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('kiro') + expect(call.model).toBe('claude-haiku-4-5') + expect(call.outputTokens).toBeGreaterThan(0) + expect(call.userMessage).toBe('explain the code') + expect(call.bashCommands).toEqual([]) + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('stores kiro-auto when model is auto', async () => { + const wsHash = 'b'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'abc.chat') + await writeFile(chatPath, makeChatFile({ + modelId: 'auto', + botResponses: ['some output'], + })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('kiro-auto') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('skips chat files with no bot output', async () => { + const wsHash = 'c'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'empty.chat') + await writeFile(chatPath, JSON.stringify({ + executionId: 'exec-empty', + actionId: 'act', + context: [], + validations: {}, + chat: [ + { role: 'human', content: '<identity>\nYou are Kiro.\n</identity>' }, + { role: 'bot', content: '' }, + { role: 'human', content: 'do something' }, + { role: 'bot', content: '' }, + ], + metadata: { + modelId: 'claude-haiku-4-5', + modelProvider: 'qdev', + workflow: 'act', + workflowId: 'wf-empty', + startTime: 1777333000000, + endTime: 1777333010000, + }, + })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('deduplicates across parser runs', async () => { + const wsHash = 'd'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'dup.chat') + await writeFile(chatPath, makeChatFile({ botResponses: ['hello'] })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const seenKeys = new Set<string>() + + const calls1: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls1.push(call) + + const calls2: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls2.push(call) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) + + it('returns empty for missing file', async () => { + const source = { path: '/nonexistent/test.chat', project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) + + it('returns empty for invalid JSON', async () => { + const wsHash = 'e'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'bad.chat') + await writeFile(chatPath, 'not json at all') + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) + + it('estimates tokens from text length', async () => { + const wsHash = 'f'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'tokens.chat') + const longResponse = 'x'.repeat(400) + await writeFile(chatPath, makeChatFile({ botResponses: [longResponse] })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(109) + }) + + it('normalizes dot-versioned model IDs to dashes', async () => { + const wsHash = 'h'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'dot.chat') + await writeFile(chatPath, makeChatFile({ + modelId: 'claude-haiku-4.5', + botResponses: ['response text here'], + })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('claude-haiku-4-5') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('uses workflowId as sessionId', async () => { + const wsHash = 'g'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'sess.chat') + await writeFile(chatPath, makeChatFile({ + workflowId: 'my-workflow-id', + botResponses: ['ok'], + })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe('my-workflow-id') + }) + + it('parses a post-February extensionless execution file', async () => { + const wsHash = 'i'.repeat(32) + const sessionHash = 'session-modern' + const wsDir = join(tmpDir, wsHash, sessionHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-modern') + await writeFile(executionPath, makeModernExecutionFile({ + executionId: 'exec-modern', + sessionId: 'session-modern', + modelId: 'claude-sonnet-4.5', + userPrompt: 'summarize this workspace', + assistantResponse: 'I reviewed it. <tool_use><name>runCommand</name></tool_use>', + })) + + const source = { path: executionPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('kiro') + expect(call.model).toBe('claude-sonnet-4-5') + expect(call.sessionId).toBe('session-modern') + expect(call.userMessage).toBe('summarize this workspace') + expect(call.inputTokens).toBeGreaterThan(0) + expect(call.outputTokens).toBeGreaterThan(0) + expect(call.tools).toEqual(['Bash', 'Read']) + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('skips session index files without conversation content', async () => { + const wsHash = 'j'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const indexPath = join(wsDir, 'session-index') + await writeFile(indexPath, JSON.stringify({ + executions: [{ + executionId: 'exec-indexed', + type: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + endTime: 1777333010000, + }], + })) + + const source = { path: indexPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('parses direct prompt and response fields from modern execution files', async () => { + const wsHash = 'k'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-direct') + await writeFile(executionPath, JSON.stringify({ + executionId: 'exec-direct', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + model: { id: 'auto' }, + prompt: 'make a small change', + response: 'Changed it. <tool_use><name>writeFile</name></tool_use>', + })) + + const source = { path: executionPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('kiro-auto') + expect(calls[0]!.userMessage).toBe('make a small change') + expect(calls[0]!.tools).toEqual(['Edit']) + }) + + it('accepts second-based modern timestamps', async () => { + const wsHash = 'n'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-seconds') + await writeFile(executionPath, makeModernExecutionFile({ + executionId: 'exec-seconds', + startTime: 1777333000, + })) + + const source = { path: executionPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe('2026-04-27T23:36:40.000Z') + }) + + it('accepts numeric-string modern timestamps', async () => { + const wsHash = 'o'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-string-time') + await writeFile(executionPath, makeModernExecutionFile({ + executionId: 'exec-string-time', + startTime: '1777333000000', + })) + + const source = { path: executionPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe('2026-04-27T23:36:40.000Z') + }) + + it('does not poison dedup keys when a modern execution has an invalid timestamp', async () => { + const wsHash = 'p'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const invalidPath = join(wsDir, 'execution-invalid-time') + const validPath = join(wsDir, 'execution-valid-time') + const shared = { + executionId: 'exec-recovered', + sessionId: 'session-recovered', + } + await writeFile(invalidPath, makeModernExecutionFile({ + ...shared, + startTime: 'not-a-timestamp', + })) + await writeFile(validPath, makeModernExecutionFile({ + ...shared, + startTime: 1777333000000, + })) + + const seenKeys = new Set<string>() + const invalidCalls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser({ path: invalidPath, project: 'test', provider: 'kiro' }, seenKeys).parse()) { + invalidCalls.push(call) + } + const validCalls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser({ path: validPath, project: 'test', provider: 'kiro' }, seenKeys).parse()) { + validCalls.push(call) + } + + expect(invalidCalls).toHaveLength(0) + expect(validCalls).toHaveLength(1) + }) + + it.each(['conversation', 'chat', 'transcript', 'entries', 'events'])('parses modern execution conversation arrays from %s', async (key) => { + const wsHash = 'q'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, `execution-${key}`) + await writeFile(executionPath, JSON.stringify({ + executionId: `exec-${key}`, + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + modelId: 'claude-sonnet-4.5', + [key]: [ + { role: 'user', content: `request from ${key}` }, + { role: 'assistant', content: `response from ${key}`, toolCalls: [{ name: 'readFile' }] }, + ], + })) + + const source = { path: executionPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe(`request from ${key}`) + expect(calls[0]!.tools).toEqual(['Read']) + }) + + it('keeps modern executions with structured assistant tool calls and no assistant text', async () => { + const wsHash = 'l'.repeat(32) + const wsDir = join(tmpDir, wsHash, 'session-tools') + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-tools') + await writeFile(executionPath, JSON.stringify({ + executionId: 'exec-tools', + sessionId: 'session-tools', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + modelId: 'claude-sonnet-4.5', + messages: [ + { role: 'user', content: 'run the test suite' }, + { role: 'assistant', toolCalls: [{ name: 'runCommand' }] }, + ], + })) + + const source = { path: executionPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Bash']) + expect(calls[0]!.inputTokens).toBeGreaterThan(0) + expect(calls[0]!.outputTokens).toBe(0) + }) + + it('keeps direct modern executions with root tool calls and no response text', async () => { + const wsHash = 'm'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-root-tools') + await writeFile(executionPath, JSON.stringify({ + executionId: 'exec-root-tools', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + model: { id: 'auto' }, + name: 'workflow-name', + prompt: 'edit a file', + toolCalls: [{ name: 'writeFile' }], + })) + + const source = { path: executionPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Edit']) + expect(calls[0]!.tools).not.toContain('workflow-name') + expect(calls[0]!.outputTokens).toBe(0) + }) +}) + +describe('kiro provider - discoverSessions', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers chat files from workspace hash directories', async () => { + const wsHash = 'a1b2c3d4e5f6'.padEnd(32, '0') + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + await writeFile(join(wsDir, 'session1.chat'), makeChatFile({})) + await writeFile(join(wsDir, 'session2.chat'), makeChatFile({})) + + const provider = createKiroProvider(tmpDir, '/nonexistent/ws') + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.every(s => s.provider === 'kiro')).toBe(true) + expect(sessions.every(s => s.path.endsWith('.chat'))).toBe(true) + }) + + it('discovers extensionless session index files and nested execution files', async () => { + const wsHash = 'd'.repeat(32) + const wsDir = join(tmpDir, wsHash) + const sessionDir = join(wsDir, 'session-dir') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(wsDir, 'session-index'), JSON.stringify({ executions: [] })) + await writeFile(join(wsDir, 'legacy.chat'), makeChatFile({})) + await writeFile(join(wsDir, 'ignored.json'), '{}') + await writeFile(join(wsDir, '.DS_Store'), 'ignored') + await writeFile(join(sessionDir, 'execution-1'), makeModernExecutionFile({})) + await writeFile(join(sessionDir, '.hidden'), 'ignored') + await writeFile(join(sessionDir, 'ignored.txt'), 'hello') + + const provider = createKiroProvider(tmpDir, '/nonexistent/ws') + const sessions = await provider.discoverSessions() + const paths = sessions.map(s => s.path).sort() + + expect(paths).toEqual([ + join(sessionDir, 'execution-1'), + join(wsDir, 'legacy.chat'), + join(wsDir, 'session-index'), + ].sort()) + }) + + it('reads project name from workspace.json', async () => { + const wsHash = 'b'.repeat(32) + const agentWsDir = join(tmpDir, wsHash) + await mkdir(agentWsDir, { recursive: true }) + await writeFile(join(agentWsDir, 'test.chat'), makeChatFile({})) + + const workspaceStorageDir = join(tmpDir, 'ws-storage') + const wsStorageEntry = join(workspaceStorageDir, wsHash) + await mkdir(wsStorageEntry, { recursive: true }) + await writeFile(join(wsStorageEntry, 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' })) + + const provider = createKiroProvider(tmpDir, workspaceStorageDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('myapp') + }) + + it('returns empty when directory does not exist', async () => { + const provider = createKiroProvider('/nonexistent/agent', '/nonexistent/ws') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('skips non-32-char directories', async () => { + const shortDir = join(tmpDir, 'short') + await mkdir(shortDir, { recursive: true }) + await writeFile(join(shortDir, 'test.chat'), makeChatFile({})) + + const provider = createKiroProvider(tmpDir, '/nonexistent/ws') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('skips files with unsupported extensions', async () => { + const wsHash = 'c'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + await writeFile(join(wsDir, 'index.json'), '{}') + await writeFile(join(wsDir, 'notes.txt'), 'hello') + + const provider = createKiroProvider(tmpDir, '/nonexistent/ws') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) +}) + +describe('kiro provider - metadata', () => { + it('has correct name and displayName', () => { + expect(kiro.name).toBe('kiro') + expect(kiro.displayName).toBe('Kiro') + }) + + it('normalizes model display names', () => { + expect(kiro.modelDisplayName('claude-haiku-4-5')).toBe('Haiku 4.5') + expect(kiro.modelDisplayName('claude-sonnet-4-5')).toBe('Sonnet 4.5') + expect(kiro.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') + expect(kiro.modelDisplayName('unknown-model')).toBe('unknown-model') + }) + + it('normalizes tool display names', () => { + expect(kiro.toolDisplayName('readFile')).toBe('Read') + expect(kiro.toolDisplayName('writeFile')).toBe('Edit') + expect(kiro.toolDisplayName('runCommand')).toBe('Bash') + expect(kiro.toolDisplayName('searchFiles')).toBe('Grep') + expect(kiro.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) + + it('normalizes CLI-specific tool names', () => { + expect(kiro.toolDisplayName('code')).toBe('Read') + expect(kiro.toolDisplayName('subagent')).toBe('Agent') + expect(kiro.toolDisplayName('web_fetch')).toBe('WebFetch') + }) + + it('passes through MCP tool names unchanged', () => { + expect(kiro.toolDisplayName('mcp__server__searchJira')).toBe('mcp__server__searchJira') + expect(kiro.toolDisplayName('mcp__atlassian__getIssue')).toBe('mcp__atlassian__getIssue') + }) + + it('longest-prefix match for versioned model IDs', () => { + expect(kiro.modelDisplayName('claude-sonnet-4-5-20260101')).toBe('Sonnet 4.5') + expect(kiro.modelDisplayName('claude-haiku-4-5-20260101')).toBe('Haiku 4.5') + }) +}) + +describe('kiro provider - CLI session discovery', () => { + let cliDir: string + + beforeEach(async () => { + cliDir = await mkdtemp(join(tmpdir(), 'kiro-cli-test-')) + }) + + afterEach(async () => { + await rm(cliDir, { recursive: true, force: true }) + }) + + it('discovers .jsonl files from CLI sessions directory', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111' + await writeFile(join(cliDir, `${sessionId}.jsonl`), '') + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/home/user/my-project', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + })) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', cliDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('my-project') + expect(sessions[0]!.path).toContain('.jsonl') + expect(sessions[0]!.provider).toBe('kiro') + }) + + it('parses CLI session JSONL into calls', async () => { + const sessionId = '22222222-2222-2222-2222-222222222222' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hello world' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'Hello! How can I help you today?' }, { kind: 'toolUse', data: { toolUseId: 't1', name: 'read', input: {} } }] } }), + JSON.stringify({ version: '1', kind: 'ToolResults', data: { message_id: 'm3', content: [{ kind: 'text', data: 'file contents here' }], results: { t1: { output: 'ok' } } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm4', content: [{ kind: 'text', data: 'I read the file for you.' }] } }), + ].join('\n') + + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/test-project', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'auto' } }, + conversation_metadata: { + user_turn_metadatas: [{ + end_timestamp: '2026-01-01T00:00:30Z', + metering_usage: [{ value: 0.05, unit: 'credit' }, { value: 0.08, unit: 'credit' }], + }], + }, + }, + })) + + const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'test-project', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('kiro') + expect(call.model).toBe('kiro-auto') + expect(call.tools).toContain('Read') + expect(call.userMessage).toBe('hello world') + expect(call.costUSD).toBeCloseTo(0.13, 2) + expect(call.deduplicationKey).toBe(`kiro-cli:${sessionId}:0`) + expect(call.timestamp).toBe('2026-01-01T00:00:30.000Z') + expect(call.project).toBe('test-project') + }) + + it('parses multiple turns from a CLI session', async () => { + const sessionId = '33333333-3333-3333-3333-333333333333' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'first question' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'first answer' }] } }), + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm3', content: [{ kind: 'text', data: 'second question' }], meta: { timestamp: 1700000060 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm4', content: [{ kind: 'text', data: 'second answer' }] } }), + ].join('\n') + + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/multi', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:02:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'claude-sonnet-4' } }, + conversation_metadata: { + user_turn_metadatas: [ + { end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 0.04, unit: 'credit' }] }, + { end_timestamp: '2026-01-01T00:01:30Z', metering_usage: [{ value: 0.06, unit: 'credit' }] }, + ], + }, + }, + })) + + const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'multi', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[0]!.model).toBe('claude-sonnet-4') + expect(calls[1]!.userMessage).toBe('second question') + expect(calls[1]!.costUSD).toBeCloseTo(0.06, 2) + }) + + it('skips non-jsonl files in CLI directory', async () => { + await writeFile(join(cliDir, 'something.json'), '{}') + await writeFile(join(cliDir, 'something.lock'), '') + + const provider = createKiroProvider('/nonexistent', '/nonexistent', cliDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) +}) + +describe('kiro provider - context.messages with entries', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kiro-ctx-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses context.messages using entries field', async () => { + // Simulates the real Kiro IDE format where messages use "entries" not "content" + const file = JSON.stringify({ + executionId: 'exec-ctx-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + chatSessionId: 'session-ctx-001', + context: { + messages: [ + { role: 'human', entries: ['What is the meaning of life?'] }, + { role: 'bot', entries: ['The meaning of life is 42, according to Douglas Adams.'] }, + { role: 'human', entries: ['Tell me more'] }, + { role: 'bot', entries: ['The answer comes from The Hitchhiker\'s Guide to the Galaxy.'] }, + ], + }, + }) + + const wsHash = 'a'.repeat(32) + const subDir = 'b'.repeat(32) + await mkdir(join(tmpDir, wsHash, subDir), { recursive: true }) + await writeFile(join(tmpDir, wsHash, subDir, 'exec-ctx-001'), file) + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const sessions = await provider.discoverSessions() + expect(sessions.length).toBeGreaterThan(0) + + const calls: ParsedProviderCall[] = [] + for (const source of sessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + expect(calls.length).toBeGreaterThan(0) + const call = calls[0]! + expect(call.inputTokens).toBeGreaterThan(0) + expect(call.outputTokens).toBeGreaterThan(0) + expect(call.sessionId).toBe('session-ctx-001') + }) + + it('extracts tools from usageSummary', async () => { + const file = JSON.stringify({ + executionId: 'exec-tools-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + chatSessionId: 'session-tools-001', + context: { + messages: [ + { role: 'human', entries: ['Search for accounts'] }, + { role: 'bot', entries: ['Found 5 accounts.'] }, + ], + }, + usageSummary: [ + { usedTools: ['mcp_aws_sentral_mcp_search_accounts'], usage: 0.5, unit: 'credit' }, + { usedTools: ['executeBash', 'readFile'], usage: 1.0, unit: 'credit' }, + ], + }) + + const wsHash = 'c'.repeat(32) + const subDir = 'd'.repeat(32) + await mkdir(join(tmpDir, wsHash, subDir), { recursive: true }) + await writeFile(join(tmpDir, wsHash, subDir, 'exec-tools-001'), file) + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const sessions = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + expect(calls.length).toBeGreaterThan(0) + const call = calls[0]! + expect(call.tools).toContain('aws_sentral_mcp_search_accounts') + expect(call.tools).toContain('Bash') + expect(call.tools).toContain('Read') + }) + + it('skips execution index files with executions array', async () => { + // The session index file has {executions: [...], version: 2} + const indexFile = JSON.stringify({ + executions: [ + { executionId: 'exec-001', type: 'chat-agent', status: 'succeed', startTime: 1777333000000 }, + ], + version: 2, + }) + + const wsHash = 'e'.repeat(32) + await mkdir(join(tmpDir, wsHash), { recursive: true }) + await writeFile(join(tmpDir, wsHash, 'f'.repeat(32)), indexFile) + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const sessions = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + expect(calls).toHaveLength(0) + }) +}) + +describe('kiro provider - workspace-sessions format', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kiro-wss-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers and parses workspace-sessions files', async () => { + // Create workspace-sessions/<base64>/<sessionId>.json + const wsSessionsDir = join(tmpDir, 'workspace-sessions', 'L3RtcC90ZXN0') + await mkdir(wsSessionsDir, { recursive: true }) + + const sessionFile = JSON.stringify({ + sessionId: 'ws-session-001', + title: 'Test session', + selectedModel: 'claude-opus-4.8', + workspaceDirectory: '/tmp/test', + history: [ + { message: { role: 'user', content: [{ type: 'text', text: 'What is TypeScript?' }] } }, + { message: { role: 'assistant', content: 'TypeScript is a typed superset of JavaScript.' } }, + { message: { role: 'user', content: [{ type: 'text', text: 'How do I use generics?' }] } }, + { message: { role: 'assistant', content: 'Generics allow you to create reusable components.' } }, + ], + }) + + await writeFile(join(wsSessionsDir, 'ws-session-001.json'), sessionFile) + // Also need sessions.json (should be skipped) + await writeFile(join(wsSessionsDir, 'sessions.json'), '[]') + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const sessions = await provider.discoverSessions() + + const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions')) + expect(wsSessions).toHaveLength(1) + expect(wsSessions[0]!.path).toContain('ws-session-001.json') + + const calls: ParsedProviderCall[] = [] + for (const source of wsSessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.model).toBe('claude-opus-4-8') + expect(call.sessionId).toBe('ws-session-001') + expect(call.inputTokens).toBeGreaterThan(0) + expect(call.outputTokens).toBeGreaterThan(0) + expect(call.deduplicationKey).toBe('kiro:ws-session:ws-session-001') + }) + + it('skips workspace-sessions with only stub assistant replies referencing execution files', async () => { + const wsSessionsDir = join(tmpDir, 'workspace-sessions', 'L3RtcC90ZXN0') + await mkdir(wsSessionsDir, { recursive: true }) + + // Session where assistant only says "On it." with executionId refs + // (real output is in execution files — skip to avoid double-counting) + const sessionFile = JSON.stringify({ + sessionId: 'ws-session-stub', + selectedModel: 'auto', + workspaceDirectory: '/tmp/test', + history: [ + { message: { role: 'user', content: [{ type: 'text', text: 'Deploy the stack' }] } }, + { message: { role: 'assistant', content: 'On it.' }, executionId: 'exec-ref-001' }, + ], + }) + + await writeFile(join(wsSessionsDir, 'ws-session-stub.json'), sessionFile) + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const sessions = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + // Should be skipped: has executionId refs but no real assistant content + expect(calls).toHaveLength(0) + }) + + it('skips sessions.json file in workspace-sessions', async () => { + const wsSessionsDir = join(tmpDir, 'workspace-sessions', 'L3RtcC90ZXN0') + await mkdir(wsSessionsDir, { recursive: true }) + await writeFile(join(wsSessionsDir, 'sessions.json'), '[]') + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const sessions = await provider.discoverSessions() + const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions')) + expect(wsSessions).toHaveLength(0) + }) +}) diff --git a/tests/providers/lingtai-tui.test.ts b/tests/providers/lingtai-tui.test.ts new file mode 100644 index 0000000..3feef28 --- /dev/null +++ b/tests/providers/lingtai-tui.test.ts @@ -0,0 +1,207 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { createLingTaiTuiProvider } from '../../src/providers/lingtai-tui.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'lingtai-tui-provider-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function writeAgent(name: string, opts: { + home?: string + manifest?: Record<string, unknown> + ledgerLines?: Array<Record<string, unknown> | string> +} = {}): Promise<string> { + const home = opts.home ?? tmpDir + const agentDir = join(home, name) + await mkdir(join(agentDir, 'logs'), { recursive: true }) + if (opts.manifest) { + await writeFile(join(agentDir, '.agent.json'), JSON.stringify(opts.manifest, null, 2)) + } + const ledgerPath = join(agentDir, 'logs', 'token_ledger.jsonl') + const lines = opts.ledgerLines ?? [] + await writeFile( + ledgerPath, + lines.map(line => typeof line === 'string' ? line : JSON.stringify(line)).join('\n') + (lines.length ? '\n' : ''), + ) + return ledgerPath +} + +async function collectCalls(provider: ReturnType<typeof createLingTaiTuiProvider>, sourcePath: string, project = 'agent'): Promise<ParsedProviderCall[]> { + const calls: ParsedProviderCall[] = [] + const parser = provider.createSessionParser({ path: sourcePath, project, provider: 'lingtai-tui' }, new Set()) + for await (const call of parser.parse()) calls.push(call) + return calls +} + +describe('lingtai-tui provider', () => { + it('discovers top-level LingTai token ledgers', async () => { + const ledgerPath = await writeAgent('agent-a', { + manifest: { + agent_id: 'agent-001', + agent_name: 'Operator Agent', + llm: { model: 'gpt-5.5', base_url: 'example-endpoint' }, + }, + ledgerLines: [ + { source: 'main', ts: '2026-06-04T01:25:09Z', input: 100, output: 20, thinking: 5, cached: 10, model: 'gpt-5.5' }, + ], + }) + await mkdir(join(tmpDir, 'agent-a', 'daemons', 'em-1', 'logs'), { recursive: true }) + await writeFile(join(tmpDir, 'agent-a', 'daemons', 'em-1', 'logs', 'token_ledger.jsonl'), '{}\n') + + const provider = createLingTaiTuiProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toEqual([{ path: ledgerPath, project: 'Operator Agent', provider: 'lingtai-tui' }]) + }) + + it('discovers project-local LingTai homes from the TUI registry', async () => { + const defaultHome = join(tmpDir, 'home', '.lingtai') + const globalDir = join(tmpDir, 'home', '.lingtai-tui') + const projectRoot = join(tmpDir, 'projects', 'sample-project') + const projectHome = join(projectRoot, '.lingtai') + + const defaultLedger = await writeAgent('personal', { + home: defaultHome, + manifest: { agent_name: 'Personal Agent' }, + ledgerLines: [{ ts: '2026-07-06T01:00:00Z', input: 1, output: 1, model: 'gpt-5.5' }], + }) + const projectLedger = await writeAgent('lingtai', { + home: projectHome, + manifest: { agent_name: 'Project Agent' }, + ledgerLines: [{ ts: '2026-07-06T02:00:00Z', input: 2, output: 2, model: 'gpt-5.5' }], + }) + await mkdir(globalDir, { recursive: true }) + await writeFile(join(globalDir, 'registry.jsonl'), JSON.stringify({ path: projectRoot }) + '\n') + + const provider = createLingTaiTuiProvider({ + defaultHomeOverride: defaultHome, + globalDirOverride: globalDir, + cwdOverride: join(tmpDir, 'elsewhere'), + }) + const sessions = await provider.discoverSessions() + + expect(sessions).toEqual([ + { path: defaultLedger, project: 'Personal Agent', provider: 'lingtai-tui' }, + { path: projectLedger, project: 'sample-project-Project Agent', provider: 'lingtai-tui' }, + ]) + }) + + it('parses ledger entries and separates cached input from fresh input', async () => { + const ledgerPath = await writeAgent('agent-b', { + manifest: { + agent_id: 'agent-002', + address: 'agent-b', + llm: { model: 'fallback-model', base_url: 'fallback-endpoint' }, + }, + ledgerLines: [ + { source: 'main', ts: '2026-06-04T01:25:09Z', input: 100, output: 20, thinking: 5, cached: 10, model: 'gpt-5.5', endpoint: 'example-endpoint' }, + { source: 'tc_wake', ts: '2026-06-04T01:28:24Z', input: 25, output: 5, thinking: 0, cached: 10 }, + { source: 'summarize_apriori', ts: '2026-06-04T01:29:24Z', input: 40, output: 10, thinking: 0, cached: 20 }, + { source: 'daemon', em_id: 'em-1', run_id: 'run-1', ts: '2026-06-04T01:30:24Z', input: 50, output: 10, thinking: 0, cached: 50 }, + ], + }) + + const calls = await collectCalls(createLingTaiTuiProvider(tmpDir), ledgerPath, 'agent-b') + + expect(calls).toHaveLength(4) + expect(calls[0]).toMatchObject({ + provider: 'lingtai-tui', + model: 'gpt-5.5', + inputTokens: 90, + outputTokens: 20, + reasoningTokens: 5, + cacheReadInputTokens: 10, + cachedInputTokens: 10, + timestamp: '2026-06-04T01:25:09.000Z', + userMessage: 'LingTai main conversation', + sessionId: 'agent-002:main', + project: 'agent-b', + }) + expect(calls[1]).toMatchObject({ + tools: ['Agent'], + subagentTypes: ['lingtai-task-coordinator'], + userMessage: 'LingTai task coordinator wake', + sessionId: 'agent-002:tc_wake', + }) + expect(calls[2]).toMatchObject({ + tools: ['EnterPlanMode'], + subagentTypes: [], + userMessage: 'LingTai planning summary', + sessionId: 'agent-002:summarize_apriori', + }) + expect(calls[1]).toMatchObject({ + model: 'fallback-model', + }) + expect(calls[3]).toMatchObject({ + model: 'fallback-model', + inputTokens: 0, + cacheReadInputTokens: 50, + tools: ['Agent'], + subagentTypes: ['lingtai-daemon'], + sessionId: 'run-1', + userMessage: 'LingTai daemon task', + }) + expect(calls[0]!.deduplicationKey).not.toBe(calls[3]!.deduplicationKey) + }) + + it('skips corrupt and zero-token lines', async () => { + const ledgerPath = await writeAgent('agent-c', { + ledgerLines: [ + 'not json', + { source: 'main', ts: '2026-06-04T01:25:09Z', input: 0, output: 0, thinking: 0, cached: 0, model: 'gpt-5.5' }, + { source: 'main', ts: '2026-06-04T01:26:09Z', input: 1, output: 2, thinking: 3, cached: 0, model: 'gpt-5.5' }, + ], + }) + + const calls = await collectCalls(createLingTaiTuiProvider(tmpDir), ledgerPath) + + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(1) + expect(calls[0]!.outputTokens).toBe(2) + expect(calls[0]!.reasoningTokens).toBe(3) + }) + + it('uses shared model display names', () => { + const provider = createLingTaiTuiProvider(tmpDir) + expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') + expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model') + }) + + // A planted .agent.json can be valid JSON with wrong-typed fields. Before the + // manifest was normalized, an object-valued agent_name reached + // sanitizeProject().trim() and threw — and discoverAllSessions loops providers + // with no try/catch, so that one file broke usage discovery for EVERY + // provider. Discovery and parsing must both survive it. + it('does not crash on a valid-JSON manifest with wrong-typed fields', async () => { + const ledgerPath = await writeAgent('agent-hostile', { + manifest: { + agent_name: {}, + agent_id: [1, 2], + address: 42, + nickname: { x: 1 }, + llm: { model: {}, base_url: [] }, + }, + ledgerLines: [ + { source: 'main', ts: '2026-06-04T01:26:09Z', input: 10, output: 5, model: 'gpt-5.5' }, + ], + }) + + const provider = createLingTaiTuiProvider(tmpDir) + await expect(provider.discoverSessions()).resolves.toBeInstanceOf(Array) + const calls = await collectCalls(provider, ledgerPath) + expect(calls).toHaveLength(1) + // Wrong-typed manifest name falls back to the sanitized agent directory. + expect(typeof calls[0]!.model).toBe('string') + }) +}) diff --git a/tests/providers/mistral-vibe.test.ts b/tests/providers/mistral-vibe.test.ts new file mode 100644 index 0000000..1a393fc --- /dev/null +++ b/tests/providers/mistral-vibe.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createMistralVibeProvider } from '../../src/providers/mistral-vibe.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'mistral-vibe-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function metadata(opts: { + sessionId?: string + cwd?: string + input?: number + output?: number + sessionCost?: number + inputPrice?: number + outputPrice?: number + activeModel?: string + modelName?: string + configInputPrice?: number + configOutputPrice?: number + endTime?: string | null + title?: string +} = {}) { + const activeModel = opts.activeModel ?? 'mistral-medium-3.5' + return { + session_id: opts.sessionId ?? 'session-abc123', + start_time: '2026-05-11T10:00:00+00:00', + end_time: Object.hasOwn(opts, 'endTime') ? opts.endTime : '2026-05-11T10:05:00+00:00', + environment: { + working_directory: opts.cwd ?? '/Users/test/mistral-project', + }, + stats: { + session_prompt_tokens: opts.input ?? 2000, + session_completion_tokens: opts.output ?? 3000, + session_cost: opts.sessionCost, + input_price_per_million: opts.inputPrice ?? 1.5, + output_price_per_million: opts.outputPrice ?? 7.5, + tokens_per_second: 42, + }, + config: { + active_model: activeModel, + models: [ + { + alias: activeModel, + name: opts.modelName ?? 'mistral-vibe-cli-latest', + provider: 'mistral', + input_price: opts.configInputPrice ?? 1.5, + output_price: opts.configOutputPrice ?? 7.5, + }, + ], + }, + title: opts.title ?? 'implement mistral support', + total_messages: 2, + } +} + +function userMessage(content: unknown = 'implement mistral support') { + return { + role: 'user', + content, + message_id: 'msg-user-1', + } +} + +function assistantMessage(toolCalls: Array<{ name: string; args?: Record<string, unknown> | string }> = []) { + return { + role: 'assistant', + content: 'Done', + message_id: 'msg-assistant-1', + tool_calls: toolCalls.map((call, idx) => ({ + id: `tool-${idx}`, + type: 'function', + function: { + name: call.name, + arguments: typeof call.args === 'string' ? call.args : JSON.stringify(call.args ?? {}), + }, + })), + } +} + +async function writeSession( + name: string, + meta: Record<string, unknown>, + messages = [userMessage(), assistantMessage()], + root = tmpDir, +) { + const sessionDir = join(root, name) + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'meta.json'), JSON.stringify(meta, null, 2)) + await writeFile(join(sessionDir, 'messages.jsonl'), messages.map(m => JSON.stringify(m)).join('\n') + '\n') + return sessionDir +} + +async function collect(sourcePath: string, provider = createMistralVibeProvider(tmpDir)): Promise<ParsedProviderCall[]> { + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser({ + path: sourcePath, + project: 'mistral-project', + provider: 'mistral-vibe', + }, new Set()).parse()) { + calls.push(call) + } + return calls +} + +describe('mistral-vibe provider - session discovery', () => { + it('discovers Vibe session folders and derives project from metadata cwd', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({ + sessionId: 'session-a', + cwd: '/Users/test/project-a', + })) + await mkdir(join(tmpDir, 'not-a-session'), { recursive: true }) + + const provider = createMistralVibeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]).toEqual({ + path: sessionDir, + project: 'project-a', + provider: 'mistral-vibe', + }) + }) + + it('discovers subagent session folders nested under agents', async () => { + const parentDir = await writeSession('session_20260511_100000_parent', metadata({ + sessionId: 'parent-session', + cwd: '/Users/test/parent-project', + })) + const childDir = await writeSession('session_20260511_100001_child', metadata({ + sessionId: 'child-session', + cwd: '/Users/test/child-project', + }), [userMessage('child task'), assistantMessage()], join(parentDir, 'agents')) + + const provider = createMistralVibeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions.map(s => s.path).sort()).toEqual([childDir, parentDir].sort()) + expect(sessions.map(s => s.project).sort()).toEqual(['child-project', 'parent-project']) + }) + + it('returns empty for a missing Vibe sessions directory', async () => { + const provider = createMistralVibeProvider('/missing/vibe/logs/session') + await expect(provider.discoverSessions()).resolves.toEqual([]) + }) + + it('uses VIBE_HOME when no override directory is provided', async () => { + const vibeHome = join(tmpDir, 'vibe-home') + process.env['VIBE_HOME'] = vibeHome + const sessionsDir = join(vibeHome, 'logs', 'session') + await writeSession('session_20260511_100000_sessiona', metadata({ + sessionId: 'env-session', + cwd: '/Users/test/env-project', + }), [userMessage(), assistantMessage()], sessionsDir) + + const provider = createMistralVibeProvider() + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('env-project') + }) +}) + +describe('mistral-vibe provider - parsing', () => { + it('parses cumulative session usage, tools, bash commands, and first user message', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata(), [ + userMessage([{ type: 'text', text: 'track Mistral Vibe usage' }]), + assistantMessage([ + { name: 'read_file', args: { path: 'src/index.ts' } }, + { name: 'search_replace', args: { file_path: 'src/index.ts', content: 'patch' } }, + { name: 'bash', args: { command: 'npm test && git status' } }, + ]), + ]) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('mistral-vibe') + expect(call.model).toBe('mistral-medium-3.5') + expect(call.inputTokens).toBe(2000) + expect(call.outputTokens).toBe(3000) + expect(call.costUSD).toBeCloseTo(0.0255, 8) + expect(call.tools).toEqual(['Read', 'Edit', 'Bash']) + expect(call.bashCommands).toEqual(['npm', 'git']) + expect(call.timestamp).toBe('2026-05-11T10:05:00+00:00') + expect(call.userMessage).toBe('track Mistral Vibe usage') + expect(call.sessionId).toBe('session-abc123') + expect(call.deduplicationKey).toBe('mistral-vibe:session-abc123:msg-assistant-1') + }) + + it('prefers Vibe session_cost over price-derived estimates when present', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({ + input: 364147, + output: 1731, + sessionCost: 0.381681, + inputPrice: 100, + outputPrice: 100, + })) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBe(0.381681) + }) + + it('uses configured model prices when stats omit prices', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({ + inputPrice: 0, + outputPrice: 0, + input: 1000, + output: 1000, + })) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(0.009, 8) + }) + + it('falls back to LiteLLM pricing when Vibe does not provide prices', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({ + activeModel: 'claude-sonnet-4-6', + modelName: 'claude-sonnet-4-6', + input: 1000, + output: 1000, + inputPrice: 0, + outputPrice: 0, + configInputPrice: 0, + configOutputPrice: 0, + })) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(0.018, 8) + }) + + it('falls back to start_time when end_time is missing', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({ + endTime: null, + })) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls[0]!.timestamp).toBe('2026-05-11T10:00:00+00:00') + }) + + it('deduplicates by session id', async () => { + const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata()) + const provider = createMistralVibeProvider(tmpDir) + const source = { path: sessionDir, project: 'mistral-project', provider: 'mistral-vibe' } + const seen = new Set<string>() + + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) + + it('skips sessions without cumulative token usage', async () => { + const sessionDir = await writeSession('session_20260511_100000_empty', metadata({ + input: 0, + output: 0, + })) + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + + expect(calls).toEqual([]) + }) + + it('skips sessions with malformed meta.json', async () => { + const sessionDir = join(tmpDir, 'session_20260511_100000_bad') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'meta.json'), '{{not json') + await writeFile(join(sessionDir, 'messages.jsonl'), JSON.stringify(userMessage()) + '\n') + + const provider = createMistralVibeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('returns empty calls when messages.jsonl is malformed', async () => { + const sessionDir = await writeSession('session_20260511_100000_badjsonl', metadata()) + await writeFile(join(sessionDir, 'messages.jsonl'), '{{not json\n{{also bad\n') + + const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir)) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual([]) + expect(calls[0]!.bashCommands).toEqual([]) + }) + + it('formats model and tool display names', () => { + const provider = createMistralVibeProvider(tmpDir) + + expect(provider.modelDisplayName('mistral-medium-3.5')).toBe('Mistral Medium 3.5') + expect(provider.modelDisplayName('devstral-small-latest')).toBe('Devstral Small') + expect(provider.toolDisplayName('search_replace')).toBe('Edit') + expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) +}) diff --git a/tests/providers/mux.test.ts b/tests/providers/mux.test.ts new file mode 100644 index 0000000..1957be3 --- /dev/null +++ b/tests/providers/mux.test.ts @@ -0,0 +1,404 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createMuxProvider } from '../../src/providers/mux.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'mux-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +// ~/.mux/config.json shape: { projects: Array<[projectPath, { workspaces: [{ id }] }]> } +async function writeConfig(root: string, entries: Array<[string, string[]]>) { + const data = { + projects: entries.map(([projectPath, ids]) => [ + projectPath, + { workspaces: ids.map(id => ({ id, name: 'main', path: `${projectPath}/wt-${id}` })) }, + ]), + } + await writeFile(join(root, 'config.json'), JSON.stringify(data)) +} + +async function writeWorkspace(root: string, workspaceId: string, lines: string[]) { + const dir = join(root, 'sessions', workspaceId) + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'chat.jsonl') + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +// Sub-agent transcripts live nested under the parent workspace, not as a +// top-level sessions/<id> dir. +async function writeSubagent(root: string, workspaceId: string, childTaskId: string, lines: string[]) { + const dir = join(root, 'sessions', workspaceId, 'subagent-transcripts', childTaskId) + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'chat.jsonl') + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +function userMessage(text: string, id = 'msg-user-1') { + return JSON.stringify({ + id, + role: 'user', + parts: [{ type: 'text', text }], + metadata: { historySequence: 0, timestamp: 1776023210000 }, + }) +} + +type AsstOpts = { + id?: string + timestamp?: number + model?: string + input?: number + output?: number + reasoning?: number + cacheRead?: number + cacheCreate?: number + tools?: Array<{ name: string; script?: string }> + text?: string +} + +function assistantMessage(opts: AsstOpts = {}) { + const parts: Array<Record<string, unknown>> = [{ type: 'text', text: opts.text ?? 'done' }] + for (const t of opts.tools ?? []) { + parts.push({ + type: 'dynamic-tool', + toolCallId: `call-${t.name}`, + toolName: t.name, + input: t.script !== undefined ? { script: t.script } : {}, + state: 'output-available', + output: {}, + }) + } + const metadata: Record<string, unknown> = { + historySequence: 1, + model: opts.model ?? 'anthropic:claude-opus-4-8', + timestamp: opts.timestamp ?? 1776023230000, + usage: { + inputTokens: opts.input ?? 1000, + outputTokens: opts.output ?? 200, + reasoningTokens: opts.reasoning ?? 0, + cachedInputTokens: opts.cacheRead ?? 0, + }, + ...(opts.cacheCreate + ? { providerMetadata: { anthropic: { cacheCreationInputTokens: opts.cacheCreate } } } + : {}), + } + return JSON.stringify({ id: opts.id ?? 'msg-asst-1', role: 'assistant', parts, metadata }) +} + +describe('mux provider - session discovery', () => { + it('discovers chat.jsonl per workspace and resolves project from config.json', async () => { + await writeWorkspace(tmpDir, 'ws-abc', [userMessage('hi'), assistantMessage({})]) + await writeConfig(tmpDir, [['/Users/test/myproject', ['ws-abc']]]) + + const provider = createMuxProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('mux') + expect(sessions[0]!.project).toBe('myproject') + expect(sessions[0]!.path).toContain(join('sessions', 'ws-abc', 'chat.jsonl')) + }) + + it('falls back to the workspaceId when config.json has no mapping', async () => { + await writeWorkspace(tmpDir, 'ws-orphan', [assistantMessage({})]) + // no config.json written + + const provider = createMuxProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('ws-orphan') + }) + + it('discovers multiple workspaces across projects', async () => { + await writeWorkspace(tmpDir, 'ws-1', [assistantMessage({})]) + await writeWorkspace(tmpDir, 'ws-2', [assistantMessage({})]) + await writeConfig(tmpDir, [ + ['/Users/test/project-a', ['ws-1']], + ['/Users/test/project-b', ['ws-2']], + ]) + + const provider = createMuxProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions.map(s => s.project).sort()).toEqual(['project-a', 'project-b']) + }) + + it('returns empty for a non-existent root', async () => { + const provider = createMuxProvider('/nonexistent/path/that/does/not/exist') + expect(await provider.discoverSessions()).toEqual([]) + }) + + it('skips workspace directories without a chat.jsonl', async () => { + await mkdir(join(tmpDir, 'sessions', 'ws-empty'), { recursive: true }) + const provider = createMuxProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + }) + + it('discovers sub-agent transcripts and attributes them to the parent project', async () => { + await writeWorkspace(tmpDir, 'ws-parent', [assistantMessage({ id: 'parent-1' })]) + await writeSubagent(tmpDir, 'ws-parent', 'child-a', [assistantMessage({ id: 'child-a-1' })]) + await writeSubagent(tmpDir, 'ws-parent', 'child-b', [assistantMessage({ id: 'child-b-1' })]) + await writeConfig(tmpDir, [['/Users/test/myproject', ['ws-parent']]]) + + const provider = createMuxProvider(tmpDir) + const sessions = await provider.discoverSessions() + + // parent chat.jsonl + two sub-agent transcripts, all under one project. + expect(sessions).toHaveLength(3) + expect(sessions.every(s => s.project === 'myproject')).toBe(true) + const subagentPaths = sessions + .map(s => s.path) + .filter(p => p.includes('subagent-transcripts')) + .sort() + expect(subagentPaths).toHaveLength(2) + expect(subagentPaths[0]).toContain(join('subagent-transcripts', 'child-a', 'chat.jsonl')) + }) + + it('discovers a sub-agent transcript even when the workspace has no top-level chat.jsonl', async () => { + // mkdir the workspace dir without a chat.jsonl, but with a sub-agent transcript. + await writeSubagent(tmpDir, 'ws-parent', 'child-only', [assistantMessage({ id: 'child-only-1' })]) + + const provider = createMuxProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toContain(join('subagent-transcripts', 'child-only', 'chat.jsonl')) + }) + + it('counts sub-agent calls once, with a workspace-distinct dedup key', async () => { + const seenKeys = new Set<string>() + await writeWorkspace(tmpDir, 'ws-parent', [assistantMessage({ id: 'shared-id', input: 100 })]) + // Same message id inside the sub-agent must NOT collide with the parent's. + await writeSubagent(tmpDir, 'ws-parent', 'child-a', [assistantMessage({ id: 'shared-id', input: 200 })]) + + const provider = createMuxProvider(tmpDir) + const sessions = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sessions) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call) + } + + expect(calls).toHaveLength(2) + expect(new Set(calls.map(c => c.deduplicationKey))).toEqual( + new Set(['mux:ws-parent:shared-id', 'mux:child-a:shared-id']), + ) + expect(calls.map(c => c.sessionId).sort()).toEqual(['child-a', 'ws-parent']) + }) +}) + +describe('mux provider - chat.jsonl parsing', () => { + it('decomposes inclusive input/output usage into codeburn token fields', async () => { + // input is inclusive of cache; output is inclusive of reasoning. + const filePath = await writeWorkspace(tmpDir, 'ws-abc', [ + userMessage('implement the feature'), + assistantMessage({ + id: 'msg-1', + input: 1000, + output: 230, + reasoning: 30, + cacheRead: 200, + cacheCreate: 50, + }), + ]) + + const provider = createMuxProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'mux' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('mux') + expect(call.model).toBe('claude-opus-4-8') // provider prefix stripped so codeburn prices/displays it + expect(call.inputTokens).toBe(750) // 1000 - 200 cacheRead - 50 cacheCreate + expect(call.outputTokens).toBe(200) // 230 - 30 reasoning + expect(call.reasoningTokens).toBe(30) + expect(call.cacheReadInputTokens).toBe(200) + expect(call.cachedInputTokens).toBe(200) + expect(call.cacheCreationInputTokens).toBe(50) + expect(call.webSearchRequests).toBe(0) + expect(call.sessionId).toBe('ws-abc') + expect(call.userMessage).toBe('implement the feature') + expect(call.timestamp).toBe(new Date(1776023230000).toISOString()) + expect(call.costUSD).toBeGreaterThan(0) + expect(call.deduplicationKey).toBe('mux:ws-abc:msg-1') + }) + + it('maps tool names and extracts bash command programs', async () => { + const filePath = await writeWorkspace(tmpDir, 'ws-abc', [ + assistantMessage({ + tools: [ + { name: 'file_read' }, + { name: 'file_edit_replace_string' }, + { name: 'bash', script: 'git status && bun test' }, + ], + }), + ]) + + const provider = createMuxProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'mux' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash']) + expect(calls[0]!.bashCommands).toEqual(['git', 'bun']) + }) + + it('skips assistant messages without a usage blob', async () => { + const filePath = await writeWorkspace(tmpDir, 'ws-abc', [ + JSON.stringify({ + id: 'no-usage', + role: 'assistant', + parts: [{ type: 'text', text: 'thinking...' }], + metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1 }, + }), + ]) + + const provider = createMuxProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'mux' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(0) + }) + + it('skips assistant messages with all-zero tokens', async () => { + const filePath = await writeWorkspace(tmpDir, 'ws-abc', [ + assistantMessage({ input: 0, output: 0 }), + ]) + + const provider = createMuxProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'mux' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(0) + }) + + it('deduplicates calls seen across multiple parses', async () => { + const filePath = await writeWorkspace(tmpDir, 'ws-abc', [assistantMessage({ id: 'dup' })]) + + const provider = createMuxProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'mux' } + const seenKeys = new Set<string>() + + const first: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) first.push(call) + const second: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) second.push(call) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) + + it('yields one call per assistant message and pairs the preceding user prompt', async () => { + const filePath = await writeWorkspace(tmpDir, 'ws-multi', [ + userMessage('first question', 'u1'), + assistantMessage({ id: 'a1', input: 500, output: 100 }), + userMessage('second question', 'u2'), + assistantMessage({ id: 'a2', input: 600, output: 120 }), + ]) + + const provider = createMuxProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'mux' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[1]!.userMessage).toBe('second question') + expect(calls[1]!.inputTokens).toBe(600) + }) + + it('handles a missing session file gracefully', async () => { + const provider = createMuxProvider(tmpDir) + const source = { path: join(tmpDir, 'sessions', 'nope', 'chat.jsonl'), project: 'x', provider: 'mux' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) + + it('keeps distinct id-less assistant messages via the line-index fallback', async () => { + const asst = (text: string, input: number) => + JSON.stringify({ + role: 'assistant', + parts: [{ type: 'text', text }], + // No `id`, identical historySequence — the fallback must stay unique. + metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1, historySequence: 5, usage: { inputTokens: input, outputTokens: 5 } }, + }) + const filePath = await writeWorkspace(tmpDir, 'ws-noid', [asst('a', 10), asst('b', 11)]) + + const provider = createMuxProvider(tmpDir) + const source = { path: filePath, project: 'p', provider: 'mux' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(2) + expect(new Set(calls.map(c => c.deduplicationKey)).size).toBe(2) + }) + + it('tolerates malformed lines without dropping later valid turns', async () => { + const filePath = await writeWorkspace(tmpDir, 'ws-abc', [ + // parts is an object, not an array (corrupt) — must not throw the loop + JSON.stringify({ id: 'bad-parts', role: 'assistant', parts: { type: 'text', text: 'x' }, metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1, usage: { inputTokens: 10, outputTokens: 5 } } }), + // out-of-range timestamp — must not throw; timestamp falls back to '' + JSON.stringify({ id: 'bad-ts', role: 'assistant', parts: [{ type: 'text', text: 'x' }], metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1.7e18, usage: { inputTokens: 10, outputTokens: 5 } } }), + assistantMessage({ id: 'good', input: 100, output: 20 }), + ]) + + const provider = createMuxProvider(tmpDir) + const source = { path: filePath, project: 'p', provider: 'mux' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(3) // none of the malformed lines aborted the parse + expect(calls.find(c => c.deduplicationKey === 'mux:ws-abc:good')?.inputTokens).toBe(100) + expect(calls.find(c => c.deduplicationKey === 'mux:ws-abc:bad-ts')?.timestamp).toBe('') + }) +}) + +describe('mux provider - display names', () => { + const provider = createMuxProvider('/tmp') + + it('has correct name and displayName', () => { + expect(provider.name).toBe('mux') + expect(provider.displayName).toBe('Mux') + }) + + it('strips the provider prefix and humanizes the model', () => { + expect(provider.modelDisplayName('anthropic:claude-opus-4-8')).toBe('Opus 4.8') + expect(provider.modelDisplayName('anthropic:claude-sonnet-4-6')).toBe('Sonnet 4.6') + }) + + it('returns the bare id for unknown / prefixless models', () => { + expect(provider.modelDisplayName('ollama:some-random-model')).toBe('some-random-model') + expect(provider.modelDisplayName('some-random-model')).toBe('some-random-model') + }) + + it('normalizes tool names to the canonical set', () => { + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('file_read')).toBe('Read') + expect(provider.toolDisplayName('file_edit_insert')).toBe('Edit') + expect(provider.toolDisplayName('task')).toBe('Agent') + expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) +}) diff --git a/tests/providers/omp.test.ts b/tests/providers/omp.test.ts new file mode 100644 index 0000000..6a25c27 --- /dev/null +++ b/tests/providers/omp.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createOmpProvider } from '../../src/providers/pi.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'omp-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function sessionMeta(opts: { id?: string; cwd?: string } = {}) { + return JSON.stringify({ + type: 'session', + version: 3, + id: opts.id ?? 'sess-001', + timestamp: '2026-04-14T10:00:00.000Z', + cwd: opts.cwd ?? '/Users/test/myproject', + }) +} + +function userMessage(text: string) { + return JSON.stringify({ + type: 'message', + id: 'msg-user-1', + timestamp: '2026-04-14T10:00:10.000Z', + message: { + role: 'user', + content: [{ type: 'text', text }], + timestamp: 1776023210000, + }, + }) +} + +function assistantMessage(opts: { + id?: string + responseId?: string + timestamp?: string + model?: string + input?: number + output?: number + cacheRead?: number + cacheWrite?: number + tools?: Array<{ name: string; command?: string }> +}) { + const content = (opts.tools ?? []).map(t => ({ + type: 'toolCall', + id: `call-${t.name}`, + name: t.name, + arguments: t.command !== undefined ? { command: t.command } : {}, + })) + + return JSON.stringify({ + type: 'message', + id: opts.id ?? 'msg-asst-1', + timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z', + message: { + role: 'assistant', + content, + provider: 'anthropic', + model: opts.model ?? 'claude-sonnet-4-5', + responseId: opts.responseId ?? 'resp-001', + usage: { + input: opts.input ?? 1000, + output: opts.output ?? 200, + cacheRead: opts.cacheRead ?? 0, + cacheWrite: opts.cacheWrite ?? 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: 1776023230000, + }, + }) +} + +async function writeSession(projectDir: string, filename: string, lines: string[]) { + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, filename) + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +describe('omp provider - identity', () => { + it('has correct name and displayName', () => { + const provider = createOmpProvider(tmpDir) + expect(provider.name).toBe('omp') + expect(provider.displayName).toBe('OMP') + }) +}) + +describe('omp provider - session discovery', () => { + it('discovers sessions from the omp sessions directory', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, '2026-04-14T10-00-00-000Z_sess-001.jsonl', [ + sessionMeta({ cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('omp') + expect(sessions[0]!.project).toBe('myproject') + }) + + it('returns empty for non-existent directory', async () => { + const provider = createOmpProvider('/nonexistent/omp/path') + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('skips files whose first line is not a session entry', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'bad.jsonl', [ + JSON.stringify({ type: 'message', id: 'x' }), + ]) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) +}) + +describe('omp provider - JSONL parsing', () => { + it('extracts token usage from an omp-format assistant message', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta({ id: 'sess-omp-1', cwd: '/Users/test/myproject' }), + userMessage('write a test'), + assistantMessage({ + responseId: 'resp-omp-1', + timestamp: '2026-04-14T10:00:30.000Z', + model: 'claude-sonnet-4-5', + input: 1500, + output: 300, + cacheRead: 2000, + cacheWrite: 50, + }), + ]) + + const provider = createOmpProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'omp' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('omp') + expect(call.model).toBe('claude-sonnet-4-5') + expect(call.inputTokens).toBe(1500) + expect(call.outputTokens).toBe(300) + expect(call.cacheReadInputTokens).toBe(2000) + expect(call.cachedInputTokens).toBe(2000) + expect(call.cacheCreationInputTokens).toBe(50) + expect(call.sessionId).toBe('sess-omp-1') + expect(call.userMessage).toBe('write a test') + expect(call.timestamp).toBe('2026-04-14T10:00:30.000Z') + expect(call.deduplicationKey).toContain('omp:') + expect(call.deduplicationKey).toContain('resp-omp-1') + }) + + it('ignores the embedded usage.cost and recalculates cost', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ input: 1000, output: 200, cacheRead: 0, cacheWrite: 0 }), + ]) + + const provider = createOmpProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'omp' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + // cost must be calculated by codeburn, not taken from usage.cost (which is zeroed in fixture) + expect(calls[0]!.costUSD).toBeGreaterThanOrEqual(0) + }) + + it('collects tool names from toolCall content items', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [{ name: 'read' }, { name: 'edit' }, { name: 'bash', command: 'bun test' }], + }), + ]) + + const provider = createOmpProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'omp' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash']) + expect(calls[0]!.bashCommands).toEqual(['bun']) + }) + + it('skips assistant messages with zero tokens', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ input: 0, output: 0 }), + ]) + + const provider = createOmpProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'omp' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(0) + }) +}) diff --git a/tests/providers/open-design.test.ts b/tests/providers/open-design.test.ts new file mode 100644 index 0000000..1595c5f --- /dev/null +++ b/tests/providers/open-design.test.ts @@ -0,0 +1,155 @@ +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { calculateCost } from '../../src/models.js' +import { clearSessionCache, filterProjectsByDateRange, parseAllSessions } from '../../src/parser.js' +import { allProviderNames } from '../../src/providers/index.js' +import { createOpenDesignProvider } from '../../src/providers/open-design.js' +import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' + +const fixtureRoot = join(import.meta.dirname, '../fixtures/open-design') +const dataDir = join(fixtureRoot, 'namespaces', 'release-stable', 'data') + +let previousOverride: string | undefined +let previousCacheDir: string | undefined +let cacheDir: string | undefined + +async function collect(source: SessionSource, seenKeys = new Set<string>()): Promise<ParsedProviderCall[]> { + const provider = createOpenDesignProvider() + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call) + return calls +} + +async function fixtureSource(runId: string): Promise<SessionSource> { + const provider = createOpenDesignProvider() + const sources = await provider.discoverSessions() + const source = sources.find(s => s.path.includes(`${runId}/events.jsonl`)) + expect(source).toBeDefined() + return source! +} + +describe('open-design provider', () => { + beforeEach(async () => { + previousOverride = process.env['CODEBURN_OPEN_DESIGN_DIR'] + previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-open-design-cache-')) + process.env['CODEBURN_OPEN_DESIGN_DIR'] = dataDir + process.env['CODEBURN_CACHE_DIR'] = cacheDir + clearSessionCache() + }) + + afterEach(async () => { + clearSessionCache() + if (previousOverride === undefined) { + delete process.env['CODEBURN_OPEN_DESIGN_DIR'] + } else { + process.env['CODEBURN_OPEN_DESIGN_DIR'] = previousOverride + } + if (previousCacheDir === undefined) { + delete process.env['CODEBURN_CACHE_DIR'] + } else { + process.env['CODEBURN_CACHE_DIR'] = previousCacheDir + } + if (cacheDir) await rm(cacheDir, { recursive: true, force: true }) + cacheDir = undefined + }) + + it('discovers per-run events.jsonl files from the env override data dir', async () => { + const provider = createOpenDesignProvider() + const sources = await provider.discoverSessions() + + expect(sources.map(s => s.provider).every(p => p === 'open-design')).toBe(true) + expect(sources.map(s => s.project)).toEqual(['release-stable', 'release-stable', 'release-stable']) + expect(sources.map(s => s.path).sort()).toEqual([ + join(dataDir, 'runs', 'run-mixed', 'events.jsonl'), + join(dataDir, 'runs', 'run-no-usage', 'events.jsonl'), + join(dataDir, 'runs', 'run-start-seeded', 'events.jsonl'), + ].sort()) + }) + + it('parses a mixed-model run into separate per-model usage calls', async () => { + const calls = await collect(await fixtureSource('run-mixed')) + + expect(calls).toHaveLength(2) + expect(calls.map(c => c.model)).toEqual(['openai-codex:gpt-5.5', 'glm-5.2']) + + const codex = calls[0]! + expect(codex.provider).toBe('open-design') + expect(codex.sessionId).toBe('run-mixed') + expect(codex.inputTokens).toBe(950) + expect(codex.outputTokens).toBe(200) + expect(codex.cacheCreationInputTokens).toBe(0) + expect(codex.cacheReadInputTokens).toBe(50) + expect(codex.cachedInputTokens).toBe(50) + expect(codex.reasoningTokens).toBe(25) + expect(codex.timestamp).toBe('2026-06-22T10:00:05.000Z') + expect(new Date(codex.timestamp).toISOString()).toBe(codex.timestamp) + expect(codex.costUSD).toBeCloseTo( + calculateCost(codex.model, 950, 225, 0, 50, 0), + 12, + ) + + const glm = calls[1]! + expect(glm.inputTokens).toBe(2900) + expect(glm.outputTokens).toBe(400) + expect(glm.cacheCreationInputTokens).toBe(0) + expect(glm.cacheReadInputTokens).toBe(100) + expect(glm.cachedInputTokens).toBe(100) + expect(glm.reasoningTokens).toBe(60) + expect(glm.timestamp).toBe('2026-06-22T10:00:15.000Z') + expect(glm.costUSD).toBeGreaterThan(0) + }) + + it('does not emit calls for a run with no usage events', async () => { + const calls = await collect(await fixtureSource('run-no-usage')) + expect(calls).toHaveLength(0) + }) + + it('uses the start-seeded model before any status transition', async () => { + const calls = await collect(await fixtureSource('run-start-seeded')) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('glm-5.2') + expect(calls[0]!.inputTokens).toBe(770) + expect(calls[0]!.outputTokens).toBe(33) + expect(calls[0]!.cacheReadInputTokens).toBe(7) + expect(calls[0]!.reasoningTokens).toBe(3) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('keeps numeric epoch timestamp usage in date-scoped aggregation', async () => { + const projects = await parseAllSessions(undefined, 'open-design') + const filtered = filterProjectsByDateRange(projects, { + start: new Date('2026-06-22T00:00:00.000Z'), + end: new Date('2026-06-22T23:59:59.999Z'), + }) + const calls = filtered.flatMap(project => + project.sessions.flatMap(session => + session.turns.flatMap(turn => turn.assistantCalls), + ), + ) + const numericTimestampCall = calls.find(call => + call.deduplicationKey === 'open-design:run-mixed:evt-codex-usage', + ) + + expect(numericTimestampCall?.timestamp).toBe('2026-06-22T10:00:05.000Z') + }) + + it('deduplicates usage events per run and event id across parser runs', async () => { + const source = await fixtureSource('run-mixed') + const seenKeys = new Set<string>() + + const first = await collect(source, seenKeys) + const second = await collect(source, seenKeys) + + expect(first).toHaveLength(2) + expect(second).toHaveLength(0) + }) + + it('registers open-design as a core provider', () => { + expect(allProviderNames()).toContain('open-design') + }) +}) diff --git a/tests/providers/openclaw.test.ts b/tests/providers/openclaw.test.ts new file mode 100644 index 0000000..d988aac --- /dev/null +++ b/tests/providers/openclaw.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { createOpenClawProvider } from '../../src/providers/openclaw.js' +import { writeFile, mkdir, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +const SESSION_LINES = [ + JSON.stringify({ type: 'session', version: 3, id: 'test-sess-1', timestamp: '2026-04-20T10:00:00.000Z', cwd: '/tmp' }), + JSON.stringify({ type: 'model_change', id: 'mc1', timestamp: '2026-04-20T10:00:01.000Z', provider: 'anthropic', modelId: 'claude-sonnet-4-6' }), + JSON.stringify({ + type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:02.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'hello world' }] }, + }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:03.000Z', + message: { + role: 'assistant', model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'Hi!' }], + usage: { input: 500, output: 100, cacheRead: 200, cacheWrite: 50, totalTokens: 850 }, + }, + }), + JSON.stringify({ + type: 'message', id: 'a2', timestamp: '2026-04-20T10:00:05.000Z', + message: { + role: 'assistant', model: 'claude-sonnet-4-6', + content: [ + { type: 'text', text: 'Running command' }, + { type: 'toolCall', name: 'exec', arguments: { command: 'ls -la' } }, + { type: 'toolCall', name: 'read', arguments: { path: '/tmp/x' } }, + { type: 'tool_use', name: 'write', arguments: { path: '/tmp/y' } }, + ], + usage: { input: 600, output: 200, cacheRead: 100, cacheWrite: 0, totalTokens: 900, cost: { total: 0.05 } }, + }, + }), +] + +async function setupFixture(dir: string, agentName: string, sessionId: string, lines: string[]): Promise<string> { + const sessionsDir = join(dir, agentName, 'sessions') + await mkdir(sessionsDir, { recursive: true }) + const filePath = join(sessionsDir, `${sessionId}.jsonl`) + await writeFile(filePath, lines.join('\n')) + return filePath +} + +describe('openclaw provider', () => { + const baseDir = join(tmpdir(), `codeburn-openclaw-test-${Date.now()}`) + + it('discovers sessions in agent directories', async () => { + const dir = join(baseDir, 'discover') + await setupFixture(dir, 'myproject', 'sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + expect(sources.length).toBe(1) + expect(sources[0].provider).toBe('openclaw') + expect(sources[0].project).toBe('myproject') + }) + + it('parses assistant messages with usage', async () => { + const dir = join(baseDir, 'parse') + await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const call of parser.parse()) { + calls.push(call) + } + expect(calls.length).toBe(2) + expect(calls[0].provider).toBe('openclaw') + expect(calls[0].model).toBe('claude-sonnet-4-6') + expect(calls[0].inputTokens).toBe(500) + expect(calls[0].outputTokens).toBe(100) + expect(calls[0].cacheReadInputTokens).toBe(200) + expect(calls[0].userMessage).toBe('hello world') + expect(calls[0].sessionId).toBe('test-sess-1') + }) + + it('uses cost.total from provider when available', async () => { + const dir = join(baseDir, 'cost') + await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const call of parser.parse()) calls.push(call) + expect(calls[1].costUSD).toBe(0.05) + }) + + it('extracts tools and bash commands', async () => { + const dir = join(baseDir, 'tools') + await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const call of parser.parse()) calls.push(call) + expect(calls[1].tools).toContain('Bash') + expect(calls[1].tools).toContain('Read') + expect(calls[1].tools).toContain('Write') + expect(calls[1].bashCommands).toContain('ls') + }) + + it('deduplicates on re-parse', async () => { + const dir = join(baseDir, 'dedup') + await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const seen = new Set<string>() + const parser1 = provider.createSessionParser(sources[0], seen) + const calls1: any[] = [] + for await (const c of parser1.parse()) calls1.push(c) + expect(calls1.length).toBe(2) + const parser2 = provider.createSessionParser(sources[0], seen) + const calls2: any[] = [] + for await (const c of parser2.parse()) calls2.push(c) + expect(calls2.length).toBe(0) + }) + + it('reads model from model_change event', async () => { + const lines = [ + JSON.stringify({ type: 'session', id: 'mc-test', timestamp: '2026-04-20T10:00:00.000Z' }), + JSON.stringify({ type: 'model_change', id: 'mc1', modelId: 'gpt-5.5', provider: 'openai' }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', + message: { role: 'assistant', usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } }, + }), + ] + const dir = join(baseDir, 'model-change') + await setupFixture(dir, 'proj', 'mc-test', lines) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const c of parser.parse()) calls.push(c) + expect(calls[0].model).toBe('gpt-5.5') + }) + + it('reads model from custom model-snapshot event', async () => { + const lines = [ + JSON.stringify({ type: 'session', id: 'snap-test', timestamp: '2026-04-20T10:00:00.000Z' }), + JSON.stringify({ type: 'custom', customType: 'model-snapshot', data: { modelId: 'glm-5.1:cloud', provider: 'ollama' }, id: 's1' }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', + message: { role: 'assistant', usage: { input: 200, output: 80, cacheRead: 0, cacheWrite: 0 } }, + }), + ] + const dir = join(baseDir, 'snapshot') + await setupFixture(dir, 'proj', 'snap-test', lines) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const c of parser.parse()) calls.push(c) + expect(calls[0].model).toBe('glm-5.1:cloud') + }) + + it('skips entries with invalid timestamps', async () => { + const lines = [ + JSON.stringify({ type: 'session', id: 'bad-ts', timestamp: 'not-a-date' }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: 'also-bad', + message: { role: 'assistant', model: 'test', usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } }, + }), + ] + const dir = join(baseDir, 'bad-ts') + await setupFixture(dir, 'proj', 'bad-ts', lines) + const provider = createOpenClawProvider(dir) + const sources = await provider.discoverSessions() + const parser = provider.createSessionParser(sources[0], new Set()) + const calls: any[] = [] + for await (const c of parser.parse()) calls.push(c) + expect(calls.length).toBe(0) + }) + + it('tool and model display names work', () => { + const provider = createOpenClawProvider() + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('dispatch_agent')).toBe('Agent') + expect(provider.toolDisplayName('unknown')).toBe('unknown') + expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6') + }) + + it('returns empty for nonexistent directory', async () => { + const provider = createOpenClawProvider('/tmp/nonexistent-openclaw-test') + const sources = await provider.discoverSessions() + expect(sources.length).toBe(0) + }) + + afterAll(async () => { + await rm(baseDir, { recursive: true, force: true }) + }) +}) diff --git a/tests/providers/opencode-file.test.ts b/tests/providers/opencode-file.test.ts new file mode 100644 index 0000000..ed3718e --- /dev/null +++ b/tests/providers/opencode-file.test.ts @@ -0,0 +1,243 @@ +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { createOpenCodeProvider } from '../../src/providers/opencode.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'opencode-file-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +type Msg = { id: string; data: Record<string, unknown>; parts?: Array<Record<string, unknown>> } + +// Mirrors the OpenCode 1.1+ on-disk layout under <dataDir>/storage/. +async function writeSession(opts: { + sessionId?: string + projectId?: string + directory?: string + title?: string + messages: Msg[] +}) { + const storage = join(tmpDir, 'opencode', 'storage') + const sessionId = opts.sessionId ?? 'ses_test1' + const projectId = opts.projectId ?? 'global' + + const sessionDir = join(storage, 'session', projectId) + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, `${sessionId}.json`), JSON.stringify({ + id: sessionId, + slug: 'cosmic-engine', + version: '1.1.65', + projectID: projectId, + directory: opts.directory ?? '/Users/test/myproject', + title: opts.title ?? 'Test session', + time: { created: 1781886356809, updated: 1781886683506 }, + })) + + const messageDir = join(storage, 'message', sessionId) + await mkdir(messageDir, { recursive: true }) + for (const m of opts.messages) { + await writeFile(join(messageDir, `${m.id}.json`), JSON.stringify({ id: m.id, sessionID: sessionId, ...m.data })) + if (m.parts?.length) { + const partDir = join(storage, 'part', m.id) + await mkdir(partDir, { recursive: true }) + let i = 0 + for (const p of m.parts) { + await writeFile(join(partDir, `prt_${m.id}_${String(i++).padStart(3, '0')}.json`), JSON.stringify(p)) + } + } + } + return { sessionId } +} + +async function parseAll(seen = new Set<string>()): Promise<ParsedProviderCall[]> { + const provider = createOpenCodeProvider(tmpDir) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call) + } + return calls +} + +describe('opencode file-based provider - discovery', () => { + it('discovers a file-based session and derives the project from directory', async () => { + await writeSession({ + directory: '/Users/test/myproject', + messages: [{ + id: 'msg_a', + data: { + role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0, + tokens: { input: 1000, output: 200, reasoning: 50, cache: { read: 5000, write: 0 } }, + time: { created: 1781886356900 }, + }, + parts: [{ type: 'text', text: 'hello' }], + }], + }) + const provider = createOpenCodeProvider(tmpDir) + const sources = await provider.discoverSessions() + expect(sources).toHaveLength(1) + expect(sources[0]!.provider).toBe('opencode') + expect(sources[0]!.project).toBe('Users-test-myproject') + expect(sources[0]!.path.endsWith('.json')).toBe(true) + }) + + it('returns nothing when neither file storage nor a DB exists', async () => { + const provider = createOpenCodeProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + }) +}) + +describe('opencode file-based provider - parsing', () => { + it('extracts tokens, tools, bash commands, and the preceding user message', async () => { + await writeSession({ + messages: [ + { + id: 'msg_user', + data: { role: 'user', time: { created: 1 } }, + parts: [{ type: 'text', text: 'find the git repos' }], + }, + { + id: 'msg_a', + data: { + role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0, + tokens: { input: 1200, output: 300, reasoning: 100, cache: { read: 8000, write: 0 } }, + time: { created: 2 }, + }, + parts: [ + { type: 'reasoning' }, + { type: 'tool', tool: 'bash', state: { status: 'completed', input: { command: 'git status' } } }, + { type: 'tool', tool: 'read', state: { input: { filePath: '/x' } } }, + { type: 'text', text: 'done' }, + ], + }, + ], + }) + const calls = await parseAll() + expect(calls).toHaveLength(1) + const c = calls[0]! + expect(c.provider).toBe('opencode') + expect(c.model).toBe('gpt-5.3-codex-spark') + expect(c.inputTokens).toBe(1200) + expect(c.outputTokens).toBe(300) + expect(c.reasoningTokens).toBe(100) + expect(c.cacheReadInputTokens).toBe(8000) + expect(c.cachedInputTokens).toBe(8000) + expect(c.tools).toEqual(['Bash', 'Read']) + expect(c.bashCommands).toContain('git') + expect(c.userMessage).toBe('find the git repos') + expect(c.deduplicationKey).toBe('opencode:ses_test1:msg_a') + }) + + it('extracts skill names and subagent types from skill/task tool parts', async () => { + await writeSession({ + messages: [{ + id: 'msg_a', + data: { + role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0, + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + }, + parts: [ + { type: 'tool', tool: 'skill', state: { input: { name: 'commit' } } }, + { type: 'tool', tool: 'task', state: { input: { description: 'find files', subagent_type: 'explore' } } }, + { type: 'text', text: 'done' }, + ], + }], + }) + const calls = await parseAll() + expect(calls).toHaveLength(1) + const c = calls[0]! + expect(c.tools).toEqual(['Skill', 'Agent']) + expect(c.skills).toEqual(['commit']) + expect(c.subagentTypes).toEqual(['explore']) + }) + + it('leaves skills and subagentTypes empty when no skill/task parts are present', async () => { + await writeSession({ + messages: [{ + id: 'msg_a', + data: { + role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0, + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + }, + parts: [{ type: 'tool', tool: 'bash', state: { input: { command: 'ls' } } }], + }], + }) + const calls = await parseAll() + expect(calls[0]!.skills).toEqual([]) + expect(calls[0]!.subagentTypes).toEqual([]) + }) + + it('skips an errored or empty assistant turn (all-zero tokens, no parts)', async () => { + await writeSession({ + messages: [{ + id: 'msg_err', + data: { + role: 'assistant', modelID: 'gpt-5.3-codex', cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + }, + }], + }) + expect(await parseAll()).toHaveLength(0) + }) + + it('falls back to message.cost when the model has no price table', async () => { + await writeSession({ + messages: [{ + id: 'msg_a', + data: { + role: 'assistant', modelID: 'totally-unknown-model-xyz', cost: 0.42, + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + }, + parts: [{ type: 'text', text: 'x' }], + }], + }) + const calls = await parseAll() + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(0.42) + }) + + it('deduplicates across repeated parses', async () => { + await writeSession({ + messages: [{ + id: 'msg_a', + data: { + role: 'assistant', modelID: 'gpt-5.3-codex-spark', + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + }, + parts: [{ type: 'text', text: 'x' }], + }], + }) + const seen = new Set<string>() + expect(await parseAll(seen)).toHaveLength(1) + expect(await parseAll(seen)).toHaveLength(0) + }) + + it('reads sessions across multiple project folders', async () => { + await writeSession({ + sessionId: 'ses_one', projectId: 'global', directory: '/Users/test/a', + messages: [{ id: 'm1', data: { role: 'assistant', modelID: 'gpt-5.3-codex-spark', tokens: { input: 10, output: 2 }, time: { created: 1 } }, parts: [{ type: 'text', text: 'x' }] }], + }) + await writeSession({ + sessionId: 'ses_two', projectId: 'proj_hash', directory: '/Users/test/b', + messages: [{ id: 'm2', data: { role: 'assistant', modelID: 'gpt-5.3-codex-spark', tokens: { input: 20, output: 4 }, time: { created: 1 } }, parts: [{ type: 'text', text: 'y' }] }], + }) + const calls = await parseAll() + expect(calls).toHaveLength(2) + expect(calls.map((c) => c.sessionId).sort()).toEqual(['ses_one', 'ses_two']) + }) +}) diff --git a/tests/providers/opencode.test.ts b/tests/providers/opencode.test.ts new file mode 100644 index 0000000..5dc9070 --- /dev/null +++ b/tests/providers/opencode.test.ts @@ -0,0 +1,901 @@ +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createOpenCodeProvider } from '../../src/providers/opencode.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'opencode-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function createTestDb(dir: string): string { + const ocDir = join(dir, 'opencode') + mkdirSync(ocDir, { recursive: true }) + const dbPath = join(ocDir, 'opencode.db') + + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, + slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, + version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, + time_archived INTEGER + ) + `) + db.exec(` + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL + ) + `) + db.exec(` + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, + session_id TEXT NOT NULL, time_created INTEGER, + time_updated INTEGER, data TEXT NOT NULL + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(dbPath) + fn(db) + db.close() +} + +function insertSession( + db: TestDb, + id: string, + opts: { directory?: string; title?: string; parentId?: string | null; archived?: number | null } = {}, +): void { + db.prepare(` + INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_archived, parent_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, 'proj-1', 'slug-1', opts.directory ?? '/home/user/myproject', opts.title ?? 'My Project', '1.0', 1700000000000, opts.archived ?? null, opts.parentId ?? null) +} + +type MessageFixture = { + role: string + modelID?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { read: number; write: number } + } +} + +type PartFixture = { + type: string + text?: string + tool?: string + state?: { status: string; input: { command?: string } } +} + +function insertMessage(db: TestDb, id: string, sessionId: string, timeCreated: number, data: MessageFixture): void { + db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`) + .run(id, sessionId, timeCreated, JSON.stringify(data)) +} + +function insertPart(db: TestDb, id: string, messageId: string, sessionId: string, data: PartFixture): void { + db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`) + .run(id, messageId, sessionId, JSON.stringify(data)) +} + +async function collectCalls(provider: ReturnType<typeof createOpenCodeProvider>, dbPath: string, sessionId: string, seenKeys?: Set<string>): Promise<ParsedProviderCall[]> { + const source = { path: `${dbPath}:${sessionId}`, project: 'myproject', provider: 'opencode' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys ?? new Set()).parse()) { + calls.push(call) + } + return calls +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('opencode provider - model display names', () => { + it('strips provider prefix and delegates to shared lookup', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('claude-opus-4-6-20260205')).toBe('Opus 4.6') + }) + + it('strips google provider prefix', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('google/gemini-2.5-pro')).toBe('Gemini 2.5 Pro') + }) + + it('strips openai provider prefix', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('openai/gpt-4o')).toBe('GPT-4o') + }) + + it('passes through models without prefix unchanged', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('gpt-4o')).toBe('GPT-4o') + expect(provider.modelDisplayName('gpt-4o-mini')).toBe('GPT-4o Mini') + }) + + it('returns unknown models as-is', () => { + const provider = createOpenCodeProvider() + expect(provider.modelDisplayName('big-pickle')).toBe('big-pickle') + }) + + it('has correct displayName', () => { + const provider = createOpenCodeProvider() + expect(provider.displayName).toBe('OpenCode') + expect(provider.name).toBe('opencode') + }) +}) + +skipUnlessSqlite('opencode provider - tool display names', () => { + it('maps opencode builtins', () => { + const provider = createOpenCodeProvider() + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('edit')).toBe('Edit') + expect(provider.toolDisplayName('task')).toBe('Agent') + expect(provider.toolDisplayName('fetch')).toBe('WebFetch') + expect(provider.toolDisplayName('grep')).toBe('Grep') + expect(provider.toolDisplayName('write')).toBe('Write') + expect(provider.toolDisplayName('skill')).toBe('Skill') + }) + + it('returns unknown tools as-is', () => { + const provider = createOpenCodeProvider() + expect(provider.toolDisplayName('github_search_code')).toBe('github_search_code') + }) +}) + +skipUnlessSqlite('opencode provider - session discovery', () => { + it('discovers sessions with correct path format', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('opencode') + expect(sessions[0]!.project).toBe('home-user-myproject') + expect(sessions[0]!.path).toBe(`${dbPath}:sess-1`) + }) + + it('excludes archived sessions', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-archived', { archived: 1700000001000 }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('excludes child sessions', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-child', { parentId: 'parent-id' }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('returns empty for non-existent path', async () => { + const provider = createOpenCodeProvider('/nonexistent/path') + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('returns empty for empty database', async () => { + createTestDb(tmpDir) + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('discovers sessions across multiple channel databases', async () => { + const ocDir = join(tmpDir, 'opencode') + await mkdir(ocDir, { recursive: true }) + + const { DatabaseSync: Database } = require('node:sqlite') + for (const file of ['opencode.db', 'opencode-dev.db']) { + const dbPath = join(ocDir, file) + const db = new Database(dbPath) + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, + slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, + version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, time_archived INTEGER) + `) + db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`) + db.exec(`CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, + session_id TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`) + db.prepare(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(`sess-${file}`, 'proj-1', 'slug-1', '/home/user/myproject', 'My Project', '1.0', 1700000000000) + db.close() + } + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.map(s => s.path)).toEqual( + expect.arrayContaining([ + expect.stringContaining('opencode.db:sess-opencode.db'), + expect.stringContaining('opencode-dev.db:sess-opencode-dev.db'), + ]), + ) + expect(sessions.every(s => s.provider === 'opencode')).toBe(true) + }) + + it('ignores non-opencode db files in the directory', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + }) + await writeFile(join(tmpDir, 'opencode', 'other.db'), '') + await writeFile(join(tmpDir, 'opencode', 'opencode.txt'), '') + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + }) + + it('sanitizes title when directory is empty', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1', { directory: '', title: 'My Session Title' }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions[0]!.project).toBe('My Session Title') + }) + + it('discovers multiple sessions in one database', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1', { directory: '/home/user/project-a', title: 'A' }) + insertSession(db, 'sess-2', { directory: '/home/user/project-b', title: 'B' }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(2) + }) +}) + +skipUnlessSqlite('opencode provider - session parsing', () => { + it('parses assistant messages with all fields', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'fix the login bug' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 50, cache: { read: 500, write: 300 } }, + }) + insertPart(db, 'part-2', 'msg-2', 'sess-1', { + type: 'tool', tool: 'bash', + state: { status: 'completed', input: { command: 'npm test && git push' } }, + }) + insertPart(db, 'part-3', 'msg-2', 'sess-1', { + type: 'tool', tool: 'edit', state: { status: 'completed', input: {} }, + }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const calls = await collectCalls(provider, dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('opencode') + expect(call.model).toBe('claude-opus-4-6') + expect(call.inputTokens).toBe(100) + expect(call.outputTokens).toBe(200) + expect(call.reasoningTokens).toBe(50) + expect(call.cacheReadInputTokens).toBe(500) + expect(call.cacheCreationInputTokens).toBe(300) + expect(call.cachedInputTokens).toBe(500) + expect(call.webSearchRequests).toBe(0) + expect(call.speed).toBe('standard') + expect(call.costUSD).toBeGreaterThan(0) + expect(call.tools).toEqual(['Bash', 'Edit']) + expect(call.bashCommands).toEqual(['npm', 'git']) + expect(call.userMessage).toBe('fix the login bug') + expect(call.sessionId).toBe('sess-1') + expect(call.timestamp).toBe(new Date(1700000001000).toISOString()) + expect(call.deduplicationKey).toBe('opencode:sess-1:msg-2') + }) + + it('normalizes opencode MCP tool names for shared MCP reporting', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'look up the ClickUp task' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-2', 'msg-2', 'sess-1', { + type: 'tool', + tool: 'clickup_clickup_get_task', + state: { status: 'completed', input: {} }, + }) + insertPart(db, 'part-3', 'msg-2', 'sess-1', { + type: 'tool', + tool: 'figma_get_file', + state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual([ + 'mcp__clickup__clickup_get_task', + 'mcp__figma__get_file', + ]) + }) + + it('preserves already-normalized MCP tool names', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { + type: 'tool', + tool: 'mcp__github__search_code', + state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['mcp__github__search_code']) + }) + + it('keeps extension tool names without a server prefix as regular tools', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { + type: 'tool', + tool: 'customtool', + state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['customtool']) + }) + + it('keeps malformed server-prefixed tool names as regular tools', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { + type: 'tool', + tool: '_missing_server', + state: { status: 'completed', input: {} }, + }) + insertPart(db, 'part-2', 'msg-1', 'sess-1', { + type: 'tool', + tool: 'missing_', + state: { status: 'completed', input: {} }, + }) + insertPart(db, 'part-3', 'msg-1', 'sess-1', { + type: 'tool', + tool: '_', + state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual([ + '_missing_server', + 'missing_', + '_', + ]) + }) + + it('skips zero-token messages with zero cost', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(0) + }) + + it('keeps zero-usage assistant messages when router responses contain text', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'use the configured router' }) + insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'edenai/router-model', cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-a1', 'msg-a1', 'sess-1', { type: 'text', text: 'router response text' }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('edenai/router-model') + expect(calls[0]!.inputTokens).toBe(0) + expect(calls[0]!.outputTokens).toBe(0) + expect(calls[0]!.costUSD).toBe(0) + expect(calls[0]!.userMessage).toBe('use the configured router') + }) + + it('keeps zero-usage assistant messages when router responses contain tool calls', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'edenai/router-model', cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-a1', 'msg-a1', 'sess-1', { + type: 'tool', tool: 'bash', + state: { status: 'completed', input: { command: 'npm test' } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Bash']) + expect(calls[0]!.bashCommands).toEqual(['npm']) + expect(calls[0]!.costUSD).toBe(0) + }) + + it('deduplicates messages across parses', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const seenKeys = new Set<string>() + const calls1 = await collectCalls(provider, dbPath, 'sess-1', seenKeys) + const calls2 = await collectCalls(provider, dbPath, 'sess-1', seenKeys) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + expect(seenKeys.has('opencode:sess-1:msg-1')).toBe(true) + }) + + it('falls back to pre-calculated cost for unknown models', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'totally-unknown-model-xyz', cost: 0.42, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBe(0.42) + }) + + it('uses calculated cost over pre-calculated for known models', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 999.99, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + expect(calls[0]!.costUSD).not.toBe(999.99) + }) + + it('handles missing tokens field gracefully', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.10, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(0) + expect(calls[0]!.outputTokens).toBe(0) + expect(calls[0]!.costUSD).toBe(0.10) + }) + + it('uses "unknown" for missing modelID', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('unknown') + }) + + it('handles corrupt JSON in message and part data', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`) + .run('msg-corrupt', 'sess-1', 1700000000500, 'not valid json {]') + + insertMessage(db, 'msg-valid', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`) + .run('part-corrupt', 'msg-valid', 'sess-1', 'corrupt {[}') + + insertPart(db, 'part-valid', 'msg-valid', 'sess-1', { + type: 'tool', tool: 'bash', state: { status: 'completed', input: {} }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('claude-opus-4-6') + expect(calls[0]!.tools).toEqual(['Bash']) + }) + + it('converts seconds-epoch timestamps to milliseconds', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe(new Date(1700000001 * 1000).toISOString()) + }) + + it('skips non-user non-assistant roles', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'system', modelID: 'claude-opus-4-6', + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(0) + }) + + it('returns empty for invalid db path', async () => { + const provider = createOpenCodeProvider(tmpDir) + const source = { path: '/nonexistent/db.db:sess-1', project: 'test', provider: 'opencode' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) + + it('tracks user messages per assistant response', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'first question' }) + + insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01, + tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + insertMessage(db, 'msg-u2', 'sess-1', 1700000002000, { role: 'user' }) + insertPart(db, 'part-u2', 'msg-u2', 'sess-1', { type: 'text', text: 'second question' }) + + insertMessage(db, 'msg-a2', 'sess-1', 1700000003000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.02, + tokens: { input: 80, output: 80, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[1]!.userMessage).toBe('second question') + }) + + it('attributes child and grandchild session calls back to the root session', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'root') + insertSession(db, 'child', { parentId: 'root' }) + insertSession(db, 'grandchild', { parentId: 'child' }) + + insertMessage(db, 'msg-root-user', 'root', 1700000000000, { role: 'user' }) + insertPart(db, 'part-root-user', 'msg-root-user', 'root', { type: 'text', text: 'root prompt' }) + insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.01, + tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-root-tool', 'msg-root-assistant', 'root', { + type: 'tool', + tool: 'read', + state: { status: 'completed', input: {} }, + }) + + insertMessage(db, 'msg-child-user', 'child', 1700000002000, { role: 'user' }) + insertPart(db, 'part-child-user', 'msg-child-user', 'child', { type: 'text', text: 'child prompt' }) + insertMessage(db, 'msg-child-assistant', 'child', 1700000003000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.02, + tokens: { input: 30, output: 40, reasoning: 5, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-child-tool', 'msg-child-assistant', 'child', { + type: 'tool', + tool: 'task', + state: { status: 'completed', input: {} }, + }) + + insertMessage(db, 'msg-grand-user', 'grandchild', 1700000004000, { role: 'user' }) + insertPart(db, 'part-grand-user', 'msg-grand-user', 'grandchild', { type: 'text', text: 'grandchild prompt' }) + insertMessage(db, 'msg-grand-assistant', 'grandchild', 1700000005000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.03, + tokens: { input: 50, output: 60, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-grand-tool', 'msg-grand-assistant', 'grandchild', { + type: 'tool', + tool: 'bash', + state: { status: 'completed', input: { command: 'npm test' } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root') + + expect(calls).toHaveLength(3) + expect(calls.map(call => call.sessionId)).toEqual(['root', 'root', 'root']) + expect(calls.map(call => call.deduplicationKey)).toEqual([ + 'opencode:root:msg-root-assistant', + 'opencode:child:msg-child-assistant', + 'opencode:grandchild:msg-grand-assistant', + ]) + expect(calls.map(call => call.userMessage)).toEqual([ + 'root prompt', + 'child prompt', + 'grandchild prompt', + ]) + expect(calls[0]!.tools).toEqual(['Read']) + expect(calls[1]!.tools).toEqual(['Agent']) + expect(calls[2]!.tools).toEqual(['Bash']) + expect(calls[2]!.bashCommands).toEqual(['npm']) + }) + + it('does not include archived child sessions in the root subtree', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'root') + insertSession(db, 'archived-child', { parentId: 'root', archived: 1700000002500 }) + + insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.01, + tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + insertMessage(db, 'msg-child-assistant', 'archived-child', 1700000003000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.02, + tokens: { input: 30, output: 40, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root') + + expect(calls).toHaveLength(1) + expect(calls[0]!.deduplicationKey).toBe('opencode:root:msg-root-assistant') + }) + + it('joins multiple text parts in user messages', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-a', 'msg-u1', 'sess-1', { type: 'text', text: 'hello' }) + insertPart(db, 'part-b', 'msg-u1', 'sess-1', { type: 'text', text: 'world' }) + + insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01, + tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls[0]!.userMessage).toBe('hello world') + }) + + it('yields nothing for session with only user messages', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'hello?' }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(0) + }) + + it('falls back to session-level tokens when per-message data yields nothing', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + db.exec(`ALTER TABLE session ADD COLUMN cost REAL`) + db.exec(`ALTER TABLE session ADD COLUMN tokens_input INTEGER`) + db.exec(`ALTER TABLE session ADD COLUMN tokens_output INTEGER`) + db.exec(`ALTER TABLE session ADD COLUMN tokens_reasoning INTEGER`) + db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_read INTEGER`) + db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_write INTEGER`) + db.exec(`ALTER TABLE session ADD COLUMN model_id TEXT`) + + insertSession(db, 'sess-1') + db.prepare(`UPDATE session SET cost = 0.15, tokens_input = 5000, tokens_output = 2000, tokens_reasoning = 0, tokens_cache_read = 3000, tokens_cache_write = 1000, model_id = 'claude-sonnet-4-20250514' WHERE id = 'sess-1'`).run() + + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-sonnet-4-20250514', + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(5000) + expect(calls[0]!.outputTokens).toBe(2000) + expect(calls[0]!.cacheReadInputTokens).toBe(3000) + expect(calls[0]!.cacheCreationInputTokens).toBe(1000) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + expect(calls[0]!.model).toBe('claude-sonnet-4-20250514') + expect(calls[0]!.deduplicationKey).toBe('opencode:sess-1:session-level') + }) + + it('accepts role "model" as equivalent to "assistant"', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'model', modelID: 'gemini-2.5-pro', cost: 0.03, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + } as any) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('gemini-2.5-pro') + }) + + it('recognizes tool-call and tool_call part types', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', + }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { + type: 'tool-call', tool: 'bash', + state: { status: 'completed', input: { command: 'ls' } }, + } as any) + insertPart(db, 'part-2', 'msg-1', 'sess-1', { + type: 'tool_call', tool: 'edit', + state: { status: 'completed', input: {} }, + } as any) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Bash', 'Edit']) + }) + + it('counts reasoning/file parts as activity even without text or tool parts', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', modelID: 'claude-opus-4-6', + }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { + type: 'reasoning', + } as any) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBe(0) + expect(calls[0]!.tools).toEqual([]) + }) +}) diff --git a/tests/providers/pi.test.ts b/tests/providers/pi.test.ts new file mode 100644 index 0000000..398d509 --- /dev/null +++ b/tests/providers/pi.test.ts @@ -0,0 +1,514 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createPiProvider } from '../../src/providers/pi.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' +import { classifyTurn } from '../../src/classifier.js' +import type { ParsedApiCall, ParsedTurn } from '../../src/types.js' + +// Mirrors src/parser.ts providerCallToTurn so we can assert that a Pi call's +// skills survive the classifier into `subCategory`, which is the sole input the +// session summary reads to build the "Skills & Agents" breakdown (#588). +function turnFromPiCall(call: ParsedProviderCall, userMessage = ''): ParsedTurn { + const apiCall: ParsedApiCall = { + provider: call.provider, + model: call.model, + usage: { + inputTokens: call.inputTokens, + outputTokens: call.outputTokens, + cacheCreationInputTokens: call.cacheCreationInputTokens, + cacheReadInputTokens: call.cacheReadInputTokens, + cachedInputTokens: call.cachedInputTokens, + reasoningTokens: call.reasoningTokens, + webSearchRequests: call.webSearchRequests, + }, + costUSD: call.costUSD, + tools: call.tools, + mcpTools: call.tools.filter(t => t.startsWith('mcp__')), + skills: call.skills ?? [], + subagentTypes: call.subagentTypes ?? [], + hasAgentSpawn: call.tools.includes('Agent'), + hasPlanMode: call.tools.includes('EnterPlanMode'), + speed: call.speed, + timestamp: call.timestamp, + bashCommands: call.bashCommands, + deduplicationKey: call.deduplicationKey, + } + return { userMessage, assistantCalls: [apiCall], timestamp: call.timestamp, sessionId: call.sessionId } +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'pi-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function sessionMeta(opts: { id?: string; cwd?: string } = {}) { + return JSON.stringify({ + type: 'session', + version: 3, + id: opts.id ?? 'sess-001', + timestamp: '2026-04-14T10:00:00.000Z', + cwd: opts.cwd ?? '/Users/test/myproject', + }) +} + +function userMessage(text: string, timestamp?: string) { + return JSON.stringify({ + type: 'message', + id: 'msg-user-1', + timestamp: timestamp ?? '2026-04-14T10:00:10.000Z', + message: { + role: 'user', + content: [{ type: 'text', text }], + timestamp: 1776023210000, + }, + }) +} + +function assistantMessage(opts: { + id?: string + responseId?: string + timestamp?: string + model?: string + input?: number + output?: number + cacheRead?: number + cacheWrite?: number + tools?: Array<{ name: string; command?: string; path?: string; filePath?: string }> +}) { + const content = (opts.tools ?? []).map(t => { + const args: Record<string, unknown> = {} + if (t.command !== undefined) args['command'] = t.command + if (t.path !== undefined) args['path'] = t.path + if (t.filePath !== undefined) args['file_path'] = t.filePath + return { + type: 'toolCall', + id: `call-${t.name}`, + name: t.name, + arguments: args, + } + }) + + return JSON.stringify({ + type: 'message', + id: opts.id ?? 'msg-asst-1', + timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z', + message: { + role: 'assistant', + content, + api: 'openai-codex-responses', + provider: 'openai-codex', + model: opts.model ?? 'gpt-5.4', + responseId: opts.responseId ?? 'resp-001', + usage: { + input: opts.input ?? 1000, + output: opts.output ?? 200, + cacheRead: opts.cacheRead ?? 0, + cacheWrite: opts.cacheWrite ?? 0, + totalTokens: (opts.input ?? 1000) + (opts.output ?? 200) + (opts.cacheRead ?? 0), + cost: { input: 0.0025, output: 0.003, cacheRead: 0, cacheWrite: 0, total: 0.0055 }, + }, + stopReason: 'stop', + timestamp: 1776023230000, + }, + }) +} + +async function writeSession(projectDir: string, filename: string, lines: string[]) { + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, filename) + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +describe('pi provider - session discovery', () => { + it('discovers sessions grouped by project directory', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, '2026-04-14T10-00-00-000Z_sess-001.jsonl', [ + sessionMeta({ cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + + const provider = createPiProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('pi') + expect(sessions[0]!.project).toBe('myproject') + expect(sessions[0]!.path).toContain('sess-001.jsonl') + }) + + it('discovers sessions across multiple project directories', async () => { + const dir1 = join(tmpDir, '--Users-test-project-a--') + const dir2 = join(tmpDir, '--Users-test-project-b--') + await writeSession(dir1, 'session1.jsonl', [sessionMeta({ cwd: '/Users/test/project-a' }), assistantMessage({})]) + await writeSession(dir2, 'session2.jsonl', [sessionMeta({ cwd: '/Users/test/project-b' }), assistantMessage({})]) + + const provider = createPiProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + const projects = sessions.map(s => s.project).sort() + expect(projects).toEqual(['project-a', 'project-b']) + }) + + it('returns empty for non-existent directory', async () => { + const provider = createPiProvider('/nonexistent/path/that/does/not/exist') + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('skips files whose first line is not a session entry', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'bad.jsonl', [ + JSON.stringify({ type: 'message', id: 'x' }), + ]) + + const provider = createPiProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('skips non-jsonl files', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'notes.txt'), 'not a session') + + const provider = createPiProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) +}) + +describe('pi provider - JSONL parsing', () => { + it('extracts token usage and metadata from an assistant message', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta({ id: 'sess-abc', cwd: '/Users/test/myproject' }), + userMessage('implement the feature'), + assistantMessage({ + responseId: 'resp-abc', + timestamp: '2026-04-14T10:00:30.000Z', + model: 'gpt-5.4', + input: 2000, + output: 400, + cacheRead: 5000, + cacheWrite: 100, + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('pi') + expect(call.model).toBe('gpt-5.4') + expect(call.inputTokens).toBe(2000) + expect(call.outputTokens).toBe(400) + expect(call.cacheReadInputTokens).toBe(5000) + expect(call.cachedInputTokens).toBe(5000) + expect(call.cacheCreationInputTokens).toBe(100) + expect(call.sessionId).toBe('sess-abc') + expect(call.userMessage).toBe('implement the feature') + expect(call.timestamp).toBe('2026-04-14T10:00:30.000Z') + expect(call.costUSD).toBeGreaterThan(0) + expect(call.deduplicationKey).toContain('pi:') + expect(call.deduplicationKey).toContain('resp-abc') + }) + + it('does not crash when a user message content is a string instead of an array (issue #441)', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + // Pi legitimately writes string `content` for some (e.g. injected) user turns. + // Before the fix this threw `content.filter is not a function`, which aborted + // the whole backfill and silently emptied the trend/history. + const stringContentUser = JSON.stringify({ + type: 'message', + id: 'msg-user-str', + timestamp: '2026-04-14T10:00:10.000Z', + message: { role: 'user', content: 'test message from file watcher', timestamp: 1776023210000 }, + }) + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta({ id: 'sess-str', cwd: '/Users/test/myproject' }), + stringContentUser, + assistantMessage({ responseId: 'resp-str', input: 500, output: 80 }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + // The assistant turn is still parsed, and the string user content is paired. + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[0]!.userMessage).toBe('test message from file watcher') + }) + + it('collects tool names from toolCall content items', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [ + { name: 'read' }, + { name: 'edit' }, + { name: 'bash', command: 'git status' }, + ], + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash']) + }) + + it('extracts bash commands from bash tool arguments', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [ + { name: 'bash', command: 'git status && bun test' }, + ], + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.bashCommands).toEqual(['git', 'bun']) + }) + + it('classifies a SKILL.md read as a skill load, not a Read (#588)', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [ + { name: 'read', path: '/Volumes/T7/repos/cuneiform/.pi/skills/bmad-create-story/SKILL.md' }, + { name: 'read', path: '/Volumes/T7/repos/cuneiform/workflow.md' }, + { name: 'edit', path: '/Volumes/T7/repos/cuneiform/workflow.md' }, + ], + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + // The SKILL.md read is surfaced as the Skill tool (not Read); the plain + // read stays a Read. + expect(calls[0]!.skills).toEqual(['bmad-create-story']) + expect(calls[0]!.tools).toEqual(['Skill', 'Read', 'Edit']) + }) + + it('classifies a skill:// read as a skill load (OMP-style URI)', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [{ name: 'read', path: 'skill://commit-workflow' }], + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.skills).toEqual(['commit-workflow']) + expect(calls[0]!.tools).toEqual(['Skill']) + }) + + it('reads the file_path key as a fallback for skill loads', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [{ name: 'read', filePath: '/home/u/.agents/skills/deep-research/SKILL.md' }], + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.skills).toEqual(['deep-research']) + expect(calls[0]!.tools).toEqual(['Skill']) + }) + + it('leaves a normal read (no SKILL.md) as a Read with no skills', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [{ name: 'read', path: '/home/u/project/src/skill-loader.ts' }], + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.skills).toEqual([]) + expect(calls[0]!.tools).toEqual(['Read']) + }) + + it('a pure skill-load turn classifies as general with the skill as subCategory (feeds the Skills breakdown, #588)', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [{ name: 'read', path: '/home/u/.pi/agent/skills/systematic-debugging/SKILL.md' }], + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + // End to end: the parsed skill load must reach `subCategory`, or the + // Skills & Agents breakdown stays empty (the second half of #588). + const classified = classifyTurn(turnFromPiCall(calls[0]!)) + expect(classified.category).toBe('general') + expect(classified.subCategory).toBe('systematic-debugging') + }) + + it('skips assistant messages with zero tokens', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ input: 0, output: 0 }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(0) + }) + + it('deduplicates calls seen across multiple parses', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ responseId: 'resp-dup' }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const seenKeys = new Set<string>() + + const firstRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + firstRun.push(call) + } + + const secondRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + secondRun.push(call) + } + + expect(firstRun).toHaveLength(1) + expect(secondRun).toHaveLength(0) + }) + + it('yields one call per assistant message in a multi-turn session', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta({ id: 'sess-multi' }), + userMessage('first question'), + assistantMessage({ responseId: 'resp-1', timestamp: '2026-04-14T10:00:30.000Z', input: 500, output: 100 }), + userMessage('second question'), + assistantMessage({ responseId: 'resp-2', timestamp: '2026-04-14T10:01:00.000Z', input: 600, output: 120 }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[1]!.userMessage).toBe('second question') + expect(calls[1]!.inputTokens).toBe(600) + }) + + it('handles missing session file gracefully', async () => { + const provider = createPiProvider(tmpDir) + const source = { path: '/nonexistent/session.jsonl', project: 'test', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(0) + }) +}) + +describe('pi provider - display names', () => { + const provider = createPiProvider('/tmp') + + it('has correct name and displayName', () => { + expect(provider.name).toBe('pi') + expect(provider.displayName).toBe('Pi') + }) + + it('maps known models to readable names', () => { + expect(provider.modelDisplayName('gpt-5.4')).toBe('GPT-5.4') + expect(provider.modelDisplayName('gpt-5.4-mini')).toBe('GPT-5.4 Mini') + expect(provider.modelDisplayName('gpt-5')).toBe('GPT-5') + }) + + it('returns raw name for unknown models', () => { + expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model') + }) + + it('normalizes tool names to capitalized form', () => { + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('read')).toBe('Read') + expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) +}) diff --git a/tests/providers/roo-code.test.ts b/tests/providers/roo-code.test.ts new file mode 100644 index 0000000..35efee5 --- /dev/null +++ b/tests/providers/roo-code.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { rooCode, createRooCodeProvider } from '../../src/providers/roo-code.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +function makeUiMessages(opts: { + tokensIn?: number + tokensOut?: number + cacheReads?: number + cacheWrites?: number + cost?: number + userMessage?: string + ts?: number +}): string { + const messages: unknown[] = [] + + if (opts.userMessage) { + messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1700000000000 }) + } + + const apiData: Record<string, unknown> = { + tokensIn: opts.tokensIn ?? 100, + tokensOut: opts.tokensOut ?? 50, + cacheReads: opts.cacheReads ?? 0, + cacheWrites: opts.cacheWrites ?? 0, + } + if (opts.cost !== undefined) apiData.cost = opts.cost + + messages.push({ + type: 'say', + say: 'api_req_started', + text: JSON.stringify(apiData), + ts: opts.ts ?? 1700000001000, + }) + + return JSON.stringify(messages) +} + +function makeApiHistory(opts?: { model?: string }): string { + const modelTag = opts?.model ? `<model>${opts.model}</model>` : '' + const messages = [ + { role: 'user', content: [{ type: 'text', text: `hello\n<environment_details>\n${modelTag}\n</environment_details>` }] }, + { role: 'assistant', content: [{ type: 'text', text: 'response' }] }, + ] + return JSON.stringify(messages) +} + +describe('roo-code provider - parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'roo-code-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses tokens and cost from ui_messages.json', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-001') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ + tokensIn: 200, + tokensOut: 100, + cacheReads: 50, + cacheWrites: 30, + cost: 0.05, + userMessage: 'fix the bug', + })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory()) + + const source = { path: taskDir, project: 'task-001', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('roo-code') + expect(call.inputTokens).toBe(200) + expect(call.outputTokens).toBe(100) + expect(call.cacheReadInputTokens).toBe(50) + expect(call.cacheCreationInputTokens).toBe(30) + expect(call.costUSD).toBe(0.05) + expect(call.userMessage).toBe('fix the bug') + expect(call.sessionId).toBe('task-001') + }) + + it('extracts model from api_conversation_history.json', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-002') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory({ model: 'claude-sonnet-4-5' })) + + const source = { path: taskDir, project: 'task-002', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('claude-sonnet-4-5') + }) + + it('falls back to cline-auto when no model indicators', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-003') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'hi' }] }, + ])) + + const source = { path: taskDir, project: 'task-003', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('cline-auto') + }) + + it('deduplicates across parser runs', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-004') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 })) + + const source = { path: taskDir, project: 'task-004', provider: 'roo-code' } + const seenKeys = new Set<string>() + + const calls1: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, seenKeys).parse()) calls1.push(call) + + const calls2: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, seenKeys).parse()) calls2.push(call) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) + + it('handles missing ui_messages.json gracefully', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-005') + await mkdir(taskDir, { recursive: true }) + + const source = { path: taskDir, project: 'task-005', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('handles invalid JSON gracefully', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-006') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), 'not valid json') + + const source = { path: taskDir, project: 'task-006', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('skips entries with zero tokens', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-007') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify([ + { type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 0, tokensOut: 0 }), ts: 1700000000000 }, + ])) + + const source = { path: taskDir, project: 'task-007', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('calculates cost from model when cost field missing', async () => { + const taskDir = join(tmpDir, 'tasks', 'task-008') + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 1000, tokensOut: 500 })) + await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory()) + + const source = { path: taskDir, project: 'task-008', provider: 'roo-code' } + const calls: ParsedProviderCall[] = [] + for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) +}) + +describe('roo-code provider - discovery', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'roo-code-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers task directories with ui_messages.json', async () => { + const task1 = join(tmpDir, 'tasks', 'task-a') + const task2 = join(tmpDir, 'tasks', 'task-b') + await mkdir(task1, { recursive: true }) + await mkdir(task2, { recursive: true }) + await writeFile(join(task1, 'ui_messages.json'), '[]') + await writeFile(join(task2, 'ui_messages.json'), '[]') + + const provider = createRooCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.every(s => s.provider === 'roo-code')).toBe(true) + }) + + it('skips tasks without ui_messages.json', async () => { + const task = join(tmpDir, 'tasks', 'task-no-ui') + await mkdir(task, { recursive: true }) + await writeFile(join(task, 'api_conversation_history.json'), '[]') + + const provider = createRooCodeProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(0) + }) + + it('returns empty for nonexistent directory', async () => { + const provider = createRooCodeProvider('/nonexistent/path') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) +}) + +describe('roo-code provider - metadata', () => { + it('has correct name and displayName', () => { + expect(rooCode.name).toBe('roo-code') + expect(rooCode.displayName).toBe('Roo Code') + }) + + it('passes through model display names', () => { + expect(rooCode.modelDisplayName('claude-sonnet-4-5')).toBe('claude-sonnet-4-5') + }) + + it('passes through tool display names', () => { + expect(rooCode.toolDisplayName('read_file')).toBe('read_file') + }) +}) diff --git a/tests/providers/vercel-gateway.test.ts b/tests/providers/vercel-gateway.test.ts new file mode 100644 index 0000000..856c675 --- /dev/null +++ b/tests/providers/vercel-gateway.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { fetchVercelGatewayReport, vercelGateway } from '../../src/providers/vercel-gateway.js' +import { parseAllSessions, clearSessionCache } from '../../src/parser.js' + +describe('vercel-gateway provider', () => { + const originalFetch = globalThis.fetch + const originalKey = process.env.AI_GATEWAY_API_KEY + + beforeEach(() => { + process.env.AI_GATEWAY_API_KEY = 'test-key' + }) + + afterEach(() => { + globalThis.fetch = originalFetch + if (originalKey === undefined) delete process.env.AI_GATEWAY_API_KEY + else process.env.AI_GATEWAY_API_KEY = originalKey + vi.restoreAllMocks() + }) + + it('discovers a session when API key is set', async () => { + const sessions = await vercelGateway.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]?.provider).toBe('vercel-gateway') + }) + + it('returns empty discovery without API key', async () => { + delete process.env.AI_GATEWAY_API_KEY + delete process.env.VERCEL_OIDC_TOKEN + const sessions = await vercelGateway.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('maps report rows to parsed calls', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + results: [{ + day: '2026-06-01', + model: 'anthropic/claude-sonnet-4.6', + total_cost: 1.25, + input_tokens: 1000, + output_tokens: 200, + request_count: 3, + }], + }), + })) as typeof fetch + + const range = { + start: new Date('2026-06-01T00:00:00.000Z'), + end: new Date('2026-06-02T23:59:59.999Z'), + } + const rows = await fetchVercelGatewayReport(range) + expect(rows).toHaveLength(1) + + const source = { path: 'vercel-ai-gateway:report', project: 'Vercel AI Gateway', provider: 'vercel-gateway' } + const seen = new Set<string>() + const calls = [] + for await (const call of vercelGateway.createSessionParser(source, seen, range).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(1) + expect(calls[0]?.costUSD).toBe(1.25) + expect(calls[0]?.model).toBe('anthropic/claude-sonnet-4.6') + }) +}) + +describe('vercel-gateway end-to-end (parseAllSessions network path)', () => { + const originalFetch = globalThis.fetch + const originalKey = process.env.AI_GATEWAY_API_KEY + const originalCacheDir = process.env.CODEBURN_CACHE_DIR + let cacheDir: string + + beforeEach(async () => { + cacheDir = await mkdtemp(join(tmpdir(), 'cb-vercel-cache-')) + process.env.CODEBURN_CACHE_DIR = cacheDir + process.env.AI_GATEWAY_API_KEY = 'test-key' + clearSessionCache() + }) + + afterEach(async () => { + globalThis.fetch = originalFetch + if (originalKey === undefined) delete process.env.AI_GATEWAY_API_KEY + else process.env.AI_GATEWAY_API_KEY = originalKey + if (originalCacheDir === undefined) delete process.env.CODEBURN_CACHE_DIR + else process.env.CODEBURN_CACHE_DIR = originalCacheDir + clearSessionCache() + vi.restoreAllMocks() + await rm(cacheDir, { recursive: true, force: true }) + }) + + // Regression: the synthetic source path `vercel-ai-gateway:report` has no file + // on disk, so it was dropped by the fingerprintFile gate in parseProviderSources + // and the provider always reported $0. Network providers must survive that gate + // and contribute their fetched cost through the real aggregation pipeline. + it('network source survives the fingerprint gate and contributes cost', async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + results: [ + { day: '2026-06-01', model: 'openai/gpt-4o', total_cost: 12.34, input_tokens: 1000, output_tokens: 500, request_count: 3 }, + ], + }), + })) as typeof fetch + + const range = { + start: new Date('2026-05-01T00:00:00.000Z'), + end: new Date('2026-06-09T23:59:59.999Z'), + } + const projects = await parseAllSessions(range, 'vercel-gateway') + const total = projects.reduce((sum, p) => sum + p.totalCostUSD, 0) + + expect(total).toBeCloseTo(12.34, 2) + }) +}) diff --git a/tests/providers/vscode-cline-parser.test.ts b/tests/providers/vscode-cline-parser.test.ts new file mode 100644 index 0000000..b250b0c --- /dev/null +++ b/tests/providers/vscode-cline-parser.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join, posix, win32 } from 'path' +import { tmpdir } from 'os' + +import { discoverClineTasks, getVSCodeGlobalStoragePaths } from '../../src/providers/vscode-cline-parser.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'vscode-cline-parser-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function writeTask(baseDir: string, taskId: string): Promise<void> { + const taskDir = join(baseDir, 'tasks', taskId) + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), '[]') +} + +describe('VS Code Cline-family storage discovery', () => { + it('includes VSCodium globalStorage paths on all supported platforms', () => { + const extensionId = 'example.extension' + + expect(getVSCodeGlobalStoragePaths(extensionId, '/Users/test', 'darwin')).toContain( + posix.join('/Users/test', 'Library', 'Application Support', 'VSCodium', 'User', 'globalStorage', extensionId), + ) + expect(getVSCodeGlobalStoragePaths(extensionId, 'C:\\Users\\test', 'win32')).toContain( + win32.join('C:\\Users\\test', 'AppData', 'Roaming', 'VSCodium', 'User', 'globalStorage', extensionId), + ) + expect(getVSCodeGlobalStoragePaths(extensionId, '/home/test', 'linux')).toContain( + posix.join('/home/test', '.config', 'VSCodium', 'User', 'globalStorage', extensionId), + ) + }) + + it('discovers tasks across multiple VS Code-compatible storage roots', async () => { + const codeRoot = join(tmpDir, 'Code', 'User', 'globalStorage', 'example.extension') + const codiumRoot = join(tmpDir, 'VSCodium', 'User', 'globalStorage', 'example.extension') + await writeTask(codeRoot, 'task-code') + await writeTask(codiumRoot, 'task-codium') + + const sessions = await discoverClineTasks( + 'example.extension', + 'example-provider', + 'Example Provider', + [codeRoot, codiumRoot], + ) + + expect(sessions).toHaveLength(2) + expect(sessions.map(s => s.path).sort()).toEqual([ + join(codeRoot, 'tasks', 'task-code'), + join(codiumRoot, 'tasks', 'task-codium'), + ].sort()) + }) +}) diff --git a/tests/providers/warp.test.ts b/tests/providers/warp.test.ts new file mode 100644 index 0000000..433a4f0 --- /dev/null +++ b/tests/providers/warp.test.ts @@ -0,0 +1,358 @@ +import { mkdtemp, rm } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createWarpProvider } from '../../src/providers/warp.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +type QueryFixture = { + exchangeId: string + conversationId: string + startTs: string + input: string + outputStatus?: string + modelId?: string + workingDirectory?: string | null +} + +type BlockFixture = { + blockId: string + conversationId: string + startTs: string + completedTs: string + exitCode: number + command: string +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'warp-provider-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function createWarpDb(dir: string): string { + mkdirSync(dir, { recursive: true }) + const dbPath = join(dir, 'warp.sqlite') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE IF NOT EXISTS agent_conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + conversation_data TEXT NOT NULL, + last_modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS ai_queries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exchange_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + start_ts DATETIME NOT NULL, + input TEXT NOT NULL, + working_directory TEXT, + output_status TEXT NOT NULL, + model_id TEXT NOT NULL DEFAULT '', + planning_model_id TEXT NOT NULL DEFAULT '', + coding_model_id TEXT NOT NULL DEFAULT '' + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pane_leaf_uuid BLOB NOT NULL, + stylized_command BLOB NOT NULL, + stylized_output BLOB NOT NULL, + pwd TEXT, + git_branch TEXT, + virtual_env TEXT, + conda_env TEXT, + exit_code INTEGER NOT NULL, + did_execute BOOLEAN NOT NULL, + completed_ts DATETIME, + start_ts DATETIME, + ps1 TEXT, + honor_ps1 BOOLEAN NOT NULL DEFAULT 0, + shell TEXT, + user TEXT, + host TEXT, + is_background BOOLEAN NOT NULL DEFAULT 0, + rprompt TEXT, + prompt_snapshot TEXT, + block_id TEXT NOT NULL DEFAULT '', + ai_metadata TEXT, + is_local BOOLEAN, + agent_view_visibility TEXT, + git_branch_name TEXT + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +function insertConversation( + db: TestDb, + conversationId: string, + conversationData: unknown, + lastModifiedAt = '2026-05-18 10:10:00', +): void { + db.prepare( + 'INSERT INTO agent_conversations (conversation_id, conversation_data, last_modified_at) VALUES (?, ?, ?)', + ).run(conversationId, JSON.stringify(conversationData), lastModifiedAt) +} + +function insertQuery(db: TestDb, q: QueryFixture): void { + db.prepare( + `INSERT INTO ai_queries ( + exchange_id, conversation_id, start_ts, input, working_directory, output_status, model_id, planning_model_id, coding_model_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, '', '')`, + ).run( + q.exchangeId, + q.conversationId, + q.startTs, + q.input, + q.workingDirectory ?? null, + q.outputStatus ?? '"Completed"', + q.modelId ?? 'auto-efficient', + ) +} + +function insertBlock(db: TestDb, b: BlockFixture): void { + db.prepare( + `INSERT INTO blocks ( + pane_leaf_uuid, stylized_command, stylized_output, exit_code, did_execute, + completed_ts, start_ts, block_id, ai_metadata + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + Buffer.from([0]), + b.command, + '', + b.exitCode, + 1, + b.completedTs, + b.startTs, + b.blockId, + JSON.stringify({ + requested_command_action_id: `call-${b.blockId}`, + conversation_id: b.conversationId, + }), + ) +} + +async function collectCalls( + dbPath: string, + conversationId: string, + seenKeys = new Set<string>(), +): Promise<ParsedProviderCall[]> { + const provider = createWarpProvider(dbPath) + const source = { + path: `${dbPath}:${conversationId}`, + project: 'warp', + provider: 'warp', + } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + calls.push(call) + } + return calls +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('warp provider', () => { + it('discovers sessions and sanitizes project names from working_directory', async () => { + const dbPath = createWarpDb(tmpDir) + withTestDb(dbPath, (db) => { + insertConversation(db, 'conv-1', { conversation_usage_metadata: { token_usage: [] } }) + insertQuery(db, { + exchangeId: 'ex-1', + conversationId: 'conv-1', + startTs: '2026-05-18 10:00:00.000000', + input: '[]', + workingDirectory: '/Users/test/project-a', + }) + }) + + const provider = createWarpProvider(dbPath) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('warp') + expect(sessions[0]!.path).toBe(`${dbPath}:conv-1`) + expect(sessions[0]!.project).toBe('Users-test-project-a') + }) + + it('parses one call per completed exchange and estimates tokens from primary-agent totals', async () => { + const dbPath = createWarpDb(tmpDir) + withTestDb(dbPath, (db) => { + insertConversation(db, 'conv-1', { + conversation_usage_metadata: { + token_usage: [ + { + model_id: 'GPT-5.3 Codex (medium reasoning)', + warp_tokens: 300, + byok_tokens: 0, + warp_token_usage_by_category: { primary_agent: 300 }, + byok_token_usage_by_category: {}, + }, + { + model_id: 'Claude Haiku 4.5', + warp_tokens: 90, + byok_tokens: 0, + warp_token_usage_by_category: { full_terminal_use: 90 }, + byok_token_usage_by_category: {}, + }, + ], + }, + }) + insertQuery(db, { + exchangeId: 'ex-1', + conversationId: 'conv-1', + startTs: '2026-05-18 10:00:00.000000', + input: JSON.stringify([{ Query: { text: 'short prompt' } }]), + modelId: 'auto-efficient', + workingDirectory: '/Users/test/project-a', + }) + insertQuery(db, { + exchangeId: 'ex-2', + conversationId: 'conv-1', + startTs: '2026-05-18 10:03:00.000000', + input: JSON.stringify([{ Query: { text: 'longer prompt with substantially more detail for weighting' } }]), + modelId: 'auto-efficient', + workingDirectory: '/Users/test/project-a', + }) + }) + + const calls = await collectCalls(dbPath, 'conv-1') + expect(calls).toHaveLength(2) + expect(calls.map(call => call.deduplicationKey)).toEqual([ + 'warp:conv-1:ex-1', + 'warp:conv-1:ex-2', + ]) + expect(calls.map(call => call.userMessage)).toEqual([ + 'short prompt', + 'longer prompt with substantially more detail for weighting', + ]) + expect(calls.every(call => call.model === 'gpt-5.3-codex')).toBe(true) + expect(calls.every(call => call.costIsEstimated === true)).toBe(true) + expect(calls.reduce((sum, call) => sum + call.inputTokens, 0)).toBe(300) + expect(calls[1]!.inputTokens).toBeGreaterThan(calls[0]!.inputTokens) + expect(calls[0]!.projectPath).toBe('/Users/test/project-a') + expect(calls[0]!.project).toBe('Users-test-project-a') + }) + + it('attributes command blocks to the nearest preceding exchange and extracts Bash commands', async () => { + const dbPath = createWarpDb(tmpDir) + withTestDb(dbPath, (db) => { + insertConversation(db, 'conv-2', { + conversation_usage_metadata: { + token_usage: [ + { + model_id: 'GPT-5.3 Codex (medium reasoning)', + warp_tokens: 120, + byok_tokens: 0, + warp_token_usage_by_category: { primary_agent: 120 }, + byok_token_usage_by_category: {}, + }, + ], + }, + }) + insertQuery(db, { + exchangeId: 'ex-a', + conversationId: 'conv-2', + startTs: '2026-05-18 11:00:00.000000', + input: JSON.stringify([{ Query: { text: 'run tests' } }]), + }) + insertQuery(db, { + exchangeId: 'ex-b', + conversationId: 'conv-2', + startTs: '2026-05-18 11:05:00.000000', + input: JSON.stringify([{ Query: { text: 'summarize results' } }]), + }) + insertBlock(db, { + blockId: 'block-1', + conversationId: 'conv-2', + startTs: '2026-05-18 11:01:00.000000', + completedTs: '2026-05-18 11:01:04.000000', + exitCode: 0, + command: 'npm test && git status', + }) + }) + + const calls = await collectCalls(dbPath, 'conv-2') + expect(calls).toHaveLength(2) + expect(calls[0]!.tools).toEqual(['Bash']) + expect(calls[0]!.bashCommands).toEqual(['npm', 'git']) + expect(calls[1]!.tools).toEqual([]) + expect(calls[1]!.bashCommands).toEqual([]) + }) + + it('skips pending or invalid exchanges and does not poison seenKeys for skipped rows', async () => { + const dbPath = createWarpDb(tmpDir) + withTestDb(dbPath, (db) => { + insertConversation(db, 'conv-3', { + conversation_usage_metadata: { + token_usage: [ + { + model_id: 'GPT-5.3 Codex (medium reasoning)', + warp_tokens: 42, + byok_tokens: 0, + warp_token_usage_by_category: { primary_agent: 42 }, + byok_token_usage_by_category: {}, + }, + ], + }, + }) + insertQuery(db, { + exchangeId: 'ex-skip-seen', + conversationId: 'conv-3', + startTs: '2026-05-18 12:00:00.000000', + input: JSON.stringify([{ Query: { text: 'already seen' } }]), + }) + insertQuery(db, { + exchangeId: 'ex-pending', + conversationId: 'conv-3', + startTs: '2026-05-18 12:01:00.000000', + input: JSON.stringify([{ Query: { text: 'still running' } }]), + outputStatus: '"Pending"', + }) + insertQuery(db, { + exchangeId: 'ex-invalid-ts', + conversationId: 'conv-3', + startTs: 'not-a-timestamp', + input: JSON.stringify([{ Query: { text: 'bad timestamp' } }]), + }) + }) + + const seen = new Set<string>(['warp:conv-3:ex-skip-seen']) + const calls = await collectCalls(dbPath, 'conv-3', seen) + expect(calls).toEqual([]) + expect(seen.has('warp:conv-3:ex-invalid-ts')).toBe(false) + expect(seen.has('warp:conv-3:ex-pending')).toBe(false) + }) +}) diff --git a/tests/providers/zcode.test.ts b/tests/providers/zcode.test.ts new file mode 100644 index 0000000..f205737 --- /dev/null +++ b/tests/providers/zcode.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createZcodeProvider } from '../../src/providers/zcode.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await mkdtemp(join(tmpdir(), 'zcode-test-')) +}) + +afterEach(async () => { + await rm(tmpRoot, { recursive: true, force: true }) +}) + +// Minimal subset of the real ZCode schema (db v0.14.8) covering only the +// columns the provider reads. +function createZcodeDb(dir: string): string { + const dbPath = join(dir, 'db.sqlite') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT NOT NULL + ) + `) + db.exec(` + 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 + ) + `) + db.exec(` + CREATE TABLE tool_usage ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + turn_id TEXT, + tool_name TEXT NOT NULL, + started_at INTEGER NOT NULL + ) + `) + db.close() + return dbPath +} + +// Seeds one session with a single GLM-5.2 request whose 9125 input tokens +// include 8064 cached, plus two tool calls in the same turn. +function seed(dbPath: string): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + db.prepare('INSERT INTO session (id, directory) VALUES (?, ?)').run('sess-1', '/Users/me/proj') + db.prepare( + `INSERT INTO model_usage + (id, session_id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens, + cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run('mu-1', 'sess-1', 'turn-1', 'GLM-5.2', 9125, 27, 0, 0, 8064, 1781981181862, 1781981202412) + db.prepare( + 'INSERT INTO tool_usage (id, session_id, turn_id, tool_name, started_at) VALUES (?, ?, ?, ?, ?)', + ).run('tu-1', 'sess-1', 'turn-1', 'Bash', 1781981299176) + db.prepare( + 'INSERT INTO tool_usage (id, session_id, turn_id, tool_name, started_at) VALUES (?, ?, ?, ?, ?)', + ).run('tu-2', 'sess-1', 'turn-1', 'Read', 1781981315829) + } finally { + db.close() + } +} + +async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> { + const out: ParsedProviderCall[] = [] + for await (const call of parser.parse()) out.push(call) + return out +} + +describe('zcode provider', () => { + it('discovers sessions that have usage', async () => { + if (!isSqliteAvailable()) return + const dbPath = createZcodeDb(tmpRoot) + seed(dbPath) + + const provider = createZcodeProvider(dbPath) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]?.provider).toBe('zcode') + expect(sessions[0]?.project).toBe('Users-me-proj') + }) + + it('splits cached tokens out of input and prices via the GLM-5.2 alias', async () => { + if (!isSqliteAvailable()) return + const dbPath = createZcodeDb(tmpRoot) + seed(dbPath) + + const provider = createZcodeProvider(dbPath) + const [source] = await provider.discoverSessions() + const calls = await collect(provider.createSessionParser(source!, new Set<string>())) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.model).toBe('GLM-5.2') + expect(call.inputTokens).toBe(1061) // 9125 - 8064 cached + expect(call.cacheReadInputTokens).toBe(8064) + expect(call.outputTokens).toBe(27) + expect(call.tools).toEqual(['Bash', 'Read']) + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('does not re-emit rows already in the seen set', async () => { + if (!isSqliteAvailable()) return + const dbPath = createZcodeDb(tmpRoot) + seed(dbPath) + + const provider = createZcodeProvider(dbPath) + const [source] = await provider.discoverSessions() + const seen = new Set<string>() + + const first = await collect(provider.createSessionParser(source!, seen)) + const second = await collect(provider.createSessionParser(source!, seen)) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) +}) diff --git a/tests/providers/zed.test.ts b/tests/providers/zed.test.ts new file mode 100644 index 0000000..4762cd7 --- /dev/null +++ b/tests/providers/zed.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' +import zlib from 'zlib' + +import { createZedProvider } from '../../src/providers/zed.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +const zstd = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync + +const skipReason = !isSqliteAvailable() + ? 'node:sqlite not available — needs Node 22+; skipping' + : !zstd + ? 'zlib zstd not available — needs Node 22.15+; skipping' + : null + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'zed-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function buildDb(fn: (db: { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +}) => void): string { + const dbPath = join(tmpDir, 'threads.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, + summary TEXT NOT NULL, + updated_at TEXT NOT NULL, + data_type TEXT NOT NULL, + data BLOB NOT NULL, + parent_id TEXT, folder_paths TEXT, folder_paths_order TEXT, created_at TEXT + )`) + fn(db) + db.close() + return dbPath +} + +function insertThread(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + id: string + summary?: string + updatedAt?: string + dataType?: string + thread?: unknown + rawData?: Buffer +}): void { + const data = opts.rawData ?? zstd!(Buffer.from(JSON.stringify(opts.thread ?? {}))) + db.prepare('INSERT INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)').run( + opts.id, + opts.summary ?? 'a thread', + opts.updatedAt ?? '2026-06-20T10:00:00Z', + opts.dataType ?? 'zstd', + data, + ) +} + +async function collectCalls(dbPath: string, seenKeys = new Set<string>()): Promise<ParsedProviderCall[]> { + const provider = createZedProvider(dbPath) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + calls.push(call) + } + } + return calls +} + +describe.skipIf(skipReason !== null)('zed provider (#480)', () => { + it('emits one call per request with exact Anthropic-shaped token fields', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-1', + summary: 'refactor the parser', + updatedAt: '2026-06-21T09:30:00Z', + thread: { + model: { provider: 'anthropic', model: 'claude-opus-4-8' }, + request_token_usage: { + 'req-1': { input_tokens: 1200, output_tokens: 300, cache_creation_input_tokens: 5000, cache_read_input_tokens: 90000 }, + 'req-2': { input_tokens: 800, output_tokens: 150, cache_creation_input_tokens: 0, cache_read_input_tokens: 95000 }, + }, + cumulative_token_usage: { input_tokens: 2000, output_tokens: 450 }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(2) + const first = calls.find(c => c.deduplicationKey === 'zed:thread-1:req-1') + expect(first).toBeDefined() + expect(first!.inputTokens).toBe(1200) + expect(first!.outputTokens).toBe(300) + expect(first!.cacheCreationInputTokens).toBe(5000) + expect(first!.cacheReadInputTokens).toBe(90000) + expect(first!.model).toBe('claude-opus-4-8') + expect(first!.costUSD).toBeGreaterThan(0) + expect(first!.sessionId).toBe('thread-1') + expect(first!.userMessage).toBe('refactor the parser') + expect(first!.timestamp).toBe('2026-06-21T09:30:00.000Z') + // Cumulative must not be counted on top of per-request entries. + expect(calls.reduce((s, c) => s + c.inputTokens, 0)).toBe(2000) + }) + + it('falls back to cumulative_token_usage when the per-request map is empty', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-2', + thread: { + model: { provider: 'anthropic', model: 'claude-sonnet-4-6' }, + request_token_usage: {}, + cumulative_token_usage: { input_tokens: 5400, output_tokens: 900, cache_read_input_tokens: 20000 }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(1) + expect(calls[0]!.deduplicationKey).toBe('zed:thread-2:cumulative-remainder') + expect(calls[0]!.inputTokens).toBe(5400) + expect(calls[0]!.cacheReadInputTokens).toBe(20000) + }) + + it('tops threads up to the cumulative counter when the per-request map undercounts', async () => { + // Mirrors a real thread: the map (keyed by user message) covered only some + // requests while cumulative carried ~3x the tokens. + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-real', + thread: { + model: { provider: 'zed.dev', model: 'gpt-5.4' }, + request_token_usage: { + 'msg-1': { input_tokens: 9514, output_tokens: 19 }, + 'msg-2': { input_tokens: 2757, output_tokens: 310, cache_read_input_tokens: 33408 }, + }, + cumulative_token_usage: { input_tokens: 35464, output_tokens: 1868, cache_read_input_tokens: 140288 }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(3) + const remainder = calls.find(c => c.deduplicationKey === 'zed:thread-real:cumulative-remainder') + expect(remainder).toBeDefined() + expect(calls.reduce((s, c) => s + c.inputTokens, 0)).toBe(35464) + expect(calls.reduce((s, c) => s + c.outputTokens, 0)).toBe(1868) + expect(calls.reduce((s, c) => s + c.cacheReadInputTokens, 0)).toBe(140288) + }) + + it('skips non-zstd rows and malformed blobs without dropping healthy threads', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { id: 'bad-type', dataType: 'protobuf', rawData: Buffer.from('{}') }) + insertThread(db, { id: 'bad-blob', rawData: Buffer.from('not zstd at all') }) + insertThread(db, { + id: 'good', + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 10, output_tokens: 5 } }, + }, + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(1) + expect(calls[0]!.sessionId).toBe('good') + }) + + it('reads legacy uncompressed json rows alongside zstd rows', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'legacy', + dataType: 'json', + rawData: Buffer.from(JSON.stringify({ + model: { model: 'claude-sonnet-4-6' }, + request_token_usage: { 'req-1': { input_tokens: 40, output_tokens: 8 } }, + })), + }) + }) + + const calls = await collectCalls(dbPath) + expect(calls.length).toBe(1) + expect(calls[0]!.inputTokens).toBe(40) + expect(calls[0]!.model).toBe('claude-sonnet-4-6') + }) + + it('dedupes across repeat parses via the shared seenKeys set', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-3', + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } }, + }, + }) + }) + + const seen = new Set<string>() + expect((await collectCalls(dbPath, seen)).length).toBe(1) + expect((await collectCalls(dbPath, seen)).length).toBe(0) + }) + + it('skips threads whose usage is entirely zero instead of emitting empty calls', async () => { + const dbPath = buildDb((db) => { + insertThread(db, { + id: 'thread-4', + thread: { + model: { model: 'claude-opus-4-8' }, + request_token_usage: { 'req-1': { input_tokens: 0, output_tokens: 0 } }, + cumulative_token_usage: { input_tokens: 0, output_tokens: 0 }, + }, + }) + }) + + expect((await collectCalls(dbPath)).length).toBe(0) + }) + + it('discovers nothing when the database does not exist', async () => { + const provider = createZedProvider(join(tmpDir, 'missing.db')) + expect(await provider.discoverSessions()).toEqual([]) + }) +}) diff --git a/tests/providers/zerostack.test.ts b/tests/providers/zerostack.test.ts new file mode 100644 index 0000000..0b5111e --- /dev/null +++ b/tests/providers/zerostack.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createZerostackProvider } from '../../src/providers/zerostack.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'zerostack-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +// Mirrors the real on-disk format: one JSON file per session with cumulative +// token totals (see src/session/mod.rs in zerostack). +function session(opts: { + id?: string + model?: string + provider?: string + workingDir?: string + input?: number + output?: number + updatedAt?: string +} = {}) { + return JSON.stringify({ + id: opts.id ?? 'sess-001', + name: '', + messages: [ + { role: 'user', content: 'hello, what is this repo about?', estimated_tokens: 7 }, + { role: 'assistant', content: 'It is a minimal coding agent in Rust.', estimated_tokens: 92 }, + ], + compactions: [], + created_at: '2026-06-19T11:33:34.022836+00:00', + updated_at: opts.updatedAt ?? '2026-06-19T11:34:14.140631+00:00', + total_input_tokens: opts.input ?? 34119, + total_output_tokens: opts.output ?? 961, + total_cost: 0.015677835, + total_estimated_tokens: 446, + model: opts.model ?? 'deepseek/deepseek-v4-pro', + provider: opts.provider ?? 'openrouter', + working_dir: opts.workingDir ?? '/Users/test/myproject', + permission_allowlist: [], + }) +} + +async function write(filename: string, content: string) { + const path = join(tmpDir, filename) + await writeFile(path, content) + return path +} + +describe('zerostack provider - discovery', () => { + it('discovers each session json and derives project from working_dir', async () => { + await write('sess-001.json', session({ workingDir: '/Users/test/myproject' })) + const sessions = await createZerostackProvider(tmpDir).discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('zerostack') + expect(sessions[0]!.project).toBe('myproject') + }) + + it('skips non-json files and unparseable files', async () => { + await write('notes.txt', 'not a session') + await write('broken.json', '{ not valid json') + const sessions = await createZerostackProvider(tmpDir).discoverSessions() + expect(sessions).toEqual([]) + }) + + it('returns empty for a non-existent directory', async () => { + const sessions = await createZerostackProvider('/nope/does/not/exist').discoverSessions() + expect(sessions).toEqual([]) + }) +}) + +describe('zerostack provider - parsing', () => { + async function parse(path: string, seen = new Set<string>()) { + const provider = createZerostackProvider(tmpDir) + const source = { path, project: 'myproject', provider: 'zerostack' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) { + calls.push(call) + } + return calls + } + + it('emits one cumulative call per session with a resolved cost', async () => { + const path = await write('sess-abc.json', session({ id: 'sess-abc' })) + const calls = await parse(path) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.model).toBe('deepseek/deepseek-v4-pro') + expect(call.inputTokens).toBe(34119) + expect(call.outputTokens).toBe(961) + expect(call.sessionId).toBe('sess-abc') + expect(call.userMessage).toBe('hello, what is this repo about?') + expect(call.timestamp).toBe('2026-06-19T11:34:14.140631+00:00') + expect(call.costUSD).toBeGreaterThan(0) + expect(call.deduplicationKey).toContain('zerostack:') + }) + + it('skips sessions with zero tokens', async () => { + const path = await write('empty.json', session({ input: 0, output: 0 })) + expect(await parse(path)).toHaveLength(0) + }) + + it('deduplicates across repeated parses', async () => { + const path = await write('dup.json', session()) + const seen = new Set<string>() + expect(await parse(path, seen)).toHaveLength(1) + expect(await parse(path, seen)).toHaveLength(0) + }) + + it('prices unknown local models at zero without throwing', async () => { + const path = await write('local.json', session({ model: 'my-local-model', provider: 'ollama' })) + const calls = await parse(path) + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBe(0) + }) +}) + +describe('zerostack provider - display names', () => { + const provider = createZerostackProvider('/tmp') + + it('has correct name and displayName', () => { + expect(provider.name).toBe('zerostack') + expect(provider.displayName).toBe('Zerostack') + }) + + it('strips the openrouter route prefix from model ids', () => { + expect(provider.modelDisplayName('deepseek/deepseek-v4-pro')).toBe('DeepSeek v4 Pro') + }) + + it('normalizes tool names', () => { + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) +}) diff --git a/tests/scan-progress.test.ts b/tests/scan-progress.test.ts new file mode 100644 index 0000000..0ffc7d2 --- /dev/null +++ b/tests/scan-progress.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { createScanProgress, setInteractiveScanUI } from '../src/parser.js' + +// The scan-progress line must be silent while an interactive Ink UI (dashboard, +// compare) is live, because it renders to the same terminal. The end-to-end +// proof (cold dashboard WITH a plan stays silent; cold overview shows progress) +// is documented in the PR and was run under a PTY. +describe('createScanProgress gate', () => { + const origTTY = process.stderr.isTTY + + afterEach(() => { + setInteractiveScanUI(false) + Object.defineProperty(process.stderr, 'isTTY', { value: origTTY, configurable: true }) + vi.restoreAllMocks() + }) + + function forceTTY(v: boolean) { + Object.defineProperty(process.stderr, 'isTTY', { value: v, configurable: true }) + } + + it('writes progress for a plain CLI command in a TTY over the threshold', async () => { + forceTTY(true) + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 100) + await p.tick(100) // final tick bypasses the 100ms throttle + expect(w).toHaveBeenCalled() + expect(String(w.mock.calls[0]![0])).toContain('scanning 100/100') + }) + + it('goes silent once an interactive Ink UI is active, even in a TTY', async () => { + forceTTY(true) + setInteractiveScanUI() // dashboard/compare call this right before render() + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 100) + for (let i = 1; i <= 100; i++) await p.tick(i) + p.finish() + expect(w).not.toHaveBeenCalled() + }) + + it('finish() clears the line when progress was shown', async () => { + forceTTY(true) + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 100) + await p.tick(100) + w.mockClear() + p.finish() + expect(String(w.mock.calls[0]![0])).toBe('\r\x1b[K') + }) + + it('writes nothing when stderr is not a TTY (piped/captured output)', async () => { + forceTTY(false) + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 100) + await p.tick(100) + p.finish() + expect(w).not.toHaveBeenCalled() + }) + + it('stays silent below the small-tree threshold', async () => { + forceTTY(true) + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 5) + await p.tick(5) + expect(w).not.toHaveBeenCalled() + }) +}) diff --git a/tests/security/prototype-pollution.test.ts b/tests/security/prototype-pollution.test.ts new file mode 100644 index 0000000..8a57ac2 --- /dev/null +++ b/tests/security/prototype-pollution.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { mkdtemp, mkdir, cp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { parseAllSessions } from '../../src/parser.js' +import type { DateRange } from '../../src/types.js' + +// Fixtures carry timestamp 2026-04-16T00:00:00Z. The range below must stay +// wide enough to include that date; if the fixtures move, move FIXTURE_DAY too. +const FIXTURE_DAY = Date.UTC(2026, 3, 16) // month index 3 = April (Date.UTC is 0-indexed) +const RANGE_BEFORE_MS = FIXTURE_DAY - 24 * 60 * 60 * 1000 +const RANGE_AFTER_MS = FIXTURE_DAY + 24 * 60 * 60 * 1000 +const PROJECT_NAME = 'codeburn-poc-testing' + +function makeRange(offsetMs: number): DateRange { + return { + start: new Date(RANGE_BEFORE_MS + offsetMs), + end: new Date(RANGE_AFTER_MS + offsetMs), + } +} + +// Hermeticity note: the Claude provider also scans a fixed Desktop sessions +// dir independent of CLAUDE_CONFIG_DIR. The narrow dateRange above excludes +// any real sessions in practice, but these tests are not strictly isolated +// on a machine with April 2026 Claude Desktop activity. A stricter fix +// belongs in a follow-up to discoverSessions itself. + +describe('HIGH-1 prototype pollution via unchecked bracket-assign', () => { + const tmpDirs: string[] = [] + + afterEach(async () => { + delete (Object.prototype as Record<string, unknown>).calls + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } + }) + + async function setupPoc(fixture: string): Promise<string> { + const base = await mkdtemp(join(tmpdir(), 'codeburn-sec-')) + tmpDirs.push(base) + const projectDir = join(base, 'projects', PROJECT_NAME) + await mkdir(projectDir, { recursive: true }) + await cp(join(__dirname, '..', 'fixtures', 'security', fixture), join(projectDir, 'pwn.jsonl')) + process.env['CLAUDE_CONFIG_DIR'] = base + return base + } + + it('does not pollute Object.prototype when session contains tool_use name "__proto__"', async () => { + await setupPoc('proto-tool.jsonl') + await expect(parseAllSessions(makeRange(0), 'claude')).resolves.not.toThrow() + expect(({} as Record<string, unknown>).calls).toBeUndefined() + }) + + it('does not pollute Object.prototype when bash command basename is "__proto__"', async () => { + await setupPoc('proto-bash.jsonl') + await expect(parseAllSessions(makeRange(1), 'claude')).resolves.not.toThrow() + expect(({} as Record<string, unknown>).calls).toBeUndefined() + }) + + it('does not pollute Object.prototype when model name is "__proto__"', async () => { + await setupPoc('proto-model.jsonl') + await expect(parseAllSessions(makeRange(2), 'claude')).resolves.not.toThrow() + expect(({} as Record<string, unknown>).calls).toBeUndefined() + }) +}) diff --git a/tests/session-cache.test.ts b/tests/session-cache.test.ts new file mode 100644 index 0000000..b14ab06 --- /dev/null +++ b/tests/session-cache.test.ts @@ -0,0 +1,585 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readFile, rm, writeFile, mkdir } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + CACHE_VERSION, + type CachedCall, + type CachedFile, + type CachedTurn, + type FileFingerprint, + type SessionCache, + cleanupOrphanedTempFiles, + computeEnvFingerprint, + emptyCache, + fingerprintFile, + loadCache, + mergeCallByDedupKey, + reconcileFile, + saveCache, +} from '../src/session-cache.js' + +const TMP_DIR = join(tmpdir(), `codeburn-scache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) + +beforeEach(() => { + process.env['CODEBURN_CACHE_DIR'] = TMP_DIR +}) + +afterEach(async () => { + if (existsSync(TMP_DIR)) await rm(TMP_DIR, { recursive: true }) +}) + +function makeCall(overrides: Partial<CachedCall> = {}): CachedCall { + return { + provider: 'claude', + model: 'claude-sonnet-4-20250514', + usage: { + inputTokens: 1000, + outputTokens: 500, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + cacheCreationOneHourTokens: 0, + }, + speed: 'standard', + timestamp: '2026-05-15T10:00:00Z', + tools: ['Read', 'Edit'], + bashCommands: [], + skills: [], + deduplicationKey: 'msg-abc123', + ...overrides, + } +} + +function makeTurn(overrides: Partial<CachedTurn> = {}): CachedTurn { + return { + timestamp: '2026-05-15T10:00:00Z', + sessionId: 'sess-1', + userMessage: 'fix the bug', + calls: [makeCall()], + ...overrides, + } +} + +function makeCachedFile(overrides: Partial<CachedFile> = {}): CachedFile { + return { + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + mcpInventory: [], + turns: [makeTurn()], + ...overrides, + } +} + +// ── emptyCache ───────────────────────────────────────────────────────── + +describe('emptyCache', () => { + it('returns a valid empty cache', () => { + const cache = emptyCache() + expect(cache.version).toBe(CACHE_VERSION) + expect(cache.providers).toEqual({}) + }) +}) + +// ── loadCache / saveCache ────────────────────────────────────────────── + +describe('loadCache / saveCache', () => { + it('returns empty cache when no file exists', async () => { + const cache = await loadCache() + expect(cache.version).toBe(CACHE_VERSION) + expect(cache.providers).toEqual({}) + }) + + it('round-trips a cache through save and load', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + claude: { + envFingerprint: 'abc123', + files: { + '/path/to/session.jsonl': makeCachedFile(), + }, + }, + }, + } + + await saveCache(cache) + const loaded = await loadCache() + expect(loaded).toEqual(cache) + }) + + it('persists a failed-parse marker across save/load (negative-result cache)', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + pi: { + envFingerprint: 'abc123', + files: { + '/path/to/bad.jsonl': makeCachedFile({ turns: [], failed: true }), + }, + }, + }, + } + + await saveCache(cache) + const loaded = await loadCache() + // The `failed` flag and empty turns survive validation + load, so the file + // stays skipped on the next run instead of being re-read and re-thrown. + expect(loaded.providers['pi']?.files['/path/to/bad.jsonl']?.failed).toBe(true) + expect(loaded.providers['pi']?.files['/path/to/bad.jsonl']?.turns).toEqual([]) + }) + + it('returns empty cache on version mismatch', async () => { + const bad: SessionCache = { version: 999, providers: { claude: { envFingerprint: 'x', files: {} } } } + await mkdir(TMP_DIR, { recursive: true }) + await writeFile(join(TMP_DIR, 'session-cache.json'), JSON.stringify(bad)) + + const loaded = await loadCache() + expect(loaded.version).toBe(CACHE_VERSION) + expect(loaded.providers).toEqual({}) + }) + + it('returns empty cache on corrupt JSON', async () => { + await mkdir(TMP_DIR, { recursive: true }) + await writeFile(join(TMP_DIR, 'session-cache.json'), '{broken') + + const loaded = await loadCache() + expect(loaded.version).toBe(CACHE_VERSION) + expect(loaded.providers).toEqual({}) + }) + + it('atomic write does not leave partial file on error', async () => { + await saveCache(emptyCache()) + const raw = await readFile(join(TMP_DIR, 'session-cache.json'), 'utf-8') + expect(JSON.parse(raw)).toEqual(emptyCache()) + }) +}) + +// ── computeEnvFingerprint ────────────────────────────────────────────── + +describe('computeEnvFingerprint', () => { + it('returns stable hash for same env', () => { + const a = computeEnvFingerprint('claude') + const b = computeEnvFingerprint('claude') + expect(a).toBe(b) + expect(a).toHaveLength(16) + }) + + it('changes when env var changes', () => { + const before = computeEnvFingerprint('claude') + process.env['CLAUDE_CONFIG_DIR'] = '/tmp/different' + const after = computeEnvFingerprint('claude') + expect(before).not.toBe(after) + }) + + it('returns stable hash for unknown provider (no env vars)', () => { + const a = computeEnvFingerprint('unknown-provider') + const b = computeEnvFingerprint('unknown-provider') + expect(a).toBe(b) + }) + + it('includes parser versions in provider fingerprints', () => { + expect(computeEnvFingerprint('claude')).not.toBe(computeEnvFingerprint('unknown-provider')) + expect(computeEnvFingerprint('copilot')).not.toBe(computeEnvFingerprint('unknown-provider')) + expect(computeEnvFingerprint('kiro')).not.toBe(computeEnvFingerprint('unknown-provider')) + expect(computeEnvFingerprint('warp')).not.toBe(computeEnvFingerprint('unknown-provider')) + }) +}) + +// ── fingerprintFile ──────────────────────────────────────────────────── + +describe('fingerprintFile', () => { + it('returns fingerprint for existing file', async () => { + await mkdir(TMP_DIR, { recursive: true }) + const filePath = join(TMP_DIR, 'test.jsonl') + await writeFile(filePath, 'line1\nline2\n') + + const fp = await fingerprintFile(filePath) + expect(fp).not.toBeNull() + expect(fp!.sizeBytes).toBe(12) + expect(fp!.dev).toBeGreaterThan(0) + expect(fp!.ino).toBeGreaterThan(0) + expect(fp!.mtimeMs).toBeGreaterThan(0) + }) + + it('returns null for non-existent file', async () => { + const fp = await fingerprintFile('/no/such/file') + expect(fp).toBeNull() + }) + + it('resolves compound path with # separator (Cursor workspace)', async () => { + await mkdir(TMP_DIR, { recursive: true }) + const filePath = join(TMP_DIR, 'state.vscdb') + await writeFile(filePath, 'cursor-data') + + const fp = await fingerprintFile(`${filePath}#cursor-ws=__orphan__`) + expect(fp).not.toBeNull() + expect(fp!.sizeBytes).toBe(11) + }) + + it('resolves compound path with : separator (OpenCode session)', async () => { + await mkdir(TMP_DIR, { recursive: true }) + const filePath = join(TMP_DIR, 'opencode.db') + await writeFile(filePath, 'opencode-data') + + const fp = await fingerprintFile(`${filePath}:ses_abc123`) + expect(fp).not.toBeNull() + expect(fp!.sizeBytes).toBe(13) + }) + + it('returns null when base file does not exist for compound path', async () => { + const fp = await fingerprintFile('/no/such/file.db#cursor-ws=workspace') + expect(fp).toBeNull() + }) + + it('prefers # separator over : when both present', async () => { + await mkdir(TMP_DIR, { recursive: true }) + const filePath = join(TMP_DIR, 'state.vscdb') + await writeFile(filePath, 'both-seps') + + // Path has both # and : — should strip at # first and find the base file + const fp = await fingerprintFile(`${filePath}#cursor-ws=ws:extra-colon`) + expect(fp).not.toBeNull() + expect(fp!.sizeBytes).toBe(9) + }) +}) + +// ── reconcileFile ────────────────────────────────────────────────────── + +describe('reconcileFile', () => { + it('returns "new" when no cached entry', () => { + const fp: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 } + expect(reconcileFile(fp, undefined)).toEqual({ action: 'new' }) + }) + + it('returns "unchanged" when all fields match', () => { + const fp: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 } + const cached = makeCachedFile({ fingerprint: { ...fp } }) + expect(reconcileFile(fp, cached)).toEqual({ action: 'unchanged' }) + }) + + it('returns "appended" when ino same, size grew, and has lastCompleteLineOffset', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + lastCompleteLineOffset: 4500, + }) + const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 8000 } + const result = reconcileFile(current, cached) + expect(result).toEqual({ action: 'appended', readFromOffset: 4500 }) + }) + + it('returns "modified" when ino changed', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + }) + const current: FileFingerprint = { dev: 1, ino: 200, mtimeMs: 2000, sizeBytes: 5000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) + + it('a failed marker at the same fingerprint stays "unchanged" (not re-parsed)', () => { + const fp: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 } + const marker = makeCachedFile({ fingerprint: { ...fp }, turns: [], failed: true }) + expect(reconcileFile(fp, marker)).toEqual({ action: 'unchanged' }) + }) + + it('a failed marker is re-parsed once the file changes', () => { + const marker = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + turns: [], + failed: true, + }) + const changed: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 6000 } + expect(reconcileFile(changed, marker)).toEqual({ action: 'modified' }) + }) + + it('returns "modified" when size shrank', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + lastCompleteLineOffset: 4500, + }) + const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 3000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) + + it('returns "modified" when same size but different mtime', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + }) + const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 5000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) + + it('returns "modified" for DB provider (no lastCompleteLineOffset) on any fingerprint change', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + }) + const current: FileFingerprint = { dev: 1, ino: 100, mtimeMs: 2000, sizeBytes: 8000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) + + it('returns "modified" when dev changed even if ino same and size grew', () => { + const cached = makeCachedFile({ + fingerprint: { dev: 1, ino: 100, mtimeMs: 1000, sizeBytes: 5000 }, + lastCompleteLineOffset: 4500, + }) + const current: FileFingerprint = { dev: 2, ino: 100, mtimeMs: 2000, sizeBytes: 8000 } + expect(reconcileFile(current, cached)).toEqual({ action: 'modified' }) + }) +}) + +// ── mergeCallByDedupKey ──────────────────────────────────────────────── + +describe('mergeCallByDedupKey', () => { + it('keeps earlier timestamp', () => { + const existing = makeCall({ timestamp: '2026-05-15T10:00:00Z' }) + const incoming = makeCall({ timestamp: '2026-05-15T10:01:00Z' }) + const merged = mergeCallByDedupKey(existing, incoming) + expect(merged.timestamp).toBe('2026-05-15T10:00:00Z') + }) + + it('takes incoming usage (latest wins)', () => { + const existing = makeCall({ usage: { ...makeCall().usage, outputTokens: 100 } }) + const incoming = makeCall({ usage: { ...makeCall().usage, outputTokens: 999 } }) + const merged = mergeCallByDedupKey(existing, incoming) + expect(merged.usage.outputTokens).toBe(999) + }) + + it('takes incoming tools (latest wins)', () => { + const existing = makeCall({ tools: ['Read'] }) + const incoming = makeCall({ tools: ['Read', 'Edit', 'Bash'] }) + const merged = mergeCallByDedupKey(existing, incoming) + expect(merged.tools).toEqual(['Read', 'Edit', 'Bash']) + }) +}) + +// ── deep validation (loadCache) ──────────────────────────────────────── + +describe('loadCache validation', () => { + async function writeRawCache(data: unknown): Promise<void> { + await mkdir(TMP_DIR, { recursive: true }) + await writeFile(join(TMP_DIR, 'session-cache.json'), JSON.stringify(data)) + } + + it('rejects providers as array', async () => { + await writeRawCache({ version: CACHE_VERSION, providers: [] }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects provider section missing envFingerprint', async () => { + await writeRawCache({ version: CACHE_VERSION, providers: { claude: { files: {} } } }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects provider section with files as array', async () => { + await writeRawCache({ version: CACHE_VERSION, providers: { claude: { envFingerprint: 'x', files: [] } } }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects file with invalid fingerprint (missing ino)', async () => { + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects file with non-numeric fingerprint field', async () => { + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 'bad', mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects turn with missing sessionId', async () => { + const badTurn = { timestamp: 'x', userMessage: 'y', calls: [] } + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [badTurn] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects call with missing usage object', async () => { + const badCall = { provider: 'claude', model: 'm', deduplicationKey: 'k', timestamp: 't', tools: [], bashCommands: [], skills: [] } + const turn = { timestamp: 'x', sessionId: 's', userMessage: 'y', calls: [badCall] } + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [turn] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects call with NaN in usage', async () => { + const badUsage = { inputTokens: NaN, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 } + const call = { provider: 'claude', model: 'm', usage: badUsage, deduplicationKey: 'k', timestamp: 't', tools: [], bashCommands: [], skills: [], speed: 'standard' } + const turn = { timestamp: 'x', sessionId: 's', userMessage: 'y', calls: [call] } + await writeRawCache({ + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [turn] }, + } } }, + }) + expect((await loadCache()).providers).toEqual({}) + }) + + function validCallJson() { + return { + provider: 'claude', model: 'm', deduplicationKey: 'k', timestamp: 't', speed: 'standard', + tools: ['Read'], bashCommands: ['ls'], skills: [], + usage: { inputTokens: 1, outputTokens: 1, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 }, + } + } + + function wrapCall(callOverride: Record<string, unknown>) { + return { + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [ + { timestamp: 'x', sessionId: 's', userMessage: 'y', calls: [{ ...validCallJson(), ...callOverride }] }, + ] }, + } } }, + } + } + + function wrapFile(fileOverride: Record<string, unknown>) { + return { + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [], ...fileOverride }, + } } }, + } + } + + it('rejects tools containing non-string element', async () => { + await writeRawCache(wrapCall({ tools: ['Read', 42] })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects bashCommands containing object element', async () => { + await writeRawCache(wrapCall({ bashCommands: [{}] })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects skills containing null element', async () => { + await writeRawCache(wrapCall({ skills: [null] })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects invalid speed value', async () => { + await writeRawCache(wrapCall({ speed: 'turbo' })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects non-string project', async () => { + await writeRawCache(wrapCall({ project: 123 })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects non-string projectPath', async () => { + await writeRawCache(wrapCall({ projectPath: true })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects mcpInventory containing non-string element', async () => { + await writeRawCache(wrapFile({ mcpInventory: ['valid', 99] })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects non-numeric lastCompleteLineOffset', async () => { + await writeRawCache(wrapFile({ lastCompleteLineOffset: 'bad' })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects NaN lastCompleteLineOffset', async () => { + await writeRawCache(wrapFile({ lastCompleteLineOffset: null })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('rejects non-string canonicalCwd', async () => { + await writeRawCache(wrapFile({ canonicalCwd: 42 })) + expect((await loadCache()).providers).toEqual({}) + }) + + it('accepts optional fields when absent', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'x', files: { + '/f': { fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, mcpInventory: [], turns: [] }, + } } }, + } + await writeRawCache(cache) + expect((await loadCache())).toEqual(cache) + }) + + it('accepts a fully valid cache with all fields populated', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + claude: { + envFingerprint: 'abc', + files: { '/f': makeCachedFile() }, + }, + }, + } + await writeRawCache(cache) + const loaded = await loadCache() + expect(loaded).toEqual(cache) + }) +}) + +// ── cleanupOrphanedTempFiles ─────────────────────────────────────────── + +describe('cleanupOrphanedTempFiles', () => { + it('removes .tmp files older than 5 minutes', async () => { + await mkdir(TMP_DIR, { recursive: true }) + + const oldTmp = join(TMP_DIR, 'session-cache.json.abc123.tmp') + await writeFile(oldTmp, 'stale') + const { utimes } = await import('fs/promises') + const oldTime = new Date(Date.now() - 10 * 60 * 1000) + await utimes(oldTmp, oldTime, oldTime) + + await cleanupOrphanedTempFiles() + expect(existsSync(oldTmp)).toBe(false) + }) + + it('preserves recent .tmp files', async () => { + await mkdir(TMP_DIR, { recursive: true }) + + const recentTmp = join(TMP_DIR, 'session-cache.json.def456.tmp') + await writeFile(recentTmp, 'recent') + + await cleanupOrphanedTempFiles() + expect(existsSync(recentTmp)).toBe(true) + }) + + it('ignores .tmp files from other caches', async () => { + await mkdir(TMP_DIR, { recursive: true }) + + const otherTmp = join(TMP_DIR, 'codex-results.json.abc123.tmp') + await writeFile(otherTmp, 'other cache temp') + const { utimes } = await import('fs/promises') + const oldTime = new Date(Date.now() - 10 * 60 * 1000) + await utimes(otherTmp, oldTime, oldTime) + + await cleanupOrphanedTempFiles() + expect(existsSync(otherTmp)).toBe(true) + }) + + it('does not fail when cache dir does not exist', async () => { + process.env['CODEBURN_CACHE_DIR'] = '/no/such/dir' + await cleanupOrphanedTempFiles() + }) +}) diff --git a/tests/setup/env-isolation.ts b/tests/setup/env-isolation.ts new file mode 100644 index 0000000..7d638a4 --- /dev/null +++ b/tests/setup/env-isolation.ts @@ -0,0 +1,111 @@ +// Vitest setup file: isolates every test from the developer's shell environment. +// +// codeburn discovers sessions through a long list of provider-specific env +// vars (CLAUDE_CONFIG_DIR, CODEX_HOME, CRUSH_GLOBAL_DATA, …) and via HOME / +// XDG_* / APPDATA / LOCALAPPDATA. Without this file, any value set in the +// developer's shell (e.g. CLAUDE_CONFIG_DIRS=/Users/me/.claude:…) bleeds into +// fixture-based tests: the parser reads the developer's REAL sessions instead +// of the temp-dir fixture, producing nonsense totals and false failures that +// pass on a clean CI runner. +// +// What this file does: +// 1. Mints an empty sandbox temp dir once per worker. +// 2. REDIRECTED vars (HOME / XDG_* / APPDATA / LOCALAPPDATA) point at the +// sandbox so any fallback to homedir() / platform defaults lands in an +// empty filesystem. +// 3. CLEARED vars (every provider's explicit override) are deleted so a test +// that does NOT set one gets "unconfigured" rather than the dev's value. +// 4. PRESERVED vars (PATH, COLUMNS, …) are snapshotted from the dev's shell +// and restored every test. We can't wipe them - Node uses PATH for spawn +// and module resolution, terminal code uses COLUMNS - but a test that +// mutates them shouldn't leak the change into the next test. +// 5. Re-asserts the above before EVERY test (global beforeEach), so a test +// that mutates an env var doesn't leak its value into the next test. +// Tests can freely set process.env['HOME'] = customDir without saving the +// previous value - the next test gets a fresh sandbox baseline. +// +// CAVEAT: env vars set in a test file's beforeAll() get overwritten by this +// file's beforeEach before each test runs. Use beforeEach (not beforeAll) when +// the test body depends on a specific env var value. + +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeEach } from 'vitest' + +const sandbox = mkdtempSync(join(tmpdir(), 'codeburn-test-env-')) + +const REDIRECTED = [ + 'HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'XDG_STATE_HOME', + 'APPDATA', + 'LOCALAPPDATA', +] as const + +const CLEARED = [ + // Provider session-discovery dirs + 'CLAUDE_CONFIG_DIR', + 'CLAUDE_CONFIG_DIRS', + 'CODEX_HOME', + 'CRUSH_GLOBAL_DATA', + 'CODEBUFF_DATA_DIR', + 'FACTORY_DIR', + 'GOOSE_PATH_ROOT', + 'GROK_HOME', + 'KIRO_HOME', + 'KIMI_SHARE_DIR', + 'MUX_ROOT', + 'QWEN_DATA_DIR', + 'VIBE_HOME', + 'WARP_DB_PATH', + 'ZS_DATA_DIR', + // codeburn override dirs / paths + 'CODEBURN_CACHE_DIR', + 'CODEBURN_COPILOT_JETBRAINS_DIR', + 'CODEBURN_COPILOT_OTEL_DB', + 'CODEBURN_COPILOT_SESSION_STATE_DIR', + 'CODEBURN_COPILOT_WS_STORAGE_DIR', + 'CODEBURN_DESKTOP_SESSIONS_DIR', + 'CODEBURN_MUX_DIR', + 'CODEBURN_ANTIGRAVITY_SETTINGS_PATH', + // codeburn behavior toggles (set by the dev to tweak local runs) + 'CODEBURN_COPILOT_DISABLE_OTEL', + 'CODEBURN_TZ', + 'CODEBURN_VERBOSE', + 'CODEBURN_CURSOR_MAX_BUBBLES', + 'CODEBURN_FORCE_MACOS_MAJOR', + // Provider model/credential overrides + 'KIMI_MODEL_NAME', + 'AI_GATEWAY_API_KEY', + 'VERCEL_OIDC_TOKEN', + // Read by detectBashBloat - a dev's real shell limit must not bleed in + 'BASH_MAX_OUTPUT_LENGTH', +] as const + +// Snapshotted from the dev's shell and restored every test. These can't be +// wiped (Node needs PATH for spawn / module resolution, dashboard/table layout +// reads COLUMNS) but a test that mutates them shouldn't leak. +const PRESERVED = ['PATH', 'COLUMNS'] as const +const preservedSnapshot = new Map<string, string | undefined>() +for (const key of PRESERVED) preservedSnapshot.set(key, process.env[key]) + +function applyIsolation(): void { + for (const key of REDIRECTED) process.env[key] = sandbox + for (const key of CLEARED) delete process.env[key] + for (const key of PRESERVED) { + const original = preservedSnapshot.get(key) + if (original === undefined) delete process.env[key] + else process.env[key] = original + } + // Pin the timezone so date grouping is deterministic regardless of the dev's + // shell TZ. Clearing it is not enough (Node falls back to the OS zone); a + // non-UTC TZ would otherwise shift day buckets versus a clean CI runner. A + // test that needs a specific zone can still set process.env.TZ in beforeEach. + process.env.TZ = 'UTC' +} + +applyIsolation() +beforeEach(applyIsolation) diff --git a/tests/sharing/approve.test.ts b/tests/sharing/approve.test.ts new file mode 100644 index 0000000..b8872ec --- /dev/null +++ b/tests/sharing/approve.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' + +import { generateIdentity, type Identity } from '../../src/sharing/identity.js' +import { PeerStore, pairingCode } from '../../src/sharing/pairing.js' +import { ShareServer } from '../../src/sharing/share-server.js' +import { pairRequest, fetchUsage } from '../../src/sharing/client.js' + +describe('pairingCode', () => { + it('is order-independent, deterministic, and 3 digits', () => { + expect(pairingCode('aaa', 'bbb')).toBe(pairingCode('bbb', 'aaa')) + expect(pairingCode('aaa', 'bbb')).toMatch(/^\d{3}$/) + expect(pairingCode('aaa', 'bbb')).toBe(pairingCode('aaa', 'bbb')) + }) +}) + +describe('approve-style pairing (no PIN)', () => { + let server: ShareServer + let serverId: Identity + let clientId: Identity + let port: number + let seenCode = '' + + beforeAll(async () => { + serverId = await generateIdentity('MacBook') + clientId = await generateIdentity('Mac Studio') + server = new ShareServer({ + identity: serverId, + peers: new PeerStore(), + getUsage: async () => ({ current: { cost: 7 } }), + approve: async (req) => { + seenCode = req.code + return req.name !== 'Intruder' + }, + }) + port = await server.listen(0, '127.0.0.1') + }) + + afterAll(async () => { + await server.close() + }) + + const ep = () => ({ identity: clientId, host: '127.0.0.1', port, expectedFingerprint: serverId.fingerprint }) + + it('accepts an approved device, with the same code on both sides, and the token works', async () => { + const r = await pairRequest(ep(), 'Mac Studio') + expect(r.status).toBe(200) + const body = r.json as { token: string; code: string } + expect(body.token).toBeTruthy() + // Both ends derive the same confirmation code from the two fingerprints. + expect(body.code).toBe(pairingCode(serverId.fingerprint, clientId.fingerprint)) + expect(seenCode).toBe(body.code) + + const usage = await fetchUsage(ep(), body.token) + expect(usage.status).toBe(200) + expect((usage.json as { current: { cost: number } }).current.cost).toBe(7) + }) + + it('rejects a declined device', async () => { + const r = await pairRequest(ep(), 'Intruder') + expect(r.status).toBe(403) + }) +}) diff --git a/tests/sharing/connect-timeout.test.ts b/tests/sharing/connect-timeout.test.ts new file mode 100644 index 0000000..3a7f658 --- /dev/null +++ b/tests/sharing/connect-timeout.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' + +import { generateIdentity } from '../../src/sharing/identity.js' +import { hello } from '../../src/sharing/client.js' + +describe('peer connect timeout', () => { + it('fails fast when a peer is unreachable instead of riding the OS connect timeout', async () => { + const id = await generateIdentity('Test') + // RFC5737 TEST-NET-1 is reserved and black-holed: the SYN gets no answer, + // so without the connect-phase cap this would hang ~75s on macOS. + const start = Date.now() + await expect(hello({ identity: id, host: '192.0.2.1', port: 7777 })).rejects.toThrow() + expect(Date.now() - start).toBeLessThan(6000) + }, 15000) +}) diff --git a/tests/sharing/host.test.ts b/tests/sharing/host.test.ts new file mode 100644 index 0000000..39db688 --- /dev/null +++ b/tests/sharing/host.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { addRemote, pullDevices, renderDevices, summarizeDeviceUsage, type DeviceUsage } from '../../src/sharing/host.js' + +const clientMock = vi.hoisted(() => ({ + hello: vi.fn(), + pair: vi.fn(), + pairRequest: vi.fn(), + fetchUsage: vi.fn(), +})) + +vi.mock('../../src/sharing/client.js', () => clientMock) + +describe('host device flow', () => { + let dir: string + + beforeEach(async () => { + vi.clearAllMocks() + dir = await mkdtemp(join(tmpdir(), 'cb-host-')) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('pairs, persists, pulls both devices, and combines', async () => { + const remoteUsage = { current: { cost: 100, calls: 10, sessions: 2, inputTokens: 1000, outputTokens: 200 } } + clientMock.hello.mockResolvedValue({ status: 200, json: { fingerprint: 'remote-fp', name: 'MacBook' } }) + clientMock.pair.mockResolvedValue({ status: 200, json: { token: 'remote-token' } }) + clientMock.fetchUsage.mockResolvedValue({ status: 200, json: remoteUsage }) + + const device = await addRemote('127.0.0.1:7777', '123456', { defaultPort: 7777, dir }) + expect(device.name).toBe('MacBook') + expect(device.token).toBeTruthy() + + const localUsage = { current: { cost: 50, calls: 5, sessions: 1, inputTokens: 500, outputTokens: 100 } } + const results = await pullDevices(async () => localUsage, { period: 'month' }, 'Mac Studio', { dir }) + + expect(results).toHaveLength(2) + expect(results[0]!.local).toBe(true) + expect(results[0]!.payload!.current!.cost).toBe(50) + const remote = results.find((r) => !r.local)! + expect(remote.name).toBe('MacBook') + expect(remote.payload!.current!.cost).toBe(100) + expect(clientMock.fetchUsage).toHaveBeenCalledWith( + expect.objectContaining({ host: '127.0.0.1', port: 7777, expectedFingerprint: 'remote-fp' }), + 'remote-token', + { period: 'month' }, + ) + + const text = renderDevices(results) + expect(text).toContain('Mac Studio (this Mac)') + expect(text).toContain('MacBook') + expect(text).toContain('Combined') + expect(text).toContain('150') // combined cost 50 + 100 + }) + + it('renders an unreachable device as an error without dropping the combined row', () => { + const results: DeviceUsage[] = [ + { id: 'local', name: 'Mac Studio', local: true, payload: { current: { cost: 10, calls: 1, sessions: 1, inputTokens: 1, outputTokens: 1 } } }, + { id: 'remote-1', name: 'MacBook', local: false, error: 'connection refused' }, + ] + const text = renderDevices(results) + expect(text).toContain('connection refused') + expect(text).toContain('Combined') + }) + + it('summarizes reachable devices and excludes error rows from combined totals', () => { + const results: DeviceUsage[] = [ + { + id: 'local', + name: 'Mac Studio', + local: true, + payload: { + current: { cost: 10, calls: 2, sessions: 1, inputTokens: 100, outputTokens: 40 }, + history: { + daily: [ + { cacheWriteTokens: 5, cacheReadTokens: 10 }, + { cacheWriteTokens: 7, cacheReadTokens: 3 }, + ], + }, + }, + }, + { + id: 'remote-1', + name: 'MacBook', + local: false, + payload: { + current: { cost: 3, calls: 4, sessions: 2, inputTokens: 20, outputTokens: 30 }, + history: { daily: [{ cacheWriteTokens: 2, cacheReadTokens: 8 }] }, + }, + }, + { id: 'remote-err', name: 'Offline', local: false, error: 'timeout' }, + ] + + const summary = summarizeDeviceUsage(results) + + expect(summary.perDevice).toEqual([ + { + id: 'local', + name: 'Mac Studio', + local: true, + cost: 10, + calls: 2, + sessions: 1, + inputTokens: 100, + outputTokens: 40, + cacheCreateTokens: 12, + cacheReadTokens: 13, + totalTokens: 165, + }, + { + id: 'remote-1', + name: 'MacBook', + local: false, + cost: 3, + calls: 4, + sessions: 2, + inputTokens: 20, + outputTokens: 30, + cacheCreateTokens: 2, + cacheReadTokens: 8, + totalTokens: 60, + }, + { + id: 'remote-err', + name: 'Offline', + local: false, + error: 'timeout', + cost: 0, + calls: 0, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + }, + ]) + expect(summary.combined).toEqual({ + cost: 13, + calls: 6, + sessions: 3, + inputTokens: 120, + outputTokens: 70, + cacheCreateTokens: 14, + cacheReadTokens: 21, + totalTokens: 225, + deviceCount: 3, + reachableCount: 2, + }) + }) + + it('scopes cache-token summaries to the optional window without changing no-window totals', () => { + const results: DeviceUsage[] = [ + { + id: 'local', + name: 'Mac Studio', + local: true, + payload: { + current: { cost: 1, calls: 1, sessions: 1, inputTokens: 100, outputTokens: 50 }, + history: { + daily: [ + { date: '2026-04-09', cacheWriteTokens: 100, cacheReadTokens: 1000 }, + { date: '2026-04-10', cacheWriteTokens: 5, cacheReadTokens: 10 }, + { date: '2026-04-11', cacheWriteTokens: 7, cacheReadTokens: 3 }, + ], + }, + }, + }, + { + id: 'remote-1', + name: 'MacBook', + local: false, + payload: { + current: { cost: 2, calls: 2, sessions: 1, inputTokens: 20, outputTokens: 30 }, + history: { + daily: [ + { date: '2026-04-08', cacheWriteTokens: 11, cacheReadTokens: 13 }, + { date: '2026-04-10', cacheWriteTokens: 2, cacheReadTokens: 8 }, + ], + }, + }, + }, + ] + + const all = summarizeDeviceUsage(results) + expect(all.perDevice[0]).toMatchObject({ + cacheCreateTokens: 112, + cacheReadTokens: 1013, + totalTokens: 1275, + }) + expect(all.perDevice[1]).toMatchObject({ + cacheCreateTokens: 13, + cacheReadTokens: 21, + totalTokens: 84, + }) + expect(all.combined).toMatchObject({ + inputTokens: 120, + outputTokens: 80, + cacheCreateTokens: 125, + cacheReadTokens: 1034, + totalTokens: 1359, + }) + + const scoped = summarizeDeviceUsage(results, { start: '2026-04-10', end: '2026-04-10' }) + expect(scoped.perDevice[0]).toMatchObject({ + cacheCreateTokens: 5, + cacheReadTokens: 10, + totalTokens: 165, + }) + expect(scoped.perDevice[1]).toMatchObject({ + cacheCreateTokens: 2, + cacheReadTokens: 8, + totalTokens: 60, + }) + expect(scoped.combined).toMatchObject({ + cost: 3, + calls: 3, + sessions: 2, + inputTokens: 120, + outputTokens: 80, + cacheCreateTokens: 7, + cacheReadTokens: 18, + totalTokens: 225, + deviceCount: 2, + reachableCount: 2, + }) + }) + + it('prefers period-scoped current cache tokens over the 365-day daily history (#583)', () => { + const results: DeviceUsage[] = [ + { + id: 'local', + name: 'Mac', + local: true, + payload: { + current: { cost: 1, calls: 1, sessions: 1, inputTokens: 100, outputTokens: 50, cacheReadTokens: 42, cacheWriteTokens: 7 }, + history: { + daily: [ + // A full 365-day backfill whose cache dwarfs the selected period. + { date: '2025-08-01', cacheWriteTokens: 900, cacheReadTokens: 5000 }, + { date: '2026-04-10', cacheWriteTokens: 3, cacheReadTokens: 8 }, + ], + }, + }, + }, + ] + + const summary = summarizeDeviceUsage(results) + // Reads current (7 / 42), not the inflated daily-history sum (903 / 5008). + expect(summary.perDevice[0]).toMatchObject({ cacheCreateTokens: 7, cacheReadTokens: 42 }) + expect(summary.combined).toMatchObject({ cacheCreateTokens: 7, cacheReadTokens: 42 }) + }) +}) diff --git a/tests/sharing/pairing.test.ts b/tests/sharing/pairing.test.ts new file mode 100644 index 0000000..cd950c1 --- /dev/null +++ b/tests/sharing/pairing.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' + +import { + certFingerprint, + generatePin, + constantTimeEqual, + mintToken, + PairingWindow, + PeerStore, +} from '../../src/sharing/pairing.js' + +describe('certFingerprint', () => { + it('is a deterministic 64-char hex digest', () => { + const fp = certFingerprint('cert-bytes') + expect(fp).toMatch(/^[0-9a-f]{64}$/) + expect(certFingerprint('cert-bytes')).toBe(fp) + }) + it('differs for different certs', () => { + expect(certFingerprint('a')).not.toBe(certFingerprint('b')) + }) +}) + +describe('generatePin', () => { + it('is always 6 digits', () => { + for (let i = 0; i < 200; i++) expect(generatePin()).toMatch(/^\d{6}$/) + }) + it('varies', () => { + const pins = new Set(Array.from({ length: 50 }, () => generatePin())) + expect(pins.size).toBeGreaterThan(1) + }) +}) + +describe('constantTimeEqual', () => { + it('matches equal strings and rejects different ones', () => { + expect(constantTimeEqual('abc', 'abc')).toBe(true) + expect(constantTimeEqual('abc', 'abd')).toBe(false) + expect(constantTimeEqual('abc', 'abcd')).toBe(false) + }) +}) + +describe('mintToken', () => { + it('is url-safe and unique', () => { + const a = mintToken() + const b = mintToken() + expect(a).toMatch(/^[A-Za-z0-9_-]+$/) + expect(a).not.toBe(b) + }) +}) + +describe('PairingWindow', () => { + it('accepts the correct PIN within the window', () => { + const w = new PairingWindow(1000, 1000, '123456') + expect(w.verify('123456', 1500)).toBe(true) + }) + it('rejects a wrong PIN', () => { + const w = new PairingWindow(1000, 1000, '123456') + expect(w.verify('000000', 1200)).toBe(false) + }) + it('rejects after the window expires', () => { + const w = new PairingWindow(1000, 1000, '123456') + expect(w.isOpen(3000)).toBe(false) + expect(w.verify('123456', 3000)).toBe(false) + }) + it('is one-time: a consumed PIN cannot be reused', () => { + const w = new PairingWindow(10_000, 1000, '123456') + expect(w.verify('123456', 1100)).toBe(true) + expect(w.verify('123456', 1200)).toBe(false) + }) + it('closes after too many wrong guesses (no brute force within the window)', () => { + const w = new PairingWindow(10_000, 1000, '123456', 5) + for (let i = 0; i < 5; i++) expect(w.verify('000000', 1000 + i)).toBe(false) + // window is now locked even though the TTL has not expired + expect(w.isOpen(1100)).toBe(false) + // and the correct PIN no longer works + expect(w.verify('123456', 1100)).toBe(false) + }) +}) + +describe('PeerStore', () => { + it('authorizes only when token AND fingerprint both match the same peer', () => { + const store = new PeerStore() + const a = store.pair('fp-aaa', 'MacBook') + const b = store.pair('fp-bbb', 'Mac Studio') + + // correct pairing + expect(store.authorize(a.token, 'fp-aaa')).toBe(true) + // right token, wrong device fingerprint -> denied (stolen-token defense) + expect(store.authorize(a.token, 'fp-bbb')).toBe(false) + // wrong token on the right device -> denied + expect(store.authorize('not-the-token', 'fp-aaa')).toBe(false) + // unknown device -> denied + expect(store.authorize(a.token, 'fp-ccc')).toBe(false) + expect(store.authorize(b.token, 'fp-bbb')).toBe(true) + }) + + it('revokes a peer on unpair', () => { + const store = new PeerStore() + const p = store.pair('fp-x', 'Laptop') + expect(store.authorize(p.token, 'fp-x')).toBe(true) + expect(store.unpair('fp-x')).toBe(true) + expect(store.authorize(p.token, 'fp-x')).toBe(false) + expect(store.list()).toHaveLength(0) + }) + + it('round-trips through serializable peer records', () => { + const store = new PeerStore() + store.pair('fp-1', 'A') + const restored = new PeerStore(store.list()) + const peer = restored.list()[0]! + expect(restored.authorize(peer.token, 'fp-1')).toBe(true) + }) +}) diff --git a/tests/sharing/sanitize.test.ts b/tests/sharing/sanitize.test.ts new file mode 100644 index 0000000..6ad7458 --- /dev/null +++ b/tests/sharing/sanitize.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' + +import { sanitizeForSharing } from '../../src/sharing/sanitize.js' +import type { MenubarPayload } from '../../src/menubar-json.js' + +function fixture(): MenubarPayload { + return { + generated: 'now', + current: { + label: 'June', + cost: 100, + calls: 5, + sessions: 2, + oneShotRate: 1, + inputTokens: 10, + outputTokens: 20, + cacheHitPercent: 90, + codexCredits: 0, + topActivities: [{ name: 'Coding', cost: 50, savingsUSD: 0, turns: 3, oneShotRate: 1 }], + topModels: [{ name: 'Opus', cost: 80, savingsUSD: 0, savingsBaselineModel: '', calls: 4 }], + providers: { claude: 100 }, + topProjects: [ + { name: 'secret-project', cost: 100, savingsUSD: 0, sessions: 2, avgCostPerSession: 50, sessionDetails: [] }, + ], + tools: [{ name: 'Bash', calls: 9 }], + topSessions: [{ project: 'secret-project', cost: 100, savingsUSD: 0, calls: 5, date: '2026-06-01' }], + }, + history: { daily: [] }, + } as unknown as MenubarPayload +} + +describe('sanitizeForSharing', () => { + it('strips project names and session detail but keeps aggregates', () => { + const clean = sanitizeForSharing(fixture()) + expect(clean.current.topProjects).toEqual([]) + expect(clean.current.topSessions).toEqual([]) + expect(clean.current.cost).toBe(100) + expect(clean.current.topModels[0]!.name).toBe('Opus') + expect(clean.current.providers).toEqual({ claude: 100 }) + }) + + it('leaks no project name anywhere in the shared payload', () => { + const clean = sanitizeForSharing(fixture()) + expect(JSON.stringify(clean)).not.toContain('secret-project') + }) +}) diff --git a/tests/sharing/transport.test.ts b/tests/sharing/transport.test.ts new file mode 100644 index 0000000..473ab50 --- /dev/null +++ b/tests/sharing/transport.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' + +import { generateIdentity, type Identity } from '../../src/sharing/identity.js' +import { PeerStore } from '../../src/sharing/pairing.js' +import { ShareServer } from '../../src/sharing/share-server.js' +import { getDateRange, parsePeriodOrThrow } from '../../src/cli-date.js' +import { hello, pair, fetchUsage } from '../../src/sharing/client.js' + +describe('device sharing transport (loopback mutual TLS)', () => { + let server: ShareServer + let serverId: Identity + let clientId: Identity + let peers: PeerStore + let port: number + + beforeAll(async () => { + serverId = await generateIdentity('MacBook') + clientId = await generateIdentity('Mac Studio') + peers = new PeerStore() + server = new ShareServer({ identity: serverId, peers, getUsage: async () => ({ current: { cost: 42 } }) }) + port = await server.listen(0, '127.0.0.1') + }) + + afterAll(async () => { + await server.close() + }) + + const ep = () => ({ identity: clientId, host: '127.0.0.1', port }) + + it('hello exposes name + fingerprint, and the client sees the right cert', async () => { + const r = await hello(ep()) + expect(r.status).toBe(200) + const body = r.json as { name: string; fingerprint: string } + expect(body.name).toBe('MacBook') + expect(body.fingerprint).toBe(serverId.fingerprint) + expect(r.serverFingerprint).toBe(serverId.fingerprint) + }) + + it('denies usage before pairing', async () => { + const r = await fetchUsage(ep(), 'no-token') + expect(r.status).toBe(401) + }) + + it('pairs with a valid PIN, then authorizes a pinned usage pull', async () => { + const pin = server.openPairing() + const pr = await pair(ep(), pin, 'Mac Studio') + expect(pr.status).toBe(200) + const token = (pr.json as { token: string }).token + expect(token).toBeTruthy() + + const ur = await fetchUsage({ ...ep(), expectedFingerprint: serverId.fingerprint }, token) + expect(ur.status).toBe(200) + expect((ur.json as { current: { cost: number } }).current.cost).toBe(42) + }) + + it('returns bad request when getUsage rejects an invalid period', async () => { + const badServer = new ShareServer({ + identity: serverId, + peers, + getUsage: async (q) => { + getDateRange(parsePeriodOrThrow(q.period ?? 'month')) + return { current: { cost: 0 } } + }, + }) + const badPort = await badServer.listen(0, '127.0.0.1') + try { + const pin = badServer.openPairing() + const pr = await pair({ ...ep(), port: badPort }, pin, 'Mac Studio') + const token = (pr.json as { token: string }).token + const ur = await fetchUsage( + { ...ep(), port: badPort, expectedFingerprint: serverId.fingerprint }, + token, + { period: 'garbage' }, + ) + expect(ur.status).toBe(400) + expect((ur.json as { error: string }).error).toMatch(/Unknown period "garbage"/) + } finally { + await badServer.close() + } + }) + + it('keeps unexpected getUsage failures as internal errors', async () => { + const badServer = new ShareServer({ + identity: serverId, + peers, + getUsage: async () => { + throw new Error('database temporarily unavailable') + }, + }) + const badPort = await badServer.listen(0, '127.0.0.1') + try { + const pin = badServer.openPairing() + const pr = await pair({ ...ep(), port: badPort }, pin, 'Mac Studio') + const token = (pr.json as { token: string }).token + const ur = await fetchUsage( + { ...ep(), port: badPort, expectedFingerprint: serverId.fingerprint }, + token, + ) + expect(ur.status).toBe(500) + expect((ur.json as { error: string }).error).toMatch(/database temporarily unavailable/) + } finally { + await badServer.close() + } + }) + + it('does not classify plain string-matched errors as usage validation errors', async () => { + const badServer = new ShareServer({ + identity: serverId, + peers, + getUsage: async () => { + throw new Error('Unknown period "garbage". Valid values: today, week, 30days, month, all.') + }, + }) + const badPort = await badServer.listen(0, '127.0.0.1') + try { + const pin = badServer.openPairing() + const pr = await pair({ ...ep(), port: badPort }, pin, 'Mac Studio') + const token = (pr.json as { token: string }).token + const ur = await fetchUsage( + { ...ep(), port: badPort, expectedFingerprint: serverId.fingerprint }, + token, + ) + expect(ur.status).toBe(500) + expect((ur.json as { error: string }).error).toMatch(/Unknown period "garbage"/) + } finally { + await badServer.close() + } + }) + + it('rejects a wrong PIN', async () => { + server.openPairing() + const pr = await pair(ep(), '000000', 'x') + expect(pr.status).toBe(401) + }) + + it('rejects a token replayed from a different device fingerprint', async () => { + const pin = server.openPairing() + const pr = await pair(ep(), pin, 'Mac Studio') + const token = (pr.json as { token: string }).token + const attacker = await generateIdentity('Evil') + const r = await fetchUsage({ identity: attacker, host: '127.0.0.1', port }, token) + expect(r.status).toBe(401) + }) + + it('aborts when the peer fingerprint does not match the pin', async () => { + await expect(hello({ ...ep(), expectedFingerprint: 'deadbeef' })).rejects.toThrow(/fingerprint mismatch/) + }) +}) diff --git a/tests/usage-aggregator.test.ts b/tests/usage-aggregator.test.ts new file mode 100644 index 0000000..3a47c7f --- /dev/null +++ b/tests/usage-aggregator.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, beforeAll } from 'vitest' +import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js' +import { getDateRange } from '../src/cli-date.js' +import { loadPricing } from '../src/models.js' + +describe('buildMenubarPayloadForRange', () => { + beforeAll(async () => { + await loadPricing() + }) + + it('returns a valid payload and skips optimize findings when optimize:false', async () => { + const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }) + expect(typeof payload.current.label).toBe('string') + expect(payload.current.cost).toBeGreaterThanOrEqual(0) + expect(Array.isArray(payload.current.topProjects)).toBe(true) + expect(Array.isArray(payload.current.topModels)).toBe(true) + expect(Array.isArray(payload.history.daily)).toBe(true) + expect(payload.current.retryTax.totalUSD).toBeGreaterThanOrEqual(0) + // Codex credits are always present in the payload (display gates them); 0 with no data. + expect(typeof payload.current.codexCredits).toBe('number') + expect(payload.current.codexCredits).toBeGreaterThanOrEqual(0) + // optimize:false => scanAndDetect skipped => empty optimize block regardless of data + expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] }) + }) +}) diff --git a/tests/web-dashboard.test.ts b/tests/web-dashboard.test.ts new file mode 100644 index 0000000..f8858ca --- /dev/null +++ b/tests/web-dashboard.test.ts @@ -0,0 +1,59 @@ +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import type { AddressInfo } from 'net' +import type { Server } from 'http' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { runWebDashboard } from '../src/web-dashboard.js' + +// Regression guard for the original bug: a bad `period` query used to hit +// process.exit(1) and kill the long-running dashboard server. The handlers must +// now answer 400 and keep serving. +describe('web dashboard server: invalid query returns 400 without exiting', () => { + let server: Server + let base: string + let homeDir: string + let cacheDir: string + const prevHome = process.env['HOME'] + const prevCache = process.env['CODEBURN_CACHE_DIR'] + + beforeAll(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'codeburn-web-home-')) + cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-web-cache-')) + process.env['HOME'] = homeDir + process.env['CODEBURN_CACHE_DIR'] = cacheDir + server = await runWebDashboard({ + period: 'today', provider: 'all', project: [], exclude: [], port: 0, open: false, + }) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterAll(async () => { + await new Promise<void>((resolve) => server.close(() => resolve())) + if (prevHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = prevHome + if (prevCache === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = prevCache + await rm(homeDir, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) + }) + + it('answers 400 for an invalid /api/usage period and keeps serving', async () => { + const bad = await fetch(`${base}/api/usage?period=garbage`) + expect(bad.status).toBe(400) + expect((await bad.json() as { error: string }).error).toMatch(/Unknown period "garbage"/) + + // The bug was process.exit; if it regressed, this test process would die. + // A successful follow-up request proves the server survived the bad one. + const ok = await fetch(`${base}/api/usage?period=today`) + expect(ok.status).toBe(200) + }) + + it('answers 400 for an invalid /api/devices period', async () => { + const bad = await fetch(`${base}/api/devices?period=garbage`) + expect(bad.status).toBe(400) + expect((await bad.json() as { error: string }).error).toMatch(/Unknown period "garbage"/) + }) +}) diff --git a/tests/yield.test.ts b/tests/yield.test.ts new file mode 100644 index 0000000..8a6f31d --- /dev/null +++ b/tests/yield.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import { buildYieldJsonReport, type YieldSummary } from '../src/yield.js' + +describe('buildYieldJsonReport', () => { + it('serializes yield buckets, ratios, and session details', () => { + const summary: YieldSummary = { + productive: { cost: 8, sessions: 2 }, + reverted: { cost: 2, sessions: 1 }, + abandoned: { cost: 10, sessions: 1 }, + total: { cost: 20, sessions: 4 }, + details: [ + { sessionId: 's1', project: 'app', cost: 8, category: 'productive', commitCount: 2 }, + { sessionId: 's2', project: 'app', cost: 2, category: 'reverted', commitCount: 1 }, + ], + } + const report = buildYieldJsonReport(summary, '30 Days', { + start: new Date('2026-05-01T00:00:00.000Z'), + end: new Date('2026-05-31T23:59:59.999Z'), + }) + + expect(report.period).toEqual({ + label: '30 Days', + start: '2026-05-01T00:00:00.000Z', + end: '2026-05-31T23:59:59.999Z', + }) + expect(report.summary.productive).toEqual({ + costUSD: 8, + sessions: 2, + costPercent: 40, + sessionPercent: 50, + }) + expect(report.summary.reverted.costPercent).toBe(10) + expect(report.summary.abandoned.sessionPercent).toBe(25) + expect(report.summary.total).toEqual({ costUSD: 20, sessions: 4 }) + expect(report.summary.productiveToRevertedCostRatio).toBe(4) + expect(report.details).toHaveLength(2) + expect(report.details[0]).toMatchObject({ sessionId: 's1', costUSD: 8, category: 'productive' }) + expect(report.details[0]).not.toHaveProperty('cost') + }) + + it('uses null ratio when no spend was reverted', () => { + const summary: YieldSummary = { + productive: { cost: 1, sessions: 1 }, + reverted: { cost: 0, sessions: 0 }, + abandoned: { cost: 0, sessions: 0 }, + total: { cost: 1, sessions: 1 }, + details: [], + } + + const report = buildYieldJsonReport(summary, 'Today', { + start: new Date('2026-06-14T00:00:00.000Z'), + end: new Date('2026-06-14T23:59:59.999Z'), + }) + + expect(report.summary.productiveToRevertedCostRatio).toBeNull() + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e153610 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..c20e55c --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/main.ts'], + format: ['esm'], + target: 'node20', + outDir: 'dist', + clean: true, + splitting: false, + sourcemap: true, + dts: false, + external: ['@modelcontextprotocol/sdk', 'zod'], +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b56c015 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + // Runs once per worker before any test. Scrubs the developer's shell so + // session-discovery env vars (CLAUDE_CONFIG_DIRS, HOME, XDG_*, every + // provider-specific *_HOME) don't bleed real local data into fixtures. + setupFiles: ['./tests/setup/env-isolation.ts'], + }, +})