mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-14 11:04:44 +00:00
Merge main into feat/kimi-code-provider to resolve conflicts
This commit is contained in:
commit
5696a74571
81 changed files with 7177 additions and 1502 deletions
18
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
18
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
## Summary
|
||||
|
||||
<!-- What does this PR do? 1-3 bullet points. -->
|
||||
|
||||
## 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 <name>` shows correct model names and pricing
|
||||
- [ ] Screenshot or terminal output attached below proving it works with real data
|
||||
|
||||
<!-- Paste screenshot / terminal output here -->
|
||||
24
.github/workflows/release-menubar.yml
vendored
24
.github/workflows/release-menubar.yml
vendored
|
|
@ -2,8 +2,8 @@ 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. `npx codeburn menubar` clears
|
||||
# the download quarantine flag on install so Gatekeeper stays quiet.
|
||||
# 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:
|
||||
|
|
@ -45,7 +45,9 @@ jobs:
|
|||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: CodeBurnMenubar-${{ steps.version.outputs.value }}
|
||||
path: mac/.build/dist/CodeBurnMenubar-*.zip
|
||||
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
|
||||
|
|
@ -58,14 +60,16 @@ jobs:
|
|||
Install with:
|
||||
|
||||
```
|
||||
npx codeburn menubar
|
||||
npm install -g codeburn
|
||||
codeburn menubar
|
||||
```
|
||||
|
||||
That command drops the app into `~/Applications`, clears the download
|
||||
quarantine, 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.
|
||||
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-*.zip
|
||||
mac/.build/dist/CodeBurnMenubar-*.zip.sha256
|
||||
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip
|
||||
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256
|
||||
fail_on_unmatched_files: true
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -40,3 +40,6 @@ assets/discord-*.png
|
|||
|
||||
# Desktop app experiments
|
||||
desktop/
|
||||
|
||||
# WIP / not ready
|
||||
src/summit.ts
|
||||
|
|
|
|||
64
CHANGELOG.md
64
CHANGELOG.md
|
|
@ -11,9 +11,73 @@
|
|||
`Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code
|
||||
model aliases to priced Kimi K2 entries.
|
||||
|
||||
## 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)
|
||||
- **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
|
||||
`<server>_<tool>` names, which the shared MCP pipeline did not recognize.
|
||||
The provider now normalizes these to the canonical `mcp__<server>__<tool>`
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -84,6 +84,23 @@ The `.github/workflows/block-claude-coauthor.yml` workflow rejects any PR whose
|
|||
|
||||
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`.
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -13,7 +13,7 @@
|
|||
<a href="https://github.com/sponsors/iamtoruk"><img src="https://img.shields.io/badge/sponsor-♥-ea4aaa?logo=github" alt="Sponsor" /></a>
|
||||
</p>
|
||||
|
||||
CodeBurn tracks token usage, cost, and performance across **18 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
|
||||
CodeBurn tracks token usage, cost, and performance across **19 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
|
||||
|
||||
Everything runs locally. No wrapper, no proxy, no API keys. CodeBurn reads session data directly from disk and prices every call using [LiteLLM](https://github.com/BerriAI/litellm).
|
||||
|
||||
|
|
@ -99,11 +99,13 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
|
|||
|---|----------|-----------|-----|
|
||||
| <img src="assets/providers/claude.jpg" width="28" /> | Claude Code | Yes | [claude.md](docs/providers/claude.md) |
|
||||
| <img src="assets/providers/claude.jpg" width="28" /> | Claude Desktop | Yes | [claude.md](docs/providers/claude.md) |
|
||||
| <img src="assets/providers/cline.svg" width="28" /> | Cline | Yes | [cline.md](docs/providers/cline.md) |
|
||||
| <img src="assets/providers/codex.png" width="28" /> | Codex (OpenAI) | Yes | [codex.md](docs/providers/codex.md) |
|
||||
| <img src="assets/providers/cursor.jpg" width="28" /> | Cursor | Yes | [cursor.md](docs/providers/cursor.md) |
|
||||
| <img src="assets/providers/cursor-agent.jpg" width="28" /> | cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) |
|
||||
| <img src="assets/providers/gemini.png" width="28" /> | Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) |
|
||||
| <img src="assets/providers/copilot.jpg" width="28" /> | GitHub Copilot | Yes | [copilot.md](docs/providers/copilot.md) |
|
||||
| <img src="assets/providers/ibm-bob.svg" width="28" /> | IBM Bob | Yes | [ibm-bob.md](docs/providers/ibm-bob.md) |
|
||||
| <img src="assets/providers/kiro.png" width="28" /> | Kiro | Yes | [kiro.md](docs/providers/kiro.md) |
|
||||
| <img src="assets/providers/opencode.png" width="28" /> | OpenCode | Yes | [opencode.md](docs/providers/opencode.md) |
|
||||
| <img src="assets/providers/openclaw.jpg" width="28" /> | OpenClaw | Yes | [openclaw.md](docs/providers/openclaw.md) |
|
||||
|
|
@ -120,7 +122,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
|
|||
|
||||
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).
|
||||
|
||||
Provider logos are trademarks of their respective owners. The icon set was sourced from [tokscale](https://github.com/junhoyeo/tokscale) (MIT) plus official vendor assets, used under nominative fair use for the purpose of identifying supported tools.
|
||||
Provider logos are trademarks of their respective owners. The icon set was sourced from [tokscale](https://github.com/junhoyeo/tokscale) (MIT), official vendor assets, and simple provider identifiers, used under nominative fair use for the purpose of identifying supported tools.
|
||||
|
||||
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
|
||||
|
||||
|
|
@ -379,7 +381,9 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
|
|||
|
||||
**OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields.
|
||||
|
||||
**Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts.
|
||||
**Cline / Roo Code / KiloCode** are Cline-family coding agents. CodeBurn reads `ui_messages.json` from each task directory, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. Cline scans both VS Code's `globalStorage/saoudrizwan.claude-dev` and `~/.cline/data`.
|
||||
|
||||
**IBM Bob** stores IDE task history in `User/globalStorage/ibm.bob-code/tasks/<task-id>/` under the IBM Bob application data directory. CodeBurn reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model, with support for both GA (`IBM Bob`) and preview (`Bob-IDE`) app data folders.
|
||||
|
||||
**Kimi Code CLI** stores session logs under `$KIMI_SHARE_DIR/sessions/<workdir-hash>/<session-id>/` or `~/.kimi/sessions/<workdir-hash>/<session-id>/`. CodeBurn reads `wire.jsonl` `StatusUpdate.token_usage` records, maps `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns, and includes subagent sessions under each session's `subagents/` folder.
|
||||
|
||||
|
|
|
|||
24
RELEASING.md
24
RELEASING.md
|
|
@ -120,25 +120,25 @@ git push origin mac-v0.9.8
|
|||
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 0.9.8`
|
||||
2. Runs `mac/Scripts/package-app.sh v0.9.8`
|
||||
3. Signs the app bundle (ad-hoc signing)
|
||||
4. Creates a zip file: `CodeBurnMenubar-0.9.8.zip`
|
||||
5. Computes a SHA-256 checksum: `CodeBurnMenubar-0.9.8.zip.sha256`
|
||||
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-0.9.8.zip
|
||||
✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-0.9.8.zip.sha256
|
||||
<sha256-hash> CodeBurnMenubar-0.9.8.zip
|
||||
✓ Built /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip
|
||||
✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256
|
||||
<sha256-hash> 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 menubar installer command in the CLI calls `npx codeburn menubar`, which fetches the latest release from GitHub and installs it into `~/Applications`.
|
||||
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 Tap Update
|
||||
|
||||
|
|
@ -227,12 +227,12 @@ If a release is published with broken assets (e.g., a menubar zip with a build e
|
|||
Use `gh release upload` with the `--clobber` flag to overwrite existing files:
|
||||
|
||||
```bash
|
||||
# After re-running mac/Scripts/package-app.sh 0.9.8 to regenerate the zip and sha256
|
||||
gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-0.9.8.zip --clobber
|
||||
gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-0.9.8.zip.sha256 --clobber
|
||||
# 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 fetches from the Release by tag, so users who run `npx codeburn menubar` after the replacement get the fixed version automatically.
|
||||
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
|
||||
|
||||
|
|
@ -245,7 +245,7 @@ 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 `npx codeburn menubar --force`).
|
||||
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
|
||||
|
||||
|
|
|
|||
4
assets/providers/cline.svg
Normal file
4
assets/providers/cline.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Cline">
|
||||
<rect width="64" height="64" rx="14" fill="#0f2f2c"/>
|
||||
<path d="M45.5 42.2c-3.4 3.2-7.6 4.8-12.7 4.8-4.7 0-8.6-1.5-11.6-4.4-3-3-4.5-6.7-4.5-11.2s1.5-8.2 4.5-11.1c3-2.9 6.9-4.4 11.6-4.4 5.1 0 9.3 1.6 12.7 4.8l-5.2 5.8c-2-1.9-4.3-2.8-7-2.8-2.4 0-4.4.7-5.9 2.2-1.5 1.4-2.2 3.3-2.2 5.5 0 2.3.7 4.2 2.2 5.6 1.5 1.4 3.5 2.2 5.9 2.2 2.8 0 5.1-.9 7-2.8l5.2 5.8z" fill="#5eead4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
6
assets/providers/ibm-bob.svg
Normal file
6
assets/providers/ibm-bob.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="IBM Bob">
|
||||
<rect width="64" height="64" rx="12" fill="#0F62FE"/>
|
||||
<path d="M14 19h36v5H14zm0 10h36v5H14zm0 10h36v5H14z" fill="#fff" opacity=".9"/>
|
||||
<circle cx="24" cy="32" r="4" fill="#0F62FE"/>
|
||||
<circle cx="40" cy="32" r="4" fill="#0F62FE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
|
|
@ -128,14 +128,14 @@ type Provider = {
|
|||
}
|
||||
```
|
||||
|
||||
`src/providers/index.ts` registers nineteen providers across two tiers:
|
||||
`src/providers/index.ts` registers twenty-one providers across two tiers:
|
||||
|
||||
- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `kimi`, `roo-code`. Imported at module load.
|
||||
- **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 `kilo-code` and `roo-code`. It is not registered as a provider on its own.
|
||||
`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/`.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ For the architectural picture, see `../architecture.md`.
|
|||
| 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 | `src/providers/copilot.ts` | `tests/providers/copilot.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 |
|
||||
| [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` |
|
||||
|
|
@ -39,7 +41,7 @@ For the architectural picture, see `../architecture.md`.
|
|||
|
||||
| Helper | Used by | Source |
|
||||
|---|---|---|
|
||||
| [vscode-cline-parser](vscode-cline-parser.md) | `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` |
|
||||
| [vscode-cline-parser](vscode-cline-parser.md) | `cline`, `ibm-bob`, `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` |
|
||||
|
||||
## File Format
|
||||
|
||||
|
|
|
|||
|
|
@ -3,41 +3,50 @@
|
|||
Google Antigravity. The only provider that does not read files off disk: it speaks to a local language-server RPC endpoint instead.
|
||||
|
||||
- **Source:** `src/providers/antigravity.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:14-27`). Lazy because the protobuf dependency is heavy.
|
||||
- **Test:** none. Mocking the RPC endpoint cleanly is the open issue.
|
||||
- **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
|
||||
|
||||
A local HTTPS RPC endpoint exposed by Antigravity's language server. The parser:
|
||||
|
||||
1. Locates the running language-server process via `ps`.
|
||||
1. Locates the running language-server process via `ps` on POSIX or
|
||||
`Get-CimInstance Win32_Process` on Windows.
|
||||
2. Reads its port and CSRF token from process metadata.
|
||||
3. Calls `GetCascadeTrajectoryGeneratorMetadata` over HTTPS.
|
||||
4. Validates the response (capped at 5-15 MB depending on cascade size).
|
||||
4. Validates the response (capped at 16 MB).
|
||||
|
||||
If the language server is not running, the parser falls back to the cached results file (`antigravity.ts:262-272`).
|
||||
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.
|
||||
|
||||
If the language server is not running, the parser falls back to the cached results file.
|
||||
|
||||
## Storage format
|
||||
|
||||
Protobuf. Cascade and response objects map to `ParsedProviderCall` directly; see `antigravity.ts:299-323`.
|
||||
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 version constant is at `antigravity.ts:12`; the cache machinery (`loadCache`, `flushCache`) lives in `antigravity.ts:75-125`. 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.
|
||||
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 `<cascadeId>:<responseId>` (`antigravity.ts:308`).
|
||||
Per `<cascadeId>:<responseId>`.
|
||||
|
||||
## 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).
|
||||
- The 5-15 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` (`antigravity.ts:313-323`). Thinking is billed at output rate.
|
||||
- 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 requires Antigravity running locally. There is no fixture for the RPC, which is a real testing gap.
|
||||
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 at `antigravity.ts:299-323` is the place to look.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,17 @@ JSONL, one event per line, per session file. Sessions live under `<project>/<ses
|
|||
|
||||
`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.
|
||||
|
|
|
|||
50
docs/providers/cline.md
Normal file
50
docs/providers/cline.md
Normal file
|
|
@ -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/<taskId>/
|
||||
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: `<providerName>:<taskId>:<index>`.
|
||||
|
||||
## 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.
|
||||
55
docs/providers/ibm-bob.md
Normal file
55
docs/providers/ibm-bob.md
Normal file
|
|
@ -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/<task-id>/` 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 `<model>...</model>` 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 `<providerName>:<taskId>:<apiRequestIndex>` 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.
|
||||
|
|
@ -25,10 +25,10 @@ Delegated. Per `<providerName>:<taskId>:<index>` (handled in `vscode-cline-parse
|
|||
## Quirks
|
||||
|
||||
- This file is a thin wrapper. Almost every bug for KiloCode actually lives in `vscode-cline-parser.ts`.
|
||||
- The two providers using the cline parser (KiloCode and Roo Code) differ **only** by extension ID.
|
||||
- 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 "KiloCode and Roo Code both broken in the same way", fix it in `vscode-cline-parser.ts`.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ OpenCode (sst/opencode).
|
|||
|
||||
- **Source:** `src/providers/opencode.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:59-75`)
|
||||
- **Test:** `tests/providers/opencode.test.ts` (558 lines, the largest provider test)
|
||||
- **Test:** `tests/providers/opencode.test.ts` (676 lines, the largest provider test)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
|
|
@ -20,14 +20,18 @@ None.
|
|||
|
||||
## Deduplication
|
||||
|
||||
Per `<sessionId>:<messageId>` (`opencode.ts:242`).
|
||||
Per `<sessionId>:<messageId>`.
|
||||
|
||||
## 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 (`opencode.ts:104-131`). This is the right behavior; do not silently swallow these.
|
||||
- Source paths are encoded as `<dbPath>:<sessionId>` (`opencode.ts:147-150`).
|
||||
- Each message's `parts` are indexed (`opencode.ts:177-191`); preserving the order matters for reasoning-token correctness.
|
||||
- **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 `<dbPath>:<sessionId>`.
|
||||
- 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.
|
||||
- External MCP tools are stored as `<server>_<tool>` names (for example
|
||||
`clickup_clickup_get_task`). The provider normalizes those to CodeBurn's
|
||||
canonical `mcp__<server>__<tool>` names before aggregation so shared MCP
|
||||
panels and `optimize` findings count OpenCode usage.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ Delegated. Per `<providerName>:<taskId>:<index>` (in `vscode-cline-parser.ts:109
|
|||
## Quirks
|
||||
|
||||
- Thin wrapper. Almost every Roo Code bug actually lives in `vscode-cline-parser.ts`.
|
||||
- The two providers using the cline parser (KiloCode and Roo Code) differ **only** by extension ID.
|
||||
- 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 KiloCode, fix it in `vscode-cline-parser.ts`.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,49 +1,50 @@
|
|||
# vscode-cline-parser (Shared Helper)
|
||||
|
||||
Shared discovery and parsing for VS Code extensions descended from Cline.
|
||||
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 `kilo-code.ts` and `roo-code.ts`.
|
||||
- **Test:** none directly. Coverage comes from `tests/providers/kilo-code.test.ts` and `tests/providers/roo-code.test.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 VS Code's `globalStorage/<extensionId>/tasks/` directories and returns one source per task that has a `ui_messages.json` file (`vscode-cline-parser.ts:25-50`).
|
||||
2. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model, tools, and token counts, and yields `ParsedProviderCall` objects.
|
||||
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/<extensionId>/` 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:
|
||||
|
||||
```
|
||||
<globalStorage>/<extensionId>/tasks/<taskId>/
|
||||
<baseDir>/tasks/<taskId>/
|
||||
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 `<model>...</model>` tag (`vscode-cline-parser.ts:54-72`). Falls back to `cline-auto` if no tag is found.
|
||||
The model is extracted from `api_conversation_history.json` by searching user message content blocks for a `<model>...</model>` 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` (`vscode-cline-parser.ts:119-134`).
|
||||
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 (`vscode-cline-parser.ts:139`).
|
||||
If `cost` is present, it is used directly. If not, `calculateCost` from `src/models.ts` computes it from tokens.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<providerName>:<taskId>:<index>` where `index` is the position of the `api_req_started` entry within `ui_messages.json` (`vscode-cline-parser.ts:109`).
|
||||
Per `<providerName>:<taskId>:<index>` 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` (`vscode-cline-parser.ts:157`). Subsequent user turns are accounted but not surfaced.
|
||||
- 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 **both** KiloCode and Roo Code. Run both test files (`tests/providers/kilo-code.test.ts` and `tests/providers/roo-code.test.ts`) before opening a PR.
|
||||
2. If you find that one of the two 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 a third Cline-derivative extension, register it as a thin wrapper file in the same shape as `kilo-code.ts` and `roo-code.ts`.
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -6,19 +6,17 @@ Native Swift + SwiftUI menubar app. The codeburn menubar surface.
|
|||
|
||||
- macOS 14+ (Sonoma)
|
||||
- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone)
|
||||
- `codeburn` CLI installed globally (`npm install -g codeburn`) or available at a path you pass via `CODEBURN_BIN`
|
||||
- `codeburn` CLI installed globally (`npm install -g codeburn`)
|
||||
|
||||
## Install (end users)
|
||||
|
||||
One command:
|
||||
|
||||
```bash
|
||||
npx codeburn menubar
|
||||
codeburn menubar
|
||||
```
|
||||
|
||||
That's it. The command downloads the latest `.app` from GitHub Releases, 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.
|
||||
|
||||
If you already have the CLI installed globally (`npm install -g codeburn`), `codeburn menubar` works the same way.
|
||||
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
|
||||
|
||||
|
|
@ -39,7 +37,7 @@ cd mac
|
|||
swift build
|
||||
# Point the app at your dev CLI build instead of the globally installed `codeburn`:
|
||||
npm --prefix .. run build
|
||||
CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run
|
||||
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.
|
||||
|
|
@ -48,7 +46,7 @@ The app registers itself as a menubar accessory (`LSUIElement = true` at runtime
|
|||
|
||||
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.
|
||||
|
||||
Override the binary via the `CODEBURN_BIN` environment variable (default: `codeburn` on PATH). The value is validated against a strict allowlist (alphanumerics plus `._/-` space) before use, so a malicious env var can't inject shell commands.
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
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"
|
||||
|
|
@ -66,9 +68,9 @@ cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${VERSION}</string>
|
||||
<string>${BUNDLE_VERSION}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${VERSION}</string>
|
||||
<string>${BUNDLE_VERSION}</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${MIN_MACOS}</string>
|
||||
<key>LSUIElement</key>
|
||||
|
|
@ -85,18 +87,17 @@ cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG'
|
|||
APPL????
|
||||
PKG
|
||||
|
||||
# Ad-hoc sign so macOS treats the bundle as internally consistent. This satisfies the
|
||||
# minimum bundle-validity checks on macOS 14+ and prevents a class of Gatekeeper edge
|
||||
# cases on managed Macs. A Developer ID signature (separate setup) would additionally
|
||||
# surface the publisher name in Finder; not required here.
|
||||
# Ad-hoc sign so macOS treats the bundle as internally consistent. Release
|
||||
# notarization can layer a Developer ID signature on top, but this local step
|
||||
# must still fail closed if signing or verification breaks.
|
||||
echo "▸ Ad-hoc signing..."
|
||||
codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true
|
||||
codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)"
|
||||
codesign --force --sign - --timestamp=none --deep "${BUNDLE}"
|
||||
codesign --verify --deep --strict "${BUNDLE}"
|
||||
|
||||
ZIP_NAME="CodeBurnMenubar-${VERSION}.zip"
|
||||
ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip"
|
||||
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
|
||||
echo "▸ Packaging ${ZIP_NAME}..."
|
||||
(cd "${DIST_DIR}" && /usr/bin/ditto -c -k --keepParent "${BUNDLE_NAME}" "${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}"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Foundation
|
|||
import Observation
|
||||
|
||||
private let cacheTTLSeconds: TimeInterval = 30
|
||||
private let interactiveRefreshResetSeconds: TimeInterval = 120
|
||||
|
||||
struct CachedPayload {
|
||||
let payload: MenubarPayload
|
||||
|
|
@ -51,6 +52,7 @@ final class AppStore {
|
|||
private var cache: [PayloadCacheKey: CachedPayload] = [:]
|
||||
private var cacheDate: String = ""
|
||||
private var switchTask: Task<Void, Never>?
|
||||
private var payloadRefreshGeneration: UInt64 = 0
|
||||
/// 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
|
||||
|
|
@ -63,6 +65,10 @@ final class AppStore {
|
|||
return Date().timeIntervalSince(last)
|
||||
}
|
||||
|
||||
private var todayAllKey: PayloadCacheKey {
|
||||
PayloadCacheKey(period: .today, provider: .all)
|
||||
}
|
||||
|
||||
private var currentKey: PayloadCacheKey {
|
||||
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
|
||||
}
|
||||
|
|
@ -74,7 +80,16 @@ final class AppStore {
|
|||
/// Today (across all providers) is pinned for the always-visible menubar icon, independent of
|
||||
/// the popover's selected period or provider.
|
||||
var todayPayload: MenubarPayload? {
|
||||
cache[PayloadCacheKey(period: .today, provider: .all)]?.payload
|
||||
cache[todayAllKey]?.payload
|
||||
}
|
||||
|
||||
var todayPayloadAgeSeconds: Int? {
|
||||
guard let cached = cache[todayAllKey] else { return nil }
|
||||
return Int(Date().timeIntervalSince(cached.fetchedAt))
|
||||
}
|
||||
|
||||
var needsStatusPayloadRefresh: Bool {
|
||||
cache[todayAllKey]?.isFresh != true
|
||||
}
|
||||
|
||||
/// All-provider payload for the selected period. Used by the tab strip to show
|
||||
|
|
@ -87,6 +102,47 @@ final class AppStore {
|
|||
cache[currentKey] != nil
|
||||
}
|
||||
|
||||
var hasStaleLoading: Bool {
|
||||
let now = Date()
|
||||
return loadingStartedAtByKey.values.contains {
|
||||
now.timeIntervalSince($0) > loadingWatchdogSeconds
|
||||
}
|
||||
}
|
||||
|
||||
var hasStaleInteractivePayload: Bool {
|
||||
staleInteractivePayloadAgeSeconds != nil
|
||||
}
|
||||
|
||||
var hasMissingInteractivePayloadWithoutAttempt: Bool {
|
||||
cache[currentKey] == nil && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad
|
||||
}
|
||||
|
||||
var shouldResetInteractiveRefreshPipeline: Bool {
|
||||
hasStaleLoading || hasStaleInteractivePayload || hasMissingInteractivePayloadWithoutAttempt
|
||||
}
|
||||
|
||||
var staleInteractivePayloadAgeSeconds: Int? {
|
||||
let keys = Set([
|
||||
currentKey,
|
||||
todayAllKey,
|
||||
PayloadCacheKey(period: selectedPeriod, provider: .all),
|
||||
])
|
||||
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 {
|
||||
let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
|
||||
return cache[currentKey]?.isFresh != true ||
|
||||
cache[todayAllKey]?.isFresh != true ||
|
||||
cache[periodAllKey]?.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
|
||||
|
|
@ -95,6 +151,12 @@ final class AppStore {
|
|||
cache.values.contains { !$0.payload.current.providers.isEmpty }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) {
|
||||
cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
|
||||
}
|
||||
#endif
|
||||
|
||||
var findingsCount: Int {
|
||||
payload.optimize.findingCount
|
||||
}
|
||||
|
|
@ -103,16 +165,7 @@ final class AppStore {
|
|||
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
|
||||
func switchTo(period: Period) {
|
||||
selectedPeriod = period
|
||||
switchTask?.cancel()
|
||||
switchTask = Task {
|
||||
if selectedProvider == .all {
|
||||
await refresh(includeOptimize: false, force: true)
|
||||
} else {
|
||||
async let main: Void = refresh(includeOptimize: false, force: true)
|
||||
async let all: Void = refreshQuietly(period: period)
|
||||
_ = await (main, all)
|
||||
}
|
||||
}
|
||||
startInteractiveSelectionRefresh()
|
||||
}
|
||||
|
||||
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
|
||||
|
|
@ -120,13 +173,21 @@ final class AppStore {
|
|||
/// in parallel so the tab strip costs stay in sync with the hero.
|
||||
func switchTo(provider: ProviderFilter) {
|
||||
selectedProvider = provider
|
||||
startInteractiveSelectionRefresh()
|
||||
}
|
||||
|
||||
private func startInteractiveSelectionRefresh() {
|
||||
switchTask?.cancel()
|
||||
resetLoadingState()
|
||||
let period = selectedPeriod
|
||||
let provider = selectedProvider
|
||||
lastErrorByKey[PayloadCacheKey(period: period, provider: provider)] = nil
|
||||
switchTask = Task {
|
||||
if provider == .all {
|
||||
await refresh(includeOptimize: false, force: true)
|
||||
await refresh(includeOptimize: false, force: true, showLoading: true)
|
||||
} else {
|
||||
async let main: Void = refresh(includeOptimize: false, force: true)
|
||||
async let all: Void = refreshQuietly(period: selectedPeriod)
|
||||
async let main: Void = refresh(includeOptimize: false, force: true, showLoading: true)
|
||||
async let all: Void = refreshQuietly(period: period)
|
||||
_ = await (main, all)
|
||||
}
|
||||
}
|
||||
|
|
@ -135,11 +196,23 @@ final class AppStore {
|
|||
private var inFlightKeys: Set<PayloadCacheKey> = []
|
||||
|
||||
func resetLoadingState() {
|
||||
payloadRefreshGeneration &+= 1
|
||||
loadingCountsByKey.removeAll()
|
||||
loadingStartedAtByKey.removeAll()
|
||||
inFlightKeys.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
|
||||
|
|
@ -150,6 +223,7 @@ final class AppStore {
|
|||
}
|
||||
guard !staleEntries.isEmpty else { return false }
|
||||
|
||||
payloadRefreshGeneration &+= 1
|
||||
for (key, started) in staleEntries {
|
||||
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
|
||||
Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue)
|
||||
|
|
@ -180,13 +254,24 @@ final class AppStore {
|
|||
}
|
||||
}
|
||||
|
||||
private func invalidateStaleDayCache() {
|
||||
private func currentCacheDate() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let today = formatter.string(from: Date())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,8 +283,9 @@ final class AppStore {
|
|||
invalidateStaleDayCache()
|
||||
let key = currentKey
|
||||
let cacheDateAtStart = cacheDate
|
||||
let generationAtStart = payloadRefreshGeneration
|
||||
if !force, cache[key]?.isFresh == true { return }
|
||||
if !force, inFlightKeys.contains(key) { return }
|
||||
if inFlightKeys.contains(key) { return }
|
||||
inFlightKeys.insert(key)
|
||||
attemptedKeys.insert(key)
|
||||
lastErrorByKey[key] = nil
|
||||
|
|
@ -226,6 +312,10 @@ final class AppStore {
|
|||
}
|
||||
do {
|
||||
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
|
||||
if generationAtStart != payloadRefreshGeneration {
|
||||
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(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
|
||||
|
|
@ -238,7 +328,8 @@ final class AppStore {
|
|||
// 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 {
|
||||
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
||||
invalidateStaleDayCache()
|
||||
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — calendar rolled mid-fetch")
|
||||
return
|
||||
}
|
||||
|
|
@ -252,7 +343,11 @@ final class AppStore {
|
|||
do {
|
||||
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false)
|
||||
guard !Task.isCancelled else { return }
|
||||
if cacheDate != cacheDateAtStart { return }
|
||||
if generationAtStart != payloadRefreshGeneration { return }
|
||||
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
||||
invalidateStaleDayCache()
|
||||
return
|
||||
}
|
||||
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
|
||||
lastSuccessByKey[key] = Date()
|
||||
lastErrorByKey[key] = nil
|
||||
|
|
@ -274,15 +369,33 @@ final class AppStore {
|
|||
/// 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) async {
|
||||
func refreshQuietly(period: Period, force: Bool = false) async {
|
||||
invalidateStaleDayCache()
|
||||
let key = PayloadCacheKey(period: period, provider: .all)
|
||||
if !force, cache[key]?.isFresh == true { return }
|
||||
if inFlightKeys.contains(key) { return }
|
||||
inFlightKeys.insert(key)
|
||||
attemptedKeys.insert(key)
|
||||
let cacheDateAtStart = cacheDate
|
||||
let generationAtStart = payloadRefreshGeneration
|
||||
if period == .today, let age = todayPayloadAgeSeconds, age > 120 {
|
||||
NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
|
||||
}
|
||||
defer {
|
||||
inFlightKeys.remove(key)
|
||||
}
|
||||
do {
|
||||
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false)
|
||||
if generationAtStart != payloadRefreshGeneration {
|
||||
NSLog("CodeBurn: dropping quiet fetch result for \(period.rawValue) — 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 { return }
|
||||
let key = PayloadCacheKey(period: period, provider: .all)
|
||||
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
||||
invalidateStaleDayCache()
|
||||
return
|
||||
}
|
||||
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||
lastSuccessByKey[key] = Date()
|
||||
lastErrorByKey[key] = nil
|
||||
|
|
@ -505,7 +618,7 @@ final class AppStore {
|
|||
|
||||
var aggregateQuotaStatus: AggregateQuotaStatus {
|
||||
var providers: [(name: String, percent: Double)] = []
|
||||
if case .loaded = subscriptionLoadState, let usage = subscription {
|
||||
if let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) {
|
||||
let worst = [
|
||||
usage.fiveHourPercent,
|
||||
usage.sevenDayPercent,
|
||||
|
|
@ -514,7 +627,7 @@ final class AppStore {
|
|||
].compactMap { $0 }.max() ?? 0
|
||||
if worst > 0 { providers.append(("Claude", worst)) }
|
||||
}
|
||||
if case .loaded = codexLoadState, let usage = codexUsage {
|
||||
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)) }
|
||||
}
|
||||
|
|
@ -525,6 +638,15 @@ final class AppStore {
|
|||
return AggregateQuotaStatus(severity: severity, warnings: warnings)
|
||||
}
|
||||
|
||||
private func shouldIncludeCachedQuota(loadState: SubscriptionLoadState) -> Bool {
|
||||
switch loadState {
|
||||
case .notBootstrapped, .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)
|
||||
|
|
@ -720,11 +842,14 @@ enum SupportedCurrency: String, CaseIterable, Identifiable {
|
|||
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 droid = "Droid"
|
||||
case gemini = "Gemini"
|
||||
case ibmBob = "IBM Bob"
|
||||
case kiro = "Kiro"
|
||||
case kimi = "Kimi"
|
||||
case kiloCode = "KiloCode"
|
||||
|
|
@ -735,15 +860,22 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case omp = "OMP"
|
||||
case rooCode = "Roo Code"
|
||||
case crush = "Crush"
|
||||
case antigravity = "Antigravity"
|
||||
case goose = "Goose"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var providerKeys: [String] {
|
||||
switch self {
|
||||
case .cursor: ["cursor", "cursor agent"]
|
||||
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"]
|
||||
default: [rawValue.lowercased()]
|
||||
}
|
||||
}
|
||||
|
|
@ -752,11 +884,14 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
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 .droid: "droid"
|
||||
case .gemini: "gemini"
|
||||
case .ibmBob: "ibm-bob"
|
||||
case .kiloCode: "kilo-code"
|
||||
case .kiro: "kiro"
|
||||
case .kimi: "kimi"
|
||||
|
|
@ -767,6 +902,8 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .omp: "omp"
|
||||
case .rooCode: "roo-code"
|
||||
case .crush: "crush"
|
||||
case .antigravity: "antigravity"
|
||||
case .goose: "goose"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
mac/Sources/CodeBurnMenubar/AppVersion.swift
Normal file
43
mac/Sources/CodeBurnMenubar/AppVersion.swift
Normal file
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@ import AppKit
|
|||
import Observation
|
||||
|
||||
private let refreshIntervalSeconds: UInt64 = 30
|
||||
private let nanosPerSecond: UInt64 = 1_000_000_000
|
||||
private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
|
||||
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
|
||||
|
|
@ -35,10 +37,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
/// Held for the lifetime of the app to opt out of App Nap and Automatic Termination.
|
||||
private var backgroundActivity: NSObjectProtocol?
|
||||
private var pendingRefreshWork: DispatchWorkItem?
|
||||
private var refreshLoopTask: Task<Void, Never>?
|
||||
private var refreshTimer: DispatchSourceTimer?
|
||||
private var forceRefreshTask: Task<Void, Never>?
|
||||
private var forceRefreshStartedAt: Date?
|
||||
private var forceRefreshGeneration: UInt64 = 0
|
||||
private var statusPayloadRefreshTask: Task<Void, Never>?
|
||||
private var statusPayloadRefreshStartedAt: Date?
|
||||
private var statusPayloadRefreshGeneration: UInt64 = 0
|
||||
private var manualRefreshTask: Task<Void, Never>?
|
||||
private var manualRefreshGeneration: UInt64 = 0
|
||||
private var claudeQuotaRefreshTask: Task<Bool, Never>?
|
||||
private var codexQuotaRefreshTask: Task<Bool, Never>?
|
||||
private var refreshLoopHeartbeatAt: Date = .distantPast
|
||||
private var lastLaunchAgentHeartbeatAt: Date = .distantPast
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
|
||||
|
|
@ -91,28 +102,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.forceRefreshTask?.cancel()
|
||||
self?.forceRefreshTask = nil
|
||||
self?.forceRefreshStartedAt = nil
|
||||
self?.forceRefreshGeneration &+= 1
|
||||
self?.refreshLoopTask?.cancel()
|
||||
self?.refreshLoopTask = nil
|
||||
self?.prepareRefreshPipelineForSleep()
|
||||
}
|
||||
}
|
||||
|
||||
// didWakeNotification + screensDidWakeNotification can both fire on
|
||||
// the same wake. forceRefresh has a 5-second rate-limit gate so the
|
||||
// duplicate is squashed there. Restart the refresh loop too, since
|
||||
// we cancelled it on willSleep.
|
||||
// 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?.store.resetLoadingState()
|
||||
self?.forceRefresh()
|
||||
if self?.refreshLoopTask == nil { self?.startRefreshLoop() }
|
||||
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +125,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.forceRefresh() }
|
||||
Task { @MainActor in
|
||||
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +137,73 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.forceRefresh() }
|
||||
Task { @MainActor in
|
||||
self?.handleLaunchAgentHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLaunchAgentHeartbeat() {
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(lastLaunchAgentHeartbeatAt) >= refreshRateLimitSeconds else { return }
|
||||
lastLaunchAgentHeartbeatAt = now
|
||||
let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt)
|
||||
guard refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds else {
|
||||
_ = store.clearStaleLoadingIfNeeded()
|
||||
_ = clearStaleForceRefreshIfNeeded(now: now)
|
||||
_ = clearStaleStatusPayloadRefreshIfNeeded(now: now)
|
||||
return
|
||||
}
|
||||
if refreshTimer != nil {
|
||||
NSLog("CodeBurn: refresh loop stale for %ds after launch agent - restarting", Int(loopAge))
|
||||
}
|
||||
startRefreshLoop(forceQuotaOnStart: false)
|
||||
}
|
||||
|
||||
private func prepareRefreshPipelineForSleep() {
|
||||
forceRefreshTask?.cancel()
|
||||
forceRefreshTask = nil
|
||||
forceRefreshStartedAt = nil
|
||||
forceRefreshGeneration &+= 1
|
||||
manualRefreshTask?.cancel()
|
||||
manualRefreshTask = nil
|
||||
manualRefreshGeneration &+= 1
|
||||
statusPayloadRefreshTask?.cancel()
|
||||
statusPayloadRefreshTask = nil
|
||||
statusPayloadRefreshStartedAt = nil
|
||||
statusPayloadRefreshGeneration &+= 1
|
||||
store.resetLoadingState()
|
||||
stopRefreshTimer()
|
||||
refreshLoopHeartbeatAt = .distantPast
|
||||
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 {
|
||||
runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +264,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
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:\"\(appPath)\", hidden:false}"
|
||||
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"
|
||||
|
|
@ -211,14 +283,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
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 let started = forceRefreshStartedAt, forceRefreshTask != nil {
|
||||
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))
|
||||
NSLog("CodeBurn: force refresh stuck for %ds - cancelling and restarting", Int(elapsed))
|
||||
forceRefreshTask?.cancel()
|
||||
forceRefreshTask = nil
|
||||
forceRefreshStartedAt = nil
|
||||
|
|
@ -229,10 +317,61 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
return false
|
||||
}
|
||||
|
||||
private func forceRefresh() {
|
||||
@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 refreshTodayStatusPayloadIfNeeded(reason: String, force: Bool = false) {
|
||||
let now = Date()
|
||||
_ = clearStaleStatusPayloadRefreshIfNeeded(now: now)
|
||||
guard statusPayloadRefreshTask == nil else { return }
|
||||
guard force || store.needsStatusPayloadRefresh else { return }
|
||||
|
||||
if let age = store.todayPayloadAgeSeconds, age > 120 {
|
||||
NSLog("CodeBurn: today 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 }
|
||||
await self.store.refreshQuietly(period: .today, 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)
|
||||
guard now.timeIntervalSince(lastRefreshTime) > 5 else { return }
|
||||
if forceRefreshTask != nil {
|
||||
refreshTodayStatusPayloadIfNeeded(reason: "blocked force refresh")
|
||||
}
|
||||
guard forceRefreshTask == nil else { return }
|
||||
if !bypassRateLimit {
|
||||
guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return }
|
||||
}
|
||||
lastRefreshTime = now
|
||||
forceRefreshStartedAt = now
|
||||
forceRefreshGeneration &+= 1
|
||||
|
|
@ -240,8 +379,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
|
||||
forceRefreshTask = Task {
|
||||
async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true)
|
||||
async let today: Void = store.refreshQuietly(period: .today)
|
||||
_ = await (main, today)
|
||||
async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota)
|
||||
if store.selectedPeriod != .today || store.selectedProvider != .all {
|
||||
await store.refreshQuietly(period: .today)
|
||||
}
|
||||
_ = await main
|
||||
refreshStatusButton()
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, self.forceRefreshGeneration == generation else { return }
|
||||
|
|
@ -249,6 +391,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
self.forceRefreshStartedAt = nil
|
||||
self.lastRefreshTime = Date()
|
||||
}
|
||||
_ = await quotas
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,75 +418,184 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
}
|
||||
|
||||
fileprivate var lastSubscriptionRefreshAt: Date?
|
||||
fileprivate var lastCodexRefreshAt: Date?
|
||||
|
||||
private func startRefreshLoop() {
|
||||
refreshLoopTask?.cancel()
|
||||
refreshLoopTask = Task { [weak self] in
|
||||
// Provider refreshes only run when the user has explicitly connected.
|
||||
// Each refresh is a no-op until its corresponding bootstrap flag is set.
|
||||
if let self {
|
||||
async let claude = self.store.refreshSubscriptionReportingSuccess()
|
||||
async let codex = self.store.refreshCodexReportingSuccess()
|
||||
if await claude { self.lastSubscriptionRefreshAt = Date() }
|
||||
if await codex { self.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()
|
||||
}
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded()
|
||||
let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded()
|
||||
// Skip the loop's tick if a wake / manual / distributed-
|
||||
// notification refresh just ran. Without this gate, every
|
||||
// wake produced two refreshes (forceRefresh from the wake
|
||||
// observer plus the loop's natural tick).
|
||||
let sinceLast = Date().timeIntervalSince(self.lastRefreshTime)
|
||||
if self.forceRefreshTask == nil && (clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= 5) {
|
||||
if self.store.selectedPeriod != .today || self.store.selectedProvider != .all {
|
||||
async let quiet: Void = self.store.refreshQuietly(period: .today)
|
||||
async let main: Void = self.store.refresh(includeOptimize: false, force: true)
|
||||
_ = await (quiet, main)
|
||||
} else {
|
||||
await self.store.refresh(includeOptimize: false, force: true)
|
||||
}
|
||||
self.lastRefreshTime = Date()
|
||||
self.refreshStatusButton()
|
||||
}
|
||||
// Cadence-driven live-quota refresh, anchored on LAST SUCCESS
|
||||
// (not last attempt) so an intermittent failure doesn't reset
|
||||
// the timer. Each provider has its own anchor so a Codex 429
|
||||
// doesn't delay a due Claude refresh.
|
||||
let cadence = SubscriptionRefreshCadence.current
|
||||
if cadence != .manual {
|
||||
let claudeElapsed = Date().timeIntervalSince(self.lastSubscriptionRefreshAt ?? .distantPast)
|
||||
if claudeElapsed >= TimeInterval(cadence.rawValue) {
|
||||
let succeeded = await self.store.refreshSubscriptionReportingSuccess()
|
||||
if succeeded { self.lastSubscriptionRefreshAt = Date() }
|
||||
}
|
||||
let codexElapsed = Date().timeIntervalSince(self.lastCodexRefreshAt ?? .distantPast)
|
||||
if codexElapsed >= TimeInterval(cadence.rawValue) {
|
||||
let succeeded = await self.store.refreshCodexReportingSuccess()
|
||||
if succeeded { self.lastCodexRefreshAt = Date() }
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var lastCodexRefreshAt: Date?
|
||||
private func refreshPayloadForPopoverOpen() {
|
||||
guard store.needsInteractivePayloadRefresh else { return }
|
||||
let shouldResetPipeline = store.shouldResetInteractiveRefreshPipeline
|
||||
if shouldResetPipeline, let age = store.staleInteractivePayloadAgeSeconds {
|
||||
NSLog("CodeBurn: popover opened with %ds stale payload cache - resetting refresh pipeline", age)
|
||||
}
|
||||
recoverRefreshPipelineAfterInterruption(
|
||||
resetLoading: shouldResetPipeline,
|
||||
reason: "popover open"
|
||||
)
|
||||
}
|
||||
|
||||
private func stopRefreshTimer() {
|
||||
refreshTimer?.setEventHandler {}
|
||||
refreshTimer?.cancel()
|
||||
refreshTimer = nil
|
||||
}
|
||||
|
||||
private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) {
|
||||
refreshLoopHeartbeatAt = Date()
|
||||
let hadForceRefreshInFlight = forceRefreshTask != nil
|
||||
let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded()
|
||||
let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded()
|
||||
let clearedStaleLoading = store.clearStaleLoadingIfNeeded()
|
||||
let statusPayloadStale = store.needsStatusPayloadRefresh
|
||||
let sinceLast = Date().timeIntervalSince(lastRefreshTime)
|
||||
let shouldForceRefresh = forcePayload ||
|
||||
clearedStaleForceRefresh ||
|
||||
clearedStaleLoading ||
|
||||
sinceLast >= TimeInterval(refreshIntervalSeconds)
|
||||
|
||||
if shouldForceRefresh {
|
||||
forceRefresh(bypassRateLimit: true, forceQuota: forceQuota)
|
||||
}
|
||||
|
||||
let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil
|
||||
if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) {
|
||||
refreshTodayStatusPayloadIfNeeded(reason: reason, force: forcePayload)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
Task { [weak self] in
|
||||
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 }
|
||||
// "Refresh Now" should refresh the menubar payload AND every
|
||||
// connected provider's live quota — the user's intent is "make
|
||||
// connected provider's live quota. The user's intent is "make
|
||||
// this match reality right now."
|
||||
let needsTodayTotal = self.store.selectedPeriod != .today || self.store.selectedProvider != .all
|
||||
async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true)
|
||||
async let claude: Bool = self.store.refreshSubscriptionReportingSuccess()
|
||||
async let codex: Bool = self.store.refreshCodexReportingSuccess()
|
||||
async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true)
|
||||
if needsTodayTotal {
|
||||
await self.store.refreshQuietly(period: .today, force: true)
|
||||
}
|
||||
_ = await payload
|
||||
if await claude { self.lastSubscriptionRefreshAt = Date() }
|
||||
if await codex { self.lastCodexRefreshAt = Date() }
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -541,6 +793,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
window.collectionBehavior.insert(.canJoinAllSpaces)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
refreshPayloadForPopoverOpen()
|
||||
refreshLiveQuotaProgressForPopoverOpen()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -626,14 +880,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
await updateChecker.check()
|
||||
let alert = NSAlert()
|
||||
alert.icon = codeburnAlertIcon()
|
||||
if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
|
||||
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"
|
||||
alert.informativeText = "v\(latest) is available (you have v\(updateChecker.currentVersion)). Run:\n\ncodeburn menubar --force"
|
||||
alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force"
|
||||
alert.alertStyle = .informational
|
||||
} else {
|
||||
alert.messageText = "Up to Date"
|
||||
alert.informativeText = "You're on the latest version (v\(updateChecker.currentVersion))."
|
||||
alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))."
|
||||
alert.alertStyle = .informational
|
||||
}
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,15 +36,11 @@ enum ClaudeCredentialStore {
|
|||
private static let credentialsRelativePath = ".claude/.credentials.json"
|
||||
private static let maxCredentialBytes = 64 * 1024
|
||||
|
||||
/// Local cache file. Stored under Application Support with 0600 permissions
|
||||
/// so only the current user can read it. We deliberately do NOT use the
|
||||
/// macOS Keychain for our own cache: keychain ACLs are bound to the binary
|
||||
/// code signature, so reading our own item triggers a prompt every time the
|
||||
/// binary changes (debug rebuilds, app updates with re-signing). Putting the
|
||||
/// cache in a plain file means the only Keychain prompt our user ever sees
|
||||
/// is the initial Connect read of Claude Code's own keychain entry.
|
||||
/// Threat model: same as ~/.claude/.credentials.json (also plaintext).
|
||||
/// Legacy local cache file. New writes use the macOS Keychain; this path is
|
||||
/// read once for migration and then removed.
|
||||
private static let cacheFilename = "claude-credentials.v1.json"
|
||||
private static let ourKeychainService = "org.agentseal.codeburn.menubar.claude.oauth.v1"
|
||||
private static let ourKeychainAccount = "default"
|
||||
|
||||
private static let lock = NSLock()
|
||||
private nonisolated(unsafe) static var memoryCache: CachedRecord?
|
||||
|
|
@ -283,6 +279,10 @@ enum ClaudeCredentialStore {
|
|||
}
|
||||
|
||||
private static func readOurCache() throws -> CredentialRecord? {
|
||||
if let record = try readOurKeychainCache() {
|
||||
return record
|
||||
}
|
||||
|
||||
let url = cacheFileURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
// Route through SafeFile.read so we lstat for symlinks before opening
|
||||
|
|
@ -291,21 +291,66 @@ enum ClaudeCredentialStore {
|
|||
// CodeBurn/ between disconnect and reconnect could redirect our read
|
||||
// to /dev/zero (unbounded memory) or another file the user owns.
|
||||
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
||||
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
|
||||
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
|
||||
try? writeOurKeychainCache(record: record)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return record
|
||||
}
|
||||
|
||||
private static func writeOurCache(record: CredentialRecord) throws {
|
||||
try writeOurKeychainCache(record: record)
|
||||
}
|
||||
|
||||
private static func readOurKeychainCache() throws -> CredentialRecord? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
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? JSONDecoder().decode(CredentialRecord.self, from: data)
|
||||
}
|
||||
|
||||
private static func writeOurKeychainCache(record: CredentialRecord) throws {
|
||||
let url = cacheFileURL()
|
||||
let data = try JSONEncoder().encode(record)
|
||||
// SafeFile.write opens the temp file with O_CREAT | O_EXCL | O_NOFOLLOW
|
||||
// and the explicit 0600 mode in a single syscall — no race window
|
||||
// where the file briefly exists at default umask, and no chance of
|
||||
// following a malicious symlink at the destination path. Also creates
|
||||
// the parent dir at 0700.
|
||||
try SafeFile.write(data, to: url.path, mode: 0o600)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var add = query
|
||||
add.merge(attributes) { _, new in new }
|
||||
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw StoreError.keychainWriteFailed(addStatus)
|
||||
}
|
||||
} else if status != errSecSuccess {
|
||||
throw StoreError.keychainWriteFailed(status)
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
private static func deleteOurCache() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
try? FileManager.default.removeItem(at: cacheFileURL())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors
|
||||
/// ClaudeCredentialStore but reads from ~/.codex/auth.json — Codex CLI
|
||||
|
|
@ -17,6 +18,8 @@ enum CodexCredentialStore {
|
|||
private static let maxCredentialBytes = 64 * 1024
|
||||
|
||||
private static let cacheFilename = "codex-credentials.v1.json"
|
||||
private static let ourKeychainService = "org.agentseal.codeburn.menubar.codex.oauth.v1"
|
||||
private static let ourKeychainAccount = "default"
|
||||
|
||||
private static let lock = NSLock()
|
||||
private nonisolated(unsafe) static var memoryCache: CachedRecord?
|
||||
|
|
@ -198,28 +201,74 @@ enum CodexCredentialStore {
|
|||
}
|
||||
|
||||
private static func readOurCache() throws -> CredentialRecord? {
|
||||
if let record = try readOurKeychainCache() {
|
||||
return record
|
||||
}
|
||||
|
||||
let url = cacheFileURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
// Symlink-defense + size cap (same hardening as ClaudeCredentialStore).
|
||||
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
||||
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
|
||||
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
|
||||
try? writeOurKeychainCache(record: record)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return record
|
||||
}
|
||||
|
||||
private static func writeOurCache(record: CredentialRecord) throws {
|
||||
try writeOurKeychainCache(record: record)
|
||||
}
|
||||
|
||||
private static func readOurKeychainCache() throws -> CredentialRecord? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
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.fileWriteFailed("keychain read failed with status \(status)")
|
||||
}
|
||||
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
|
||||
}
|
||||
|
||||
private static func writeOurKeychainCache(record: CredentialRecord) throws {
|
||||
let url = cacheFileURL()
|
||||
let data = try JSONEncoder().encode(record)
|
||||
do {
|
||||
// SafeFile.write opens the temp file with O_CREAT | O_EXCL | O_NOFOLLOW
|
||||
// and the explicit 0600 mode in a single syscall — no race window
|
||||
// where the file briefly exists at default umask, and no chance of
|
||||
// following a malicious symlink at the destination path.
|
||||
try SafeFile.write(data, to: url.path, mode: 0o600)
|
||||
} catch {
|
||||
throw StoreError.fileWriteFailed(String(describing: error))
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var add = query
|
||||
add.merge(attributes) { _, new in new }
|
||||
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw StoreError.fileWriteFailed("keychain write failed with status \(addStatus)")
|
||||
}
|
||||
} else if status != errSecSuccess {
|
||||
throw StoreError.fileWriteFailed("keychain update failed with status \(status)")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
private static func deleteOurCache() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
try? FileManager.default.removeItem(at: cacheFileURL())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,28 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
|
||||
private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases/latest"
|
||||
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 updateTimeoutSeconds: UInt64 = 120
|
||||
private let maxUpdateStderrBytes = 64 * 1024
|
||||
|
||||
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
|
||||
|
|
@ -16,14 +34,14 @@ final class UpdateChecker {
|
|||
var updateAvailable: Bool {
|
||||
guard let latest = latestVersion else { return false }
|
||||
let current = currentVersion
|
||||
let normalizedLatest = latest.hasPrefix("v") ? String(latest.dropFirst()) : latest
|
||||
let normalizedCurrent = current.hasPrefix("v") ? String(current.dropFirst()) : current
|
||||
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 currentVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
AppVersion.normalizedBundleShortVersion
|
||||
}
|
||||
|
||||
func checkIfNeeded() async {
|
||||
|
|
@ -37,19 +55,24 @@ final class UpdateChecker {
|
|||
}
|
||||
|
||||
func check() async {
|
||||
updateError = nil
|
||||
guard let url = URL(string: releasesAPI) else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
|
||||
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
||||
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
|
||||
guard let asset = release.assets.first(where: {
|
||||
$0.name.hasPrefix("CodeBurnMenubar-") && $0.name.hasSuffix(".zip")
|
||||
}) else { return }
|
||||
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 = asset.name
|
||||
let version = resolved.asset.name
|
||||
.replacingOccurrences(of: "CodeBurnMenubar-", with: "")
|
||||
.replacingOccurrences(of: ".zip", with: "")
|
||||
|
||||
|
|
@ -57,22 +80,50 @@ final class UpdateChecker {
|
|||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
|
||||
UserDefaults.standard.set(version, forKey: cachedVersionKey)
|
||||
} catch {
|
||||
updateError = "Update check failed: \(error.localizedDescription)"
|
||||
NSLog("CodeBurn: update check failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func performUpdate() {
|
||||
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
|
||||
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stderr = String(data: errData, encoding: .utf8) ?? ""
|
||||
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
|
||||
|
|
@ -93,14 +144,41 @@ final class UpdateChecker {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GitHubRelease: Decodable {
|
||||
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]
|
||||
}
|
||||
|
||||
private struct GitHubAsset: Decodable {
|
||||
struct GitHubAsset: Decodable {
|
||||
let name: String
|
||||
let browser_download_url: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,20 +13,50 @@ enum CodeburnCLI {
|
|||
/// 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 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] {
|
||||
guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else {
|
||||
return ["codeburn"]
|
||||
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
|
||||
}
|
||||
let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
guard parts.allSatisfy(isSafe) else {
|
||||
NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'")
|
||||
return ["codeburn"]
|
||||
|
||||
return installedArgv()
|
||||
}
|
||||
|
||||
private static func installedArgv() -> [String] {
|
||||
if let persisted = persistedCLIPath(), isSafe(persisted), FileManager.default.isExecutableFile(atPath: persisted) {
|
||||
return [persisted]
|
||||
}
|
||||
return parts
|
||||
for candidate in additionalPathEntries.map({ "\($0)/codeburn" }) {
|
||||
if 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`
|
||||
|
|
|
|||
|
|
@ -1,26 +1,111 @@
|
|||
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 {
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.frame(height: 38)
|
||||
}
|
||||
|
||||
private var todayAll: MenubarPayload {
|
||||
|
|
@ -55,6 +140,60 @@ struct AgentTabStrip: View {
|
|||
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 {
|
||||
|
|
@ -340,11 +479,14 @@ extension ProviderFilter {
|
|||
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 .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)
|
||||
|
|
@ -355,6 +497,8 @@ extension ProviderFilter {
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ private struct Header: View {
|
|||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if updateChecker.updateAvailable {
|
||||
if updateChecker.updateAvailable || updateChecker.updateError != nil {
|
||||
UpdateBadge()
|
||||
}
|
||||
AccentPicker()
|
||||
|
|
@ -409,18 +409,25 @@ private struct UpdateBadge: View {
|
|||
|
||||
var body: some View {
|
||||
Button {
|
||||
updateChecker.performUpdate()
|
||||
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..." : "Update")
|
||||
Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed"))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
|
@ -430,6 +437,7 @@ private struct UpdateBadge: View {
|
|||
.tint(Theme.brandAccent)
|
||||
.controlSize(.mini)
|
||||
.disabled(updateChecker.isUpdating)
|
||||
.help(updateChecker.updateError ?? "Install the latest menubar build")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -537,12 +545,7 @@ struct FooterBar: View {
|
|||
.fixedSize()
|
||||
|
||||
Button {
|
||||
// showLoading: true is safe now that the overlay condition uses
|
||||
// `!hasCachedData` instead of `isLoading`. The button icon swaps
|
||||
// to the spinner glyph (driven by store.isLoading), giving the
|
||||
// user visible feedback the click was registered, but the
|
||||
// popover body keeps the existing data instead of blanking out.
|
||||
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
|
||||
refreshNow()
|
||||
} label: {
|
||||
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
|
|
@ -567,7 +570,7 @@ struct FooterBar: View {
|
|||
|
||||
Spacer()
|
||||
|
||||
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")")
|
||||
Text(AppVersion.displayBundleShortVersion)
|
||||
.font(.system(size: 10, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
|
|
@ -588,6 +591,14 @@ struct FooterBar: View {
|
|||
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" }
|
||||
|
|
|
|||
|
|
@ -337,10 +337,8 @@ private struct CodexConnectionRow: View {
|
|||
// MARK: - About
|
||||
|
||||
private struct AboutSettingsTab: View {
|
||||
private let appVersion: String =
|
||||
(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "—"
|
||||
private let buildVersion: String =
|
||||
(Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "—"
|
||||
private let appVersion: String = AppVersion.normalizedBundleShortVersion
|
||||
private let buildVersion: String = AppVersion.normalizedBundleBuildVersion
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
private func menubarPayload(cost: Double) -> MenubarPayload {
|
||||
MenubarPayload(
|
||||
generated: "test",
|
||||
current: CurrentBlock(
|
||||
label: "Today",
|
||||
cost: cost,
|
||||
calls: 1,
|
||||
sessions: 1,
|
||||
oneShotRate: nil,
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheHitPercent: 0,
|
||||
topActivities: [],
|
||||
topModels: [],
|
||||
providers: ["claude": cost]
|
||||
),
|
||||
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
|
||||
history: HistoryBlock(daily: [])
|
||||
)
|
||||
}
|
||||
|
||||
@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("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)
|
||||
}
|
||||
}
|
||||
19
mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift
Normal file
19
mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
39
mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift
Normal file
39
mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.9.7",
|
||||
"version": "0.9.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codeburn",
|
||||
"version": "0.9.7",
|
||||
"version": "0.9.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
"vitest": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.9",
|
||||
"description": "See where your AI coding tokens go - by task, tool, model, and project",
|
||||
"type": "module",
|
||||
"main": "./dist/cli.js",
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
],
|
||||
"scripts": {
|
||||
"bundle-litellm": "node scripts/bundle-litellm.mjs",
|
||||
"build": "node scripts/bundle-litellm.mjs && tsup",
|
||||
"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)\"",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"test": "vitest",
|
||||
"prepublishOnly": "npm run build"
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"cursor",
|
||||
"codex",
|
||||
"kimi",
|
||||
"ibm-bob",
|
||||
"opencode",
|
||||
"pi",
|
||||
"ai-coding",
|
||||
|
|
@ -31,7 +32,7 @@
|
|||
"developer-tools"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"author": "AgentSeal <hello@agentseal.org>",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
987
src/cli.ts
987
src/cli.ts
|
|
@ -1,978 +1,15 @@
|
|||
import { Command } from 'commander'
|
||||
import { installMenubarApp } from './menubar-installer.js'
|
||||
import { exportCsv, exportJson, type PeriodExport } from './export.js'
|
||||
import { loadPricing, setModelAliases } from './models.js'
|
||||
import { parseAllSessions, filterProjectsByName } from './parser.js'
|
||||
import { convertCost } from './currency.js'
|
||||
import { renderStatusBar } from './format.js'
|
||||
import { type PeriodData, type ProviderCost } from './menubar-json.js'
|
||||
import { buildMenubarPayload } from './menubar-json.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { renderDashboard } from './dashboard.js'
|
||||
import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
|
||||
import { runOptimize, scanAndDetect } from './optimize.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
|
||||
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
|
||||
import { getPresetPlan, isPlanId, isPlanProvider, 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'
|
||||
|
||||
async function hydrateCache() {
|
||||
try {
|
||||
return await ensureCacheHydrated(
|
||||
(range) => parseAllSessions(range, 'all'),
|
||||
aggregateProjectsIntoDays,
|
||||
)
|
||||
} catch {
|
||||
return emptyCache()
|
||||
}
|
||||
#!/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)
|
||||
}
|
||||
|
||||
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 JsonPlanSummary = {
|
||||
id: PlanId
|
||||
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,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(period)
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
|
||||
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
|
||||
const planUsage = await getPlanUsageOrNull()
|
||||
if (planUsage) {
|
||||
report.plan = toJsonPlanSummary(planUsage)
|
||||
}
|
||||
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 <zone>', '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 ?? {})
|
||||
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
|
||||
process.env['CODEBURN_VERBOSE'] = '1'
|
||||
}
|
||||
await loadCurrency()
|
||||
import('./main.js').catch((err) => {
|
||||
process.stderr.write(String(err?.message ?? err) + '\n')
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
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 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<string, { cost: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
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, 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].calls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
|
||||
date,
|
||||
cost: convertCost(d.cost),
|
||||
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),
|
||||
avgCostPerSession: p.sessions.length > 0
|
||||
? convertCost(p.totalCostUSD / p.sessions.length)
|
||||
: null,
|
||||
calls: p.totalApiCalls,
|
||||
sessions: p.sessions.length,
|
||||
}))
|
||||
|
||||
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
|
||||
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, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
|
||||
modelMap[model].calls += d.calls
|
||||
modelMap[model].cost += d.costUSD
|
||||
modelMap[model].inputTokens += d.tokens.inputTokens
|
||||
modelMap[model].outputTokens += d.tokens.outputTokens
|
||||
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
|
||||
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
|
||||
}
|
||||
}
|
||||
const models = Object.entries(modelMap)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([name, { cost, ...rest }]) => {
|
||||
const efficiency = modelEfficiency.get(name)
|
||||
return {
|
||||
name,
|
||||
...rest,
|
||||
cost: convertCost(cost),
|
||||
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<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
for (const sess of sessions) {
|
||||
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
|
||||
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
|
||||
catMap[cat].turns += d.turns
|
||||
catMap[cat].cost += d.costUSD
|
||||
catMap[cat].editTurns += d.editTurns
|
||||
catMap[cat].oneShotTurns += d.oneShotTurns
|
||||
}
|
||||
}
|
||||
const activities = Object.entries(catMap)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([cat, d]) => ({
|
||||
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
|
||||
cost: convertCost(d.cost),
|
||||
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<string, number> = {}
|
||||
const mcpMap: Record<string, number> = {}
|
||||
const bashMap: Record<string, number> = {}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const sortedMap = (m: Record<string, number>) =>
|
||||
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), calls: s.apiCalls })))
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, 5)
|
||||
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
currency: code,
|
||||
period,
|
||||
periodKey,
|
||||
overview: {
|
||||
cost: convertCost(totalCostUSD),
|
||||
calls: totalCalls,
|
||||
sessions: totalSessions,
|
||||
cacheHitPercent,
|
||||
tokens: {
|
||||
input: totalInput,
|
||||
output: totalOutput,
|
||||
cacheRead: totalCacheRead,
|
||||
cacheWrite: totalCacheWrite,
|
||||
},
|
||||
},
|
||||
daily,
|
||||
projects: projectList,
|
||||
models,
|
||||
activities,
|
||||
tools: sortedMap(toolMap),
|
||||
mcpServers: sortedMap(mcpMap),
|
||||
shellCommands: sortedMap(bashMap),
|
||||
topSessions,
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('report', { isDefault: true })
|
||||
.description('Interactive usage dashboard')
|
||||
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
|
||||
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['tui', 'json'], 'report')
|
||||
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)
|
||||
}
|
||||
|
||||
const period = toPeriod(opts.period)
|
||||
if (opts.format === 'json') {
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
if (customRange) {
|
||||
const label = formatDateRangeLabel(opts.from, opts.to)
|
||||
const projects = filterProjectsByName(
|
||||
await parseAllSessions(customRange, opts.provider),
|
||||
opts.project,
|
||||
opts.exclude,
|
||||
)
|
||||
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
|
||||
} else {
|
||||
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
|
||||
}
|
||||
return
|
||||
}
|
||||
await hydrateCache()
|
||||
const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
|
||||
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel)
|
||||
})
|
||||
|
||||
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
const sessions = projects.flatMap(p => p.sessions)
|
||||
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
const modelTotals: Record<string, { calls: number; cost: 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, editTurns: 0, oneShotTurns: 0 }
|
||||
catTotals[cat].turns += d.turns
|
||||
catTotals[cat].cost += d.costUSD
|
||||
catTotals[cat].editTurns += d.editTurns
|
||||
catTotals[cat].oneShotTurns += d.oneShotTurns
|
||||
}
|
||||
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
|
||||
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 }
|
||||
modelTotals[model].calls += d.calls
|
||||
modelTotals[model].cost += d.costUSD
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
|
||||
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
|
||||
sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
|
||||
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
||||
categories: Object.entries(catTotals)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
|
||||
models: Object.entries(modelTotals)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([name, d]) => ({ name, ...d })),
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Compact status output (today + month)')
|
||||
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
|
||||
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
|
||||
await loadPricing()
|
||||
const pf = opts.provider
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
||||
if (opts.format === 'menubar-json') {
|
||||
const periodInfo = getDateRange(opts.period)
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
const isAllProviders = pf === 'all'
|
||||
|
||||
const cache = await hydrateCache()
|
||||
|
||||
// CURRENT PERIOD DATA
|
||||
// - .all provider: assemble from cache + today (fast)
|
||||
// - specific provider: parse the period range with provider filter (correct, but slower)
|
||||
let currentData: PeriodData
|
||||
let scanProjects: ProjectSummary[]
|
||||
let scanRange: DateRange
|
||||
|
||||
if (isAllProviders) {
|
||||
// Parse only today's sessions; historical data comes from cache to avoid double-counting
|
||||
const todayRange: DateRange = { start: todayStart, end: new Date() }
|
||||
const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
|
||||
const todayDays = aggregateProjectsIntoDays(todayProjects)
|
||||
const rangeStartStr = toDateString(periodInfo.range.start)
|
||||
const rangeEndStr = toDateString(periodInfo.range.end)
|
||||
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
|
||||
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
|
||||
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
|
||||
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
|
||||
scanProjects = todayProjects
|
||||
scanRange = periodInfo.range
|
||||
} else {
|
||||
const projects = fp(await parseAllSessions(periodInfo.range, pf))
|
||||
currentData = buildPeriodData(periodInfo.label, projects)
|
||||
scanProjects = projects
|
||||
scanRange = periodInfo.range
|
||||
}
|
||||
|
||||
// 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 (isAllProviders) {
|
||||
// Parse only today; historical provider costs come from cache
|
||||
const todayRangeForProviders: DateRange = { start: todayStart, end: new Date() }
|
||||
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
|
||||
const rangeStartStr = toDateString(periodInfo.range.start)
|
||||
const todayStr = toDateString(todayStart)
|
||||
const allDaysForProviders = [
|
||||
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
|
||||
...todayDaysForProviders.filter(d => d.date === todayStr),
|
||||
]
|
||||
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 p.discoverSessions()
|
||||
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)
|
||||
// Parse only today for history; historical days come from cache
|
||||
const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() }
|
||||
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForHistory, 'all')))
|
||||
const todayStrForHistory = toDateString(todayStart)
|
||||
const fullHistory = [...allCacheDays, ...allTodayDaysForHistory.filter(d => d.date === todayStrForHistory)]
|
||||
const dailyHistory = fullHistory.map(d => {
|
||||
if (isAllProviders) {
|
||||
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,
|
||||
calls: m.calls,
|
||||
inputTokens: m.inputTokens,
|
||||
outputTokens: m.outputTokens,
|
||||
}))
|
||||
return {
|
||||
date: d.date,
|
||||
cost: d.cost,
|
||||
calls: d.calls,
|
||||
inputTokens: d.inputTokens,
|
||||
outputTokens: d.outputTokens,
|
||||
cacheReadTokens: d.cacheReadTokens,
|
||||
cacheWriteTokens: d.cacheWriteTokens,
|
||||
topModels,
|
||||
}
|
||||
}
|
||||
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
|
||||
return {
|
||||
date: d.date,
|
||||
cost: prov.cost,
|
||||
calls: prov.calls,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
topModels: [],
|
||||
}
|
||||
})
|
||||
|
||||
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
|
||||
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
|
||||
return
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
await hydrateCache()
|
||||
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
|
||||
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
|
||||
const { code, rate } = getCurrency()
|
||||
const payload: {
|
||||
currency: string
|
||||
today: { cost: number; calls: number }
|
||||
month: { cost: number; calls: number }
|
||||
plan?: JsonPlanSummary
|
||||
} = {
|
||||
currency: code,
|
||||
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
|
||||
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
|
||||
}
|
||||
const planUsage = await getPlanUsageOrNull()
|
||||
if (planUsage) {
|
||||
payload.plan = toJsonPlanSummary(planUsage)
|
||||
}
|
||||
console.log(JSON.stringify(payload))
|
||||
return
|
||||
}
|
||||
|
||||
await hydrateCache()
|
||||
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
|
||||
console.log(renderStatusBar(monthProjects))
|
||||
})
|
||||
|
||||
program
|
||||
.command('today')
|
||||
.description('Today\'s usage dashboard')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['tui', 'json'], 'today')
|
||||
if (opts.format === 'json') {
|
||||
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
|
||||
return
|
||||
}
|
||||
await hydrateCache()
|
||||
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
|
||||
})
|
||||
|
||||
program
|
||||
.command('month')
|
||||
.description('This month\'s usage dashboard')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['tui', 'json'], 'month')
|
||||
if (opts.format === 'json') {
|
||||
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
|
||||
return
|
||||
}
|
||||
await hydrateCache()
|
||||
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 <format>', 'Export format: csv, json', 'csv')
|
||||
.option('-o, --output <path>', 'Output file path')
|
||||
.option('--from <date>', 'Start date (YYYY-MM-DD). Exports a single custom period when set')
|
||||
.option('--to <date>', 'End date (YYYY-MM-DD). Exports a single custom period when set')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['csv', 'json'], 'export')
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
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)
|
||||
}
|
||||
|
||||
const periods: PeriodExport[] = customRange
|
||||
? [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }]
|
||||
: [
|
||||
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
|
||||
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
|
||||
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
|
||||
]
|
||||
|
||||
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 })
|
||||
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 <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 <from>', '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 <from> <to>\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('plan [action] [id]')
|
||||
.description('Show or configure a subscription plan for overage tracking')
|
||||
.option('--format <format>', 'Output format: text or json', 'text')
|
||||
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
|
||||
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor', 'all')
|
||||
.option('--reset-day <n>', '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'
|
||||
|
||||
if (mode === 'show') {
|
||||
const plan = await readPlan()
|
||||
const displayPlan = !plan || plan.id === 'none'
|
||||
? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
|
||||
: {
|
||||
id: plan.id,
|
||||
monthlyUsd: plan.monthlyUsd,
|
||||
provider: plan.provider,
|
||||
resetDay: clampResetDay(plan.resetDay),
|
||||
setAt: plan.setAt,
|
||||
}
|
||||
if (opts?.format === 'json') {
|
||||
console.log(JSON.stringify(displayPlan))
|
||||
return
|
||||
}
|
||||
if (!plan || plan.id === 'none') {
|
||||
console.log('\n Plan: none')
|
||||
console.log(' API-pricing view is active.')
|
||||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
return
|
||||
}
|
||||
console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
|
||||
console.log(` Budget: $${plan.monthlyUsd}/month`)
|
||||
console.log(` Provider: ${plan.provider}`)
|
||||
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
|
||||
console.log(` Set at: ${plan.setAt}`)
|
||||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'reset') {
|
||||
await clearPlan()
|
||||
console.log('\n Plan reset. API-pricing view is active.\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (mode !== 'set') {
|
||||
console.error('\n Usage: codeburn plan [set <id> | reset]\n')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!id || !isPlanId(id)) {
|
||||
console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; 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()
|
||||
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 <positive number>.\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 = opts?.provider ?? 'all'
|
||||
if (!isPlanProvider(provider)) {
|
||||
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 <period>', 'Analysis period: today, week, 30days, month, all', '30days')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
const { range, label } = getDateRange(opts.period)
|
||||
const projects = await parseAllSessions(range, opts.provider)
|
||||
await runOptimize(projects, label, range)
|
||||
})
|
||||
|
||||
program
|
||||
.command('compare')
|
||||
.description('Compare two AI models side-by-side')
|
||||
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'all')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
const { range } = getDateRange(opts.period)
|
||||
await renderCompare(range, opts.provider)
|
||||
})
|
||||
|
||||
program
|
||||
.command('models')
|
||||
.description('Per-model token + cost table, optionally exploded by task type')
|
||||
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
|
||||
.option('--from <date>', 'Custom range start (YYYY-MM-DD)')
|
||||
.option('--to <date>', 'Custom range end (YYYY-MM-DD)')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
|
||||
.option('--task <category>', '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 <n>', 'Show only the top N rows', (v: string) => parseInt(v, 10))
|
||||
.option('--min-cost <usd>', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
|
||||
.option('--no-totals', 'Suppress the footer totals row')
|
||||
.option('--format <format>', 'Output format: table, markdown, json, csv', 'table')
|
||||
.action(async (opts) => {
|
||||
const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js')
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
|
||||
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 <period>', 'Analysis period: today, week, 30days, month, all', 'week')
|
||||
.action(async (opts) => {
|
||||
const { computeYield, formatYieldSummary } = await import('./yield.js')
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
const { range, label } = getDateRange(opts.period)
|
||||
console.log(`\n Analyzing yield for ${label}...\n`)
|
||||
const summary = await computeYield(range, process.cwd())
|
||||
console.log(formatYieldSummary(summary))
|
||||
})
|
||||
|
||||
program.parse()
|
||||
|
|
|
|||
|
|
@ -5,24 +5,19 @@ import { homedir } from 'os'
|
|||
import { join } from 'path'
|
||||
import type { DateRange, ProjectSummary } from './types.js'
|
||||
|
||||
// Bumped to 5 alongside the Cursor per-project breakdown: prior daily
|
||||
// entries recorded every Cursor session under a single 'cursor' project
|
||||
// label. After the upgrade, the breakdown produces per-workspace project
|
||||
// labels for new days; without invalidation the dashboard would show
|
||||
// 'cursor' for historical days and `-Users-you-myproject` for new ones
|
||||
// in the same window, producing a confusing mixed projection.
|
||||
export const DAILY_CACHE_VERSION = 5
|
||||
// MIN_SUPPORTED_VERSION bumped to 5 too. The migration path
|
||||
// Bumped to 6 alongside the Claude 1-hour cache-write pricing fix: prior
|
||||
// daily entries priced all Claude cache writes at the 5-minute rate, so
|
||||
// cached historical cost/model/provider/category totals would remain
|
||||
// under-reported unless discarded and recomputed from raw sessions.
|
||||
export const DAILY_CACHE_VERSION = 6
|
||||
// MIN_SUPPORTED_VERSION bumped to 6 too. The migration path
|
||||
// (isMigratableCache + migrateDays) only fills in missing default fields;
|
||||
// it does NOT recompute the providers / categories / models rollups from
|
||||
// session data, because those raw sessions are not stored in the cache.
|
||||
// So a migrated v2/v3/v4 cache would carry forward stale provider totals
|
||||
// (single 'cursor' bucket instead of per-workspace) for the full cache
|
||||
// retention window. Setting the floor to 5 forces those older caches to
|
||||
// be discarded and recomputed cleanly. Confirmed by live test:
|
||||
// menubar-json --period all reported cursor=$3.78 against a migrated
|
||||
// v4 cache but $4.08 (correct) after the cache was discarded.
|
||||
const MIN_SUPPORTED_VERSION = 5
|
||||
// So a migrated v5 cache would carry forward stale pricing totals for
|
||||
// the full cache retention window. Setting the floor to 6 forces older
|
||||
// caches to be discarded and recomputed cleanly.
|
||||
const MIN_SUPPORTED_VERSION = 6
|
||||
const DAILY_CACHE_FILENAME = 'daily-cache.json'
|
||||
|
||||
export type DailyEntry = {
|
||||
|
|
|
|||
|
|
@ -9,13 +9,12 @@ import { parseAllSessions, filterProjectsByName } from './parser.js'
|
|||
import { loadPricing } from './models.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
|
||||
import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js'
|
||||
import { estimateContextBudget, type ContextBudget } from './context-budget.js'
|
||||
import { dateKey } from './day-aggregator.js'
|
||||
import { CompareView } from './compare.js'
|
||||
import { getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
|
||||
import { planDisplayName } from './plans.js'
|
||||
import { getDateRange, PERIODS, PERIOD_LABELS, type Period, formatDateRangeLabel } from './cli-date.js'
|
||||
import { join } from 'path'
|
||||
import { patchStdoutForWindows } from './ink-win.js'
|
||||
|
||||
type View = 'dashboard' | 'optimize' | 'compare'
|
||||
|
|
@ -25,6 +24,7 @@ const ORANGE = '#FF8C42'
|
|||
const DIM = '#555555'
|
||||
const GOLD = '#FFD700'
|
||||
const PLAN_BAR_WIDTH = 10
|
||||
const HEAVY_PERIODS = new Set<Period>(['30days', 'month', 'all'])
|
||||
|
||||
const LANG_DISPLAY_NAMES: Record<string, string> = {
|
||||
javascript: 'JavaScript', typescript: 'TypeScript', python: 'Python',
|
||||
|
|
@ -52,6 +52,7 @@ const PROVIDER_COLORS: Record<string, string> = {
|
|||
claude: '#FF8C42',
|
||||
codex: '#5BF5A0',
|
||||
cursor: '#00B4D8',
|
||||
'ibm-bob': '#0F62FE',
|
||||
opencode: '#A78BFA',
|
||||
pi: '#F472B6',
|
||||
kimi: '#B6E34A',
|
||||
|
|
@ -101,6 +102,14 @@ function getPeriodRange(period: Period): { start: Date; end: Date } {
|
|||
return getDateRange(period).range
|
||||
}
|
||||
|
||||
function isHeavyPeriod(period: Period): boolean {
|
||||
return HEAVY_PERIODS.has(period)
|
||||
}
|
||||
|
||||
function nextTick(): Promise<void> {
|
||||
return new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
|
||||
|
||||
function getLayout(columns?: number): Layout {
|
||||
|
|
@ -248,16 +257,19 @@ function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSumma
|
|||
)
|
||||
}
|
||||
|
||||
const _homeEncoded = homedir().replace(/\//g, '-')
|
||||
const _home = homedir()
|
||||
const _homePrefix = _home.endsWith('/') ? _home : _home + '/'
|
||||
|
||||
function shortProject(encoded: string): string {
|
||||
let path = encoded.replace(/^-/, '')
|
||||
if (path.startsWith(_homeEncoded.replace(/^-/, ''))) {
|
||||
path = path.slice(_homeEncoded.replace(/^-/, '').length).replace(/^-/, '')
|
||||
}
|
||||
path = path.replace(/^private-tmp-[^-]+-[^-]+-/, '').replace(/^private-tmp-/, '').replace(/^tmp-/, '')
|
||||
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)
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
if (parts.length <= 3) return parts.join('/')
|
||||
return parts.slice(-3).join('/')
|
||||
}
|
||||
|
|
@ -283,7 +295,7 @@ function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSumm
|
|||
return (
|
||||
<Text key={`${project.project}-${i}`} wrap="truncate-end">
|
||||
<HBar value={project.totalCostUSD} max={maxCost} width={bw} />
|
||||
<Text dimColor> {fit(shortProject(project.project), nw)}</Text>
|
||||
<Text dimColor> {fit(shortProject(project.projectPath), nw)}</Text>
|
||||
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
|
||||
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
|
||||
<Text>{String(project.sessions.length).padStart(6)}</Text>
|
||||
|
|
@ -443,7 +455,7 @@ const TOP_SESSIONS_CALLS_COL = 6
|
|||
|
||||
function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
const allSessions = projects.flatMap(p =>
|
||||
p.sessions.map(s => ({ ...s, projectName: p.project }))
|
||||
p.sessions.map(s => ({ ...s, projectPath: p.projectPath }))
|
||||
)
|
||||
const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5)
|
||||
|
||||
|
|
@ -461,7 +473,7 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num
|
|||
const date = session.firstTimestamp
|
||||
? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN)
|
||||
: '----------'
|
||||
const label = `${date} ${shortProject(session.projectName)}`
|
||||
const label = `${date} ${shortProject(session.projectPath)}`
|
||||
return (
|
||||
<Text key={`${session.sessionId}-${i}`} wrap="truncate-end">
|
||||
<HBar value={session.totalCostUSD} max={maxCost} width={bw} />
|
||||
|
|
@ -514,6 +526,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
cursor: 'Cursor',
|
||||
'ibm-bob': 'IBM Bob',
|
||||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
kimi: 'Kimi',
|
||||
|
|
@ -656,8 +669,8 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable,
|
|||
<Text color={ORANGE} bold>5</Text><Text dimColor> 6 months</Text>
|
||||
</>
|
||||
)}
|
||||
{!isOptimize && optimizeAvailable && findingCount != null && findingCount > 0 && (
|
||||
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text><Text color="#F55B5B"> ({findingCount})</Text></>
|
||||
{!isOptimize && optimizeAvailable && (
|
||||
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text>{findingCount != null && findingCount > 0 ? <Text color="#F55B5B"> ({findingCount})</Text> : null}</>
|
||||
)}
|
||||
{!isOptimize && compareAvailable && (
|
||||
<><Text dimColor> </Text><Text color={ORANGE} bold>c</Text><Text dimColor> compare</Text></>
|
||||
|
|
@ -713,6 +726,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
|
||||
const [view, setView] = useState<View>('dashboard')
|
||||
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
|
||||
const [optimizeLoading, setOptimizeLoading] = useState(false)
|
||||
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
|
||||
const [planUsage, setPlanUsage] = useState<PlanUsage | undefined>(initialPlanUsage)
|
||||
// Cursor for the OptimizeView's findings window. Reset whenever the user
|
||||
|
|
@ -723,13 +737,16 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
const { columns } = useWindowSize()
|
||||
const { dashWidth } = getLayout(columns)
|
||||
const multipleProviders = detectedProviders.length > 1
|
||||
const optimizeAvailable = activeProvider === 'all' || activeProvider === 'claude'
|
||||
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<ReturnType<typeof setTimeout> | null>(null)
|
||||
const reloadGenerationRef = useRef(0)
|
||||
const reloadInFlightRef = useRef(false)
|
||||
const currentReloadRef = useRef<{ period: Period; provider: string } | null>(null)
|
||||
const pendingReloadRef = useRef<{ period: Period; provider: string } | null>(null)
|
||||
const findingCount = optimizeResult?.findings.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -746,13 +763,11 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadBudgets() {
|
||||
const claudeDir = join(homedir(), '.claude', 'projects')
|
||||
const budgets = new Map<string, ContextBudget>()
|
||||
for (const project of projects.slice(0, 8)) {
|
||||
if (cancelled) return
|
||||
const cwd = await discoverProjectCwd(join(claudeDir, project.project))
|
||||
if (!cwd) continue
|
||||
budgets.set(project.project, await estimateContextBudget(cwd))
|
||||
if (!project.projectPath.startsWith('/')) continue
|
||||
budgets.set(project.project, await estimateContextBudget(project.projectPath))
|
||||
}
|
||||
if (!cancelled) setProjectBudgets(budgets)
|
||||
}
|
||||
|
|
@ -760,23 +775,30 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
return () => { cancelled = true }
|
||||
}, [projects])
|
||||
|
||||
useEffect(() => {
|
||||
if (!optimizeAvailable) { setOptimizeResult(null); return }
|
||||
let cancelled = false
|
||||
async function scan() {
|
||||
if (projects.length === 0) { setOptimizeResult(null); return }
|
||||
const result = await scanAndDetect(projects, getPeriodRange(period))
|
||||
if (!cancelled) setOptimizeResult(result)
|
||||
}
|
||||
scan()
|
||||
return () => { cancelled = true }
|
||||
}, [projects, period, optimizeAvailable])
|
||||
|
||||
const reloadData = useCallback(async (p: Period, prov: string) => {
|
||||
if (reloadInFlightRef.current) {
|
||||
const current = currentReloadRef.current
|
||||
if (current?.period === p && current.provider === prov) {
|
||||
pendingReloadRef.current = null
|
||||
return
|
||||
}
|
||||
reloadGenerationRef.current++
|
||||
pendingReloadRef.current = { period: p, provider: prov }
|
||||
return
|
||||
}
|
||||
reloadInFlightRef.current = true
|
||||
currentReloadRef.current = { period: p, provider: prov }
|
||||
const generation = ++reloadGenerationRef.current
|
||||
setLoading(true)
|
||||
setOptimizeLoading(false)
|
||||
setOptimizeResult(null)
|
||||
try {
|
||||
if (isHeavyPeriod(p)) {
|
||||
setProjects([])
|
||||
setProjectBudgets(new Map())
|
||||
await nextTick()
|
||||
if (reloadGenerationRef.current !== generation) return
|
||||
}
|
||||
const range = getPeriodRange(p)
|
||||
const data = await parseAllSessions(range, prov)
|
||||
if (reloadGenerationRef.current !== generation) return
|
||||
|
|
@ -794,11 +816,37 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
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)
|
||||
}
|
||||
}
|
||||
}, [projectFilter, excludeFilter])
|
||||
|
||||
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, getPeriodRange(period))
|
||||
if (reloadGenerationRef.current === generation) setOptimizeResult(result)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (reloadGenerationRef.current === generation) setOptimizeLoading(false)
|
||||
}
|
||||
}, [optimizeAvailable, projects, period, optimizeLoading, optimizeResult])
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshSeconds || refreshSeconds <= 0) return
|
||||
if (isHeavyPeriod(period)) return
|
||||
const id = setInterval(() => { reloadData(period, activeProvider) }, refreshSeconds * 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [refreshSeconds, period, activeProvider, reloadData])
|
||||
|
|
@ -828,7 +876,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
|
||||
useInput((input, key) => {
|
||||
if (input === 'q') { exit(); return }
|
||||
if (input === 'o' && findingCount > 0 && view === 'dashboard' && optimizeAvailable) { setView('optimize'); 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
|
||||
|
|
@ -866,7 +914,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
|
||||
const headerLabel = customRangeLabel ?? PERIOD_LABELS[period]
|
||||
|
||||
if (loading) {
|
||||
if (loading || optimizeLoading) {
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
{!isCustomRange && <PeriodTabs active={period} providerName={activeProvider} showProvider={view !== 'compare' && multipleProviders} />}
|
||||
|
|
@ -879,7 +927,9 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
<Text dimColor>Loading {headerLabel} model data...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
: <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {headerLabel}...</Text></Panel>}
|
||||
: view === 'optimize'
|
||||
? <Panel title="CodeBurn Optimize" color={ORANGE} width={dashWidth}><Text dimColor>Scanning {headerLabel}...</Text></Panel>
|
||||
: <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {headerLabel}...</Text></Panel>}
|
||||
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={0} optimizeAvailable={false} compareAvailable={false} customRange={isCustomRange} />}
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
147
src/fs-utils.ts
147
src/fs-utils.ts
|
|
@ -1,12 +1,11 @@
|
|||
import { readFile, stat } from 'fs/promises'
|
||||
import { readFileSync, statSync, createReadStream } from 'fs'
|
||||
import { createInterface } from 'readline'
|
||||
|
||||
// Hard cap well below V8's 512 MB string limit even with split('\n') doubling.
|
||||
// Stream threshold chosen as empirical breakeven between readFile+split peak
|
||||
// memory and createReadStream+readline overhead for typical session files.
|
||||
// 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 STREAM_THRESHOLD_BYTES = 8 * 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
|
||||
|
|
@ -23,14 +22,6 @@ function warn(msg: string): void {
|
|||
if (verbose()) process.stderr.write(`codeburn: ${msg}\n`)
|
||||
}
|
||||
|
||||
async function readViaStream(filePath: string): Promise<string> {
|
||||
const chunks: string[] = []
|
||||
const stream = createReadStream(filePath, { encoding: 'utf-8' })
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity })
|
||||
for await (const line of rl) chunks.push(line)
|
||||
return chunks.join('\n')
|
||||
}
|
||||
|
||||
export async function readSessionFile(filePath: string): Promise<string | null> {
|
||||
let size: number
|
||||
try {
|
||||
|
|
@ -46,7 +37,6 @@ export async function readSessionFile(filePath: string): Promise<string | null>
|
|||
}
|
||||
|
||||
try {
|
||||
if (size >= STREAM_THRESHOLD_BYTES) return await readViaStream(filePath)
|
||||
return await readFile(filePath, 'utf-8')
|
||||
} catch (err) {
|
||||
warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
|
||||
|
|
@ -76,7 +66,29 @@ export function readSessionFileSync(filePath: string): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
export async function* readSessionLines(filePath: string): AsyncGenerator<string> {
|
||||
export type SessionLine = string | Buffer
|
||||
|
||||
type ReadSessionLinesOptions = {
|
||||
largeLineAsBuffer?: boolean
|
||||
largeLineThresholdBytes?: number
|
||||
startByteOffset?: number
|
||||
byteOffsetTracker?: { lastCompleteLineOffset: number }
|
||||
}
|
||||
|
||||
export function readSessionLines(
|
||||
filePath: string,
|
||||
shouldSkipHead?: (head: string) => boolean,
|
||||
): AsyncGenerator<string>
|
||||
export function readSessionLines(
|
||||
filePath: string,
|
||||
shouldSkipHead?: (head: string) => boolean,
|
||||
options?: ReadSessionLinesOptions & { largeLineAsBuffer: true },
|
||||
): AsyncGenerator<SessionLine>
|
||||
export async function* readSessionLines(
|
||||
filePath: string,
|
||||
shouldSkipHead?: (head: string) => boolean,
|
||||
options: ReadSessionLinesOptions = {},
|
||||
): AsyncGenerator<SessionLine> {
|
||||
let size: number
|
||||
try {
|
||||
size = (await stat(filePath)).size
|
||||
|
|
@ -92,10 +104,109 @@ export async function* readSessionLines(filePath: string): AsyncGenerator<string
|
|||
return
|
||||
}
|
||||
|
||||
const stream = createReadStream(filePath, { encoding: 'utf-8' })
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity })
|
||||
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 line of rl) yield line
|
||||
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 {
|
||||
|
|
|
|||
988
src/main.ts
Normal file
988
src/main.ts
Normal file
|
|
@ -0,0 +1,988 @@
|
|||
import { Command } from 'commander'
|
||||
import { installMenubarApp } from './menubar-installer.js'
|
||||
import { exportCsv, exportJson, type PeriodExport } from './export.js'
|
||||
import { loadPricing, setModelAliases } from './models.js'
|
||||
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
|
||||
import { convertCost } from './currency.js'
|
||||
import { renderStatusBar } from './format.js'
|
||||
import { type PeriodData, type ProviderCost } from './menubar-json.js'
|
||||
import { buildMenubarPayload } from './menubar-json.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { renderDashboard } from './dashboard.js'
|
||||
import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
|
||||
import { runOptimize, scanAndDetect } from './optimize.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
|
||||
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
|
||||
import { getPresetPlan, isPlanId, isPlanProvider, 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'
|
||||
|
||||
async function hydrateCache() {
|
||||
try {
|
||||
return await ensureCacheHydrated(
|
||||
(range) => parseAllSessions(range, 'all'),
|
||||
aggregateProjectsIntoDays,
|
||||
)
|
||||
} catch {
|
||||
return emptyCache()
|
||||
}
|
||||
}
|
||||
|
||||
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 JsonPlanSummary = {
|
||||
id: PlanId
|
||||
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,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(period)
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
|
||||
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
|
||||
const planUsage = await getPlanUsageOrNull()
|
||||
if (planUsage) {
|
||||
report.plan = toJsonPlanSummary(planUsage)
|
||||
}
|
||||
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 <zone>', '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 ?? {})
|
||||
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 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<string, { cost: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
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, 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].calls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
|
||||
date,
|
||||
cost: convertCost(d.cost),
|
||||
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),
|
||||
avgCostPerSession: p.sessions.length > 0
|
||||
? convertCost(p.totalCostUSD / p.sessions.length)
|
||||
: null,
|
||||
calls: p.totalApiCalls,
|
||||
sessions: p.sessions.length,
|
||||
}))
|
||||
|
||||
const modelMap: Record<string, { calls: number; cost: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> = {}
|
||||
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, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
|
||||
modelMap[model].calls += d.calls
|
||||
modelMap[model].cost += d.costUSD
|
||||
modelMap[model].inputTokens += d.tokens.inputTokens
|
||||
modelMap[model].outputTokens += d.tokens.outputTokens
|
||||
modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
|
||||
modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
|
||||
}
|
||||
}
|
||||
const models = Object.entries(modelMap)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([name, { cost, ...rest }]) => {
|
||||
const efficiency = modelEfficiency.get(name)
|
||||
return {
|
||||
name,
|
||||
...rest,
|
||||
cost: convertCost(cost),
|
||||
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<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
for (const sess of sessions) {
|
||||
for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
|
||||
if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
|
||||
catMap[cat].turns += d.turns
|
||||
catMap[cat].cost += d.costUSD
|
||||
catMap[cat].editTurns += d.editTurns
|
||||
catMap[cat].oneShotTurns += d.oneShotTurns
|
||||
}
|
||||
}
|
||||
const activities = Object.entries(catMap)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([cat, d]) => ({
|
||||
category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
|
||||
cost: convertCost(d.cost),
|
||||
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<string, number> = {}
|
||||
const mcpMap: Record<string, number> = {}
|
||||
const bashMap: Record<string, number> = {}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const sortedMap = (m: Record<string, number>) =>
|
||||
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), calls: s.apiCalls })))
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, 5)
|
||||
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
currency: code,
|
||||
period,
|
||||
periodKey,
|
||||
overview: {
|
||||
cost: convertCost(totalCostUSD),
|
||||
calls: totalCalls,
|
||||
sessions: totalSessions,
|
||||
cacheHitPercent,
|
||||
tokens: {
|
||||
input: totalInput,
|
||||
output: totalOutput,
|
||||
cacheRead: totalCacheRead,
|
||||
cacheWrite: totalCacheWrite,
|
||||
},
|
||||
},
|
||||
daily,
|
||||
projects: projectList,
|
||||
models,
|
||||
activities,
|
||||
tools: sortedMap(toolMap),
|
||||
mcpServers: sortedMap(mcpMap),
|
||||
shellCommands: sortedMap(bashMap),
|
||||
topSessions,
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('report', { isDefault: true })
|
||||
.description('Interactive usage dashboard')
|
||||
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
|
||||
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['tui', 'json'], 'report')
|
||||
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)
|
||||
}
|
||||
|
||||
const period = toPeriod(opts.period)
|
||||
if (opts.format === 'json') {
|
||||
await loadPricing()
|
||||
if (customRange) {
|
||||
const label = formatDateRangeLabel(opts.from, opts.to)
|
||||
const projects = filterProjectsByName(
|
||||
await parseAllSessions(customRange, opts.provider),
|
||||
opts.project,
|
||||
opts.exclude,
|
||||
)
|
||||
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), 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)
|
||||
})
|
||||
|
||||
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
const sessions = projects.flatMap(p => p.sessions)
|
||||
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
const modelTotals: Record<string, { calls: number; cost: 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, editTurns: 0, oneShotTurns: 0 }
|
||||
catTotals[cat].turns += d.turns
|
||||
catTotals[cat].cost += d.costUSD
|
||||
catTotals[cat].editTurns += d.editTurns
|
||||
catTotals[cat].oneShotTurns += d.oneShotTurns
|
||||
}
|
||||
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
|
||||
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 }
|
||||
modelTotals[model].calls += d.calls
|
||||
modelTotals[model].cost += d.costUSD
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
|
||||
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
|
||||
sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
|
||||
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
||||
categories: Object.entries(catTotals)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
|
||||
models: Object.entries(modelTotals)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([name, d]) => ({ name, ...d })),
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Compact status output (today + month)')
|
||||
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
|
||||
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
|
||||
await loadPricing()
|
||||
const pf = opts.provider
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
||||
if (opts.format === 'menubar-json') {
|
||||
const periodInfo = getDateRange(opts.period)
|
||||
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 isAllProviders = pf === 'all'
|
||||
|
||||
const cache = await hydrateCache()
|
||||
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
|
||||
}
|
||||
|
||||
// CURRENT PERIOD DATA
|
||||
// - .all provider: assemble from cache + today (fast)
|
||||
// - specific provider: parse the period range with provider filter (correct, but slower)
|
||||
let currentData: PeriodData
|
||||
let scanProjects: ProjectSummary[]
|
||||
let scanRange: DateRange
|
||||
|
||||
if (isAllProviders) {
|
||||
// Parse today's all-provider sessions once; historical data comes from cache to avoid
|
||||
// double-counting. Reusing the same parsed object is important for the menubar path:
|
||||
// large active sessions can OOM if this command retains multiple near-identical scans.
|
||||
const todayProjects = await getTodayAllProjects()
|
||||
const todayDays = await getTodayAllDays()
|
||||
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
|
||||
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
|
||||
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
|
||||
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
|
||||
scanProjects = todayProjects
|
||||
scanRange = periodInfo.range
|
||||
} else {
|
||||
const projects = fp(await parseAllSessions(periodInfo.range, pf))
|
||||
currentData = buildPeriodData(periodInfo.label, projects)
|
||||
scanProjects = projects
|
||||
scanRange = periodInfo.range
|
||||
}
|
||||
|
||||
// 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 (isAllProviders) {
|
||||
const allDaysForProviders = [
|
||||
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
|
||||
...(await getTodayAllDays()).filter(d => d.date === todayStr),
|
||||
]
|
||||
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 p.discoverSessions()
|
||||
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)
|
||||
const fullHistory = [...allCacheDays, ...(await getTodayAllDays()).filter(d => d.date === todayStr)]
|
||||
const dailyHistory = fullHistory.map(d => {
|
||||
if (isAllProviders) {
|
||||
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,
|
||||
calls: m.calls,
|
||||
inputTokens: m.inputTokens,
|
||||
outputTokens: m.outputTokens,
|
||||
}))
|
||||
return {
|
||||
date: d.date,
|
||||
cost: d.cost,
|
||||
calls: d.calls,
|
||||
inputTokens: d.inputTokens,
|
||||
outputTokens: d.outputTokens,
|
||||
cacheReadTokens: d.cacheReadTokens,
|
||||
cacheWriteTokens: d.cacheWriteTokens,
|
||||
topModels,
|
||||
}
|
||||
}
|
||||
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
|
||||
return {
|
||||
date: d.date,
|
||||
cost: prov.cost,
|
||||
calls: prov.calls,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
topModels: [],
|
||||
}
|
||||
})
|
||||
|
||||
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
|
||||
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
|
||||
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; calls: number }
|
||||
month: { cost: number; calls: number }
|
||||
plan?: JsonPlanSummary
|
||||
} = {
|
||||
currency: code,
|
||||
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
|
||||
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
|
||||
}
|
||||
const planUsage = await getPlanUsageOrNull()
|
||||
if (planUsage) {
|
||||
payload.plan = toJsonPlanSummary(planUsage)
|
||||
}
|
||||
console.log(JSON.stringify(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 <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['tui', 'json'], '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 <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--format <format>', 'Output format: tui, json', 'tui')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['tui', 'json'], '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 <format>', 'Export format: csv, json', 'csv')
|
||||
.option('-o, --output <path>', 'Output file path')
|
||||
.option('--from <date>', 'Start date (YYYY-MM-DD). Exports a single custom period when set')
|
||||
.option('--to <date>', 'End date (YYYY-MM-DD). Exports a single custom period when set')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['csv', 'json'], '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 })
|
||||
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 <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 <from>', '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 <from> <to>\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('plan [action] [id]')
|
||||
.description('Show or configure a subscription plan for overage tracking')
|
||||
.option('--format <format>', 'Output format: text or json', 'text')
|
||||
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
|
||||
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor', 'all')
|
||||
.option('--reset-day <n>', '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'
|
||||
|
||||
if (mode === 'show') {
|
||||
const plan = await readPlan()
|
||||
const displayPlan = !plan || plan.id === 'none'
|
||||
? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
|
||||
: {
|
||||
id: plan.id,
|
||||
monthlyUsd: plan.monthlyUsd,
|
||||
provider: plan.provider,
|
||||
resetDay: clampResetDay(plan.resetDay),
|
||||
setAt: plan.setAt,
|
||||
}
|
||||
if (opts?.format === 'json') {
|
||||
console.log(JSON.stringify(displayPlan))
|
||||
return
|
||||
}
|
||||
if (!plan || plan.id === 'none') {
|
||||
console.log('\n Plan: none')
|
||||
console.log(' API-pricing view is active.')
|
||||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
return
|
||||
}
|
||||
console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
|
||||
console.log(` Budget: $${plan.monthlyUsd}/month`)
|
||||
console.log(` Provider: ${plan.provider}`)
|
||||
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
|
||||
console.log(` Set at: ${plan.setAt}`)
|
||||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'reset') {
|
||||
await clearPlan()
|
||||
console.log('\n Plan reset. API-pricing view is active.\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (mode !== 'set') {
|
||||
console.error('\n Usage: codeburn plan [set <id> | reset]\n')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!id || !isPlanId(id)) {
|
||||
console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; 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()
|
||||
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 <positive number>.\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 = opts?.provider ?? 'all'
|
||||
if (!isPlanProvider(provider)) {
|
||||
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 <period>', 'Analysis period: today, week, 30days, month, all', '30days')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(opts.period)
|
||||
const projects = await parseAllSessions(range, opts.provider)
|
||||
await runOptimize(projects, label, range)
|
||||
})
|
||||
|
||||
program
|
||||
.command('compare')
|
||||
.description('Compare two AI models side-by-side')
|
||||
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'all')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
const { range } = getDateRange(opts.period)
|
||||
await renderCompare(range, opts.provider)
|
||||
})
|
||||
|
||||
program
|
||||
.command('models')
|
||||
.description('Per-model token + cost table, optionally exploded by task type')
|
||||
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
|
||||
.option('--from <date>', 'Custom range start (YYYY-MM-DD)')
|
||||
.option('--to <date>', 'Custom range end (YYYY-MM-DD)')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
|
||||
.option('--task <category>', '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 <n>', 'Show only the top N rows', (v: string) => parseInt(v, 10))
|
||||
.option('--min-cost <usd>', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
|
||||
.option('--no-totals', 'Suppress the footer totals row')
|
||||
.option('--format <format>', 'Output format: table, markdown, json, csv', 'table')
|
||||
.action(async (opts) => {
|
||||
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 <period>', 'Analysis period: today, week, 30days, month, all', 'week')
|
||||
.action(async (opts) => {
|
||||
const { computeYield, formatYieldSummary } = await import('./yield.js')
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(opts.period)
|
||||
console.log(`\n Analyzing yield for ${label}...\n`)
|
||||
const summary = await computeYield(range, process.cwd())
|
||||
console.log(formatYieldSummary(summary))
|
||||
})
|
||||
|
||||
program.parse()
|
||||
|
|
@ -1,27 +1,56 @@
|
|||
import { spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'
|
||||
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'
|
||||
|
||||
/// Public GitHub repo that hosts signed macOS release builds. `/releases/latest` returns the
|
||||
/// newest tagged release; we filter its assets list for our zipped .app bundle.
|
||||
const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases/latest'
|
||||
/// Public GitHub repo that hosts macOS release builds. CLI and menubar releases share
|
||||
/// the repository, so we scan recent releases and choose the newest `mac-v*` release
|
||||
/// that actually contains the menubar zip.
|
||||
const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20'
|
||||
const APP_BUNDLE_NAME = 'CodeBurnMenubar.app'
|
||||
const ASSET_PATTERN = /^CodeBurnMenubar-.*\.zip$/
|
||||
const CHECKSUM_PATTERN = /^CodeBurnMenubar-.*\.zip\.sha256$/
|
||||
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')
|
||||
|
||||
export type InstallResult = { installedPath: string; launched: boolean }
|
||||
|
||||
type ReleaseAsset = { name: string; browser_download_url: string }
|
||||
type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
|
||||
type ResolvedAssets = { zip: ReleaseAsset; checksum: ReleaseAsset | null }
|
||||
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 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 userApplicationsDir(): string {
|
||||
return join(homedir(), 'Applications')
|
||||
|
|
@ -70,16 +99,8 @@ async function fetchLatestReleaseAssets(): Promise<ResolvedAssets> {
|
|||
if (!response.ok) {
|
||||
throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
|
||||
}
|
||||
const body = await response.json() as ReleaseResponse
|
||||
const zip = body.assets.find(a => ASSET_PATTERN.test(a.name))
|
||||
if (!zip) {
|
||||
throw new Error(
|
||||
`No ${APP_BUNDLE_NAME} zip found in release ${body.tag_name}. ` +
|
||||
`Check https://github.com/getagentseal/codeburn/releases.`
|
||||
)
|
||||
}
|
||||
const checksum = body.assets.find(a => CHECKSUM_PATTERN.test(a.name)) ?? null
|
||||
return { zip, checksum }
|
||||
const body = await response.json() as ReleaseResponse[]
|
||||
return resolveLatestMenubarReleaseAssets(body)
|
||||
}
|
||||
|
||||
async function verifyChecksum(archivePath: string, checksumUrl: string): Promise<void> {
|
||||
|
|
@ -128,6 +149,57 @@ async function runCommand(command: string, args: string[]): Promise<void> {
|
|||
})
|
||||
}
|
||||
|
||||
async function captureCommand(command: string, args: string[]): Promise<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const path = await captureCommand('/usr/bin/env', [
|
||||
'PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin',
|
||||
'which',
|
||||
'codeburn',
|
||||
])
|
||||
if (!path.startsWith('/')) {
|
||||
throw new Error('Resolved codeburn path is not absolute.')
|
||||
}
|
||||
if (path.includes('/_npx/') || path.includes('/.npm/_npx/')) {
|
||||
throw new Error(
|
||||
'The menubar app needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn'
|
||||
)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async function persistCodeburnPath(): Promise<void> {
|
||||
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<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME])
|
||||
|
|
@ -150,6 +222,7 @@ async function killRunningApp(): Promise<void> {
|
|||
|
||||
export async function installMenubarApp(options: { force?: boolean } = {}): Promise<InstallResult> {
|
||||
await ensureSupportedPlatform()
|
||||
await persistCodeburnPath()
|
||||
|
||||
const appsDir = userApplicationsDir()
|
||||
const targetPath = join(appsDir, APP_BUNDLE_NAME)
|
||||
|
|
@ -171,21 +244,20 @@ export async function installMenubarApp(options: { force?: boolean } = {}): Prom
|
|||
console.log(`Downloading ${zip.name}...`)
|
||||
await downloadToFile(zip.browser_download_url, archivePath)
|
||||
|
||||
if (checksum) {
|
||||
console.log('Verifying checksum...')
|
||||
await verifyChecksum(archivePath, checksum.browser_download_url)
|
||||
} else {
|
||||
console.log('Warning: no checksum file found in release, skipping verification.')
|
||||
}
|
||||
console.log('Verifying checksum...')
|
||||
await verifyChecksum(archivePath, checksum.browser_download_url)
|
||||
|
||||
console.log('Unpacking...')
|
||||
await runCommand('/usr/bin/unzip', ['-q', archivePath, '-d', stagingDir])
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ type Bucket = {
|
|||
}
|
||||
|
||||
type ModelKey = string
|
||||
type CategoryKey = string
|
||||
type CategoryKey = TaskCategory
|
||||
|
||||
function bucketKey(provider: string, model: string, category: TaskCategory | null): string {
|
||||
return `${provider} ${model} ${category ?? ''}`
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type SnapshotEntry = [number, number, 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
|
||||
|
||||
const FAST_MULTIPLIERS: Record<string, number> = {
|
||||
'claude-opus-4-7': 6,
|
||||
|
|
@ -166,6 +167,7 @@ const BUILTIN_ALIASES: Record<string, string> = {
|
|||
'copilot-auto': 'claude-sonnet-4-5',
|
||||
'copilot-openai-auto': 'gpt-5.3-codex',
|
||||
'copilot-anthropic-auto': 'claude-sonnet-4-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',
|
||||
|
|
@ -313,6 +315,7 @@ export function calculateCost(
|
|||
cacheReadTokens: number,
|
||||
webSearchRequests: number,
|
||||
speed: 'standard' | 'fast' = 'standard',
|
||||
oneHourCacheCreationTokens = 0,
|
||||
): number {
|
||||
const costs = getModelCosts(model)
|
||||
if (!costs) {
|
||||
|
|
@ -338,11 +341,15 @@ export function calculateCost(
|
|||
// 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 +
|
||||
safe(cacheCreationTokens) * costs.cacheWriteCostPerToken +
|
||||
safeFiveMinuteCacheCreation * costs.cacheWriteCostPerToken +
|
||||
safeOneHourCacheCreation * costs.cacheWriteCostPerToken * ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE +
|
||||
safe(cacheReadTokens) * costs.cacheReadCostPerToken +
|
||||
safe(webSearchRequests) * costs.webSearchCostPerRequest
|
||||
)
|
||||
|
|
@ -354,6 +361,7 @@ const autoModelNames: Record<string, string> = {
|
|||
'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)',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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'
|
||||
|
|
@ -141,6 +142,8 @@ 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
|
||||
|
|
@ -209,7 +212,33 @@ type ScanData = {
|
|||
// JSONL scanner
|
||||
// ============================================================================
|
||||
|
||||
const FILE_READ_CONCURRENCY = 16
|
||||
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<string, unknown> {
|
||||
if (!input || typeof input !== 'object') return {}
|
||||
const raw = input as Record<string, unknown>
|
||||
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
|
||||
|
|
@ -286,10 +315,19 @@ export async function scanJsonlFile(
|
|||
const sessionId = basename(filePath, '.jsonl')
|
||||
let lastVersion = ''
|
||||
|
||||
for await (const line of readSessionLines(filePath)) {
|
||||
if (!line.trim()) continue
|
||||
let entry: Record<string, unknown>
|
||||
try { entry = JSON.parse(line) } catch { continue }
|
||||
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<string, unknown>
|
||||
|
||||
if (entry.version && typeof entry.version === 'string') lastVersion = entry.version
|
||||
|
||||
|
|
@ -304,11 +342,15 @@ export async function scanJsonlFile(
|
|||
const msg = entry.message as Record<string, unknown> | undefined
|
||||
const msgContent = msg?.content
|
||||
if (typeof msgContent === 'string') {
|
||||
userMessages.push(msgContent)
|
||||
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') {
|
||||
userMessages.push(block.text)
|
||||
const text = block.text.slice(0, remaining)
|
||||
userMessages.push(text)
|
||||
remaining -= text.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -330,9 +372,10 @@ export async function scanJsonlFile(
|
|||
|
||||
for (const block of blocks) {
|
||||
if (block.type !== 'tool_use') continue
|
||||
const name = typeof block.name === 'string' ? block.name : ''
|
||||
calls.push({
|
||||
name: block.name as string,
|
||||
input: (block.input as Record<string, unknown>) ?? {},
|
||||
name,
|
||||
input: compactOptimizeInput(name, block.input),
|
||||
sessionId,
|
||||
project,
|
||||
recent,
|
||||
|
|
|
|||
1334
src/parser.ts
1334
src/parser.ts
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,7 @@ const CACHE_VERSION = 2
|
|||
const RPC_TIMEOUT_MS = 5000
|
||||
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
type ServerInfo = {
|
||||
export type ServerInfo = {
|
||||
port: number
|
||||
csrfToken: string
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ type UsageEntry = {
|
|||
responseId?: string
|
||||
}
|
||||
|
||||
type GeneratorMetadata = {
|
||||
export type GeneratorMetadata = {
|
||||
stepIndices?: number[]
|
||||
chatModel?: {
|
||||
model: string
|
||||
|
|
@ -42,6 +42,20 @@ type GeneratorMetadata = {
|
|||
}
|
||||
}
|
||||
|
||||
type ModelMapResponse = {
|
||||
models?: Record<string, { model?: string }>
|
||||
response?: {
|
||||
models?: Record<string, { model?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
type GeneratorMetadataResponse = {
|
||||
generatorMetadata?: GeneratorMetadata[]
|
||||
response?: {
|
||||
generatorMetadata?: GeneratorMetadata[]
|
||||
}
|
||||
}
|
||||
|
||||
type CachedCascade = {
|
||||
mtimeMs: number
|
||||
sizeBytes: number
|
||||
|
|
@ -59,6 +73,9 @@ let memCache: AntigravityCache | null = null
|
|||
let cacheDirty = false
|
||||
let httpsAgent: https.Agent | undefined
|
||||
|
||||
const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port']
|
||||
const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token']
|
||||
|
||||
function getAgent(): https.Agent {
|
||||
if (!httpsAgent) httpsAgent = new https.Agent({ rejectUnauthorized: false })
|
||||
return httpsAgent
|
||||
|
|
@ -72,6 +89,72 @@ function getCachePath(): string {
|
|||
return join(getCacheDir(), 'antigravity-results.json')
|
||||
}
|
||||
|
||||
function execFileText(command: string, args: string[], timeout = 3000): Promise<string> {
|
||||
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)
|
||||
}
|
||||
|
||||
export function parseAntigravityServerInfoFromLine(line: string): ServerInfo | 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 }
|
||||
}
|
||||
|
||||
export function parseAntigravityServerInfo(lines: string[]): ServerInfo | null {
|
||||
for (const line of lines) {
|
||||
const server = parseAntigravityServerInfoFromLine(line)
|
||||
if (server) return server
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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: ModelMap = {}
|
||||
if (!models) return map
|
||||
for (const [key, info] of Object.entries(models)) {
|
||||
if (info && typeof info === 'object' && typeof info.model === 'string') {
|
||||
map[info.model] = key
|
||||
}
|
||||
}
|
||||
return 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<AntigravityCache> {
|
||||
if (memCache) return memCache
|
||||
try {
|
||||
|
|
@ -124,27 +207,27 @@ async function flushCache(liveCascadeIds?: Set<string>): Promise<void> {
|
|||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
async function readProcessCommandLines(): Promise<string[]> {
|
||||
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 detectServer(): Promise<ServerInfo | null> {
|
||||
if (cachedServer !== undefined) return cachedServer
|
||||
try {
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
execFile('ps', ['-eo', 'args'], { encoding: 'utf-8', timeout: 3000 }, (err, stdout) => {
|
||||
if (err) reject(err)
|
||||
else resolve(stdout)
|
||||
})
|
||||
})
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line.includes('language_server') || !line.includes('antigravity')) continue
|
||||
if (!line.includes('--https_server_port')) continue
|
||||
|
||||
const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]{32,})/)
|
||||
const portMatch = line.match(/--https_server_port\s+(\d+)/)
|
||||
if (csrfMatch && portMatch) {
|
||||
cachedServer = { csrfToken: csrfMatch[1]!, port: parseInt(portMatch[1]!, 10) }
|
||||
return cachedServer
|
||||
}
|
||||
}
|
||||
} catch { /* ps failed or timed out */ }
|
||||
cachedServer = parseAntigravityServerInfo(await readProcessCommandLines())
|
||||
return cachedServer
|
||||
} catch { /* process discovery failed or timed out */ }
|
||||
cachedServer = null
|
||||
return null
|
||||
}
|
||||
|
|
@ -199,20 +282,12 @@ async function rpc(server: ServerInfo, method: string, body: Record<string, unkn
|
|||
|
||||
async function getModelMap(server: ServerInfo): Promise<ModelMap> {
|
||||
if (cachedModelMap) return cachedModelMap
|
||||
const map: ModelMap = {}
|
||||
try {
|
||||
const resp = await rpc(server, 'GetAvailableModels') as {
|
||||
response?: { models?: Record<string, { model?: string }> }
|
||||
}
|
||||
const models = resp?.response?.models
|
||||
if (models) {
|
||||
for (const [key, info] of Object.entries(models)) {
|
||||
if (info.model) map[info.model] = key
|
||||
}
|
||||
}
|
||||
cachedModelMap = extractAntigravityModelMap(await rpc(server, 'GetAvailableModels'))
|
||||
return cachedModelMap
|
||||
} catch { /* best-effort */ }
|
||||
cachedModelMap = map
|
||||
return map
|
||||
cachedModelMap = {}
|
||||
return cachedModelMap
|
||||
}
|
||||
|
||||
// Strip Antigravity-specific suffixes so the pricing DB can match
|
||||
|
|
@ -275,10 +350,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
|
||||
let metadata: GeneratorMetadata[]
|
||||
try {
|
||||
const resp = await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }) as {
|
||||
generatorMetadata?: GeneratorMetadata[]
|
||||
}
|
||||
metadata = resp?.generatorMetadata ?? []
|
||||
metadata = extractAntigravityGeneratorMetadata(
|
||||
await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }),
|
||||
)
|
||||
} catch {
|
||||
if (cached) {
|
||||
for (const call of cached.calls) {
|
||||
|
|
|
|||
73
src/providers/cline.ts
Normal file
73
src/providers/cline.ts
Normal file
|
|
@ -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<SessionSource[]> {
|
||||
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<string>()
|
||||
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<SessionSource[]> {
|
||||
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<string>): SessionParser {
|
||||
return createClineParser(source, seenKeys, 'cline')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const cline = createClineProvider()
|
||||
|
|
@ -65,6 +65,8 @@ type CodexTokenUsage = {
|
|||
}
|
||||
|
||||
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')
|
||||
|
|
@ -126,6 +128,116 @@ async function isValidCodexSession(filePath: string): Promise<{ valid: boolean;
|
|||
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'),
|
||||
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<SessionSource[]> {
|
||||
const sessionsDir = join(codexDir, 'sessions')
|
||||
const sources: SessionSource[] = []
|
||||
|
|
@ -224,18 +336,12 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
// 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 via readline so memory
|
||||
// stays bounded to the longest line.
|
||||
for await (const rawLine of readSessionLines(source.path)) {
|
||||
// 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 line = rawLine.trim()
|
||||
if (!line) continue
|
||||
let entry: CodexEntry
|
||||
try {
|
||||
entry = JSON.parse(line) as CodexEntry
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const entry = parseCodexLine(rawLine)
|
||||
if (!entry) continue
|
||||
|
||||
if (entry.type === 'session_meta') {
|
||||
sessionId = entry.payload?.session_id ?? basename(source.path, '.jsonl')
|
||||
|
|
|
|||
|
|
@ -329,7 +329,8 @@ const USER_MESSAGES_QUERY = `
|
|||
// 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') > ? OR json_extract(value, '$.createdAt') IS NULL)`
|
||||
AND json_extract(value, '$.createdAt') IS NOT NULL
|
||||
AND json_extract(value, '$.createdAt') > ?`
|
||||
const BUBBLE_QUERY_SINCE_TAIL = `
|
||||
ORDER BY ROWID ASC
|
||||
`
|
||||
|
|
@ -458,6 +459,7 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set<string>): { calls: Parse
|
|||
}
|
||||
|
||||
const createdAt = row.created_at ?? ''
|
||||
if (!createdAt) continue
|
||||
// The JSON `conversationId` field on bubbles is empty in current
|
||||
// Cursor builds. The real composerId lives in the row key
|
||||
// `bubbleId:<composerId>:<bubbleUuid>`. Extract from the key so the
|
||||
|
|
@ -487,7 +489,7 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set<string>): { calls: Parse
|
|||
|
||||
const costUSD = calculateCost(pricingModel, inputTokens, outputTokens, 0, 0, 0)
|
||||
|
||||
const timestamp = createdAt || new Date().toISOString()
|
||||
const timestamp = createdAt
|
||||
const userQuestion = takeUserMessage(userMessages, conversationId)
|
||||
const assistantText = blobToText(row.user_text)
|
||||
const userText = (userQuestion + ' ' + assistantText).trim()
|
||||
|
|
|
|||
59
src/providers/ibm-bob.ts
Normal file
59
src/providers/ibm-bob.ts
Normal file
|
|
@ -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()
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { claude } from './claude.js'
|
||||
import { cline } from './cline.js'
|
||||
import { codex } from './codex.js'
|
||||
import { copilot } from './copilot.js'
|
||||
import { droid } from './droid.js'
|
||||
import { gemini } from './gemini.js'
|
||||
import { ibmBob } from './ibm-bob.js'
|
||||
import { kiloCode } from './kilo-code.js'
|
||||
import { kiro } from './kiro.js'
|
||||
import { kimi } from './kimi.js'
|
||||
|
|
@ -102,7 +104,7 @@ async function loadCrush(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, kimi, rooCode]
|
||||
const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, openclaw, pi, omp, qwen, rooCode]
|
||||
|
||||
export async function getAllProviders(): Promise<Provider[]> {
|
||||
const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()])
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { homedir } 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 { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js'
|
||||
import type {
|
||||
Provider,
|
||||
SessionSource,
|
||||
|
|
@ -64,6 +64,25 @@ const toolNameMap: Record<string, string> = {
|
|||
patch: 'Patch',
|
||||
}
|
||||
|
||||
function normalizeToolName(rawTool?: string): string {
|
||||
if (!rawTool) return ''
|
||||
if (rawTool.startsWith('mcp__')) return rawTool
|
||||
|
||||
const builtIn = toolNameMap[rawTool]
|
||||
if (builtIn) return builtIn
|
||||
|
||||
// OpenCode stores MCP calls as `<server>_<tool>` with no separate server field.
|
||||
// Built-ins are handled above, and server ids are assumed not to contain `_`.
|
||||
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
|
||||
}
|
||||
|
||||
function sanitize(dir: string): string {
|
||||
return dir.replace(/^\//, '').replace(/\//g, '-')
|
||||
}
|
||||
|
|
@ -107,7 +126,8 @@ function validateSchemaDetailed(db: SqliteDatabase): SchemaCheckResult {
|
|||
for (const table of required) {
|
||||
try {
|
||||
db.query<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table} LIMIT 1`)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (isSqliteBusyError(err)) throw err
|
||||
missing.push(table)
|
||||
}
|
||||
}
|
||||
|
|
@ -232,7 +252,7 @@ function createParser(
|
|||
const msgParts = partsByMsg.get(msg.id) ?? []
|
||||
const toolParts = msgParts.filter((p) => p.type === 'tool')
|
||||
const tools = toolParts
|
||||
.map((p) => toolNameMap[p.tool ?? ''] ?? p.tool ?? '')
|
||||
.map((p) => normalizeToolName(p.tool))
|
||||
.filter(Boolean)
|
||||
|
||||
const bashCommands = toolParts
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export type ParsedProviderCall = {
|
|||
deduplicationKey: string
|
||||
userMessage: string
|
||||
sessionId: string
|
||||
project?: string
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
export type Provider = {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,23 @@ export function getVSCodeGlobalStoragePath(extensionId: string): string {
|
|||
|
||||
export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string): Promise<SessionSource[]> {
|
||||
const baseDir = overrideDir ?? getVSCodeGlobalStoragePath(extensionId)
|
||||
return discoverClineTasksInBaseDirs([baseDir], 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[] = []
|
||||
|
||||
|
|
@ -50,28 +67,43 @@ export async function discoverClineTasks(extensionId: string, providerName: stri
|
|||
}
|
||||
|
||||
const MODEL_TAG_RE = /<model>([^<]+)<\/model>/
|
||||
const WORKSPACE_DIR_RE = /Current Workspace Directory \(([^)]+)\)/
|
||||
|
||||
function extractModelFromHistory(taskDir: string): Promise<string> {
|
||||
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 'cline-auto'
|
||||
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) {
|
||||
const match = typeof block.text === 'string' && MODEL_TAG_RE.exec(block.text)
|
||||
if (match) {
|
||||
const raw = match[1]
|
||||
return raw.includes('/') ? raw.split('/').pop()! : raw
|
||||
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 'cline-auto'
|
||||
return { model: model ?? fallbackModel, workspace }
|
||||
})
|
||||
.catch(() => 'cline-auto')
|
||||
.catch(() => ({ model: fallbackModel, workspace: null }))
|
||||
}
|
||||
|
||||
export function createClineParser(source: SessionSource, seenKeys: Set<string>, providerName: string): SessionParser {
|
||||
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
|
||||
|
|
@ -93,7 +125,10 @@ export function createClineParser(source: SessionSource, seenKeys: Set<string>,
|
|||
|
||||
if (!Array.isArray(uiMessages)) return
|
||||
|
||||
const model = await extractModelFromHistory(taskDir)
|
||||
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) {
|
||||
|
|
@ -156,6 +191,8 @@ export function createClineParser(source: SessionSource, seenKeys: Set<string>,
|
|||
deduplicationKey: dedupKey,
|
||||
userMessage: index === 0 ? userMessage : '',
|
||||
sessionId: taskId,
|
||||
project,
|
||||
projectPath,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
319
src/session-cache.ts
Normal file
319
src/session-cache.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
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'
|
||||
|
||||
// ── 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
|
||||
speed: 'standard' | 'fast'
|
||||
timestamp: string
|
||||
tools: string[]
|
||||
bashCommands: string[]
|
||||
skills: string[]
|
||||
deduplicationKey: string
|
||||
project?: string
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
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
|
||||
mcpInventory: string[]
|
||||
turns: CachedTurn[]
|
||||
}
|
||||
|
||||
export type ProviderSection = {
|
||||
envFingerprint: string
|
||||
files: Record<string, CachedFile>
|
||||
}
|
||||
|
||||
export type SessionCache = {
|
||||
version: number
|
||||
providers: Record<string, ProviderSection>
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
export const CACHE_VERSION = 1
|
||||
|
||||
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'],
|
||||
droid: ['FACTORY_DIR'],
|
||||
cursor: ['XDG_DATA_HOME'],
|
||||
'cursor-agent': ['XDG_DATA_HOME'],
|
||||
opencode: ['XDG_DATA_HOME'],
|
||||
goose: ['XDG_DATA_HOME'],
|
||||
crush: ['XDG_DATA_HOME'],
|
||||
antigravity: ['CODEBURN_CACHE_DIR'],
|
||||
qwen: ['QWEN_DATA_DIR'],
|
||||
'ibm-bob': ['XDG_CONFIG_HOME'],
|
||||
}
|
||||
|
||||
// ── 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] ?? ''}`)
|
||||
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 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')
|
||||
&& isStringArray(o['tools'])
|
||||
&& isStringArray(o['bashCommands'])
|
||||
&& isStringArray(o['skills'])
|
||||
&& isOptionalString(o['project'])
|
||||
&& isOptionalString(o['projectPath'])
|
||||
&& 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'])
|
||||
&& 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`
|
||||
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 {
|
||||
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 {}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ export type SqliteDatabase = {
|
|||
|
||||
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => {
|
||||
prepare(sql: string): { all(...params: unknown[]): Row[] }
|
||||
exec?(sql: string): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
|
|
@ -97,12 +98,35 @@ 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[] {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ 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
|
||||
|
|
@ -79,6 +83,7 @@ export type ParsedApiCall = {
|
|||
timestamp: string
|
||||
bashCommands: string[]
|
||||
deduplicationKey: string
|
||||
cacheCreationOneHourTokens?: number
|
||||
}
|
||||
|
||||
export type TaskCategory =
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { blobToText } from '../src/sqlite.js'
|
||||
import { blobToText, isSqliteBusyError } from '../src/sqlite.js'
|
||||
|
||||
describe('blobToText', () => {
|
||||
it('returns empty string for null', () => {
|
||||
|
|
@ -37,3 +37,17 @@ describe('blobToText', () => {
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ function runCli(args: string[], home: string) {
|
|||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home, // os.homedir() uses USERPROFILE on Windows
|
||||
HOMEPATH: home,
|
||||
HOMEDRIVE: '',
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
|
|
|||
108
tests/cli-status-menubar.test.ts
Normal file
108
tests/cli-status-menubar.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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(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 - 60_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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -104,6 +104,36 @@ describe('loadDailyCache', () => {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
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'
|
||||
|
||||
|
|
@ -53,7 +56,7 @@ function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary {
|
|||
|
||||
// Logic replicated from TopSessions component
|
||||
function getTopSessions(projects: ProjectSummary[], n = 5) {
|
||||
const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectName: p.project })))
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +102,36 @@ describe('TopSessions - top-5 selection', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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', [])
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: '2026-04-09T10:00:00Z',
|
||||
lastTimestamp: '2026-04-10T08:00:00Z',
|
||||
firstTimestamp: '2026-04-09T10:00:00',
|
||||
lastTimestamp: '2026-04-10T08:00:00',
|
||||
totalCostUSD: 10,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
|
|
@ -57,14 +57,14 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
turns: [
|
||||
{
|
||||
userMessage: 'hi',
|
||||
timestamp: '2026-04-09T10:00:00Z',
|
||||
timestamp: '2026-04-09T10:00:00',
|
||||
sessionId: 's1',
|
||||
category: 'coding',
|
||||
retries: 0,
|
||||
hasEdits: true,
|
||||
assistantCalls: [
|
||||
makeCall('2026-04-09T10:00:00Z', 4),
|
||||
makeCall('2026-04-10T08:00:00Z', 6),
|
||||
makeCall('2026-04-09T10:00:00', 4),
|
||||
makeCall('2026-04-10T08:00:00', 6),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -92,8 +92,8 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: '2026-04-09T10:00:00Z',
|
||||
lastTimestamp: '2026-04-09T10:05:00Z',
|
||||
firstTimestamp: '2026-04-09T10:00:00',
|
||||
lastTimestamp: '2026-04-09T10:05:00',
|
||||
totalCostUSD: 3,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
|
|
@ -103,12 +103,12 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
turns: [
|
||||
{
|
||||
userMessage: 'hi',
|
||||
timestamp: '2026-04-09T10:00:00Z',
|
||||
timestamp: '2026-04-09T10:00:00',
|
||||
sessionId: 's1',
|
||||
category: 'coding',
|
||||
retries: 0,
|
||||
hasEdits: true,
|
||||
assistantCalls: [makeCall('2026-04-09T10:00:00Z', 3)],
|
||||
assistantCalls: [makeCall('2026-04-09T10:00:00', 3)],
|
||||
},
|
||||
],
|
||||
modelBreakdown: {},
|
||||
|
|
@ -138,8 +138,8 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: '2026-04-09T23:59:00Z',
|
||||
lastTimestamp: '2026-04-10T00:10:00Z',
|
||||
firstTimestamp: '2026-04-09T23:59:00',
|
||||
lastTimestamp: '2026-04-10T00:10:00',
|
||||
totalCostUSD: 1,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
||||
apiCalls: 0,
|
||||
|
|
@ -151,7 +151,7 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
}),
|
||||
]
|
||||
const days = aggregateProjectsIntoDays(projects)
|
||||
const expectedDate = dateKey('2026-04-09T23:59:00Z')
|
||||
const expectedDate = dateKey('2026-04-09T23:59:00')
|
||||
expect(days[0]!.date).toBe(expectedDate)
|
||||
expect(days[0]!.sessions).toBe(1)
|
||||
})
|
||||
|
|
@ -162,18 +162,18 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: '2026-04-10T10:00:00Z',
|
||||
lastTimestamp: '2026-04-10T10:00:00Z',
|
||||
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:00Z', sessionId: 's1',
|
||||
userMessage: 'x', timestamp: '2026-04-10T10:00:00', sessionId: 's1',
|
||||
category: 'coding', retries: 0, hasEdits: false,
|
||||
assistantCalls: [
|
||||
makeCall('2026-04-10T10:00:00Z', 7, 'Opus 4.7', 'claude'),
|
||||
makeCall('2026-04-10T10:00:00Z', 3, 'gpt-5', 'codex'),
|
||||
makeCall('2026-04-10T10:00:00', 7, 'Opus 4.7', 'claude'),
|
||||
makeCall('2026-04-10T10:00:00', 3, 'gpt-5', 'codex'),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { join } from 'path'
|
|||
|
||||
import {
|
||||
MAX_SESSION_FILE_BYTES,
|
||||
STREAM_THRESHOLD_BYTES,
|
||||
readSessionFile,
|
||||
readSessionLines,
|
||||
} from '../src/fs-utils.js'
|
||||
|
|
@ -34,11 +33,12 @@ describe('readSessionFile', () => {
|
|||
expect(await readSessionFile(p)).toBe('hello\nworld\n')
|
||||
})
|
||||
|
||||
it('returns content for files at the stream threshold via stream path', async () => {
|
||||
const p = await tmpPath(Buffer.alloc(STREAM_THRESHOLD_BYTES, 'a'))
|
||||
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(STREAM_THRESHOLD_BYTES)
|
||||
expect(got!.length).toBe(size)
|
||||
})
|
||||
|
||||
it('returns null and skips files over the cap', async () => {
|
||||
|
|
@ -88,6 +88,28 @@ describe('readSessionLines', () => {
|
|||
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)
|
||||
|
|
@ -95,4 +117,56 @@ describe('readSessionLines', () => {
|
|||
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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
75
tests/menubar-installer.test.ts
Normal file
75
tests/menubar-installer.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveLatestMenubarReleaseAssets,
|
||||
resolveMenubarReleaseAssets,
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -158,6 +158,18 @@ describe('calculateCost - OMP names produce non-zero cost', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ describe('scanJsonlFile', () => {
|
|||
message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] },
|
||||
}))
|
||||
await scanJsonlFile(filePath, 'p1', undefined)
|
||||
expect(readSessionLinesSpy).toHaveBeenCalledWith(filePath)
|
||||
expect(readSessionLinesSpy).toHaveBeenCalledWith(filePath, undefined, { largeLineAsBuffer: true })
|
||||
expect(readSessionFileSpy).not.toHaveBeenCalled()
|
||||
readSessionLinesSpy.mockRestore()
|
||||
readSessionFileSpy.mockRestore()
|
||||
|
|
|
|||
|
|
@ -31,7 +31,14 @@ function dayRange(day: string): DateRange {
|
|||
}
|
||||
}
|
||||
|
||||
async function writeClaudeSession(projectSlug: string, sessionId: string, cwd: string, timestamp: string): Promise<void> {
|
||||
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`)
|
||||
|
|
@ -44,12 +51,9 @@ async function writeClaudeSession(projectSlug: string, sessionId: string, cwd: s
|
|||
id: `msg-${sessionId}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-sonnet-4-5',
|
||||
model,
|
||||
content: [],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
},
|
||||
usage,
|
||||
},
|
||||
}) + '\n')
|
||||
|
||||
|
|
@ -158,3 +162,51 @@ describe('Claude cwd project paths', () => {
|
|||
expect(projects[0]!.projectPath).toBe('fallback/slug')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
434
tests/parser-compact-entry.test.ts
Normal file
434
tests/parser-compact-entry.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
87
tests/parser-large-json-scanner.test.ts
Normal file
87
tests/parser-large-json-scanner.test.ts
Normal file
|
|
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
180
tests/parser-large-session.test.ts
Normal file
180
tests/parser-large-session.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
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()
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
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('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)
|
||||
})
|
||||
})
|
||||
86
tests/parser-skip-line.test.ts
Normal file
86
tests/parser-skip-line.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'kimi', 'roo-code'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
|
||||
})
|
||||
|
||||
it('includes sqlite providers after async load', async () => {
|
||||
|
|
|
|||
123
tests/providers/antigravity.test.ts
Normal file
123
tests/providers/antigravity.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extractAntigravityGeneratorMetadata,
|
||||
extractAntigravityModelMap,
|
||||
parseAntigravityServerInfo,
|
||||
parseAntigravityServerInfoFromLine,
|
||||
} from '../../src/providers/antigravity.js'
|
||||
|
||||
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('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(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([])
|
||||
})
|
||||
})
|
||||
139
tests/providers/cline.test.ts
Normal file
139
tests/providers/cline.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
164
tests/providers/ibm-bob.test.ts
Normal file
164
tests/providers/ibm-bob.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -337,6 +337,124 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
|
|||
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) => {
|
||||
|
|
|
|||
509
tests/session-cache.test.ts
Normal file
509
tests/session-cache.test.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
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 () => {
|
||||
delete process.env['CODEBURN_CACHE_DIR']
|
||||
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('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')
|
||||
const orig = process.env['CLAUDE_CONFIG_DIR']
|
||||
process.env['CLAUDE_CONFIG_DIR'] = '/tmp/different'
|
||||
const after = computeEnvFingerprint('claude')
|
||||
if (orig === undefined) delete process.env['CLAUDE_CONFIG_DIR']
|
||||
else process.env['CLAUDE_CONFIG_DIR'] = orig
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// ── 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()
|
||||
})
|
||||
})
|
||||
|
||||
// ── 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('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()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/cli.ts'],
|
||||
entry: ['src/main.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
outDir: 'dist',
|
||||
|
|
@ -9,7 +9,4 @@ export default defineConfig({
|
|||
splitting: false,
|
||||
sourcemap: true,
|
||||
dts: false,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue