mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-18 21:14:33 +00:00
Merge branch 'main' into feat/omp-support-model-aliases
This commit is contained in:
commit
0c5c6fa655
75 changed files with 9776 additions and 1298 deletions
43
.github/workflows/block-claude-coauthor.yml
vendored
Normal file
43
.github/workflows/block-claude-coauthor.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Block Claude / Anthropic co-author trailers
|
||||
|
||||
# Rejects PRs that contain a `Co-authored-by: ... claude ...` or `... anthropic ...`
|
||||
# trailer in any of their commits. Contributors can still use AI tools to help
|
||||
# write code, but they must remove the co-author attribution before the PR is
|
||||
# eligible to merge, per the project's contributor guidelines.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scan PR commits for disallowed co-author trailers
|
||||
run: |
|
||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||
FOUND=0
|
||||
while IFS= read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
SUBJECT=$(git log -1 "$c" --format=%s)
|
||||
if git log -1 "$c" --format="%B" | grep -qiE 'co-authored-by:.*(claude|anthropic)'; then
|
||||
echo "::error::Commit $c ($SUBJECT) has a Claude/Anthropic co-author trailer. Please remove it before this PR can merge."
|
||||
FOUND=1
|
||||
fi
|
||||
done < <(git log --format=%H "$BASE_SHA".."$HEAD_SHA")
|
||||
if [ "$FOUND" != "0" ]; then
|
||||
echo ""
|
||||
echo "How to fix:"
|
||||
echo " 1. Run: git rebase -i $BASE_SHA"
|
||||
echo " 2. Mark each flagged commit as 'reword'"
|
||||
echo " 3. Delete the Co-authored-by line from the commit message"
|
||||
echo " 4. Save, then: git push --force-with-lease"
|
||||
exit 1
|
||||
fi
|
||||
echo "No disallowed co-author trailers found."
|
||||
27
.github/workflows/ci.yml
vendored
Normal file
27
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Semgrep
|
||||
run: pip install semgrep
|
||||
|
||||
- name: Run Semgrep bracket-assign guard
|
||||
run: |
|
||||
set -e
|
||||
semgrep --config .semgrep/rules/no-bracket-assign-hot-paths.yml \
|
||||
--strict --json \
|
||||
src/providers/ src/parser.ts > semgrep-out.json
|
||||
FINDINGS=$(jq '.results | length' semgrep-out.json)
|
||||
if [ "$FINDINGS" -gt 0 ]; then
|
||||
jq -r '.results[] | "::error file=\(.path),line=\(.start.line)::\(.extra.message)"' semgrep-out.json
|
||||
exit 1
|
||||
fi
|
||||
69
.github/workflows/release-menubar.yml
vendored
Normal file
69
.github/workflows/release-menubar.yml
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
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.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'mac-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version label for the bundle (e.g. v0.8.0 or dev-preview)'
|
||||
required: true
|
||||
default: 'dev-preview'
|
||||
|
||||
permissions:
|
||||
contents: write # Needed to create the release + upload assets.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version label
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" == refs/tags/mac-v* ]]; then
|
||||
echo "value=${GITHUB_REF#refs/tags/mac-}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "value=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Show Swift toolchain
|
||||
run: swift --version
|
||||
|
||||
- name: Build + bundle + zip
|
||||
run: mac/Scripts/package-app.sh "${{ steps.version.outputs.value }}"
|
||||
|
||||
- name: Upload artifact (for manual runs)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: CodeBurnMenubar-${{ steps.version.outputs.value }}
|
||||
path: mac/.build/dist/CodeBurnMenubar-*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create / update GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/mac-v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Menubar ${{ steps.version.outputs.value }}
|
||||
body: |
|
||||
Install with:
|
||||
|
||||
```
|
||||
npx 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.
|
||||
files: mac/.build/dist/CodeBurnMenubar-*.zip
|
||||
fail_on_unmatched_files: true
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -15,6 +15,7 @@ Thumbs.db
|
|||
|
||||
# Planning artifacts (internal, not shipped)
|
||||
docs/superpowers/
|
||||
.claude/
|
||||
|
||||
# Config / secrets
|
||||
.env
|
||||
|
|
@ -32,3 +33,6 @@ npm-debug.log*
|
|||
|
||||
# Build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local Discord brand / promo assets not yet ready to publish
|
||||
assets/discord-*.png
|
||||
|
|
|
|||
22
.semgrep/rules/no-bracket-assign-hot-paths.yml
Normal file
22
.semgrep/rules/no-bracket-assign-hot-paths.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
rules:
|
||||
- id: no-bracket-assign-on-literal-object-map
|
||||
languages: [typescript]
|
||||
severity: ERROR
|
||||
message: >
|
||||
Bracket-assign on a map created with `{}` allows prototype pollution when
|
||||
the key comes from external data. Initialize the map with
|
||||
`Object.create(null)` instead.
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: $MAP[$KEY] = $MAP[$KEY] ?? $INIT
|
||||
- pattern: $MAP[$KEY] = $MAP[$KEY] || $INIT
|
||||
- pattern: $MAP[$KEY] ??= $INIT
|
||||
- pattern: |
|
||||
if (!$MAP[$KEY]) $MAP[$KEY] = $INIT
|
||||
- pattern-not-inside: |
|
||||
const $MAP = Object.create(null)
|
||||
...
|
||||
paths:
|
||||
include:
|
||||
- '/src/providers/*.ts'
|
||||
- '/src/parser.ts'
|
||||
112
CHANGELOG.md
112
CHANGELOG.md
|
|
@ -1,5 +1,117 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- **`codeburn report --from/--to`.** Filter sessions to an exact `YYYY-MM-DD` date range (local time). Either flag alone is valid: `--from` alone runs from the given date through end-of-today, `--to` alone runs from the earliest data through the given date. Inverted ranges or malformed dates exit with a clear error. In the TUI, pressing `1`-`5` still switches to the predefined periods. Credit: @lfl1337 (PR #80).
|
||||
- **`avgCostPerSession` in reports.** JSON `projects[]` entries gain an `avgCostPerSession` field and `export -f csv` adds an `Avg/Session (USD)` column to `projects.csv`. Column order in `projects.csv` is now `Project, Cost, Avg/Session, Share, API Calls, Sessions` -- scripts parsing by column position should read by header instead. Credit: @lfl1337 (PR #80).
|
||||
|
||||
### Security
|
||||
- **Semgrep CI guard against prototype pollution regressions.** New `.github/workflows/ci.yml` runs a bracket-assign guard on `src/providers/` and `src/parser.ts` on every push to main and every PR. Blocks re-introducing `$MAP[$KEY] = $MAP[$KEY] ?? $INIT` patterns on `{}`-initialized maps. `categoryBreakdown` in `parser.ts` switched to `Object.create(null)` for consistency with its sibling breakdown maps. Credit: @lfl1337 (PR #78).
|
||||
|
||||
## 0.7.3 - 2026-04-18
|
||||
|
||||
### Changed
|
||||
- **Dropped `better-sqlite3` in favor of Node's built-in `node:sqlite`.** Removes the deprecated `prebuild-install` transitive dependency that npm warned about on every install (issue #75, credit @primeminister). End-user install is now 40 packages down from 167 and shows zero deprecation notices. The experimental-SQLite warning Node 22/23 normally prints on module load is silenced for this specific warning; other warnings pass through unchanged.
|
||||
- **Minimum Node version raised to 22.** Node 20 reached EOL on 2026-04-30; `node:sqlite` lives in 22+. Users on older Node get a clear upgrade message when a SQLite-backed provider (Cursor, OpenCode) is loaded.
|
||||
|
||||
|
||||
## 0.7.2 - 2026-04-17
|
||||
|
||||
### Added
|
||||
- **Native macOS menubar app.** Swift + SwiftUI app under `mac/` replaces the SwiftBar plugin. Agent tabs, Today/7/30/Month/All period switcher, Trend/Forecast/Pulse/Stats/Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, instant currency switching, live 60s refresh.
|
||||
- **`codeburn menubar`.** One-command install: downloads the latest `.app` from GitHub Releases, strips Gatekeeper quarantine, drops it into `~/Applications`, and launches it. `--force` reinstalls in place.
|
||||
- **`status --format menubar-json`.** Structured payload consumed by the native menubar app. Current-period totals, per-activity and per-model breakdowns, provider costs, optimize findings, and 365-day history.
|
||||
- **Release workflow.** `.github/workflows/release-menubar.yml` builds a universal `.app` bundle and zip on `mac-v*` tag push.
|
||||
|
||||
### Changed
|
||||
- **`codeburn export -f csv`** now writes a folder of one-table-per-file CSVs (`summary`, `daily`, `activity`, `models`, `projects`, `sessions`, `tools`, `shell-commands`) plus a `README.txt` index. Each file opens cleanly as a single table in any spreadsheet.
|
||||
- **`codeburn export -f json`** upgraded to schema `codeburn.export.v2` with currency metadata.
|
||||
|
||||
### Fixed
|
||||
- **`codeburn status` terminal Today/Month** now buckets by local date instead of UTC, so spend shows correctly during the window between local midnight and UTC midnight.
|
||||
- **FX rate validation.** Frankfurter responses are checked to be finite and within `[0.0001, 1_000_000]` before they affect displayed costs.
|
||||
|
||||
### Removed
|
||||
- **SwiftBar plugin.** `src/menubar.ts`, `codeburn install-menubar`, `codeburn uninstall-menubar`, and `status --format menubar` are gone. The native Swift app is the single menubar surface.
|
||||
|
||||
### Security
|
||||
- **`codeburn export -o` guard.** Writes a `.codeburn-export` marker into every folder it creates and refuses to reuse non-marked directories or overwrite existing files, so a typo like `-o ~/.ssh/id_ed25519` cannot delete a sensitive file.
|
||||
|
||||
## 0.7.1 - 2026-04-17
|
||||
|
||||
### Security
|
||||
- **External security audit closed.** 1 HIGH, 2 MEDIUM, and 1 LOW finding fixed. Threat model: a compromised third-party AI CLI with write access to `~/.claude/projects/` dropping malicious session JSONL.
|
||||
- **Prototype pollution blocked.** Breakdown maps in `parser.ts` (model, tool, MCP, bash) now use `Object.create(null)` so attacker-controlled keys like `__proto__` create own properties instead of mutating `Object.prototype`. Credit: @lfl1337 (PR #67).
|
||||
- **Bounded session-file reads.** New `src/fs-utils.ts` helper caps reads at 128 MB and switches to stream-based parsing above 8 MB. Applied to 13 reachable read sites across parser, Codex, Copilot, Pi, context-budget, and optimize. Credit: @lfl1337 (PR #67).
|
||||
- **Menubar label sanitizer.** SwiftBar directive-separator (`|`) and ANSI escape injection via crafted model or category names is now prevented by an allowlist (`[A-Za-z0-9 ._/-]`) plus 14-character truncation. Credit: @lfl1337 (PR #67).
|
||||
|
||||
### Added
|
||||
- **`--verbose` flag.** Global CLI option that prints warnings to stderr on skipped (oversize) or failed session-file reads. Silent by default. Credit: @lfl1337 (PR #67).
|
||||
- **11 new security tests.** `tests/security/prototype-pollution.test.ts`, `tests/security/menubar-injection.test.ts`, `tests/fs-utils.test.ts`. Total suite: 209 tests.
|
||||
|
||||
## 0.7.0 - 2026-04-16
|
||||
|
||||
### Added
|
||||
- **`codeburn optimize` command.** Scans your sessions and your `~/.claude/`
|
||||
setup for 11 common waste patterns and hands back exact copy-paste fixes.
|
||||
Detection-only, never writes to user files. Supports `--period` (today,
|
||||
week, 30days, month, all) and `--provider` (all, claude, codex, cursor).
|
||||
- **Setup health grade (A-F).** Urgency-weighted rollup of all findings, with
|
||||
impact scored against observed waste so the most expensive issues rank
|
||||
first. High findings penalise more, medium less, low least.
|
||||
- **Trend tracking.** Repeat runs classify each finding as new, improving,
|
||||
or resolved against a 48-hour recent window, so fixed issues disappear
|
||||
instead of lingering as noise.
|
||||
- **11 detectors:** files Claude re-reads across sessions, low Read:Edit
|
||||
ratio, projects missing `.claudeignore`, uncapped `BASH_MAX_OUTPUT_LENGTH`,
|
||||
unused MCP servers, ghost agents, ghost skills, ghost slash commands,
|
||||
bloated `CLAUDE.md` files (with `@-import` expansion counted), cache
|
||||
creation overhead, and junk directory reads.
|
||||
- **Copy-paste fixes.** Each finding comes with a ready-to-paste remedy: a
|
||||
`CLAUDE.md` line, a `.claudeignore` template, an environment variable, or
|
||||
a `mv` command to archive unused items.
|
||||
- **In-TUI optimize view.** Press `o` in the dashboard when the status bar
|
||||
shows a finding count, `b` to return. Same engine as the standalone
|
||||
command, scoped to the current period and provider.
|
||||
- **Per-project context budget column.** By Project panel now shows the
|
||||
estimated per-session context overhead for each project (system prompt +
|
||||
tools + `CLAUDE.md` + skills).
|
||||
- **34 filesystem-mocking tests.** Tmpdir fixtures with `os.homedir` mocked
|
||||
via `vi.mock` cover the detector surface end to end. Total suite: 198
|
||||
tests across 13 files.
|
||||
|
||||
### Performance
|
||||
- **mtime pre-filter + parallel reads + 60s result cache** cut a cold scan
|
||||
from 12-17s to 6-7s on a 10k-session history.
|
||||
|
||||
## 0.6.1 - 2026-04-16
|
||||
|
||||
### Added
|
||||
- **JSON output on `report`, `today`, `month`.** `--format json` writes the
|
||||
full dashboard (overview, daily, projects, models, activities, tools, MCP
|
||||
servers, shell commands, top sessions) to stdout. Contributed by @mallek.
|
||||
- **Project filters.** `--project <name>` and `--exclude <name>` on all
|
||||
commands (`report`, `today`, `month`, `status`, `export`). Case-insensitive
|
||||
substring match against project name and path. Both flags are repeatable.
|
||||
Contributed by @mallek.
|
||||
- **claude-opus-4-7 model mapping and pricing.** Displays as `Opus 4.7` with
|
||||
the same Opus pricing as 4.6 and a 6x fast multiplier. Contributed by @mallek.
|
||||
- **Unit tests for `filterProjectsByName`** covering include/exclude
|
||||
semantics, case-insensitivity, path matching, and input immutability.
|
||||
|
||||
### Fixed
|
||||
- **Top Sessions panel truncating the calls column.** Row width filled the
|
||||
full panel width without leaving room for the border and padding, so Ink
|
||||
truncated the last 4 characters -- landing exactly on the calls column and
|
||||
producing rows like `$182.58 ...` with no value.
|
||||
- **SwiftBar custom plugin directory** now honoured when installing the
|
||||
menubar widget. Reads the configured path from SwiftBar's defaults before
|
||||
falling back to the standard location. Contributed by @Galeas.
|
||||
- **`status --format menubar` per-provider today totals** now respect
|
||||
`--project`/`--exclude`. The main period blocks already did, the provider
|
||||
breakdown loop was the one spot that bypassed the filter.
|
||||
|
||||
## 0.6.0 - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -75,3 +75,10 @@ gh pr comment <number> --body "Merged, thanks!"
|
|||
- NEVER include personal names or usernames in commits
|
||||
- Small, focused commits. One feature per commit
|
||||
- Test locally before every commit
|
||||
|
||||
### Public-facing language (commits, PRs, release notes, README)
|
||||
- Commits and release notes are public. Write like you'd publish them.
|
||||
- NEVER use words like "steal", "stealing", "copy", "rip off", "inspired by" in commit messages
|
||||
- Describe what the code does, not where ideas came from
|
||||
- If you must credit prior art, do it in code comments or docs, not commit messages
|
||||
- No snark, no filler, no self-deprecation. Treat each commit as a product statement
|
||||
|
|
|
|||
117
README.md
117
README.md
|
|
@ -1,5 +1,5 @@
|
|||
<p align="center">
|
||||
<img src="https://cdn.jsdelivr.net/gh/AgentSeal/codeburn@main/assets/logo.png" alt="CodeBurn" width="120" />
|
||||
<img src="https://cdn.jsdelivr.net/gh/getagentseal/codeburn@main/assets/logo.png" alt="CodeBurn" width="120" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">CodeBurn</h1>
|
||||
|
|
@ -11,15 +11,15 @@
|
|||
<a href="https://www.npmjs.com/package/codeburn"><img src="https://img.shields.io/npm/dt/codeburn.svg" alt="total downloads" /></a>
|
||||
<a href="https://www.npmjs.com/package/codeburn"><img src="https://img.shields.io/npm/dm/codeburn.svg" alt="monthly downloads" /></a>
|
||||
<a href="https://bundlephobia.com/package/codeburn"><img src="https://img.shields.io/bundlephobia/min/codeburn" alt="install size" /></a>
|
||||
<a href="https://github.com/agentseal/codeburn/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/codeburn.svg" alt="license" /></a>
|
||||
<a href="https://github.com/agentseal/codeburn"><img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg" alt="node version" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/codeburn.svg" alt="license" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn"><img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg" alt="node version" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/AgentSeal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
|
||||
</p>
|
||||
|
||||
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, **[OMP](https://github.com/can1357/oh-my-pi)** (Oh My Pi), and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
|
||||
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, **[OMP](https://github.com/can1357/oh-my-pi)** (Oh My Pi), and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. Native macOS menubar app in `mac/`. CSV/JSON export.
|
||||
|
||||
Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported).
|
||||
|
||||
|
|
@ -44,20 +44,44 @@ npx codeburn
|
|||
## Usage
|
||||
|
||||
```bash
|
||||
codeburn # interactive dashboard (default: 7 days)
|
||||
codeburn today # today's usage
|
||||
codeburn month # this month's usage
|
||||
codeburn report -p 30days # rolling 30-day window
|
||||
codeburn report -p all # every recorded session
|
||||
codeburn report --refresh 60 # auto-refresh every 60 seconds
|
||||
codeburn status # compact one-liner (today + month)
|
||||
codeburn # interactive dashboard (default: 7 days)
|
||||
codeburn today # today's usage
|
||||
codeburn month # this month's usage
|
||||
codeburn report -p 30days # rolling 30-day window
|
||||
codeburn report -p all # every recorded session
|
||||
codeburn report --from 2026-04-01 --to 2026-04-10 # exact date range
|
||||
codeburn report --format json # full dashboard data as JSON
|
||||
codeburn report --refresh 60 # auto-refresh every 60 seconds
|
||||
codeburn status # compact one-liner (today + month)
|
||||
codeburn status --format json
|
||||
codeburn export # CSV with today, 7 days, 30 days
|
||||
codeburn export -f json # JSON export
|
||||
codeburn export # CSV with today, 7 days, 30 days
|
||||
codeburn export -f json # JSON export
|
||||
codeburn optimize # find waste, get copy-paste fixes
|
||||
codeburn optimize -p week # scope the scan to last 7 days
|
||||
```
|
||||
|
||||
Arrow keys switch between Today / 7 Days / 30 Days / Month / All Time. Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
|
||||
|
||||
### JSON output
|
||||
|
||||
`report`, `today`, and `month` support `--format json` to output the full dashboard data as structured JSON to stdout:
|
||||
|
||||
```bash
|
||||
codeburn report --format json # 7-day JSON report
|
||||
codeburn today --format json # today's data as JSON
|
||||
codeburn month --format json # this month as JSON
|
||||
codeburn report -p 30days --format json # 30-day window
|
||||
```
|
||||
|
||||
The JSON includes all dashboard panels: overview (cost, calls, sessions, cache hit %), daily breakdown, projects (with `avgCostPerSession`), models with token counts, activities with one-shot rates, core tools, MCP servers, and shell commands. Pipe to `jq` for filtering:
|
||||
|
||||
```bash
|
||||
codeburn report --format json | jq '.projects'
|
||||
codeburn today --format json | jq '.overview.cost'
|
||||
```
|
||||
|
||||
For the lighter `status --format json` (today + month totals only) or file-based exports (`export -f json`), see above.
|
||||
|
||||
## Providers
|
||||
|
||||
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.
|
||||
|
|
@ -77,6 +101,33 @@ codeburn export --provider claude # export Claude data only
|
|||
|
||||
The `--provider` flag works on all commands: `report`, `today`, `month`, `status`, `export`.
|
||||
|
||||
### Project filtering
|
||||
|
||||
Filter results by project name (case-insensitive substring match). Both flags are repeatable:
|
||||
|
||||
```bash
|
||||
codeburn report --project myapp # show only projects matching "myapp"
|
||||
codeburn report --exclude myapp # show everything except "myapp"
|
||||
codeburn report --exclude myapp --exclude tests # exclude multiple projects
|
||||
codeburn month --project api --project web # include multiple projects
|
||||
codeburn export --project inventory # export only "inventory" project data
|
||||
```
|
||||
|
||||
The `--project` and `--exclude` flags work on all commands and can be combined with `--provider`.
|
||||
|
||||
### Date range filtering
|
||||
|
||||
Beyond the preset periods, specify an exact window with `--from` and `--to` (`YYYY-MM-DD`, local time):
|
||||
|
||||
```bash
|
||||
codeburn report --from 2026-04-01 --to 2026-04-10 # explicit window
|
||||
codeburn report --from 2026-04-01 # this date through today
|
||||
codeburn report --to 2026-04-10 # earliest data through this date
|
||||
codeburn report --from 2026-04-01 --to 2026-04-10 --format json
|
||||
```
|
||||
|
||||
Either flag alone is valid. Inverted or malformed dates exit with a clear error. In the TUI, the custom range sets the initial load only -- pressing `1`-`5` switches back to predefined periods.
|
||||
|
||||
### Supported providers
|
||||
|
||||
| Provider | Data location | Status |
|
||||
|
|
@ -137,14 +188,13 @@ The menu bar widget includes a currency picker with 17 common currencies. For an
|
|||
|
||||
## Menu Bar
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/AgentSeal/codeburn@main/assets/menubar.png" alt="CodeBurn SwiftBar menu bar widget" width="260" />
|
||||
<img src="https://cdn.jsdelivr.net/gh/getagentseal/codeburn@main/assets/menubar-0.7.2.png" alt="CodeBurn macOS menubar app" width="420" />
|
||||
|
||||
```bash
|
||||
codeburn install-menubar # install SwiftBar/xbar plugin
|
||||
codeburn uninstall-menubar # remove it
|
||||
npx codeburn menubar
|
||||
```
|
||||
|
||||
Requires [SwiftBar](https://github.com/swiftbar/SwiftBar) (`brew install --cask swiftbar`). Shows today's cost in the menu bar with a flame icon. Dropdown shows activity breakdown, model costs, token stats, per-provider cost breakdown, and a currency picker. Refreshes every 5 minutes.
|
||||
One command: downloads the latest `.app`, installs into `~/Applications`, and launches it. Re-run with `--force` to reinstall. Native Swift + SwiftUI app lives in `mac/` (see `mac/README.md` for build details). Shows today's cost with a flame icon, opens a popover with agent tabs, period switcher (Today / 7 Days / 30 Days / Month / All), Trend / Forecast / Pulse / Stats / Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes live via FSEvents plus a 60-second poll.
|
||||
|
||||
## What it tracks
|
||||
|
||||
|
|
@ -189,6 +239,35 @@ CodeBurn surfaces the data, you read the story. A few patterns worth knowing:
|
|||
|
||||
These are starting points, not verdicts. A 60% cache hit on a single experimental session is fine. A persistent 60% cache hit across weeks of work is a config issue.
|
||||
|
||||
## Optimize
|
||||
|
||||
Once you know what to look for, `codeburn optimize` scans your sessions and your `~/.claude/` setup for the most common waste patterns and hands back exact, copy-paste fixes. It never writes to your files.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/optimize.jpg" alt="CodeBurn optimize output" width="720" />
|
||||
</p>
|
||||
|
||||
```bash
|
||||
codeburn optimize # scan the last 30 days
|
||||
codeburn optimize -p today # today only
|
||||
codeburn optimize -p week # last 7 days
|
||||
codeburn optimize --provider claude # restrict to one provider
|
||||
```
|
||||
|
||||
**What it detects**
|
||||
|
||||
- Files Claude re-reads across sessions (same content, same context, over and over)
|
||||
- Low Read:Edit ratio (editing without reading leads to retries and wasted tokens)
|
||||
- Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise)
|
||||
- Unused MCP servers still paying their tool-schema overhead every session
|
||||
- Ghost agents, skills, and slash commands defined in `~/.claude/` but never invoked
|
||||
- Bloated `CLAUDE.md` files (with `@-import` expansion counted)
|
||||
- Cache creation overhead and junk directory reads
|
||||
|
||||
Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A-F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window.
|
||||
|
||||
You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return.
|
||||
|
||||
## How it reads data
|
||||
|
||||
**Claude Code** stores session transcripts as JSONL at `~/.claude/projects/<sanitized-path>/<session-id>.jsonl`. Each assistant entry contains model name, token usage (input, output, cache read, cache write), tool_use blocks, and timestamps.
|
||||
|
|
@ -221,7 +300,7 @@ src/
|
|||
classifier.ts 13-category task classifier
|
||||
types.ts Type definitions
|
||||
format.ts Text rendering (status bar)
|
||||
menubar.ts SwiftBar plugin generator
|
||||
menubar-json.ts Payload builder consumed by the native macOS menubar app in mac/
|
||||
export.ts CSV/JSON multi-period export
|
||||
config.ts Config file management (~/.config/codeburn/)
|
||||
currency.ts Currency conversion, exchange rates, Intl formatting
|
||||
|
|
|
|||
BIN
assets/menubar-0.7.2.png
Normal file
BIN
assets/menubar-0.7.2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 298 KiB |
BIN
assets/optimize.jpg
Normal file
BIN
assets/optimize.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
6
mac/.gitignore
vendored
Normal file
6
mac/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.build/
|
||||
.swiftpm/
|
||||
Package.resolved
|
||||
*.xcodeproj/
|
||||
*.xcworkspace/
|
||||
DerivedData/
|
||||
26
mac/Package.swift
Normal file
26
mac/Package.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "CodeBurnMenubar",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"])
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "CodeBurnMenubar",
|
||||
path: "Sources/CodeBurnMenubar",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "CodeBurnMenubarTests",
|
||||
dependencies: ["CodeBurnMenubar"],
|
||||
path: "Tests/CodeBurnMenubarTests"
|
||||
)
|
||||
]
|
||||
)
|
||||
88
mac/README.md
Normal file
88
mac/README.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# CodeBurn Menubar (macOS)
|
||||
|
||||
Native Swift + SwiftUI menubar app. The codeburn menubar surface.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 14+ (Sonoma)
|
||||
- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone)
|
||||
- `codeburn` CLI installed globally (`npm install -g codeburn`) or available at a path you pass via `CODEBURN_BIN`
|
||||
|
||||
## Install (end users)
|
||||
|
||||
One command:
|
||||
|
||||
```bash
|
||||
npx 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.
|
||||
|
||||
### Build from source
|
||||
|
||||
For contributors running a local build instead of the packaged release:
|
||||
|
||||
```bash
|
||||
npm install -g codeburn # CLI the app shells out to for data
|
||||
git clone https://github.com/getagentseal/codeburn.git
|
||||
cd codeburn/mac
|
||||
swift build -c release
|
||||
.build/release/CodeBurnMenubar # launch
|
||||
```
|
||||
|
||||
## Build & run (dev against a local CLI checkout)
|
||||
|
||||
```bash
|
||||
cd mac
|
||||
swift build
|
||||
# Point the app at your dev CLI build instead of the globally installed `codeburn`:
|
||||
npm --prefix .. run build
|
||||
CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run
|
||||
```
|
||||
|
||||
The app registers itself as a menubar accessory (`LSUIElement = true` at runtime). No Dock icon.
|
||||
|
||||
## Data source
|
||||
|
||||
On launch and every 60 seconds thereafter, the app spawns `codeburn status --format menubar-json --no-optimize` directly (argv, no shell) via `CodeburnCLI.makeProcess` and decodes the JSON into `MenubarPayload`. The manual refresh button in the footer invokes the same command without `--no-optimize`, which includes optimize findings but takes longer.
|
||||
|
||||
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.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
mac/
|
||||
├── Package.swift SwiftPM manifest
|
||||
├── Sources/CodeBurnMenubar/
|
||||
│ ├── CodeBurnApp.swift @main + MenuBarExtra scene
|
||||
│ ├── AppStore.swift @Observable store + enums
|
||||
│ ├── Data/MenubarPayload.swift Codable payload types + placeholder
|
||||
│ ├── Theme/Theme.swift Design tokens (warm terracotta palette)
|
||||
│ └── Views/MenuBarContent.swift Popover layout + footer action bar
|
||||
└── README.md This file
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Live data wired. Next iterations:
|
||||
|
||||
1. FSEvents watch for `~/.claude/projects/` changes (debounced refresh on real edits)
|
||||
2. Persistent disk cache for optimize findings so the default refresh can include them without the 30-second penalty
|
||||
3. Currency metadata in the JSON payload + Swift-side formatting
|
||||
4. Sparkle auto-update
|
||||
5. DMG packaging + Homebrew Cask tap
|
||||
|
||||
## Design tokens
|
||||
|
||||
Sourced from `~/codeburn-menubar-mac-swiftui.html`. Warm terracotta-ember palette:
|
||||
|
||||
- Accent (light): `#C9521D`
|
||||
- Accent (dark): `#E8774A`
|
||||
- Ember deep: `#8B3E13`
|
||||
- Ember glow: `#F0A070`
|
||||
- Surface (light): `#FAF7F3`
|
||||
- Surface (dark): `#1C1816`
|
||||
|
||||
SF Mono for currency values; SF Pro Rounded for hero.
|
||||
103
mac/Scripts/package-app.sh
Executable file
103
mac/Scripts/package-app.sh
Executable file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env bash
|
||||
# Builds a universal CodeBurnMenubar.app bundle from the SwiftPM target and drops a
|
||||
# distributable zip alongside. Used by the GitHub release workflow; also runnable locally.
|
||||
#
|
||||
# Usage:
|
||||
# mac/Scripts/package-app.sh [<version>]
|
||||
# Defaults to `dev` if no version is given.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-dev}"
|
||||
BUNDLE_NAME="CodeBurnMenubar.app"
|
||||
BUNDLE_ID="org.agentseal.codeburn-menubar"
|
||||
EXECUTABLE_NAME="CodeBurnMenubar"
|
||||
MIN_MACOS="14.0"
|
||||
|
||||
repo_root() {
|
||||
git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/../.." && pwd)
|
||||
}
|
||||
|
||||
ROOT=$(repo_root)
|
||||
MAC_DIR="${ROOT}/mac"
|
||||
DIST_DIR="${MAC_DIR}/.build/dist"
|
||||
|
||||
cd "${MAC_DIR}"
|
||||
|
||||
echo "▸ Cleaning previous dist..."
|
||||
rm -rf "${DIST_DIR}"
|
||||
mkdir -p "${DIST_DIR}"
|
||||
|
||||
echo "▸ Building universal binary (arm64 + x86_64)..."
|
||||
swift build -c release --arch arm64 --arch x86_64
|
||||
|
||||
BIN_PATH=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)
|
||||
BUILT_BINARY="${BIN_PATH}/${EXECUTABLE_NAME}"
|
||||
if [[ ! -x "${BUILT_BINARY}" ]]; then
|
||||
echo "Binary not found at ${BUILT_BINARY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "▸ Assembling ${BUNDLE_NAME}..."
|
||||
BUNDLE="${DIST_DIR}/${BUNDLE_NAME}"
|
||||
mkdir -p "${BUNDLE}/Contents/MacOS"
|
||||
mkdir -p "${BUNDLE}/Contents/Resources"
|
||||
cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}"
|
||||
|
||||
cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>CodeBurn Menubar</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>${EXECUTABLE_NAME}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${BUNDLE_ID}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>${EXECUTABLE_NAME}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${MIN_MACOS}</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© AgentSeal</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
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.
|
||||
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)"
|
||||
|
||||
ZIP_NAME="CodeBurnMenubar-${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}")
|
||||
|
||||
echo ""
|
||||
echo "✓ Built ${ZIP_PATH}"
|
||||
ls -la "${DIST_DIR}"
|
||||
311
mac/Sources/CodeBurnMenubar/AppStore.swift
Normal file
311
mac/Sources/CodeBurnMenubar/AppStore.swift
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
|
||||
private let cacheTTLSeconds: TimeInterval = 300
|
||||
|
||||
struct CachedPayload {
|
||||
let payload: MenubarPayload
|
||||
let fetchedAt: Date
|
||||
var isFresh: Bool { Date().timeIntervalSince(fetchedAt) < cacheTTLSeconds }
|
||||
}
|
||||
|
||||
struct PayloadCacheKey: Hashable {
|
||||
let period: Period
|
||||
let provider: ProviderFilter
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppStore {
|
||||
var selectedProvider: ProviderFilter = .all
|
||||
var selectedPeriod: Period = .today
|
||||
var selectedInsight: InsightMode = .trend
|
||||
var currency: String = "USD"
|
||||
var isLoading: Bool = false
|
||||
var lastError: String?
|
||||
var subscription: SubscriptionUsage?
|
||||
var subscriptionError: String?
|
||||
var subscriptionLoadState: SubscriptionLoadState = .idle
|
||||
var capacityEstimates: [String: CapacityEstimate] = [:]
|
||||
|
||||
private var cache: [PayloadCacheKey: CachedPayload] = [:]
|
||||
|
||||
private var currentKey: PayloadCacheKey {
|
||||
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
|
||||
}
|
||||
|
||||
var payload: MenubarPayload {
|
||||
cache[currentKey]?.payload ?? .empty
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
var hasCachedData: Bool {
|
||||
cache[currentKey] != nil
|
||||
}
|
||||
|
||||
var findingsCount: Int {
|
||||
payload.optimize.findingCount
|
||||
}
|
||||
|
||||
/// Switch to a period. Uses cached payload if fresh; otherwise fetches.
|
||||
func switchTo(period: Period) async {
|
||||
selectedPeriod = period
|
||||
if let cached = cache[currentKey], cached.isFresh { return }
|
||||
await refresh(includeOptimize: true)
|
||||
}
|
||||
|
||||
/// Switch to a provider filter. Uses cached payload if fresh; otherwise fetches.
|
||||
func switchTo(provider: ProviderFilter) async {
|
||||
selectedProvider = provider
|
||||
if let cached = cache[currentKey], cached.isFresh { return }
|
||||
await refresh(includeOptimize: true)
|
||||
}
|
||||
|
||||
private var inFlightKeys: Set<PayloadCacheKey> = []
|
||||
|
||||
/// Refresh the currently selected (period, provider) combination. Guards against concurrent
|
||||
/// fetches for the same key so a slow initial request can't overwrite a newer one that
|
||||
/// finished first (which would show stale numbers the user has already moved past).
|
||||
func refresh(includeOptimize: Bool) async {
|
||||
let key = currentKey
|
||||
guard !inFlightKeys.contains(key) else { return }
|
||||
inFlightKeys.insert(key)
|
||||
isLoading = true
|
||||
defer {
|
||||
inFlightKeys.remove(key)
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
|
||||
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = String(describing: error)
|
||||
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
do {
|
||||
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: true)
|
||||
cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||
} catch {
|
||||
NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch Claude subscription usage. Sets subscription = nil on missing creds (API users / unauthenticated).
|
||||
/// Triggered lazily when the user opens the Plan pill, so the Keychain prompt only fires on intent.
|
||||
func refreshSubscription() async {
|
||||
subscriptionLoadState = .loading
|
||||
do {
|
||||
let usage = try await SubscriptionClient.fetch()
|
||||
subscription = usage
|
||||
subscriptionError = nil
|
||||
subscriptionLoadState = .loaded
|
||||
await captureSnapshots(for: usage)
|
||||
} catch SubscriptionError.noCredentials {
|
||||
subscription = nil
|
||||
subscriptionError = nil
|
||||
subscriptionLoadState = .noCredentials
|
||||
} catch {
|
||||
subscription = nil
|
||||
subscriptionError = String(describing: error)
|
||||
subscriptionLoadState = .failed
|
||||
NSLog("CodeBurn: subscription fetch failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
|
||||
/// when the current window has just reset and projection from current data isn't meaningful.
|
||||
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
|
||||
/// which the CapacityEstimator uses to derive the absolute token capacity per tier.
|
||||
private func captureSnapshots(for usage: SubscriptionUsage) async {
|
||||
let now = Date()
|
||||
let history = payload.history.daily
|
||||
|
||||
let captures: [(key: String, percent: Double?, resetsAt: Date?, effective: Double?)] = [
|
||||
("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, nil),
|
||||
("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt,
|
||||
effectiveTokensInLast7Days(history: history, asOf: now)),
|
||||
("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, nil),
|
||||
("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, nil),
|
||||
]
|
||||
for capture in captures {
|
||||
guard let percent = capture.percent, let resetsAt = capture.resetsAt else { continue }
|
||||
await SubscriptionSnapshotStore.record(SubscriptionSnapshot(
|
||||
windowKey: capture.key,
|
||||
percent: percent,
|
||||
resetsAt: resetsAt,
|
||||
capturedAt: now,
|
||||
effectiveTokens: capture.effective
|
||||
))
|
||||
}
|
||||
|
||||
await refreshCapacityEstimates()
|
||||
}
|
||||
|
||||
/// Sum effective tokens (input + 5*output + cache_creation + 0.1*cache_read) across the
|
||||
/// last 7 days of dailyHistory. Used as the "tokens consumed in 7-day window" reading paired
|
||||
/// with the API-reported percent for capacity estimation.
|
||||
private func effectiveTokensInLast7Days(history: [DailyHistoryEntry], asOf now: Date) -> Double {
|
||||
let cutoff = ISO8601DateFormatter().string(from: now.addingTimeInterval(-7 * 86400)).prefix(10)
|
||||
return history
|
||||
.filter { $0.date >= cutoff }
|
||||
.reduce(0.0) { $0 + $1.effectiveTokens }
|
||||
}
|
||||
|
||||
/// Run CapacityEstimator over each window's accumulated snapshots. Only snapshots with a
|
||||
/// non-nil effectiveTokens contribute. Result lives in capacityEstimates dict for UI gating.
|
||||
private func refreshCapacityEstimates() async {
|
||||
var next: [String: CapacityEstimate] = [:]
|
||||
for key in ["seven_day", "seven_day_opus", "seven_day_sonnet"] {
|
||||
let snaps = await SubscriptionSnapshotStore.snapshots(for: key)
|
||||
let capacitySnaps = snaps.compactMap { s -> CapacitySnapshot? in
|
||||
guard let effective = s.effectiveTokens, effective > 0 else { return nil }
|
||||
return CapacitySnapshot(percent: s.percent, effectiveTokens: effective, capturedAt: s.capturedAt)
|
||||
}
|
||||
if let estimate = CapacityEstimator.estimate(capacitySnaps) {
|
||||
next[key] = estimate
|
||||
}
|
||||
}
|
||||
capacityEstimates = next
|
||||
}
|
||||
}
|
||||
|
||||
enum SupportedCurrency: String, CaseIterable, Identifiable {
|
||||
case USD, GBP, EUR, AUD, CAD, NZD, JPY, CHF, INR, BRL, SEK, SGD, HKD, KRW, MXN, ZAR, DKK
|
||||
var id: String { rawValue }
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .USD: "US Dollar"
|
||||
case .GBP: "British Pound"
|
||||
case .EUR: "Euro"
|
||||
case .AUD: "Australian Dollar"
|
||||
case .CAD: "Canadian Dollar"
|
||||
case .NZD: "New Zealand Dollar"
|
||||
case .JPY: "Japanese Yen"
|
||||
case .CHF: "Swiss Franc"
|
||||
case .INR: "Indian Rupee"
|
||||
case .BRL: "Brazilian Real"
|
||||
case .SEK: "Swedish Krona"
|
||||
case .SGD: "Singapore Dollar"
|
||||
case .HKD: "Hong Kong Dollar"
|
||||
case .KRW: "South Korean Won"
|
||||
case .MXN: "Mexican Peso"
|
||||
case .ZAR: "South African Rand"
|
||||
case .DKK: "Danish Krone"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProviderFilter: String, CaseIterable, Identifiable {
|
||||
case all = "All"
|
||||
case claude = "Claude"
|
||||
case codex = "Codex"
|
||||
case cursor = "Cursor"
|
||||
case copilot = "Copilot"
|
||||
case opencode = "OpenCode"
|
||||
case pi = "Pi"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Maps to the CLI's `--provider` argument values.
|
||||
var cliArg: String {
|
||||
switch self {
|
||||
case .all: "all"
|
||||
case .claude: "claude"
|
||||
case .codex: "codex"
|
||||
case .cursor: "cursor"
|
||||
case .copilot: "copilot"
|
||||
case .opencode: "opencode"
|
||||
case .pi: "pi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SubscriptionLoadState: Sendable, Equatable {
|
||||
case idle // never tried, awaiting user intent
|
||||
case loading // fetch in progress
|
||||
case loaded // success; subscription is populated
|
||||
case noCredentials // tried; user has no Claude OAuth (API user / not logged in)
|
||||
case failed // tried; error occurred
|
||||
}
|
||||
|
||||
enum InsightMode: String, CaseIterable, Identifiable {
|
||||
case plan = "Plan"
|
||||
case trend = "Trend"
|
||||
case forecast = "Forecast"
|
||||
case pulse = "Pulse"
|
||||
case stats = "Stats"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum Period: String, CaseIterable, Identifiable {
|
||||
case today = "Today"
|
||||
case sevenDays = "7 Days"
|
||||
case thirtyDays = "30 Days"
|
||||
case month = "Month"
|
||||
case all = "All"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Maps to the CLI's `--period` argument values.
|
||||
var cliArg: String {
|
||||
switch self {
|
||||
case .today: "today"
|
||||
case .sevenDays: "week"
|
||||
case .thirtyDays: "30days"
|
||||
case .month: "month"
|
||||
case .all: "all"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NumberFormatter is expensive to instantiate (~microseconds each) and currency/token values
|
||||
/// are formatted dozens of times per popover refresh. These shared instances avoid thousands of
|
||||
/// allocations per frame while SwiftUI's Observation framework still triggers redraws when
|
||||
/// CurrencyState.shared mutates.
|
||||
private let groupedDecimalFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.groupingSeparator = ","
|
||||
f.decimalSeparator = "."
|
||||
f.maximumFractionDigits = 2
|
||||
f.minimumFractionDigits = 2
|
||||
return f
|
||||
}()
|
||||
|
||||
private let thousandsFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.groupingSeparator = ","
|
||||
return f
|
||||
}()
|
||||
|
||||
extension Double {
|
||||
func asCurrency() -> String {
|
||||
let state = CurrencyState.shared
|
||||
let converted = self * state.rate
|
||||
return state.symbol + (groupedDecimalFormatter.string(from: NSNumber(value: converted)) ?? "\(converted)")
|
||||
}
|
||||
|
||||
func asCompactCurrency() -> String {
|
||||
let state = CurrencyState.shared
|
||||
return String(format: "\(state.symbol)%.2f", self * state.rate)
|
||||
}
|
||||
}
|
||||
|
||||
extension Int {
|
||||
func asThousandsSeparated() -> String {
|
||||
thousandsFormatter.string(from: NSNumber(value: self)) ?? "\(self)"
|
||||
}
|
||||
}
|
||||
185
mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Normal file
185
mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import SwiftUI
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
private let refreshIntervalSeconds: UInt64 = 60
|
||||
private let nanosPerSecond: UInt64 = 1_000_000_000
|
||||
private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
|
||||
/// Fixed so the popover's anchor point doesn't shift each time today's cost changes.
|
||||
private let statusItemFixedWidth: CGFloat = 130
|
||||
private let popoverWidth: CGFloat = 360
|
||||
private let popoverHeight: CGFloat = 660
|
||||
private let menubarTitleFontSize: CGFloat = 13
|
||||
|
||||
@main
|
||||
struct CodeBurnApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
||||
|
||||
var body: some Scene {
|
||||
// SwiftUI App needs at least one scene. Settings is invisible by default.
|
||||
Settings {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||
private var statusItem: NSStatusItem!
|
||||
private var popover: NSPopover!
|
||||
private let store = AppStore()
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// Menubar accessory -- no Dock icon, no app switcher entry.
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
|
||||
restorePersistedCurrency()
|
||||
setupStatusItem()
|
||||
setupPopover()
|
||||
observeStore()
|
||||
startRefreshLoop()
|
||||
// Subscription is fetched lazily when the user opens the Plan pill, so the macOS
|
||||
// Keychain prompt never fires until the user explicitly asks for it.
|
||||
}
|
||||
|
||||
/// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where
|
||||
/// the user left off. Rate is resolved from the on-disk FX cache if present, otherwise
|
||||
/// fetched live in the background.
|
||||
private func restorePersistedCurrency() {
|
||||
guard let code = CLICurrencyConfig.loadCode(), code != "USD" else { return }
|
||||
let symbol = CurrencyState.symbolForCode(code)
|
||||
store.currency = code
|
||||
|
||||
Task {
|
||||
let cached = await FXRateCache.shared.cachedRate(for: code)
|
||||
await MainActor.run {
|
||||
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
|
||||
}
|
||||
let fresh = await FXRateCache.shared.rate(for: code)
|
||||
if let fresh, fresh != cached {
|
||||
await MainActor.run {
|
||||
CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
refreshTask?.cancel()
|
||||
}
|
||||
|
||||
private func startRefreshLoop() {
|
||||
refreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
// Always keep the (today, all) payload warm. The menubar title and the
|
||||
// agent tab strip both read from it, so it has to refresh every cycle
|
||||
// regardless of whether the user is currently viewing Today or a
|
||||
// different period / provider.
|
||||
await self.store.refreshQuietly(period: .today)
|
||||
// Refresh the currently-viewed payload. Optimize is fast (~1s warm-cache)
|
||||
// so include findings on every refresh.
|
||||
await self.store.refresh(includeOptimize: true)
|
||||
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observeStore() {
|
||||
withObservationTracking {
|
||||
_ = store.payload
|
||||
_ = store.todayPayload
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.refreshStatusButton()
|
||||
self?.observeStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status Item
|
||||
|
||||
private func setupStatusItem() {
|
||||
// Fixed width so the popover anchor (and thus popover position) doesn't shift
|
||||
// every time today's cost or findings badge changes.
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: statusItemFixedWidth)
|
||||
guard let button = statusItem.button else { return }
|
||||
button.target = self
|
||||
button.action = #selector(handleButtonClick(_:))
|
||||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||
refreshStatusButton()
|
||||
}
|
||||
|
||||
/// Composes the menubar title as a single attributed string with the flame as an inline
|
||||
/// NSTextAttachment. NSStatusItem's separate `image` + `attributedTitle` path leaves a
|
||||
/// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge
|
||||
/// of the status item, the title starts at its own baseline), so we inline both so they
|
||||
/// flow as one typographic unit with a single, controllable gap.
|
||||
private func refreshStatusButton() {
|
||||
guard let button = statusItem.button else { return }
|
||||
|
||||
// Clear any previously-set image so the attachment is the only glyph rendered.
|
||||
button.image = nil
|
||||
button.imagePosition = .noImage
|
||||
|
||||
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium)
|
||||
let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
|
||||
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
|
||||
.withSymbolConfiguration(flameConfig)
|
||||
flame?.isTemplate = true
|
||||
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = flame
|
||||
if let size = flame?.size {
|
||||
// Nudge the image down ~2pt so its visual centre sits on the text baseline mid-line
|
||||
// rather than riding high. Exact value tuned against SF Pro Display 13pt.
|
||||
attachment.bounds = CGRect(x: 0, y: -2, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
let hasPayload = store.todayPayload != nil
|
||||
let valueText = " " + (store.todayPayload?.current.cost.asCompactCurrency() ?? "$—")
|
||||
let color: NSColor = hasPayload ? .labelColor : .secondaryLabelColor
|
||||
|
||||
let composed = NSMutableAttributedString()
|
||||
composed.append(NSAttributedString(attachment: attachment))
|
||||
composed.append(NSAttributedString(
|
||||
string: valueText,
|
||||
attributes: [.font: font, .foregroundColor: color]
|
||||
))
|
||||
button.attributedTitle = composed
|
||||
}
|
||||
|
||||
// MARK: - Popover
|
||||
|
||||
private func setupPopover() {
|
||||
popover = NSPopover()
|
||||
popover.contentSize = NSSize(width: popoverWidth, height: popoverHeight)
|
||||
popover.behavior = .transient // auto-close only on explicit outside click
|
||||
popover.animates = true
|
||||
popover.delegate = self
|
||||
|
||||
let content = MenuBarContent()
|
||||
.environment(store)
|
||||
.frame(width: popoverWidth)
|
||||
|
||||
popover.contentViewController = NSHostingController(rootView: content)
|
||||
}
|
||||
|
||||
@objc private func handleButtonClick(_ sender: AnyObject?) {
|
||||
guard let button = statusItem.button else { return }
|
||||
if popover.isShown {
|
||||
popover.performClose(sender)
|
||||
} else {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
popover.contentViewController?.view.window?.makeKey()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSPopoverDelegate
|
||||
|
||||
func popoverShouldDetach(_ popover: NSPopover) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
209
mac/Sources/CodeBurnMenubar/CurrencyState.swift
Normal file
209
mac/Sources/CodeBurnMenubar/CurrencyState.swift
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
|
||||
private let fxCacheTTLSeconds: TimeInterval = 24 * 3600
|
||||
private let frankfurterBaseURL = "https://api.frankfurter.app/latest?from=USD&to="
|
||||
/// Defensive bounds on any fetched FX rate. Real-world USD→X rates sit in [0.0001, 200000]
|
||||
/// for every ISO 4217 pair; anything outside is either a parser bug or a MITM poisoning
|
||||
/// attempt. We clamp hard so UI can't render NaN, negative, or astronomical numbers.
|
||||
private let minValidFXRate: Double = 0.0001
|
||||
private let maxValidFXRate: Double = 1_000_000
|
||||
private let fxFetchTimeoutSeconds: TimeInterval = 10
|
||||
|
||||
@Observable
|
||||
final class CurrencyState: @unchecked Sendable {
|
||||
static let shared = CurrencyState()
|
||||
|
||||
var code: String = "USD"
|
||||
var rate: Double = 1.0
|
||||
var symbol: String = "$"
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Applies a new currency context. Callers must invoke on the main actor so @Observable
|
||||
/// view updates run on the UI thread. Rejects non-finite or out-of-band rates so a
|
||||
/// poisoned Frankfurter response can't corrupt displayed costs.
|
||||
func apply(code: String, rate: Double?, symbol: String) {
|
||||
self.code = code
|
||||
self.symbol = symbol
|
||||
if let r = rate, r.isFinite, r >= minValidFXRate, r <= maxValidFXRate {
|
||||
self.rate = r
|
||||
}
|
||||
}
|
||||
|
||||
static func symbolForCode(_ code: String) -> String {
|
||||
// Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the
|
||||
// plain glyph form everyone recognises.
|
||||
if let override = symbolOverrides[code] { return override }
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = code
|
||||
formatter.locale = Locale(identifier: "en_\(code.prefix(2))")
|
||||
return formatter.currencySymbol ?? code
|
||||
}
|
||||
|
||||
private static let symbolOverrides: [String: String] = [
|
||||
"USD": "$",
|
||||
"CAD": "$",
|
||||
"AUD": "$",
|
||||
"NZD": "$",
|
||||
"HKD": "$",
|
||||
"SGD": "$",
|
||||
"MXN": "$",
|
||||
"EUR": "\u{20AC}",
|
||||
"GBP": "\u{00A3}",
|
||||
"JPY": "\u{00A5}",
|
||||
"CNY": "\u{00A5}",
|
||||
"KRW": "\u{20A9}",
|
||||
"INR": "\u{20B9}",
|
||||
"BRL": "R$",
|
||||
"CHF": "CHF",
|
||||
"SEK": "kr",
|
||||
"DKK": "kr",
|
||||
"ZAR": "R"
|
||||
]
|
||||
}
|
||||
|
||||
actor FXRateCache {
|
||||
static let shared = FXRateCache()
|
||||
|
||||
private struct Entry: Codable {
|
||||
let rate: Double
|
||||
let savedAt: TimeInterval
|
||||
}
|
||||
|
||||
private var entries: [String: Entry] = [:]
|
||||
private var loaded = false
|
||||
|
||||
private var cacheFilePath: String {
|
||||
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
return base
|
||||
.appendingPathComponent("codeburn-mac", isDirectory: true)
|
||||
.appendingPathComponent("fx-rates.json")
|
||||
.path
|
||||
}
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard !loaded else { return }
|
||||
loaded = true
|
||||
do {
|
||||
let data = try SafeFile.read(from: cacheFilePath)
|
||||
let decoded = try JSONDecoder().decode([String: Entry].self, from: data)
|
||||
// Drop any persisted entries whose rate violates the sanity bounds -- covers an
|
||||
// old cache that was written before the clamp was introduced.
|
||||
entries = decoded.filter { _, entry in
|
||||
entry.rate.isFinite && entry.rate >= minValidFXRate && entry.rate <= maxValidFXRate
|
||||
}
|
||||
} catch {
|
||||
entries = [:]
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let data = try? JSONEncoder().encode(entries) else { return }
|
||||
try? SafeFile.write(data, to: cacheFilePath)
|
||||
}
|
||||
|
||||
/// Returns a cached rate regardless of freshness. Nil if never fetched.
|
||||
func cachedRate(for code: String) -> Double? {
|
||||
if code == "USD" { return 1.0 }
|
||||
loadIfNeeded()
|
||||
return entries[code]?.rate
|
||||
}
|
||||
|
||||
/// Returns a fresh rate, fetching from Frankfurter when cache is stale or absent. Nil on
|
||||
/// failure. The returned rate is always finite, positive, and within the sanity bounds.
|
||||
func rate(for code: String) async -> Double? {
|
||||
if code == "USD" { return 1.0 }
|
||||
loadIfNeeded()
|
||||
|
||||
if let entry = entries[code],
|
||||
Date().timeIntervalSince1970 - entry.savedAt < fxCacheTTLSeconds {
|
||||
return entry.rate
|
||||
}
|
||||
|
||||
guard let url = URL(string: "\(frankfurterBaseURL)\(code)") else { return entries[code]?.rate }
|
||||
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = fxFetchTimeoutSeconds
|
||||
config.tlsMinimumSupportedProtocolVersion = .TLSv12
|
||||
let session = URLSession(configuration: config)
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(from: url)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
return entries[code]?.rate
|
||||
}
|
||||
struct Response: Decodable { let rates: [String: Double] }
|
||||
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||
guard let fresh = decoded.rates[code],
|
||||
fresh.isFinite, fresh >= minValidFXRate, fresh <= maxValidFXRate else {
|
||||
NSLog("CodeBurn: discarding out-of-band FX rate for \(code)")
|
||||
return entries[code]?.rate
|
||||
}
|
||||
entries[code] = Entry(rate: fresh, savedAt: Date().timeIntervalSince1970)
|
||||
persist()
|
||||
return fresh
|
||||
} catch {
|
||||
return entries[code]?.rate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and writes the CLI's persisted currency config (~/.config/codeburn/config.json).
|
||||
/// Uses an on-disk flock so a concurrent `codeburn currency ...` invocation from a terminal
|
||||
/// can't race the menubar and silently drop each other's writes (TOCTOU on config.json).
|
||||
enum CLICurrencyConfig {
|
||||
private static var configDir: String {
|
||||
(NSHomeDirectory() as NSString).appendingPathComponent(".config/codeburn")
|
||||
}
|
||||
private static var configPath: String {
|
||||
(configDir as NSString).appendingPathComponent("config.json")
|
||||
}
|
||||
private static var lockPath: String {
|
||||
(configDir as NSString).appendingPathComponent(".config.lock")
|
||||
}
|
||||
|
||||
static func loadCode() -> String? {
|
||||
guard
|
||||
let data = try? SafeFile.read(from: configPath),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let currency = json["currency"] as? [String: Any],
|
||||
let code = currency["code"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return code.uppercased()
|
||||
}
|
||||
|
||||
static func persist(code: String) {
|
||||
do {
|
||||
try SafeFile.withExclusiveLock(at: lockPath) {
|
||||
var existing: [String: Any] = [:]
|
||||
if let data = try? SafeFile.read(from: configPath),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
existing = parsed
|
||||
}
|
||||
|
||||
if code == "USD" {
|
||||
existing.removeValue(forKey: "currency")
|
||||
} else {
|
||||
existing["currency"] = [
|
||||
"code": code,
|
||||
"symbol": CurrencyState.symbolForCode(code)
|
||||
]
|
||||
}
|
||||
|
||||
guard let data = try? JSONSerialization.data(
|
||||
withJSONObject: existing,
|
||||
options: [.prettyPrinted, .sortedKeys]
|
||||
) else {
|
||||
return
|
||||
}
|
||||
try SafeFile.write(data, to: configPath, mode: 0o600)
|
||||
}
|
||||
} catch {
|
||||
NSLog("CodeBurn: failed to persist currency config: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
127
mac/Sources/CodeBurnMenubar/Data/CapacityEstimator.swift
Normal file
127
mac/Sources/CodeBurnMenubar/Data/CapacityEstimator.swift
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import Foundation
|
||||
|
||||
public struct CapacitySnapshot: Sendable, Equatable {
|
||||
public let percent: Double // 0..100, Anthropic-reported utilization
|
||||
public let effectiveTokens: Double // weighted sum of input/output/cache tokens consumed at capture
|
||||
public let capturedAt: Date
|
||||
|
||||
public init(percent: Double, effectiveTokens: Double, capturedAt: Date) {
|
||||
self.percent = percent
|
||||
self.effectiveTokens = effectiveTokens
|
||||
self.capturedAt = capturedAt
|
||||
}
|
||||
}
|
||||
|
||||
public enum CapacityConfidence: String, Sendable {
|
||||
case low, medium, solid
|
||||
}
|
||||
|
||||
public struct CapacityEstimate: Sendable, Equatable {
|
||||
public let capacity: Double // tokens equivalent to 100%
|
||||
public let confidence: CapacityConfidence
|
||||
public let sampleSize: Int // post-decorrelation count
|
||||
public let nonLinearityWarning: Bool
|
||||
|
||||
public init(capacity: Double, confidence: CapacityConfidence, sampleSize: Int, nonLinearityWarning: Bool) {
|
||||
self.capacity = capacity
|
||||
self.confidence = confidence
|
||||
self.sampleSize = sampleSize
|
||||
self.nonLinearityWarning = nonLinearityWarning
|
||||
}
|
||||
}
|
||||
|
||||
public enum CapacityEstimator {
|
||||
private static let minSampleSize = 5
|
||||
private static let minPercentRange = 15.0
|
||||
private static let recencyHalfLifeSeconds: Double = 30 * 86400
|
||||
private static let solidR2 = 0.97
|
||||
private static let mediumR2 = 0.85
|
||||
private static let solidSampleThreshold = 15
|
||||
private static let mediumSampleThreshold = 6
|
||||
private static let nonLinearityRunLengthThreshold = 0.7
|
||||
|
||||
public static func estimate(_ snapshots: [CapacitySnapshot], asOf now: Date = Date()) -> CapacityEstimate? {
|
||||
guard snapshots.count >= minSampleSize else { return nil }
|
||||
let percents = snapshots.map(\.percent)
|
||||
let range = (percents.max() ?? 0) - (percents.min() ?? 0)
|
||||
guard range >= minPercentRange else { return nil }
|
||||
|
||||
let weighted = snapshots.map { snap -> (p: Double, t: Double, w: Double) in
|
||||
let ageSeconds = now.timeIntervalSince(snap.capturedAt)
|
||||
let weight = pow(0.5, max(0, ageSeconds) / recencyHalfLifeSeconds)
|
||||
return (snap.percent, snap.effectiveTokens, weight)
|
||||
}
|
||||
|
||||
// Weighted least squares through origin: minimize sum(w * (t - p * cap/100)^2)
|
||||
// Solution: cap = 100 * sum(w * t * p) / sum(w * p * p)
|
||||
let numerator = weighted.reduce(0.0) { $0 + $1.w * $1.t * $1.p }
|
||||
let denominator = weighted.reduce(0.0) { $0 + $1.w * $1.p * $1.p }
|
||||
guard denominator > 0 else { return nil }
|
||||
let capacity = 100.0 * numerator / denominator
|
||||
guard capacity > 0 else { return nil }
|
||||
|
||||
// Weighted R^2 against the through-origin fit.
|
||||
let weightedTokenSum = weighted.reduce(0.0) { $0 + $1.w * $1.t }
|
||||
let weightSum = weighted.reduce(0.0) { $0 + $1.w }
|
||||
let weightedMeanT = weightedTokenSum / max(weightSum, .ulpOfOne)
|
||||
let ssRes = weighted.reduce(0.0) { acc, s in
|
||||
let predicted = s.p * capacity / 100
|
||||
let diff = s.t - predicted
|
||||
return acc + s.w * diff * diff
|
||||
}
|
||||
let ssTot = weighted.reduce(0.0) { acc, s in
|
||||
let diff = s.t - weightedMeanT
|
||||
return acc + s.w * diff * diff
|
||||
}
|
||||
let r2 = ssTot > 0 ? max(0.0, 1.0 - ssRes / ssTot) : 0.0
|
||||
|
||||
let n = snapshots.count
|
||||
let confidence: CapacityConfidence = {
|
||||
if n >= solidSampleThreshold && r2 >= solidR2 { return .solid }
|
||||
if n >= mediumSampleThreshold && r2 >= mediumR2 { return .medium }
|
||||
return .low
|
||||
}()
|
||||
|
||||
let nonLinearityWarning = detectNonLinearity(snapshots: weighted, capacity: capacity)
|
||||
|
||||
return CapacityEstimate(
|
||||
capacity: capacity,
|
||||
confidence: confidence,
|
||||
sampleSize: n,
|
||||
nonLinearityWarning: nonLinearityWarning
|
||||
)
|
||||
}
|
||||
|
||||
/// Sign-test on residuals across the percent range. If residuals form a long monotonic run
|
||||
/// (e.g. all-negative in low percents then all-positive at high), the relationship isn't linear.
|
||||
private static func detectNonLinearity(
|
||||
snapshots: [(p: Double, t: Double, w: Double)],
|
||||
capacity: Double
|
||||
) -> Bool {
|
||||
let sorted = snapshots.sorted { $0.p < $1.p }
|
||||
let signs = sorted.map { s -> Int in
|
||||
let predicted = s.p * capacity / 100
|
||||
let diff = s.t - predicted
|
||||
if abs(diff) < .ulpOfOne { return 0 }
|
||||
return diff > 0 ? 1 : -1
|
||||
}.filter { $0 != 0 }
|
||||
guard signs.count >= minSampleSize else { return false }
|
||||
|
||||
// Longest single-sign run length / total
|
||||
var longestRun = 0
|
||||
var currentRun = 0
|
||||
var currentSign = 0
|
||||
for s in signs {
|
||||
if s == currentSign {
|
||||
currentRun += 1
|
||||
} else {
|
||||
longestRun = max(longestRun, currentRun)
|
||||
currentSign = s
|
||||
currentRun = 1
|
||||
}
|
||||
}
|
||||
longestRun = max(longestRun, currentRun)
|
||||
let runFraction = Double(longestRun) / Double(signs.count)
|
||||
return runFraction >= nonLinearityRunLengthThreshold
|
||||
}
|
||||
}
|
||||
107
mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Normal file
107
mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import Foundation
|
||||
|
||||
/// Upper bound on payload + stderr bytes read from the CLI. Real payloads top out near 500 KB
|
||||
/// (365 days of history with dozens of models); anything larger is pathological and truncating
|
||||
/// prevents unbounded memory growth. Hard timeout guards against a hung CLI keeping Process and
|
||||
/// Pipe file descriptors pinned forever.
|
||||
private let maxPayloadBytes = 20 * 1024 * 1024
|
||||
private let maxStderrBytes = 256 * 1024
|
||||
private let spawnTimeoutSeconds: UInt64 = 60
|
||||
|
||||
enum DataClientError: Error {
|
||||
case spawn(String)
|
||||
case nonZeroExit(code: Int32, stderr: String)
|
||||
case decode(Error)
|
||||
case timeout
|
||||
case outputTooLarge
|
||||
}
|
||||
|
||||
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
|
||||
/// commands through `/bin/zsh -c` anymore.
|
||||
struct DataClient {
|
||||
static func fetch(period: Period, provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
|
||||
var subcommand = [
|
||||
"status",
|
||||
"--format", "menubar-json",
|
||||
"--period", period.cliArg,
|
||||
"--provider", provider.cliArg,
|
||||
]
|
||||
if !includeOptimize {
|
||||
subcommand.append("--no-optimize")
|
||||
}
|
||||
|
||||
let result = try await runCLI(subcommand: subcommand)
|
||||
guard result.exitCode == 0 else {
|
||||
throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
|
||||
} catch {
|
||||
throw DataClientError.decode(error)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessResult {
|
||||
let stdout: Data
|
||||
let stderr: String
|
||||
let exitCode: Int32
|
||||
}
|
||||
|
||||
private static func runCLI(subcommand: [String]) async throws -> ProcessResult {
|
||||
let process = CodeburnCLI.makeProcess(subcommand: subcommand)
|
||||
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw DataClientError.spawn(error.localizedDescription)
|
||||
}
|
||||
|
||||
// Drain both pipes concurrently so a large stderr can't deadlock stdout (the child
|
||||
// blocks on write once the pipe buffer fills). `drain` also enforces a byte cap.
|
||||
async let stdoutData = drain(outPipe.fileHandleForReading, limit: maxPayloadBytes)
|
||||
async let stderrData = drain(errPipe.fileHandleForReading, limit: maxStderrBytes)
|
||||
|
||||
// Wall-clock timeout: if the CLI hangs (parser stuck, disk stall), kill it.
|
||||
let timeoutTask = Task.detached(priority: .utility) {
|
||||
try? await Task.sleep(nanoseconds: spawnTimeoutSeconds * 1_000_000_000)
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
let (out, err) = await (stdoutData, stderrData)
|
||||
process.waitUntilExit()
|
||||
|
||||
if out.count >= maxPayloadBytes {
|
||||
throw DataClientError.outputTooLarge
|
||||
}
|
||||
|
||||
let stderrString = String(data: err, encoding: .utf8) ?? ""
|
||||
return ProcessResult(stdout: out, stderr: stderrString, exitCode: process.terminationStatus)
|
||||
}
|
||||
|
||||
/// Pulls bytes off a pipe until EOF or `limit`. Intentionally uses `availableData`, which
|
||||
/// returns empty on EOF -- no blocking once the child exits.
|
||||
private static func drain(_ handle: FileHandle, limit: Int) async -> Data {
|
||||
await Task.detached(priority: .utility) {
|
||||
var buffer = Data()
|
||||
while buffer.count < limit {
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { break }
|
||||
let remaining = limit - buffer.count
|
||||
if chunk.count > remaining {
|
||||
buffer.append(chunk.prefix(remaining))
|
||||
break
|
||||
}
|
||||
buffer.append(chunk)
|
||||
}
|
||||
return buffer
|
||||
}.value
|
||||
}
|
||||
}
|
||||
123
mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Normal file
123
mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import Foundation
|
||||
|
||||
/// Shape of `codeburn status --format menubar-json --period <period>`.
|
||||
/// `current` is scoped to the requested period; the whole payload reflects that slice.
|
||||
struct MenubarPayload: Codable, Sendable {
|
||||
let generated: String
|
||||
let current: CurrentBlock
|
||||
let optimize: OptimizeBlock
|
||||
let history: HistoryBlock
|
||||
}
|
||||
|
||||
struct HistoryBlock: Codable, Sendable {
|
||||
let daily: [DailyHistoryEntry]
|
||||
}
|
||||
|
||||
struct DailyModelBreakdown: Codable, Sendable {
|
||||
let name: String
|
||||
let cost: Double
|
||||
let calls: Int
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
|
||||
var totalTokens: Int { inputTokens + outputTokens }
|
||||
}
|
||||
|
||||
struct DailyHistoryEntry: Codable, Sendable {
|
||||
let date: String
|
||||
let cost: Double
|
||||
let calls: Int
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
let topModels: [DailyModelBreakdown]
|
||||
|
||||
/// Pricing-ratio prior: input + 5x output + cache_creation + 0.1x cache_read.
|
||||
/// Matches Anthropic's published per-token pricing on Sonnet/Opus closely enough to be a useful proxy.
|
||||
var effectiveTokens: Double {
|
||||
Double(inputTokens) + 5.0 * Double(outputTokens) + Double(cacheWriteTokens) + 0.1 * Double(cacheReadTokens)
|
||||
}
|
||||
}
|
||||
|
||||
extension DailyHistoryEntry {
|
||||
/// Required for legacy payloads (no topModels emitted yet).
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date, cost, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels
|
||||
}
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
date = try c.decode(String.self, forKey: .date)
|
||||
cost = try c.decode(Double.self, forKey: .cost)
|
||||
calls = try c.decode(Int.self, forKey: .calls)
|
||||
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
|
||||
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
|
||||
cacheReadTokens = try c.decode(Int.self, forKey: .cacheReadTokens)
|
||||
cacheWriteTokens = try c.decode(Int.self, forKey: .cacheWriteTokens)
|
||||
topModels = try c.decodeIfPresent([DailyModelBreakdown].self, forKey: .topModels) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct CurrentBlock: Codable, Sendable {
|
||||
let label: String
|
||||
let cost: Double
|
||||
let calls: Int
|
||||
let sessions: Int
|
||||
let oneShotRate: Double?
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let cacheHitPercent: Double
|
||||
let topActivities: [ActivityEntry]
|
||||
let topModels: [ModelEntry]
|
||||
let providers: [String: Double]
|
||||
}
|
||||
|
||||
struct ActivityEntry: Codable, Sendable {
|
||||
let name: String
|
||||
let cost: Double
|
||||
let turns: Int
|
||||
let oneShotRate: Double?
|
||||
}
|
||||
|
||||
struct ModelEntry: Codable, Sendable {
|
||||
let name: String
|
||||
let cost: Double
|
||||
let calls: Int
|
||||
}
|
||||
|
||||
struct OptimizeBlock: Codable, Sendable {
|
||||
let findingCount: Int
|
||||
let savingsUSD: Double
|
||||
let topFindings: [FindingEntry]
|
||||
}
|
||||
|
||||
struct FindingEntry: Codable, Sendable {
|
||||
let title: String
|
||||
let impact: String
|
||||
let savingsUSD: Double
|
||||
}
|
||||
|
||||
// MARK: - Empty fallback
|
||||
|
||||
extension MenubarPayload {
|
||||
/// Strictly-empty payload. Used as the fallback before real data arrives, so no
|
||||
/// plausible-looking fake numbers leak into the UI.
|
||||
static let empty = MenubarPayload(
|
||||
generated: "",
|
||||
current: CurrentBlock(
|
||||
label: "",
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
oneShotRate: nil,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheHitPercent: 0,
|
||||
topActivities: [],
|
||||
topModels: [],
|
||||
providers: [:]
|
||||
),
|
||||
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
|
||||
history: HistoryBlock(daily: [])
|
||||
)
|
||||
}
|
||||
306
mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift
Normal file
306
mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
private let credentialsRelativePath = ".claude/.credentials.json"
|
||||
private let keychainService = "Claude Code-credentials"
|
||||
private let oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
private let refreshURL = URL(string: "https://platform.claude.com/v1/oauth/token")!
|
||||
private let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
|
||||
private let betaHeader = "oauth-2025-04-20"
|
||||
private let userAgent = "claude-code/2.1.0"
|
||||
private let requestTimeout: TimeInterval = 30
|
||||
|
||||
/// Claude Code writes Keychain items with `kSecAttrAccount = "default"`. Filtering on this
|
||||
/// prevents a planted Keychain item from another app (or a stale install with a mangled
|
||||
/// account) from being accepted as our source of OAuth credentials.
|
||||
private let expectedKeychainAccounts: Set<String> = ["default"]
|
||||
private let maxCredentialBytes = 64 * 1024
|
||||
|
||||
enum SubscriptionError: Error, LocalizedError {
|
||||
case noCredentials
|
||||
case credentialsInvalid
|
||||
case refreshFailed(Int, String?)
|
||||
case usageFetchFailed(Int, String?)
|
||||
case decodeFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCredentials: "No Claude OAuth credentials found"
|
||||
case .credentialsInvalid: "Claude OAuth credentials malformed"
|
||||
case let .refreshFailed(code, body): "Token refresh failed (\(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case let .usageFetchFailed(code, body): "Usage fetch failed (\(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case let .decodeFailed(err): "Decode failed: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionClient {
|
||||
static func fetch() async throws -> SubscriptionUsage {
|
||||
let creds = try loadCredentials()
|
||||
|
||||
// Try the usage call with the existing token first. Only refresh on 401.
|
||||
do {
|
||||
let response = try await fetchUsage(token: creds.accessToken)
|
||||
return mapResponse(response, rawTier: creds.rateLimitTier)
|
||||
} catch SubscriptionError.usageFetchFailed(401, _) {
|
||||
guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else {
|
||||
throw SubscriptionError.usageFetchFailed(401, "no refresh token available")
|
||||
}
|
||||
let newToken = try await refreshAccessToken(refreshToken: refreshToken)
|
||||
let response = try await fetchUsage(token: newToken)
|
||||
return mapResponse(response, rawTier: creds.rateLimitTier)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Credentials
|
||||
|
||||
private static func loadCredentials() throws -> StoredCredentials {
|
||||
if let data = try readFileCredentials() {
|
||||
return try parseCredentials(data: sanitizeKeychainData(data))
|
||||
}
|
||||
if let creds = try readKeychainCredentials() {
|
||||
return creds
|
||||
}
|
||||
throw SubscriptionError.noCredentials
|
||||
}
|
||||
|
||||
private static func readFileCredentials() throws -> Data? {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
// SafeFile refuses to follow symlinks and caps the read, so a 6 GB /dev/urandom
|
||||
// masquerading as the creds file can't blow up the app.
|
||||
return try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
||||
}
|
||||
|
||||
/// Two-phase keychain enumeration: (1) list persistent refs + accounts, (2) fetch each
|
||||
/// item's data by ref. The combination kSecMatchLimitAll + kSecReturnData errors with -50,
|
||||
/// so the data fetch has to be per-item.
|
||||
private static func readKeychainCredentials() throws -> StoredCredentials? {
|
||||
let listQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecReturnPersistentRef as String: true,
|
||||
]
|
||||
var listResult: CFTypeRef?
|
||||
let listStatus = SecItemCopyMatching(listQuery as CFDictionary, &listResult)
|
||||
if listStatus == errSecItemNotFound {
|
||||
NSLog("CodeBurn: keychain query found no items for service \(keychainService)")
|
||||
return nil
|
||||
}
|
||||
guard listStatus == errSecSuccess, let rows = listResult as? [[String: Any]] else {
|
||||
NSLog("CodeBurn: keychain enumerate failed status=\(listStatus)")
|
||||
return nil
|
||||
}
|
||||
|
||||
var best: StoredCredentials? = nil
|
||||
for row in rows {
|
||||
guard let ref = row[kSecValuePersistentRef as String] as? Data else { continue }
|
||||
let account = (row[kSecAttrAccount as String] as? String) ?? ""
|
||||
// Ignore rows whose account doesn't match Claude Code's known writer. Stops another
|
||||
// app's item (or a legacy install with an unexpected account) from being accepted.
|
||||
guard expectedKeychainAccounts.contains(account) else { continue }
|
||||
let dataQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecValuePersistentRef as String: ref,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var dataResult: CFTypeRef?
|
||||
let dataStatus = SecItemCopyMatching(dataQuery as CFDictionary, &dataResult)
|
||||
guard dataStatus == errSecSuccess, let data = dataResult as? Data else { continue }
|
||||
let sanitized = sanitizeKeychainData(data)
|
||||
guard let parsed = try? parseCredentials(data: sanitized) else { continue }
|
||||
if let current = best {
|
||||
if (parsed.expiresAt ?? .distantPast) > (current.expiresAt ?? .distantPast) {
|
||||
best = parsed
|
||||
}
|
||||
} else {
|
||||
best = parsed
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/// Claude Code's keychain writer line-wraps long string values (newline + leading spaces)
|
||||
/// mid-token, producing JSON with literal control chars and stray spaces inside string
|
||||
/// values. Replace every newline (CR/LF) plus the run of spaces/tabs that follows it.
|
||||
/// Drops both the wrapping in tokens AND pretty-print indentation between fields (both
|
||||
/// produce valid, compact JSON afterward).
|
||||
private static func sanitizeKeychainData(_ data: Data) -> Data {
|
||||
guard var s = String(data: data, encoding: .utf8) else { return data }
|
||||
s = s.replacingOccurrences(of: "\r", with: "")
|
||||
let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: [])
|
||||
if let regex {
|
||||
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||||
s = regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "")
|
||||
}
|
||||
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return s.data(using: .utf8) ?? data
|
||||
}
|
||||
|
||||
/// Decodes the credential JSON blob. Never logs the blob contents or any slice of it --
|
||||
/// even a partial access token reaching Console.app is a leak, and the byte-window
|
||||
/// diagnostic that used to live here could overlap the `accessToken` field bytes.
|
||||
private static func parseCredentials(data: Data) throws -> StoredCredentials {
|
||||
do {
|
||||
let root = try JSONDecoder().decode(CredentialsRoot.self, from: data)
|
||||
guard let oauth = root.claudeAiOauth else { throw SubscriptionError.credentialsInvalid }
|
||||
let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !token.isEmpty else { throw SubscriptionError.credentialsInvalid }
|
||||
let expiresAt = oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) }
|
||||
return StoredCredentials(
|
||||
accessToken: token,
|
||||
refreshToken: oauth.refreshToken,
|
||||
expiresAt: expiresAt,
|
||||
rateLimitTier: oauth.rateLimitTier
|
||||
)
|
||||
} catch let err as SubscriptionError {
|
||||
throw err
|
||||
} catch {
|
||||
throw SubscriptionError.decodeFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Refresh
|
||||
|
||||
private static func refreshAccessToken(refreshToken: String) async throws -> String {
|
||||
var request = URLRequest(url: refreshURL)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = requestTimeout
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var components = URLComponents()
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "grant_type", value: "refresh_token"),
|
||||
URLQueryItem(name: "refresh_token", value: refreshToken),
|
||||
URLQueryItem(name: "client_id", value: oauthClientID),
|
||||
]
|
||||
request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw SubscriptionError.refreshFailed(-1, nil)
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
let body = String(data: data, encoding: .utf8)
|
||||
throw SubscriptionError.refreshFailed(http.statusCode, body)
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(TokenRefreshResponse.self, from: data)
|
||||
return decoded.accessToken
|
||||
} catch {
|
||||
throw SubscriptionError.decodeFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Usage fetch
|
||||
|
||||
private static func fetchUsage(token: String) async throws -> UsageResponse {
|
||||
var request = URLRequest(url: usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = requestTimeout
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta")
|
||||
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw SubscriptionError.usageFetchFailed(-1, nil)
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
let body = String(data: data, encoding: .utf8)
|
||||
throw SubscriptionError.usageFetchFailed(http.statusCode, body)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||
} catch {
|
||||
throw SubscriptionError.decodeFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mapping
|
||||
|
||||
private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
|
||||
SubscriptionUsage(
|
||||
tier: SubscriptionUsage.tier(from: rawTier),
|
||||
rawTier: rawTier,
|
||||
fiveHourPercent: r.fiveHour?.utilization,
|
||||
fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt),
|
||||
sevenDayPercent: r.sevenDay?.utilization,
|
||||
sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt),
|
||||
sevenDayOpusPercent: r.sevenDayOpus?.utilization,
|
||||
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
|
||||
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
|
||||
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
|
||||
fetchedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseDate(_ s: String?) -> Date? {
|
||||
guard let s, !s.isEmpty else { return nil }
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = f.date(from: s) { return d }
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f.date(from: s)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internal models
|
||||
|
||||
private struct StoredCredentials {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresAt: Date?
|
||||
let rateLimitTier: String?
|
||||
}
|
||||
|
||||
private struct CredentialsRoot: Decodable {
|
||||
let claudeAiOauth: OAuthBlock?
|
||||
}
|
||||
|
||||
private struct OAuthBlock: Decodable {
|
||||
let accessToken: String?
|
||||
let refreshToken: String?
|
||||
let expiresAt: Double?
|
||||
let rateLimitTier: String?
|
||||
}
|
||||
|
||||
private struct TokenRefreshResponse: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case expiresIn = "expires_in"
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageResponse: Decodable {
|
||||
let fiveHour: Window?
|
||||
let sevenDay: Window?
|
||||
let sevenDayOpus: Window?
|
||||
let sevenDaySonnet: Window?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fiveHour = "five_hour"
|
||||
case sevenDay = "seven_day"
|
||||
case sevenDayOpus = "seven_day_opus"
|
||||
case sevenDaySonnet = "seven_day_sonnet"
|
||||
}
|
||||
}
|
||||
|
||||
private struct Window: Decodable {
|
||||
let utilization: Double?
|
||||
let resetsAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case utilization
|
||||
case resetsAt = "resets_at"
|
||||
}
|
||||
}
|
||||
102
mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift
Normal file
102
mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import Foundation
|
||||
|
||||
/// Persisted snapshot of a single utilization reading. We capture one per window every time
|
||||
/// SubscriptionClient.fetch() succeeds so we can answer "what did the prior 7-day cycle finish at?"
|
||||
/// when the current window has no usable data yet (just reset).
|
||||
struct SubscriptionSnapshot: Codable, Sendable {
|
||||
let windowKey: String // "five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"
|
||||
let percent: Double // 0..100
|
||||
let resetsAt: Date // resets_at active at capture (identifies which window cycle this belongs to)
|
||||
let capturedAt: Date // when the snapshot was recorded
|
||||
let effectiveTokens: Double? // tokens consumed in window at capture (nil if not computed)
|
||||
}
|
||||
|
||||
private let snapshotFilename = "subscription-snapshots.json"
|
||||
private let pruneOlderThanSeconds: TimeInterval = 30 * 24 * 3600
|
||||
|
||||
private func snapshotsCacheDir() -> String {
|
||||
return ProcessInfo.processInfo.environment["CODEBURN_CACHE_DIR"]
|
||||
?? (NSHomeDirectory() as NSString).appendingPathComponent(".cache/codeburn")
|
||||
}
|
||||
|
||||
private func snapshotsPath() -> String {
|
||||
return (snapshotsCacheDir() as NSString).appendingPathComponent(snapshotFilename)
|
||||
}
|
||||
|
||||
private actor SnapshotLock {
|
||||
static let shared = SnapshotLock()
|
||||
func run<T>(_ fn: () throws -> T) rethrows -> T { try fn() }
|
||||
}
|
||||
|
||||
enum SubscriptionSnapshotStore {
|
||||
/// Append a snapshot. Auto-prunes entries older than 30 days. Idempotent: if a snapshot
|
||||
/// with the same windowKey + resetsAt already exists, only update percent if new is higher
|
||||
/// (so "final" reading near reset is preserved).
|
||||
static func record(_ snapshot: SubscriptionSnapshot) async {
|
||||
await SnapshotLock.shared.run {
|
||||
do {
|
||||
var all = loadAll()
|
||||
let key = "\(snapshot.windowKey)|\(snapshot.resetsAt.timeIntervalSince1970)"
|
||||
if let idx = all.firstIndex(where: { "\($0.windowKey)|\($0.resetsAt.timeIntervalSince1970)" == key }) {
|
||||
if snapshot.percent > all[idx].percent {
|
||||
all[idx] = snapshot
|
||||
}
|
||||
} else {
|
||||
all.append(snapshot)
|
||||
}
|
||||
let cutoff = Date().addingTimeInterval(-pruneOlderThanSeconds)
|
||||
all = all.filter { $0.capturedAt >= cutoff }
|
||||
try save(all)
|
||||
} catch {
|
||||
NSLog("CodeBurn: snapshot record failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the final percent of the immediately-prior cycle for this window, or nil if no
|
||||
/// prior data is available. Logic: among snapshots whose resetsAt < currentResetsAt, pick
|
||||
/// the group with the largest resetsAt (most recent prior cycle), then return the max
|
||||
/// percent in that group (the closest-to-final reading we have).
|
||||
static func previousWindowFinal(windowKey: String, currentResetsAt: Date) async -> Double? {
|
||||
await SnapshotLock.shared.run {
|
||||
let all = loadAll()
|
||||
let priors = all.filter { $0.windowKey == windowKey && $0.resetsAt < currentResetsAt }
|
||||
guard let mostRecentPriorReset = priors.map({ $0.resetsAt }).max() else { return nil }
|
||||
let priorWindow = priors.filter { $0.resetsAt == mostRecentPriorReset }
|
||||
return priorWindow.map(\.percent).max()
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all snapshots for a given window key, useful for capacity estimation.
|
||||
static func snapshots(for windowKey: String) async -> [SubscriptionSnapshot] {
|
||||
await SnapshotLock.shared.run {
|
||||
loadAll().filter { $0.windowKey == windowKey }
|
||||
}
|
||||
}
|
||||
|
||||
/// Test seam: clear all snapshots.
|
||||
static func resetForTesting() async {
|
||||
await SnapshotLock.shared.run {
|
||||
try? FileManager.default.removeItem(atPath: snapshotsPath())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func loadAll() -> [SubscriptionSnapshot] {
|
||||
let path = snapshotsPath()
|
||||
guard FileManager.default.fileExists(atPath: path) else { return [] }
|
||||
guard let data = try? SafeFile.read(from: path) else { return [] }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return (try? decoder.decode([SubscriptionSnapshot].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
private static func save(_ snapshots: [SubscriptionSnapshot]) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(snapshots)
|
||||
// SafeFile.write refuses symlinked targets and does the tmp+rename atomic dance.
|
||||
try SafeFile.write(data, to: snapshotsPath(), mode: 0o600)
|
||||
}
|
||||
}
|
||||
46
mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift
Normal file
46
mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import Foundation
|
||||
|
||||
struct SubscriptionUsage: Sendable, Equatable {
|
||||
enum Tier: String, Sendable, Equatable {
|
||||
case pro
|
||||
case max5x
|
||||
case max20x
|
||||
case team
|
||||
case enterprise
|
||||
case unknown
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .pro: "Pro"
|
||||
case .max5x: "Max 5x"
|
||||
case .max20x: "Max 20x"
|
||||
case .team: "Team"
|
||||
case .enterprise: "Enterprise"
|
||||
case .unknown: "Subscription"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tier: Tier
|
||||
let rawTier: String?
|
||||
let fiveHourPercent: Double?
|
||||
let fiveHourResetsAt: Date?
|
||||
let sevenDayPercent: Double?
|
||||
let sevenDayResetsAt: Date?
|
||||
let sevenDayOpusPercent: Double?
|
||||
let sevenDayOpusResetsAt: Date?
|
||||
let sevenDaySonnetPercent: Double?
|
||||
let sevenDaySonnetResetsAt: Date?
|
||||
let fetchedAt: Date
|
||||
|
||||
static func tier(from raw: String?) -> Tier {
|
||||
guard let raw = raw?.lowercased() else { return .unknown }
|
||||
if raw.contains("max_20x") || raw.contains("max20x") || raw.contains("max-20x") { return .max20x }
|
||||
if raw.contains("max_5x") || raw.contains("max5x") || raw.contains("max-5x") { return .max5x }
|
||||
if raw.contains("max") { return .max5x }
|
||||
if raw.contains("pro") { return .pro }
|
||||
if raw.contains("team") { return .team }
|
||||
if raw.contains("enterprise") { return .enterprise }
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
59
mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
Normal file
59
mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import Foundation
|
||||
|
||||
/// Single entry point for spawning the `codeburn` CLI. All callers route through here so the
|
||||
/// binary argv is validated once and no code path ever passes user-influenced strings through
|
||||
/// a shell (`/bin/zsh -c`, `open --args`, AppleScript). This closes the shell-injection attack
|
||||
/// surface end-to-end.
|
||||
enum CodeburnCLI {
|
||||
/// Matches a plain file path / program name: alphanumerics, dot, underscore, slash, hyphen,
|
||||
/// space. Deliberately excludes shell metacharacters (`$`, `;`, `&`, `|`, quotes, backticks,
|
||||
/// newlines) so a malicious `CODEBURN_BIN="codeburn; rm -rf ~"` can't slip through.
|
||||
private static let safeArgPattern = try! NSRegularExpression(pattern: "^[A-Za-z0-9 ._/\\-]+$")
|
||||
|
||||
/// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses
|
||||
/// Homebrew and npm global installs.
|
||||
private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"]
|
||||
|
||||
/// 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"]
|
||||
}
|
||||
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 parts
|
||||
}
|
||||
|
||||
/// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env`
|
||||
/// so PATH lookup happens without involving a shell, and augments PATH with Homebrew
|
||||
/// defaults. Caller sets stdout/stderr pipes and calls `run()`.
|
||||
static func makeProcess(subcommand: [String]) -> Process {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["PATH"] = augmentedPath(environment["PATH"] ?? "")
|
||||
process.environment = environment
|
||||
// `env --` treats everything following as argv, not VAR=val pairs -- guards against an
|
||||
// argument accidentally resembling an env assignment.
|
||||
process.arguments = ["--"] + baseArgv() + subcommand
|
||||
return process
|
||||
}
|
||||
|
||||
static func isSafe(_ s: String) -> Bool {
|
||||
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||||
return safeArgPattern.firstMatch(in: s, range: range) != nil
|
||||
}
|
||||
|
||||
private static func augmentedPath(_ existing: String) -> String {
|
||||
var parts = existing.split(separator: ":", omittingEmptySubsequences: true).map(String.init)
|
||||
for extra in additionalPathEntries where !parts.contains(extra) {
|
||||
parts.append(extra)
|
||||
}
|
||||
return parts.joined(separator: ":")
|
||||
}
|
||||
}
|
||||
128
mac/Sources/CodeBurnMenubar/Security/SafeFile.swift
Normal file
128
mac/Sources/CodeBurnMenubar/Security/SafeFile.swift
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import Foundation
|
||||
|
||||
/// Symlink-safe file I/O with atomic writes and optional cross-process flock.
|
||||
///
|
||||
/// Every cache file we touch (`~/Library/Caches/codeburn-mac/fx-rates.json`,
|
||||
/// `~/.cache/codeburn/subscription-snapshots.json`, `~/.config/codeburn/config.json`) is a
|
||||
/// legitimate target for a local-symlink attack: if an attacker plants a symlink from one of
|
||||
/// those paths to, say, `~/.ssh/config`, a naive `Data.write(to:)` blindly follows the link and
|
||||
/// clobbers the real file. `O_NOFOLLOW` on the write() refuses the operation instead.
|
||||
enum SafeFile {
|
||||
enum Error: Swift.Error {
|
||||
case symlinkDetected(String)
|
||||
case openFailed(String, Int32)
|
||||
case writeFailed(String, Int32)
|
||||
case renameFailed(String, Int32)
|
||||
case readFailed(String, Int32)
|
||||
case sizeLimitExceeded(String, Int)
|
||||
}
|
||||
|
||||
/// Default max bytes when reading untrusted cache files. Prevents a malicious cache file
|
||||
/// from exhausting memory in the Swift process.
|
||||
static let defaultReadLimit = 8 * 1024 * 1024
|
||||
|
||||
/// Refuses to follow symlinks and writes atomically via a tmp file + rename. `mode` is the
|
||||
/// final file permission (0o600 by default so cache files stay user-private).
|
||||
static func write(_ data: Data, to path: String, mode: mode_t = 0o600) throws {
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: parent,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: NSNumber(value: 0o700)]
|
||||
)
|
||||
|
||||
// Reject if the existing file is a symlink. We use lstat so the link itself is
|
||||
// inspected, not its target.
|
||||
var linkInfo = stat()
|
||||
if lstat(path, &linkInfo) == 0, (linkInfo.st_mode & S_IFMT) == S_IFLNK {
|
||||
throw Error.symlinkDetected(path)
|
||||
}
|
||||
|
||||
let tmpPath = parent + "/.codeburn-" + UUID().uuidString + ".tmp"
|
||||
let flags: Int32 = O_CREAT | O_WRONLY | O_EXCL | O_NOFOLLOW
|
||||
let fd = Darwin.open(tmpPath, flags, mode)
|
||||
guard fd >= 0 else {
|
||||
throw Error.openFailed(tmpPath, errno)
|
||||
}
|
||||
|
||||
let writeResult: Int = data.withUnsafeBytes { buffer -> Int in
|
||||
guard let base = buffer.baseAddress else { return 0 }
|
||||
return Darwin.write(fd, base, buffer.count)
|
||||
}
|
||||
let writeErrno = errno
|
||||
fsync(fd)
|
||||
Darwin.close(fd)
|
||||
|
||||
guard writeResult == data.count else {
|
||||
unlink(tmpPath)
|
||||
throw Error.writeFailed(tmpPath, writeErrno)
|
||||
}
|
||||
|
||||
if rename(tmpPath, path) != 0 {
|
||||
let renameErrno = errno
|
||||
unlink(tmpPath)
|
||||
throw Error.renameFailed(path, renameErrno)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses to read through a symlink. `maxBytes` bounds the read so a tampered cache file
|
||||
/// can't balloon the process.
|
||||
static func read(from path: String, maxBytes: Int = defaultReadLimit) throws -> Data {
|
||||
var linkInfo = stat()
|
||||
guard lstat(path, &linkInfo) == 0 else {
|
||||
throw Error.readFailed(path, errno)
|
||||
}
|
||||
if (linkInfo.st_mode & S_IFMT) == S_IFLNK {
|
||||
throw Error.symlinkDetected(path)
|
||||
}
|
||||
|
||||
let fd = Darwin.open(path, O_RDONLY | O_NOFOLLOW)
|
||||
guard fd >= 0 else {
|
||||
throw Error.readFailed(path, errno)
|
||||
}
|
||||
defer { Darwin.close(fd) }
|
||||
|
||||
let size = Int(linkInfo.st_size)
|
||||
if size > maxBytes {
|
||||
throw Error.sizeLimitExceeded(path, size)
|
||||
}
|
||||
|
||||
var data = Data(count: size)
|
||||
let readBytes: Int = data.withUnsafeMutableBytes { buffer -> Int in
|
||||
guard let base = buffer.baseAddress else { return 0 }
|
||||
return Darwin.read(fd, base, buffer.count)
|
||||
}
|
||||
guard readBytes >= 0 else {
|
||||
throw Error.readFailed(path, errno)
|
||||
}
|
||||
if readBytes < size {
|
||||
data = data.prefix(readBytes)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Runs `body` while holding an exclusive POSIX advisory lock on `path`. The lock file is
|
||||
/// created if missing (with 0o600 permissions) and released on scope exit, so other
|
||||
/// codeburn processes (the CLI running in a terminal, say) block on the same file instead
|
||||
/// of racing on a shared config.
|
||||
static func withExclusiveLock<T>(at path: String, body: () throws -> T) throws -> T {
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: parent,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: NSNumber(value: 0o700)]
|
||||
)
|
||||
let fd = Darwin.open(path, O_CREAT | O_RDWR | O_NOFOLLOW, 0o600)
|
||||
guard fd >= 0 else {
|
||||
throw Error.openFailed(path, errno)
|
||||
}
|
||||
defer { Darwin.close(fd) }
|
||||
|
||||
guard flock(fd, LOCK_EX) == 0 else {
|
||||
throw Error.openFailed(path, errno)
|
||||
}
|
||||
defer { _ = flock(fd, LOCK_UN) }
|
||||
|
||||
return try body()
|
||||
}
|
||||
}
|
||||
65
mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift
Normal file
65
mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Runs commands in the user's Terminal. Every string that reaches AppleScript `do script`
|
||||
/// must be whitespace-joined argv where each token passes `CodeburnCLI.isSafe` (regex allowlist
|
||||
/// that excludes shell metacharacters), OR a hardcoded literal defined here. The private
|
||||
/// `runInTerminal` re-validates any non-literal input defensively so a future caller can't
|
||||
/// bypass the invariant.
|
||||
/// Falls back to a detached headless spawn on machines without Terminal.app (iTerm/Ghostty/Warp
|
||||
/// users) so the subcommand still runs.
|
||||
enum TerminalLauncher {
|
||||
private static let terminalPaths = [
|
||||
"/System/Applications/Utilities/Terminal.app",
|
||||
"/Applications/Utilities/Terminal.app",
|
||||
]
|
||||
|
||||
static func open(subcommand: [String]) {
|
||||
let argv = CodeburnCLI.baseArgv() + subcommand
|
||||
guard argv.allSatisfy(CodeburnCLI.isSafe) else {
|
||||
NSLog("CodeBurn: refusing to open terminal with unsafe argv")
|
||||
return
|
||||
}
|
||||
let command = argv.joined(separator: " ")
|
||||
|
||||
if terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) {
|
||||
runInTerminal(command: command, preValidated: true)
|
||||
return
|
||||
}
|
||||
|
||||
let headless = CodeburnCLI.makeProcess(subcommand: subcommand)
|
||||
try? headless.run()
|
||||
}
|
||||
|
||||
/// Launches `claude login` in Terminal.app so the user can complete the OAuth flow
|
||||
/// without leaving CodeBurn. The command is a hardcoded literal -- no user input is
|
||||
/// interpolated, so there's no injection surface.
|
||||
static func openClaudeLogin() -> Bool {
|
||||
guard terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) else {
|
||||
NSLog("CodeBurn: Terminal.app not present; user must run `claude login` manually")
|
||||
return false
|
||||
}
|
||||
runInTerminal(command: "claude login", preValidated: true)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func runInTerminal(command: String, preValidated: Bool) {
|
||||
if !preValidated {
|
||||
let tokens = command.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
guard tokens.allSatisfy(CodeburnCLI.isSafe) else {
|
||||
NSLog("CodeBurn: refusing to run unvalidated command in Terminal")
|
||||
return
|
||||
}
|
||||
}
|
||||
let script = """
|
||||
tell application "Terminal"
|
||||
activate
|
||||
do script "\(command)"
|
||||
end tell
|
||||
"""
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
process.arguments = ["-e", script]
|
||||
try? process.run()
|
||||
}
|
||||
}
|
||||
32
mac/Sources/CodeBurnMenubar/Theme/Theme.swift
Normal file
32
mac/Sources/CodeBurnMenubar/Theme/Theme.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Design tokens. Warm terracotta-ember palette, not generic orange.
|
||||
enum Theme {
|
||||
static let brandAccent = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
|
||||
static let brandAccentDark = Color(red: 0xE8/255.0, green: 0x77/255.0, blue: 0x4A/255.0)
|
||||
static let brandEmberDeep = Color(red: 0x8B/255.0, green: 0x3E/255.0, blue: 0x13/255.0)
|
||||
static let brandEmberGlow = Color(red: 0xF0/255.0, green: 0xA0/255.0, blue: 0x70/255.0)
|
||||
|
||||
static let warmSurface = Color(red: 0xFA/255.0, green: 0xF7/255.0, blue: 0xF3/255.0)
|
||||
static let warmSurfaceDark = Color(red: 0x1C/255.0, green: 0x18/255.0, blue: 0x16/255.0)
|
||||
|
||||
static let categoricalClaude = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
|
||||
static let categoricalCursor = Color(red: 0x3F/255.0, green: 0x6B/255.0, blue: 0x8C/255.0)
|
||||
static let categoricalCodex = Color(red: 0x4A/255.0, green: 0x7D/255.0, blue: 0x5C/255.0)
|
||||
|
||||
static let oneShotGood = Color(red: 0x30/255.0, green: 0xD1/255.0, blue: 0x58/255.0)
|
||||
static let oneShotMid = Color(red: 0xFF/255.0, green: 0x9F/255.0, blue: 0x0A/255.0)
|
||||
static let oneShotLow = Color(red: 0xFF/255.0, green: 0x45/255.0, blue: 0x3A/255.0)
|
||||
|
||||
// Semantic colors -- tuned to sit alongside the terracotta accent without clashing.
|
||||
static let semanticDanger = Color(red: 0xC8/255.0, green: 0x3F/255.0, blue: 0x2C/255.0) // brick-red, terracotta-leaning
|
||||
static let semanticWarning = Color(red: 0xD9/255.0, green: 0x8F/255.0, blue: 0x29/255.0) // amber, warmer than vanilla
|
||||
static let semanticSuccess = Color(red: 0x4E/255.0, green: 0xA8/255.0, blue: 0x65/255.0) // muted green that holds against terracotta
|
||||
}
|
||||
|
||||
extension Font {
|
||||
/// SF Mono for currency values -- developer-tool identity.
|
||||
static func codeMono(size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||
.system(size: size, weight: weight, design: .monospaced)
|
||||
}
|
||||
}
|
||||
87
mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift
Normal file
87
mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ActivitySection: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var isExpanded: Bool = true
|
||||
|
||||
var body: some View {
|
||||
CollapsibleSection(
|
||||
caption: "Activity",
|
||||
isExpanded: $isExpanded,
|
||||
trailing: {
|
||||
HStack(spacing: 8) {
|
||||
Text("Cost").frame(minWidth: 54, alignment: .trailing)
|
||||
Text("Turns").frame(minWidth: 52, alignment: .trailing)
|
||||
Text("1-shot").frame(minWidth: 44, alignment: .trailing)
|
||||
}
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
.tracking(-0.05)
|
||||
}
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
let maxCost = store.payload.current.topActivities.map(\.cost).max() ?? 1
|
||||
ForEach(store.payload.current.topActivities, id: \.name) { activity in
|
||||
ActivityRow(activity: activity, maxCost: maxCost)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityRow: View {
|
||||
let activity: ActivityEntry
|
||||
let maxCost: Double
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
FixedBar(fraction: activity.cost / maxCost)
|
||||
.frame(width: 56, height: 6)
|
||||
|
||||
Text(activity.name)
|
||||
.font(.system(size: 12.5, weight: .medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(activity.cost.asCompactCurrency())
|
||||
.font(.codeMono(size: 12, weight: .medium))
|
||||
.tracking(-0.2)
|
||||
.frame(minWidth: 54, alignment: .trailing)
|
||||
|
||||
Text("\(activity.turns)")
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 52, alignment: .trailing)
|
||||
|
||||
Text(oneShotText)
|
||||
.font(.system(size: 10.5))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 44, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
|
||||
private var oneShotText: String {
|
||||
guard let rate = activity.oneShotRate else { return "—" }
|
||||
return "\(Int(rate * 100))%"
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed-width horizontal bar that shows a fill fraction.
|
||||
struct FixedBar: View {
|
||||
let fraction: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(.secondary.opacity(0.15))
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Theme.brandAccent)
|
||||
.frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(fraction))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
Normal file
100
mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AgentTabStrip: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(visibleFilters) { filter in
|
||||
Button {
|
||||
Task { await store.switchTo(provider: filter) }
|
||||
} label: {
|
||||
AgentTab(
|
||||
filter: filter,
|
||||
cost: cost(for: filter),
|
||||
isActive: store.selectedProvider == filter
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive tab visibility and per-tab cost labels from the *all-provider* payload (today),
|
||||
/// not the currently selected provider's payload. Without this, switching to Codex (which
|
||||
/// has no data) would hide every other tab including Claude.
|
||||
private var allProvidersToday: MenubarPayload {
|
||||
store.todayPayload ?? store.payload
|
||||
}
|
||||
|
||||
private var visibleFilters: [ProviderFilter] {
|
||||
// Show a tab for every provider detected on this machine. The CLI decides what
|
||||
// to include in the providers map based on session dirs / credential files it
|
||||
// finds, so zero-cost-today is still "installed" and the user expects to see
|
||||
// it. Only providers that aren't installed at all are absent from the map.
|
||||
let detectedKeys = Set(
|
||||
allProvidersToday.current.providers.keys.map { $0.lowercased() }
|
||||
)
|
||||
return ProviderFilter.allCases.filter { filter in
|
||||
if filter == .all { return true }
|
||||
return detectedKeys.contains(filter.rawValue.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
private func cost(for filter: ProviderFilter) -> Double? {
|
||||
switch filter {
|
||||
case .all:
|
||||
return allProvidersToday.current.cost
|
||||
default:
|
||||
let key = filter.rawValue.lowercased()
|
||||
return allProvidersToday.current.providers[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentTab: View {
|
||||
let filter: ProviderFilter
|
||||
let cost: Double?
|
||||
let isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Text(filter.rawValue)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.tracking(-0.05)
|
||||
if let cost, cost > 0 {
|
||||
Text(cost.asCompactCurrency())
|
||||
.font(.codeMono(size: 10.5, weight: .medium))
|
||||
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
|
||||
.tracking(-0.2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(isActive ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.08)))
|
||||
)
|
||||
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
extension ProviderFilter {
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .all: return Theme.brandAccent
|
||||
case .claude: return Theme.categoricalClaude
|
||||
case .codex: return Theme.categoricalCodex
|
||||
case .cursor: return Theme.categoricalCursor
|
||||
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
|
||||
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
|
||||
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
290
mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift
Normal file
290
mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import SwiftUI
|
||||
|
||||
private let winColor = Theme.brandAccent
|
||||
private let riskColor = Theme.brandAccent
|
||||
private let improveColor = Theme.brandAccent
|
||||
|
||||
/// Three-category insights panel: wins, improvements, risks.
|
||||
/// Wins/risks are derived from current + history; improvements come from the optimize findings.
|
||||
struct FindingsSection: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var isExpanded: Bool = true
|
||||
|
||||
var body: some View {
|
||||
let groups = computeTipGroups(payload: store.payload)
|
||||
if groups.allSatisfy({ $0.items.isEmpty }) { return AnyView(EmptyView()) }
|
||||
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
|
||||
} label: {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
Text("Tips for you")
|
||||
.font(.system(size: 12.5, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(groups.flatMap { $0.items }.count) signals")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
.opacity(0.55)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(groups) { group in
|
||||
if !group.items.isEmpty {
|
||||
TipsGroup(group: group)
|
||||
}
|
||||
}
|
||||
|
||||
if store.payload.optimize.findingCount > 0 {
|
||||
Button {
|
||||
openOptimize()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Open Full Optimize")
|
||||
.font(.system(size: 11.5, weight: .semibold))
|
||||
Image(systemName: "arrow.forward")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.secondary.opacity(0.06))
|
||||
)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
)
|
||||
}
|
||||
|
||||
private func openOptimize() {
|
||||
TerminalLauncher.open(subcommand: ["optimize"])
|
||||
}
|
||||
}
|
||||
|
||||
private struct TipsGroup: View {
|
||||
let group: TipGroup
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: group.icon)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(group.color)
|
||||
Text(group.label)
|
||||
.font(.system(size: 10.5, weight: .semibold))
|
||||
.foregroundStyle(group.color)
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.4)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(group.items) { item in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Circle().fill(group.color).frame(width: 3, height: 3).padding(.top, 4)
|
||||
Text(item.text)
|
||||
.font(.system(size: 11.5))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let trailing = item.trailing {
|
||||
Text(trailing)
|
||||
.font(.codeMono(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(-0.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TipGroup: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
let items: [TipItem]
|
||||
}
|
||||
|
||||
private struct TipItem: Identifiable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
let trailing: String?
|
||||
}
|
||||
|
||||
private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
|
||||
let stats = computeHistoryStats(history: payload.history.daily)
|
||||
|
||||
// What's working
|
||||
var wins: [TipItem] = []
|
||||
let cacheHit = payload.current.cacheHitPercent
|
||||
if cacheHit >= 80 {
|
||||
wins.append(TipItem(
|
||||
text: "Cache hit at \(Int(cacheHit))% — most prompts reuse cache",
|
||||
trailing: nil
|
||||
))
|
||||
}
|
||||
if let oneShot = payload.current.oneShotRate, oneShot >= 0.75 {
|
||||
wins.append(TipItem(
|
||||
text: "\(Int(oneShot * 100))% one-shot — edits landing first try",
|
||||
trailing: nil
|
||||
))
|
||||
}
|
||||
if let delta = stats.weekDeltaPercent, delta < -10 {
|
||||
wins.append(TipItem(
|
||||
text: "Spend down \(Int(abs(delta)))% vs last 7 days",
|
||||
trailing: nil
|
||||
))
|
||||
}
|
||||
if stats.activeStreakDays >= 5 {
|
||||
wins.append(TipItem(
|
||||
text: "\(stats.activeStreakDays)-day usage streak",
|
||||
trailing: nil
|
||||
))
|
||||
}
|
||||
|
||||
// What to improve (existing optimize findings)
|
||||
var improvements: [TipItem] = []
|
||||
for finding in payload.optimize.topFindings.prefix(3) {
|
||||
improvements.append(TipItem(
|
||||
text: finding.title,
|
||||
trailing: finding.savingsUSD.asCompactCurrency()
|
||||
))
|
||||
}
|
||||
|
||||
// Risks
|
||||
var risks: [TipItem] = []
|
||||
if let delta = stats.weekDeltaPercent, delta > 25 {
|
||||
risks.append(TipItem(
|
||||
text: "Spend up \(Int(delta))% vs prior 7 days",
|
||||
trailing: nil
|
||||
))
|
||||
}
|
||||
if cacheHit > 0 && cacheHit < 50 {
|
||||
risks.append(TipItem(
|
||||
text: "Cache hit only \(Int(cacheHit))% — paying for cold prompts",
|
||||
trailing: nil
|
||||
))
|
||||
}
|
||||
if let oneShot = payload.current.oneShotRate, oneShot < 0.5 {
|
||||
risks.append(TipItem(
|
||||
text: "\(Int(oneShot * 100))% one-shot — lots of iteration",
|
||||
trailing: nil
|
||||
))
|
||||
}
|
||||
if let projected = stats.projectedMonth, let prevMonth = stats.previousMonthTotal, projected > prevMonth * 1.3 {
|
||||
risks.append(TipItem(
|
||||
text: "On pace for \(projected.asCompactCurrency()) this month (+\(Int(((projected - prevMonth) / prevMonth) * 100))% vs last)",
|
||||
trailing: nil
|
||||
))
|
||||
}
|
||||
|
||||
return [
|
||||
TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: winColor, items: wins),
|
||||
TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: improveColor, items: improvements),
|
||||
TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: riskColor, items: risks),
|
||||
]
|
||||
}
|
||||
|
||||
private struct HistoryStats {
|
||||
let weekDeltaPercent: Double?
|
||||
let activeStreakDays: Int
|
||||
let projectedMonth: Double?
|
||||
let previousMonthTotal: Double?
|
||||
}
|
||||
|
||||
private func computeHistoryStats(history: [DailyHistoryEntry]) -> HistoryStats {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = TimeZone(identifier: "UTC")
|
||||
return f
|
||||
}()
|
||||
let now = Date()
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let costByDate = Dictionary(uniqueKeysWithValues: history.map { ($0.date, $0.cost) })
|
||||
|
||||
let lastWeekStart = calendar.date(byAdding: .day, value: -6, to: today)
|
||||
let priorWeekStart = calendar.date(byAdding: .day, value: -13, to: today)
|
||||
let priorWeekEnd = calendar.date(byAdding: .day, value: -7, to: today)
|
||||
var weekDeltaPercent: Double? = nil
|
||||
if let lws = lastWeekStart, let pws = priorWeekStart, let pwe = priorWeekEnd {
|
||||
let lwsStr = formatter.string(from: lws)
|
||||
let pwsStr = formatter.string(from: pws)
|
||||
let pweStr = formatter.string(from: pwe)
|
||||
let thisWeek = history.filter { $0.date >= lwsStr }.reduce(0.0) { $0 + $1.cost }
|
||||
let prior = history.filter { $0.date >= pwsStr && $0.date <= pweStr }.reduce(0.0) { $0 + $1.cost }
|
||||
if prior > 0 {
|
||||
weekDeltaPercent = ((thisWeek - prior) / prior) * 100
|
||||
}
|
||||
}
|
||||
|
||||
var streak = 0
|
||||
for offset in 0..<60 {
|
||||
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break }
|
||||
let key = formatter.string(from: d)
|
||||
if (costByDate[key] ?? 0) > 0 { streak += 1 } else { break }
|
||||
}
|
||||
|
||||
var projectedMonth: Double? = nil
|
||||
var previousMonthTotal: Double? = nil
|
||||
let comps = calendar.dateComponents([.year, .month, .day], from: now)
|
||||
if
|
||||
let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
|
||||
let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
|
||||
{
|
||||
let firstStr = formatter.string(from: firstOfMonth)
|
||||
let mtd = history.filter { $0.date >= firstStr }.reduce(0.0) { $0 + $1.cost }
|
||||
let dayOfMonth = comps.day ?? 1
|
||||
if dayOfMonth > 0 {
|
||||
projectedMonth = (mtd / Double(dayOfMonth)) * Double(rangeOfMonth.count)
|
||||
}
|
||||
|
||||
if
|
||||
let prevMonth = calendar.date(byAdding: .month, value: -1, to: firstOfMonth),
|
||||
let prevRange = calendar.range(of: .day, in: .month, for: prevMonth),
|
||||
let prevFirst = calendar.date(from: DateComponents(
|
||||
year: calendar.component(.year, from: prevMonth),
|
||||
month: calendar.component(.month, from: prevMonth),
|
||||
day: 1
|
||||
)),
|
||||
let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst)
|
||||
{
|
||||
let prevFirstStr = formatter.string(from: prevFirst)
|
||||
let prevLastStr = formatter.string(from: prevLast)
|
||||
let prevTotal = history.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr }
|
||||
.reduce(0.0) { $0 + $1.cost }
|
||||
if prevTotal > 0 { previousMonthTotal = prevTotal }
|
||||
}
|
||||
}
|
||||
|
||||
return HistoryStats(
|
||||
weekDeltaPercent: weekDeltaPercent,
|
||||
activeStreakDays: streak,
|
||||
projectedMonth: projectedMonth,
|
||||
previousMonthTotal: previousMonthTotal
|
||||
)
|
||||
}
|
||||
1253
mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Normal file
1253
mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Normal file
File diff suppressed because it is too large
Load diff
55
mac/Sources/CodeBurnMenubar/Views/HeroSection.swift
Normal file
55
mac/Sources/CodeBurnMenubar/Views/HeroSection.swift
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import SwiftUI
|
||||
|
||||
struct HeroSection: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionCaption(text: caption)
|
||||
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(store.payload.current.cost.asCurrency())
|
||||
.font(.system(size: 32, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.tracking(-1)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Theme.brandAccent, Theme.brandEmberDeep],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(store.payload.current.calls.asThousandsSeparated()) calls")
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(store.payload.current.sessions) sessions")
|
||||
.font(.system(size: 10.5))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
private var caption: String {
|
||||
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
|
||||
if store.selectedPeriod == .today {
|
||||
return "\(label) · \(todayDate)"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
private var todayDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEE MMM d"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
}
|
||||
410
mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
Normal file
410
mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Popover root. Assembles all sections matching the HTML design spec.
|
||||
struct MenuBarContent: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Header()
|
||||
|
||||
Divider()
|
||||
|
||||
if showAgentTabs {
|
||||
AgentTabStrip()
|
||||
Divider()
|
||||
}
|
||||
|
||||
ZStack {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
HeroSection()
|
||||
Divider().opacity(0.5)
|
||||
PeriodSegmentedControl()
|
||||
Divider().opacity(0.5)
|
||||
if isFilteredEmpty {
|
||||
EmptyProviderState(provider: store.selectedProvider, period: store.selectedPeriod)
|
||||
} else {
|
||||
HeatmapSection()
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 10)
|
||||
.zIndex(10)
|
||||
Divider().opacity(0.5)
|
||||
ActivitySection()
|
||||
Divider().opacity(0.5)
|
||||
ModelsSection()
|
||||
Divider().opacity(0.5)
|
||||
FindingsSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if store.isLoading {
|
||||
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(height: 520)
|
||||
.animation(.easeInOut(duration: 0.2), value: store.isLoading)
|
||||
|
||||
Divider()
|
||||
|
||||
FooterBar()
|
||||
|
||||
StarBanner()
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a specific provider tab is selected and that provider has no spend in the
|
||||
/// currently selected period. The .all tab is exempt -- it always shows aggregated data.
|
||||
private var isFilteredEmpty: Bool {
|
||||
guard store.selectedProvider != .all else { return false }
|
||||
return store.payload.current.cost <= 0 && store.payload.current.calls == 0
|
||||
}
|
||||
|
||||
/// Show the tab row whenever the CLI detected at least one AI coding tool installed
|
||||
/// on this machine. Hidden only when nothing is detected, which means there's
|
||||
/// nothing to filter by anyway.
|
||||
private var showAgentTabs: Bool {
|
||||
let payload = store.todayPayload ?? store.payload
|
||||
return !payload.current.providers.isEmpty
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct EmptyProviderState: View {
|
||||
let provider: ProviderFilter
|
||||
let period: Period
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No \(provider.rawValue) data for \(periodPhrase)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private var periodPhrase: String {
|
||||
switch period {
|
||||
case .today: "today"
|
||||
case .sevenDays: "the last 7 days"
|
||||
case .thirtyDays: "the last 30 days"
|
||||
case .month: "this month"
|
||||
case .all: "all time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translucent overlay that blurs whatever's behind it (the previous tab/period content)
|
||||
/// and centers an animated burning flame -- the brand mark filling up bottom-to-top in
|
||||
/// yellow→orange→red, looping.
|
||||
private struct BurnLoadingOverlay: View {
|
||||
let periodLabel: String
|
||||
@State private var fillProgress: CGFloat = 0
|
||||
@State private var glowing: Bool = false
|
||||
|
||||
private let flameSize: CGFloat = 64
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Blur backdrop -- ultraThinMaterial uses live blur of underlying content.
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
BurnFlame(size: flameSize, fillProgress: fillProgress, glowing: glowing)
|
||||
Text("Loading \(periodLabel)…")
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
|
||||
fillProgress = 1.0
|
||||
}
|
||||
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||
glowing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BurnFlame: View {
|
||||
let size: CGFloat
|
||||
let fillProgress: CGFloat
|
||||
let glowing: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Soft outer glow that pulses, matching the brand terracotta palette.
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: size, weight: .regular))
|
||||
.foregroundStyle(Theme.brandEmberGlow.opacity(glowing ? 0.55 : 0.20))
|
||||
.blur(radius: glowing ? 14 : 6)
|
||||
|
||||
// Empty (cool) flame as base
|
||||
Image(systemName: "flame")
|
||||
.font(.system(size: size, weight: .regular))
|
||||
.foregroundStyle(Theme.brandAccent.opacity(0.25))
|
||||
|
||||
// Burning gradient (brand orange) masked by an animated bottom-up rectangle
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: size, weight: .regular))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Theme.brandEmberGlow,
|
||||
Theme.brandAccentDark,
|
||||
Theme.brandAccent,
|
||||
Theme.brandEmberDeep
|
||||
],
|
||||
startPoint: .bottom,
|
||||
endPoint: .top
|
||||
)
|
||||
)
|
||||
.mask(
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.frame(height: geo.size.height * fillProgress)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Header: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
(
|
||||
Text("Code").foregroundStyle(.primary)
|
||||
+ Text("Burn").foregroundStyle(Theme.brandAccent)
|
||||
)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(-0.15)
|
||||
Text("AI Coding Cost Tracker")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct FlameMark: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Theme.brandAccentDark, Theme.brandEmberDeep],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.2), radius: 1, y: 0.5)
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let starBannerGitHubURL = URL(string: "https://github.com/getagentseal/codeburn")!
|
||||
|
||||
/// Shown at the very bottom on first launch. A small terracotta strip nudges users to star the
|
||||
/// repo; clicking opens GitHub, clicking the close icon hides it forever (persisted to
|
||||
/// UserDefaults so it never returns across launches).
|
||||
struct StarBanner: View {
|
||||
@AppStorage("codeburn.starBannerDismissed") private var dismissed: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if !dismissed {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(starBannerGitHubURL)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Enjoying CodeBurn?")
|
||||
.foregroundStyle(.primary)
|
||||
Text("Star us on GitHub")
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
.underline(true, pattern: .solid)
|
||||
}
|
||||
.font(.system(size: 10.5, weight: .medium))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
dismissed = true
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Hide this banner")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Theme.brandAccent.opacity(0.08))
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.18))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FooterBar: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Menu {
|
||||
ForEach(SupportedCurrency.allCases) { currency in
|
||||
Button {
|
||||
applyCurrency(code: currency.rawValue)
|
||||
} label: {
|
||||
if currency.rawValue == store.currency {
|
||||
Label("\(currency.displayName) (\(currency.rawValue))", systemImage: "checkmark")
|
||||
} else {
|
||||
Text("\(currency.displayName) (\(currency.rawValue))")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(store.currency, systemImage: "dollarsign.circle")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.menuIndicator(.hidden)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.fixedSize()
|
||||
|
||||
Button {
|
||||
Task { await store.refresh(includeOptimize: true) }
|
||||
} label: {
|
||||
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(store.isLoading)
|
||||
|
||||
Menu {
|
||||
Button("CSV (folder)") { runExport(format: .csv) }
|
||||
Button("JSON") { runExport(format: .json) }
|
||||
} label: {
|
||||
Label("Export", systemImage: "square.and.arrow.down")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.menuIndicator(.hidden)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.fixedSize()
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { openReport() } label: {
|
||||
Label("Open Full Report", systemImage: "terminal")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.tint(Theme.brandAccent)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func openReport() {
|
||||
TerminalLauncher.open(subcommand: ["report"])
|
||||
}
|
||||
|
||||
private enum ExportFormat {
|
||||
case csv, json
|
||||
var cliName: String { self == .csv ? "csv" : "json" }
|
||||
var suffix: String { self == .csv ? "" : ".json" }
|
||||
}
|
||||
|
||||
/// Runs `codeburn export` directly into ~/Downloads and reveals the result in Finder. CSV
|
||||
/// produces a folder of clean one-table-per-file CSVs; JSON produces a single structured
|
||||
/// file. The CLI is spawned with argv (no shell interpretation), so the output path cannot
|
||||
/// be abused to inject shell commands even if a pathological value slips through.
|
||||
private func runExport(format: ExportFormat) {
|
||||
Task {
|
||||
let downloads = (NSHomeDirectory() as NSString).appendingPathComponent("Downloads")
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let base = "codeburn-\(formatter.string(from: Date()))"
|
||||
let outputPath = (downloads as NSString).appendingPathComponent(base + format.suffix)
|
||||
|
||||
let process = CodeburnCLI.makeProcess(subcommand: [
|
||||
"export", "-f", format.cliName, "-o", outputPath
|
||||
])
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
if process.terminationStatus == 0 {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
|
||||
} else {
|
||||
NSLog("CodeBurn: \(format.cliName.uppercased()) export exited with status \(process.terminationStatus)")
|
||||
}
|
||||
} catch {
|
||||
NSLog("CodeBurn: \(format.cliName.uppercased()) export failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Instant-feeling currency switch. Updates the symbol and any cached FX rate on the main
|
||||
/// thread right away so the UI redraws the next frame, then fetches a fresh rate in the
|
||||
/// background. CLI config is persisted so other codeburn commands stay in sync.
|
||||
private func applyCurrency(code: String) {
|
||||
store.currency = code
|
||||
let symbol = CurrencyState.symbolForCode(code)
|
||||
|
||||
Task {
|
||||
let cached = await FXRateCache.shared.cachedRate(for: code)
|
||||
await MainActor.run {
|
||||
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
|
||||
}
|
||||
|
||||
let fresh = await FXRateCache.shared.rate(for: code)
|
||||
if let fresh, fresh != cached {
|
||||
await MainActor.run {
|
||||
CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CLICurrencyConfig.persist(code: code)
|
||||
}
|
||||
}
|
||||
97
mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift
Normal file
97
mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ModelsSection: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var isExpanded: Bool = true
|
||||
|
||||
var body: some View {
|
||||
CollapsibleSection(
|
||||
caption: "Models",
|
||||
isExpanded: $isExpanded,
|
||||
trailing: {
|
||||
HStack(spacing: 8) {
|
||||
Text("Cost").frame(minWidth: 54, alignment: .trailing)
|
||||
Text("Calls").frame(minWidth: 52, alignment: .trailing)
|
||||
}
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
.tracking(-0.05)
|
||||
}
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
let maxCost = store.payload.current.topModels.map(\.cost).max() ?? 1
|
||||
ForEach(store.payload.current.topModels, id: \.name) { model in
|
||||
ModelRow(model: model, maxCost: maxCost)
|
||||
}
|
||||
|
||||
TokensLine()
|
||||
.padding(.top, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModelRow: View {
|
||||
let model: ModelEntry
|
||||
let maxCost: Double
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
FixedBar(fraction: model.cost / maxCost)
|
||||
.frame(width: 56, height: 6)
|
||||
|
||||
Text(model.name)
|
||||
.font(.system(size: 12.5, weight: .medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(model.cost.asCompactCurrency())
|
||||
.font(.codeMono(size: 12, weight: .medium))
|
||||
.tracking(-0.2)
|
||||
.frame(minWidth: 54, alignment: .trailing)
|
||||
|
||||
Text("\(model.calls)")
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 52, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TokensLine: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
let t = store.payload.current
|
||||
let cacheHit = String(format: "%.0f", t.cacheHitPercent)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("Tokens")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formatTokens(t.inputTokens) + " in")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formatTokens(t.outputTokens) + " out")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(cacheHit + "% cache hit")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.font(.system(size: 10.5))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
private func formatTokens(_ n: Int) -> String {
|
||||
if n >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(n) / 1_000_000)
|
||||
} else if n >= 1_000 {
|
||||
return String(format: "%.1fK", Double(n) / 1_000)
|
||||
}
|
||||
return "\(n)"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import SwiftUI
|
||||
|
||||
struct PeriodSegmentedControl: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 1) {
|
||||
ForEach(Period.allCases) { period in
|
||||
Button {
|
||||
Task { await store.switchTo(period: period) }
|
||||
} label: {
|
||||
Text(period.rawValue)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(store.selectedPeriod == period ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(store.selectedPeriod == period ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
|
||||
.shadow(color: .black.opacity(store.selectedPeriod == period ? 0.06 : 0), radius: 1, y: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(Color.secondary.opacity(0.08))
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
85
mac/Sources/CodeBurnMenubar/Views/SectionCaption.swift
Normal file
85
mac/Sources/CodeBurnMenubar/Views/SectionCaption.swift
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SectionCaption: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(Theme.brandAccent.opacity(0.7))
|
||||
.frame(width: 3, height: 3)
|
||||
Text(text)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(-0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsible section shell with a clickable caption, optional inline trailing
|
||||
/// view (e.g. column headers), and a chevron.
|
||||
struct CollapsibleSection<Trailing: View, Content: View>: View {
|
||||
let caption: String
|
||||
@Binding var isExpanded: Bool
|
||||
let trailing: Trailing
|
||||
let content: Content
|
||||
|
||||
init(
|
||||
caption: String,
|
||||
isExpanded: Binding<Bool>,
|
||||
@ViewBuilder trailing: () -> Trailing,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.caption = caption
|
||||
self._isExpanded = isExpanded
|
||||
self.trailing = trailing()
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(Theme.brandAccent.opacity(0.7))
|
||||
.frame(width: 3, height: 3)
|
||||
Text(caption)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.tracking(-0.1)
|
||||
}
|
||||
Spacer()
|
||||
trailing
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
.opacity(0.55)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
content
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
extension CollapsibleSection where Trailing == EmptyView {
|
||||
init(
|
||||
caption: String,
|
||||
isExpanded: Binding<Bool>,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.init(caption: caption, isExpanded: isExpanded, trailing: { EmptyView() }, content: content)
|
||||
}
|
||||
}
|
||||
99
mac/Sources/CodeBurnMenubar/Views/SparklineView.swift
Normal file
99
mac/Sources/CodeBurnMenubar/Views/SparklineView.swift
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SparklineView: View {
|
||||
let points: [Double]
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let cgPoints = makePoints(in: geo.size)
|
||||
let smooth = smoothPath(cgPoints)
|
||||
|
||||
ZStack {
|
||||
// Gradient fill under the curve
|
||||
let fill = closedPath(smooth, width: geo.size.width, height: geo.size.height)
|
||||
fill.fill(
|
||||
LinearGradient(
|
||||
colors: [Theme.brandAccent.opacity(0.25), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
// Smooth accent stroke
|
||||
smooth.stroke(
|
||||
Theme.brandAccent.opacity(0.85),
|
||||
style: StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
|
||||
// Highlighted current-day point
|
||||
if let last = cgPoints.last {
|
||||
Circle()
|
||||
.fill(Theme.brandAccent)
|
||||
.frame(width: 6, height: 6)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color(NSColor.windowBackgroundColor).opacity(0.9), lineWidth: 1.3)
|
||||
)
|
||||
.position(last)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
private func makePoints(in size: CGSize) -> [CGPoint] {
|
||||
guard !points.isEmpty else { return [] }
|
||||
let w = size.width
|
||||
let h = size.height
|
||||
let maxV = points.max() ?? 1
|
||||
let minV = points.min() ?? 0
|
||||
let range = max(maxV - minV, 1)
|
||||
let count = max(points.count - 1, 1)
|
||||
let topPad: CGFloat = 5
|
||||
let bottomPad: CGFloat = 5
|
||||
let usable = max(h - topPad - bottomPad, 1)
|
||||
|
||||
return points.enumerated().map { idx, v in
|
||||
CGPoint(
|
||||
x: w * CGFloat(idx) / CGFloat(count),
|
||||
y: h - bottomPad - usable * CGFloat(v - minV) / CGFloat(range)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Catmull-Rom → cubic bezier. Standard smooth interpolation, no overshoot.
|
||||
private func smoothPath(_ pts: [CGPoint]) -> Path {
|
||||
var path = Path()
|
||||
guard pts.count >= 2 else { return path }
|
||||
path.move(to: pts[0])
|
||||
|
||||
let tension: CGFloat = 0.5
|
||||
for i in 0..<(pts.count - 1) {
|
||||
let p0 = i > 0 ? pts[i - 1] : pts[i]
|
||||
let p1 = pts[i]
|
||||
let p2 = pts[i + 1]
|
||||
let p3 = i + 2 < pts.count ? pts[i + 2] : p2
|
||||
|
||||
let cp1 = CGPoint(
|
||||
x: p1.x + (p2.x - p0.x) * tension / 3,
|
||||
y: p1.y + (p2.y - p0.y) * tension / 3
|
||||
)
|
||||
let cp2 = CGPoint(
|
||||
x: p2.x - (p3.x - p1.x) * tension / 3,
|
||||
y: p2.y - (p3.y - p1.y) * tension / 3
|
||||
)
|
||||
path.addCurve(to: p2, control1: cp1, control2: cp2)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/// Close the path along the bottom to form a fill region.
|
||||
private func closedPath(_ line: Path, width: CGFloat, height: CGFloat) -> Path {
|
||||
var p = line
|
||||
p.addLine(to: CGPoint(x: width, y: height))
|
||||
p.addLine(to: CGPoint(x: 0, y: height))
|
||||
p.closeSubpath()
|
||||
return p
|
||||
}
|
||||
}
|
||||
158
mac/Tests/CodeBurnMenubarTests/CapacityEstimatorTests.swift
Normal file
158
mac/Tests/CodeBurnMenubarTests/CapacityEstimatorTests.swift
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
private let now = Date(timeIntervalSince1970: 1_734_000_000)
|
||||
|
||||
private func snap(_ percent: Double, _ tokens: Double, ageDays: Double = 0) -> CapacitySnapshot {
|
||||
CapacitySnapshot(
|
||||
percent: percent,
|
||||
effectiveTokens: tokens,
|
||||
capturedAt: now.addingTimeInterval(-ageDays * 86400)
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- gating")
|
||||
struct CapacityEstimatorGatingTests {
|
||||
@Test("returns nil with no snapshots")
|
||||
func emptyReturnsNil() {
|
||||
#expect(CapacityEstimator.estimate([], asOf: now) == nil)
|
||||
}
|
||||
|
||||
@Test("returns nil with fewer than 5 snapshots")
|
||||
func tooFewReturnsNil() {
|
||||
let snaps = (1...4).map { snap(Double($0 * 10), Double($0) * 100_000) }
|
||||
#expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
|
||||
}
|
||||
|
||||
@Test("returns nil when percent range is below 15 points")
|
||||
func tooNarrowReturnsNil() {
|
||||
let snaps = [
|
||||
snap(40, 4_000_000),
|
||||
snap(42, 4_200_000),
|
||||
snap(44, 4_400_000),
|
||||
snap(46, 4_600_000),
|
||||
snap(48, 4_800_000),
|
||||
snap(50, 5_000_000),
|
||||
]
|
||||
#expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- recovery")
|
||||
struct CapacityEstimatorRecoveryTests {
|
||||
@Test("recovers capacity from 10 noise-free snapshots within 0.5%")
|
||||
func recoverFromCleanData() {
|
||||
let trueCapacity: Double = 10_000_000
|
||||
let percents = [5.0, 12, 20, 28, 35, 47, 55, 68, 80, 92]
|
||||
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.capacity > trueCapacity * 0.995)
|
||||
#expect(est!.capacity < trueCapacity * 1.005)
|
||||
// 10 perfect samples is below the solid sample threshold (15) but easily medium.
|
||||
#expect(est!.confidence == .medium || est!.confidence == .solid)
|
||||
}
|
||||
|
||||
@Test("recovers capacity within 5% from 30 noisy snapshots")
|
||||
func recoverFromNoisyData() {
|
||||
let trueCapacity: Double = 8_000_000
|
||||
var rng = LinearCongruentialGenerator(seed: 42)
|
||||
let snaps: [CapacitySnapshot] = (0..<30).map { i in
|
||||
let p = 5.0 + Double(i) * 3.0 // 5..92, spanning enough
|
||||
let noise = (rng.nextDouble() - 0.5) * 0.10 // ±5%
|
||||
let tokens = (p / 100) * trueCapacity * (1 + noise)
|
||||
return snap(p, tokens)
|
||||
}
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
let ratio = est!.capacity / trueCapacity
|
||||
#expect(ratio > 0.95 && ratio < 1.05)
|
||||
#expect(est!.confidence == .solid || est!.confidence == .medium)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- confidence tiers")
|
||||
struct CapacityEstimatorConfidenceTests {
|
||||
@Test("six clean snapshots span sufficient range -> at least medium")
|
||||
func sixCleanSnapshotsMedium() {
|
||||
let trueCapacity: Double = 5_000_000
|
||||
let percents = [5.0, 18, 32, 51, 70, 88]
|
||||
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.confidence == .medium || est!.confidence == .solid)
|
||||
}
|
||||
|
||||
@Test("noisy small-sample data falls to low confidence")
|
||||
func noisySmallSampleLow() {
|
||||
let trueCapacity: Double = 5_000_000
|
||||
var rng = LinearCongruentialGenerator(seed: 7)
|
||||
let percents = [5.0, 22, 40, 60, 80, 95]
|
||||
let snaps: [CapacitySnapshot] = percents.map { p in
|
||||
let noise = (rng.nextDouble() - 0.5) * 1.6 // ±80% noise -> drops R^2 below medium gate
|
||||
return snap(p, p / 100 * trueCapacity * (1 + noise))
|
||||
}
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.confidence == .low)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- recency weighting")
|
||||
struct CapacityEstimatorRecencyTests {
|
||||
@Test("recent snapshots dominate over old ones with different capacity")
|
||||
func recencyShiftsEstimate() {
|
||||
// Old data: capacity = 5M (45 days ago)
|
||||
// New data: capacity = 10M (today)
|
||||
// With 30-day half-life, recent data should win.
|
||||
let oldSnaps = (0..<10).map { i -> CapacitySnapshot in
|
||||
let p = 10.0 + Double(i) * 8
|
||||
return snap(p, p / 100 * 5_000_000, ageDays: 45)
|
||||
}
|
||||
let newSnaps = (0..<10).map { i -> CapacitySnapshot in
|
||||
let p = 10.0 + Double(i) * 8
|
||||
return snap(p, p / 100 * 10_000_000, ageDays: 1)
|
||||
}
|
||||
let est = CapacityEstimator.estimate(oldSnaps + newSnaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
// Recent capacity is 10M; estimate should be closer to 10M than 5M.
|
||||
#expect(est!.capacity > 7_500_000)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- non-linearity")
|
||||
struct CapacityEstimatorNonLinearityTests {
|
||||
@Test("flags non-linearity when residuals show systematic sign pattern")
|
||||
func detectsKneePattern() {
|
||||
// Data follows a knee: linear up to 60%, then flatter (Anthropic capping).
|
||||
let snaps: [CapacitySnapshot] = (0..<20).map { i in
|
||||
let p = 5.0 + Double(i) * 5
|
||||
let tokens: Double = p < 60 ? p / 100 * 8_000_000 : 0.6 * 8_000_000 + (p - 60) / 100 * 4_000_000
|
||||
return snap(p, tokens)
|
||||
}
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.nonLinearityWarning == true)
|
||||
}
|
||||
|
||||
@Test("does not flag clean linear data")
|
||||
func cleanLinearNoFlag() {
|
||||
let trueCapacity: Double = 6_000_000
|
||||
let percents = stride(from: 5.0, to: 95.0, by: 5.0).map { $0 }
|
||||
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.nonLinearityWarning == false)
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight deterministic RNG for reproducible noise in tests.
|
||||
struct LinearCongruentialGenerator {
|
||||
private var state: UInt64
|
||||
init(seed: UInt64) { self.state = seed }
|
||||
mutating func nextDouble() -> Double {
|
||||
state = state &* 6364136223846793005 &+ 1442695040888963407
|
||||
return Double(state >> 11) / Double(1 << 53)
|
||||
}
|
||||
}
|
||||
525
package-lock.json
generated
525
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codeburn",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"codeburn": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/react": "^19.2.14",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
|
|
@ -26,10 +26,7 @@
|
|||
"vitest": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.0.0"
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
|
|
@ -876,16 +873,6 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
|
@ -912,13 +899,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"version": "22.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
|
|
@ -1127,89 +1114,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.9.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
|
||||
"integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/bundle-require": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
|
||||
|
|
@ -1291,13 +1195,6 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cli-boxes": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz",
|
||||
|
|
@ -1413,22 +1310,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
|
|
@ -1439,36 +1320,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||
|
|
@ -1559,16 +1410,6 @@
|
|||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
|
|
@ -1597,13 +1438,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fix-dts-default-cjs-exports": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
|
||||
|
|
@ -1616,13 +1450,6 @@
|
|||
"rollup": "^4.34.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -1663,34 +1490,6 @@
|
|||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
|
||||
|
|
@ -1703,20 +1502,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-7.0.0.tgz",
|
||||
|
|
@ -1869,36 +1654,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
|
|
@ -1950,26 +1705,6 @@
|
|||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.89.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -1980,16 +1715,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||
|
|
@ -2145,61 +1870,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
|
|
@ -2224,21 +1894,6 @@
|
|||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
|
|
@ -2334,46 +1989,12 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
|
|
@ -2387,53 +2008,6 @@
|
|||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz",
|
||||
|
|
@ -2496,16 +2070,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
|
||||
|
|
@ -2537,16 +2101,6 @@
|
|||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
|
|
@ -2605,36 +2159,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/terminal-size": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz",
|
||||
|
|
@ -2821,19 +2345,6 @@
|
|||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",
|
||||
|
|
@ -2871,19 +2382,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
|
|
@ -3104,13 +2608,6 @@
|
|||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
|
|
|
|||
17
package.json
17
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.3",
|
||||
"description": "See where your AI coding tokens go - by task, tool, model, and project",
|
||||
"type": "module",
|
||||
"main": "./dist/cli.js",
|
||||
|
|
@ -29,21 +29,26 @@
|
|||
"developer-tools"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"author": "AgentSeal <hello@agentseal.org>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/getagentseal/codeburn.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/getagentseal/codeburn/issues"
|
||||
},
|
||||
"homepage": "https://github.com/getagentseal/codeburn#readme",
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
"commander": "^13.1.0",
|
||||
"ink": "^7.0.0",
|
||||
"react": "^19.2.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/react": "^19.2.14",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
|
|
|
|||
39
src/cli-date.ts
Normal file
39
src/cli-date.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { DateRange } from './types.js'
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
const END_OF_DAY_HOURS = 23
|
||||
const END_OF_DAY_MINUTES = 59
|
||||
const END_OF_DAY_SECONDS = 59
|
||||
const END_OF_DAY_MS = 999
|
||||
|
||||
function parseLocalDate(s: string): Date {
|
||||
if (!ISO_DATE_RE.test(s)) {
|
||||
throw new Error(`Invalid date format "${s}": expected YYYY-MM-DD`)
|
||||
}
|
||||
const [y, m, d] = s.split('-').map(Number) as [number, number, number]
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null {
|
||||
if (from === undefined && to === undefined) return null
|
||||
|
||||
const now = new Date()
|
||||
const start = from !== undefined ? parseLocalDate(from) : new Date(0)
|
||||
|
||||
const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const end = new Date(
|
||||
endDate.getFullYear(),
|
||||
endDate.getMonth(),
|
||||
endDate.getDate(),
|
||||
END_OF_DAY_HOURS,
|
||||
END_OF_DAY_MINUTES,
|
||||
END_OF_DAY_SECONDS,
|
||||
END_OF_DAY_MS,
|
||||
)
|
||||
|
||||
if (start > end) {
|
||||
throw new Error(`--from must not be after --to (got ${from} > ${to})`)
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
451
src/cli.ts
451
src/cli.ts
|
|
@ -1,11 +1,18 @@
|
|||
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 } from './parser.js'
|
||||
import { parseAllSessions, filterProjectsByName } from './parser.js'
|
||||
import { convertCost } from './currency.js'
|
||||
import { renderStatusBar } from './format.js'
|
||||
import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js'
|
||||
import { type PeriodData, type ProviderCost } from './menubar-json.js'
|
||||
import { buildMenubarPayload } from './menubar-json.js'
|
||||
import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { renderDashboard } from './dashboard.js'
|
||||
import { parseDateRangeFlags } from './cli-date.js'
|
||||
import { runOptimize, scanAndDetect } from './optimize.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { readConfig, saveConfig, getConfigFilePath } from './config.js'
|
||||
import { createRequire } from 'node:module'
|
||||
|
|
@ -14,6 +21,13 @@ const require = createRequire(import.meta.url)
|
|||
const { version } = require('../package.json')
|
||||
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
const BACKFILL_DAYS = 365
|
||||
|
||||
function toDateString(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function getDateRange(period: string): { range: DateRange; label: string } {
|
||||
const now = new Date()
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
|
||||
|
|
@ -41,7 +55,11 @@ function getDateRange(period: string): { range: DateRange; label: string } {
|
|||
return { range: { start, end }, label: 'Last 30 Days' }
|
||||
}
|
||||
case 'all': {
|
||||
return { range: { start: new Date(0), end }, label: 'All Time' }
|
||||
// Cap "All Time" to the last 6 months. Older data is rarely actionable for a cost
|
||||
// tracker and keeps the parse path bounded so providers like Codex/Cursor with sparse
|
||||
// data still load in seconds.
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate())
|
||||
return { range: { start, end }, label: 'Last 6 months' }
|
||||
}
|
||||
default: {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
|
||||
|
|
@ -50,7 +68,9 @@ function getDateRange(period: string): { range: DateRange; label: string } {
|
|||
}
|
||||
}
|
||||
|
||||
function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
|
||||
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
|
||||
function toPeriod(s: string): Period {
|
||||
if (s === 'today') return 'today'
|
||||
if (s === 'month') return 'month'
|
||||
if (s === '30days') return '30days'
|
||||
|
|
@ -58,25 +78,204 @@ function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
|
|||
return 'week'
|
||||
}
|
||||
|
||||
function collect(val: string, acc: string[]): string[] {
|
||||
acc.push(val)
|
||||
return acc
|
||||
}
|
||||
|
||||
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)
|
||||
console.log(JSON.stringify(buildJsonReport(projects, label, period), 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')
|
||||
|
||||
program.hook('preAction', async () => {
|
||||
program.hook('preAction', async (thisCommand) => {
|
||||
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
|
||||
|
||||
const dailyMap: Record<string, { cost: number; calls: number }> = {}
|
||||
for (const sess of sessions) {
|
||||
for (const turn of sess.turns) {
|
||||
if (!turn.timestamp) { continue }
|
||||
const day = turn.timestamp.slice(0, 10)
|
||||
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } }
|
||||
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,
|
||||
}))
|
||||
|
||||
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 }> = {}
|
||||
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 }]) => ({ name, ...rest, cost: convertCost(cost) }))
|
||||
|
||||
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?.slice(0, 10) ?? 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: all, claude, codex, cursor', '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', parseInt)
|
||||
.action(async (opts) => {
|
||||
await renderDashboard(toPeriod(opts.period), opts.provider, opts.refresh)
|
||||
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 = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}`
|
||||
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 renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange)
|
||||
})
|
||||
|
||||
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
|
|
@ -108,6 +307,7 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData
|
|||
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)
|
||||
|
|
@ -121,30 +321,154 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData
|
|||
program
|
||||
.command('status')
|
||||
.description('Compact status output (today + week + month)')
|
||||
.option('--format <format>', 'Output format: terminal, menubar, json', 'terminal')
|
||||
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', '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) => {
|
||||
await loadPricing()
|
||||
const pf = opts.provider
|
||||
if (opts.format === 'menubar') {
|
||||
const todayRange = getDateRange('today').range
|
||||
const todayData = buildPeriodData('Today', await parseAllSessions(todayRange, pf))
|
||||
const weekData = buildPeriodData('7 Days', await parseAllSessions(getDateRange('week').range, pf))
|
||||
const thirtyDayData = buildPeriodData('30 Days', await parseAllSessions(getDateRange('30days').range, pf))
|
||||
const monthData = buildPeriodData('Month', await parseAllSessions(getDateRange('month').range, pf))
|
||||
const todayProviders: ProviderCost[] = []
|
||||
for (const p of await getAllProviders()) {
|
||||
const data = await parseAllSessions(todayRange, p.name)
|
||||
const cost = data.reduce((s, proj) => s + proj.totalCostUSD, 0)
|
||||
if (cost > 0) todayProviders.push({ name: p.displayName, cost })
|
||||
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 yesterdayEnd = new Date(todayStart.getTime() - 1)
|
||||
const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY))
|
||||
const isAllProviders = pf === 'all'
|
||||
|
||||
// The daily cache is provider-agnostic: always backfill it from .all so subsequent
|
||||
// provider-filtered reads can derive per-provider cost+calls from DailyEntry.providers.
|
||||
const cache = await withDailyCacheLock(async () => {
|
||||
let c = await loadDailyCache()
|
||||
const gapStart = c.lastComputedDate
|
||||
? new Date(new Date(`${c.lastComputedDate}T00:00:00.000Z`).getTime() + MS_PER_DAY)
|
||||
: new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY)
|
||||
|
||||
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
|
||||
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
|
||||
const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all'), opts.project, opts.exclude)
|
||||
const gapDays = aggregateProjectsIntoDays(gapProjects)
|
||||
c = addNewDays(c, gapDays, yesterdayStr)
|
||||
await saveDailyCache(c)
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
// 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) {
|
||||
const todayRange: DateRange = { start: todayStart, end: now }
|
||||
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 = todayRange
|
||||
} else {
|
||||
const projects = fp(await parseAllSessions(periodInfo.range, pf))
|
||||
currentData = buildPeriodData(periodInfo.label, projects)
|
||||
scanProjects = projects
|
||||
scanRange = periodInfo.range
|
||||
}
|
||||
console.log(renderMenubarFormat(todayData, weekData, thirtyDayData, monthData, todayProviders))
|
||||
|
||||
// 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 todayRangeForProviders: DateRange = { start: todayStart, end: now }
|
||||
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
|
||||
const rangeStartStr = toDateString(periodInfo.range.start)
|
||||
const allDaysForProviders = [
|
||||
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
|
||||
...todayDaysForProviders.filter(d => d.date >= rangeStartStr),
|
||||
]
|
||||
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(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY))
|
||||
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
|
||||
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions({ start: todayStart, end: now }, 'all')))
|
||||
const fullHistory = [...allCacheDays, ...allTodayDaysForHistory]
|
||||
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 todayData = buildPeriodData('today', await parseAllSessions(getDateRange('today').range, pf))
|
||||
const monthData = buildPeriodData('month', await parseAllSessions(getDateRange('month').range, pf))
|
||||
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()
|
||||
console.log(JSON.stringify({
|
||||
currency: code,
|
||||
|
|
@ -154,7 +478,7 @@ program
|
|||
return
|
||||
}
|
||||
|
||||
const monthProjects = await parseAllSessions(getDateRange('month').range, pf)
|
||||
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
|
||||
console.log(renderStatusBar(monthProjects))
|
||||
})
|
||||
|
||||
|
|
@ -162,18 +486,32 @@ program
|
|||
.command('today')
|
||||
.description('Today\'s usage dashboard')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', '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', parseInt)
|
||||
.action(async (opts) => {
|
||||
await renderDashboard('today', opts.provider, opts.refresh)
|
||||
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: all, claude, codex, cursor', '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', parseInt)
|
||||
.action(async (opts) => {
|
||||
await renderDashboard('month', opts.provider, opts.refresh)
|
||||
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
|
||||
|
|
@ -182,13 +520,16 @@ program
|
|||
.option('-f, --format <format>', 'Export format: csv, json', 'csv')
|
||||
.option('-o, --output <path>', 'Output file path')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.action(async (opts) => {
|
||||
await loadPricing()
|
||||
const pf = opts.provider
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
||||
const periods: PeriodExport[] = [
|
||||
{ label: 'Today', projects: await parseAllSessions(getDateRange('today').range, pf) },
|
||||
{ label: '7 Days', projects: await parseAllSessions(getDateRange('week').range, pf) },
|
||||
{ label: '30 Days', projects: await parseAllSessions(getDateRange('30days').range, 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)) {
|
||||
|
|
@ -200,29 +541,37 @@ program
|
|||
const outputPath = opts.output ?? `${defaultName}.${opts.format}`
|
||||
|
||||
let savedPath: string
|
||||
if (opts.format === 'json') {
|
||||
savedPath = await exportJson(periods, outputPath)
|
||||
} else {
|
||||
savedPath = await exportCsv(periods, outputPath)
|
||||
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)
|
||||
}
|
||||
|
||||
console.log(`\n Exported (Today + 7 Days + 30 Days) to: ${savedPath}\n`)
|
||||
})
|
||||
|
||||
program
|
||||
.command('install-menubar')
|
||||
.description('Install macOS menu bar plugin (SwiftBar/xbar)')
|
||||
.action(async () => {
|
||||
const result = await installMenubar()
|
||||
console.log(result)
|
||||
})
|
||||
|
||||
program
|
||||
.command('uninstall-menubar')
|
||||
.description('Remove macOS menu bar plugin')
|
||||
.action(async () => {
|
||||
const result = await uninstallMenubar()
|
||||
console.log(result)
|
||||
.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
|
||||
|
|
@ -326,4 +675,16 @@ program
|
|||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
})
|
||||
|
||||
program.parse()
|
||||
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: all, claude, codex, cursor', '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.parse()
|
||||
|
|
|
|||
149
src/context-budget.ts
Normal file
149
src/context-budget.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { readdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile } from './fs-utils.js'
|
||||
|
||||
const CHARS_PER_TOKEN = 4
|
||||
const SYSTEM_BASE_TOKENS = 10400
|
||||
const TOOL_TOKENS_OVERHEAD = 400
|
||||
const SKILL_FRONTMATTER_TOKENS = 80
|
||||
|
||||
export type ContextBudget = {
|
||||
systemBase: number
|
||||
mcpTools: { count: number; tokens: number }
|
||||
skills: { count: number; tokens: number }
|
||||
memory: { count: number; tokens: number; files: Array<{ name: string; tokens: number }> }
|
||||
total: number
|
||||
modelContext: number
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
||||
}
|
||||
|
||||
async function readConfigFile(path: string): Promise<Record<string, unknown> | null> {
|
||||
if (!existsSync(path)) return null
|
||||
const raw = await readSessionFile(path)
|
||||
if (raw === null) return null
|
||||
try { return JSON.parse(raw) } catch { return null }
|
||||
}
|
||||
|
||||
async function countMcpTools(projectPath?: string): Promise<number> {
|
||||
const home = homedir()
|
||||
const configPaths = [
|
||||
join(home, '.claude', 'settings.json'),
|
||||
join(home, '.claude', 'settings.local.json'),
|
||||
]
|
||||
if (projectPath) {
|
||||
configPaths.push(join(projectPath, '.mcp.json'))
|
||||
configPaths.push(join(projectPath, '.claude', 'settings.json'))
|
||||
configPaths.push(join(projectPath, '.claude', 'settings.local.json'))
|
||||
}
|
||||
|
||||
const servers = new Set<string>()
|
||||
let toolCount = 0
|
||||
|
||||
for (const p of configPaths) {
|
||||
const config = await readConfigFile(p)
|
||||
if (!config) continue
|
||||
const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>
|
||||
for (const name of Object.keys(mcpServers)) {
|
||||
if (servers.has(name)) continue
|
||||
servers.add(name)
|
||||
toolCount += 5
|
||||
}
|
||||
}
|
||||
|
||||
return toolCount
|
||||
}
|
||||
|
||||
async function countSkills(projectPath?: string): Promise<number> {
|
||||
const dirs = [join(homedir(), '.claude', 'skills')]
|
||||
if (projectPath) dirs.push(join(projectPath, '.claude', 'skills'))
|
||||
|
||||
let count = 0
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue
|
||||
try {
|
||||
const entries = await readdir(dir)
|
||||
for (const entry of entries) {
|
||||
const skillFile = join(dir, entry, 'SKILL.md')
|
||||
if (existsSync(skillFile)) count++
|
||||
}
|
||||
} catch { continue }
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
async function scanMemoryFiles(projectPath?: string): Promise<Array<{ name: string; tokens: number }>> {
|
||||
const home = homedir()
|
||||
const files: Array<{ name: string; tokens: number }> = []
|
||||
const paths: Array<{ path: string; name: string }> = [
|
||||
{ path: join(home, '.claude', 'CLAUDE.md'), name: '~/.claude/CLAUDE.md' },
|
||||
]
|
||||
|
||||
if (projectPath) {
|
||||
paths.push({ path: join(projectPath, 'CLAUDE.md'), name: 'CLAUDE.md' })
|
||||
paths.push({ path: join(projectPath, '.claude', 'CLAUDE.md'), name: '.claude/CLAUDE.md' })
|
||||
paths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' })
|
||||
}
|
||||
|
||||
for (const { path, name } of paths) {
|
||||
if (!existsSync(path)) continue
|
||||
const content = await readSessionFile(path)
|
||||
if (content === null) continue
|
||||
files.push({ name, tokens: estimateTokens(content) })
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
export async function estimateContextBudget(projectPath?: string, modelContext = 1_000_000): Promise<ContextBudget> {
|
||||
const mcpToolCount = await countMcpTools(projectPath)
|
||||
const skillCount = await countSkills(projectPath)
|
||||
const memoryFiles = await scanMemoryFiles(projectPath)
|
||||
|
||||
const mcpTokens = mcpToolCount * TOOL_TOKENS_OVERHEAD
|
||||
const skillTokens = skillCount * SKILL_FRONTMATTER_TOKENS
|
||||
const memoryTokens = memoryFiles.reduce((s, f) => s + f.tokens, 0)
|
||||
const total = SYSTEM_BASE_TOKENS + mcpTokens + skillTokens + memoryTokens
|
||||
|
||||
return {
|
||||
systemBase: SYSTEM_BASE_TOKENS,
|
||||
mcpTools: { count: mcpToolCount, tokens: mcpTokens },
|
||||
skills: { count: skillCount, tokens: skillTokens },
|
||||
memory: { count: memoryFiles.length, tokens: memoryTokens, files: memoryFiles },
|
||||
total,
|
||||
modelContext,
|
||||
}
|
||||
}
|
||||
|
||||
export async function estimateBudgetsByProject(projectPaths: Map<string, string>): Promise<Map<string, ContextBudget>> {
|
||||
const results = new Map<string, ContextBudget>()
|
||||
for (const [project, cwd] of projectPaths) {
|
||||
const budget = await estimateContextBudget(cwd)
|
||||
results.set(project, budget)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function discoverProjectCwd(sessionDir: string): Promise<string | null> {
|
||||
let files: string[]
|
||||
try {
|
||||
files = (await readdir(sessionDir)).filter(f => f.endsWith('.jsonl'))
|
||||
} catch { return null }
|
||||
if (files.length === 0) return null
|
||||
const content = await readSessionFile(join(sessionDir, files[0]))
|
||||
if (content === null) return null
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const entry = JSON.parse(line)
|
||||
if (entry.cwd && typeof entry.cwd === 'string') return entry.cwd
|
||||
} catch { continue }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -12,6 +12,17 @@ type CurrencyState = {
|
|||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const FRANKFURTER_URL = 'https://api.frankfurter.app/latest?from=USD&to='
|
||||
// Defensive bounds on any fetched FX rate. Outside this band the rate is either a parser bug
|
||||
// or a tampered Frankfurter response, and we refuse to multiply it into displayed costs.
|
||||
const MIN_VALID_FX_RATE = 0.0001
|
||||
const MAX_VALID_FX_RATE = 1_000_000
|
||||
|
||||
function isValidRate(value: unknown): value is number {
|
||||
return typeof value === 'number'
|
||||
&& Number.isFinite(value)
|
||||
&& value >= MIN_VALID_FX_RATE
|
||||
&& value <= MAX_VALID_FX_RATE
|
||||
}
|
||||
|
||||
let active: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
|
||||
|
||||
|
|
@ -54,18 +65,22 @@ function getRateCachePath(): string {
|
|||
async function fetchRate(code: string): Promise<number> {
|
||||
const response = await fetch(`${FRANKFURTER_URL}${code}`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const data = await response.json() as { rates: Record<string, number> }
|
||||
const rate = data.rates[code]
|
||||
if (!rate) throw new Error(`No rate returned for ${code}`)
|
||||
const data = await response.json() as { rates?: Record<string, unknown> }
|
||||
const rate = data.rates?.[code]
|
||||
if (!isValidRate(rate)) throw new Error(`Invalid rate returned for ${code}`)
|
||||
return rate
|
||||
}
|
||||
|
||||
async function loadCachedRate(code: string): Promise<number | null> {
|
||||
try {
|
||||
const raw = await readFile(getRateCachePath(), 'utf-8')
|
||||
const cached = JSON.parse(raw) as { timestamp: number; code: string; rate: number }
|
||||
if (cached.code !== code) return null
|
||||
const cached = JSON.parse(raw) as Partial<{ timestamp: number; code: string; rate: number }>
|
||||
// Validate every field -- a tampered cache file could set rate to a string, null, or
|
||||
// Infinity and break downstream math silently.
|
||||
if (typeof cached.code !== 'string' || cached.code !== code) return null
|
||||
if (typeof cached.timestamp !== 'number' || !Number.isFinite(cached.timestamp)) return null
|
||||
if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null
|
||||
if (!isValidRate(cached.rate)) return null
|
||||
return cached.rate
|
||||
} catch {
|
||||
return null
|
||||
|
|
|
|||
118
src/daily-cache.ts
Normal file
118
src/daily-cache.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { randomBytes } from 'crypto'
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, open, readFile, rename, unlink } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
export const DAILY_CACHE_VERSION = 2
|
||||
const DAILY_CACHE_FILENAME = 'daily-cache.json'
|
||||
|
||||
export type DailyEntry = {
|
||||
date: string
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
editTurns: number
|
||||
oneShotTurns: number
|
||||
models: Record<string, {
|
||||
calls: number
|
||||
cost: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
}>
|
||||
categories: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }>
|
||||
providers: Record<string, { calls: number; cost: number }>
|
||||
}
|
||||
|
||||
export type DailyCache = {
|
||||
version: number
|
||||
lastComputedDate: string | null
|
||||
days: DailyEntry[]
|
||||
}
|
||||
|
||||
function getCacheDir(): string {
|
||||
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
|
||||
}
|
||||
|
||||
function getCachePath(): string {
|
||||
return join(getCacheDir(), DAILY_CACHE_FILENAME)
|
||||
}
|
||||
|
||||
function emptyCache(): DailyCache {
|
||||
return { version: DAILY_CACHE_VERSION, lastComputedDate: null, days: [] }
|
||||
}
|
||||
|
||||
function isValidCache(parsed: unknown): parsed is DailyCache {
|
||||
if (!parsed || typeof parsed !== 'object') return false
|
||||
const c = parsed as Partial<DailyCache>
|
||||
if (c.version !== DAILY_CACHE_VERSION) return false
|
||||
if (!Array.isArray(c.days)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export async function loadDailyCache(): Promise<DailyCache> {
|
||||
const path = getCachePath()
|
||||
if (!existsSync(path)) return emptyCache()
|
||||
try {
|
||||
const raw = await readFile(path, 'utf-8')
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!isValidCache(parsed)) return emptyCache()
|
||||
return parsed
|
||||
} catch {
|
||||
return emptyCache()
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDailyCache(cache: DailyCache): 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 { /* ignore */ }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate: string): DailyCache {
|
||||
const seen = new Set(cache.days.map(d => d.date))
|
||||
const merged = [...cache.days]
|
||||
for (const day of incoming) {
|
||||
if (seen.has(day.date)) continue
|
||||
seen.add(day.date)
|
||||
merged.push(day)
|
||||
}
|
||||
merged.sort((a, b) => a.date.localeCompare(b.date))
|
||||
const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate
|
||||
? cache.lastComputedDate
|
||||
: newestDate
|
||||
return { version: DAILY_CACHE_VERSION, lastComputedDate: nextLast, days: merged }
|
||||
}
|
||||
|
||||
export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] {
|
||||
return cache.days.filter(d => d.date >= start && d.date <= end)
|
||||
}
|
||||
|
||||
let lockChain: Promise<unknown> = Promise.resolve()
|
||||
|
||||
export function withDailyCacheLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = lockChain.then(() => fn())
|
||||
lockChain = next.catch(() => undefined)
|
||||
return next
|
||||
}
|
||||
|
|
@ -1,14 +1,18 @@
|
|||
import { homedir } from 'os'
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { formatCost, formatTokens } from './format.js'
|
||||
import { parseAllSessions } from './parser.js'
|
||||
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 { join } from 'path'
|
||||
|
||||
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
type View = 'dashboard' | 'optimize'
|
||||
|
||||
const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
|
|
@ -71,6 +75,8 @@ const CATEGORY_COLORS: Record<TaskCategory, string> = {
|
|||
general: '#666666',
|
||||
}
|
||||
|
||||
const IMPACT_PANEL_COLORS: Record<string, string> = { high: '#F55B5B', medium: ORANGE, low: DIM }
|
||||
|
||||
function toHex(r: number, g: number, b: number): string {
|
||||
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
|
@ -79,7 +85,6 @@ function lerp(a: number, b: number, t: number): number {
|
|||
return a + t * (b - a)
|
||||
}
|
||||
|
||||
// Blue -> amber -> orange gradient across the bar width
|
||||
function gradientColor(pct: number): string {
|
||||
if (pct <= 0.33) {
|
||||
const t = pct / 0.33
|
||||
|
|
@ -132,6 +137,8 @@ function HBar({ value, max, width }: { value: number; max: number; width: number
|
|||
)
|
||||
}
|
||||
|
||||
const PANEL_CHROME = 4
|
||||
|
||||
function Panel({ title, color, children, width }: { title: string; color: string; children: React.ReactNode; width: number }) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width} overflowX="hidden">
|
||||
|
|
@ -216,32 +223,31 @@ const _homeEncoded = homedir().replace(/\//g, '-')
|
|||
|
||||
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-[^-]+-[^-]+-/, '') // /private/tmp/<org>/<env>/
|
||||
.replace(/^private-tmp-/, '')
|
||||
.replace(/^tmp-/, '')
|
||||
|
||||
path = path.replace(/^private-tmp-[^-]+-[^-]+-/, '').replace(/^private-tmp-/, '').replace(/^tmp-/, '')
|
||||
if (!path) return 'home'
|
||||
|
||||
const parts = path.split('-').filter(Boolean)
|
||||
if (parts.length <= 3) return parts.join('/')
|
||||
return parts.slice(-3).join('/')
|
||||
}
|
||||
|
||||
const PROJECT_COL_AVG = 7
|
||||
const PROJECT_COL_BASE_WIDTH = 30
|
||||
const PROJECT_COL_WITH_OVERHEAD_WIDTH = 40
|
||||
|
||||
function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map<string, ContextBudget> }) {
|
||||
const maxCost = Math.max(...projects.map(p => p.totalCostUSD))
|
||||
const nw = Math.max(8, pw - bw - 30)
|
||||
const hasBudgets = budgets && budgets.size > 0
|
||||
const nw = Math.max(8, pw - bw - (hasBudgets ? PROJECT_COL_WITH_OVERHEAD_WIDTH : PROJECT_COL_BASE_WIDTH))
|
||||
return (
|
||||
<Panel title="By Project" color={PANEL_COLORS.project} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}</Text>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''}
|
||||
</Text>
|
||||
{projects.slice(0, 8).map((project, i) => {
|
||||
const budget = budgets?.get(project.project)
|
||||
const avgCost = project.sessions.length > 0
|
||||
? formatCost(project.totalCostUSD / project.sessions.length)
|
||||
: '-'
|
||||
|
|
@ -252,6 +258,7 @@ function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw
|
|||
<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>
|
||||
{hasBudgets && <Text color="#7B9EF5">{(budget ? formatTokens(budget.total) : '-').padStart(10)}</Text>}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
|
@ -317,7 +324,6 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p
|
|||
}
|
||||
const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
|
||||
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
|
||||
|
||||
return (
|
||||
<Panel title="By Activity" color={PANEL_COLORS.activity} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)}</Text>
|
||||
|
|
@ -326,9 +332,7 @@ function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; p
|
|||
return (
|
||||
<Text key={cat} wrap="truncate-end">
|
||||
<HBar value={data.costUSD} max={maxCost} width={bw} />
|
||||
<Text color={CATEGORY_COLORS[cat as TaskCategory] ?? '#666666'}>
|
||||
{' '}{fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)}
|
||||
</Text>
|
||||
<Text color={CATEGORY_COLORS[cat as TaskCategory] ?? '#666666'}> {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)}</Text>
|
||||
<Text color={GOLD}>{formatCost(data.costUSD).padStart(8)}</Text>
|
||||
<Text>{String(data.turns).padStart(6)}</Text>
|
||||
<Text color={data.editTurns === 0 ? DIM : oneShotPct === '100%' ? '#5BF58C' : ORANGE}>{String(oneShotPct).padStart(7)}</Text>
|
||||
|
|
@ -344,11 +348,7 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr
|
|||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const [tool, data] of Object.entries(session.toolBreakdown)) {
|
||||
if (filterPrefix) {
|
||||
if (!tool.startsWith(filterPrefix)) continue
|
||||
} else {
|
||||
if (tool.startsWith('lang:')) continue
|
||||
}
|
||||
if (filterPrefix) { if (!tool.startsWith(filterPrefix)) continue } else { if (tool.startsWith('lang:')) continue }
|
||||
toolTotals[tool] = (toolTotals[tool] ?? 0) + data.calls
|
||||
}
|
||||
}
|
||||
|
|
@ -356,7 +356,6 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr
|
|||
const sorted = Object.entries(toolTotals).sort(([, a], [, b]) => b - a)
|
||||
const maxCalls = sorted[0]?.[1] ?? 0
|
||||
const nw = Math.max(6, pw - bw - 15)
|
||||
|
||||
return (
|
||||
<Panel title={title ?? 'Core Tools'} color={PANEL_COLORS.tools} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
|
||||
|
|
@ -390,7 +389,7 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num
|
|||
}
|
||||
|
||||
const maxCost = top[0].totalCostUSD
|
||||
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1)
|
||||
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME)
|
||||
|
||||
return (
|
||||
<Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}>
|
||||
|
|
@ -415,29 +414,16 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num
|
|||
|
||||
function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
const mcpTotals: Record<string, number> = {}
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const [server, data] of Object.entries(session.mcpBreakdown)) {
|
||||
mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const project of projects) { for (const session of project.sessions) { for (const [server, data] of Object.entries(session.mcpBreakdown)) { mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls } } }
|
||||
const sorted = Object.entries(mcpTotals).sort(([, a], [, b]) => b - a)
|
||||
if (sorted.length === 0) {
|
||||
return <Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}><Text dimColor>No MCP usage</Text></Panel>
|
||||
}
|
||||
if (sorted.length === 0) return <Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}><Text dimColor>No MCP usage</Text></Panel>
|
||||
const maxCalls = sorted[0]?.[1] ?? 0
|
||||
const nw = Math.max(6, pw - bw - 15)
|
||||
|
||||
return (
|
||||
<Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}</Text>
|
||||
{sorted.slice(0, 8).map(([server, calls]) => (
|
||||
<Text key={server} wrap="truncate-end">
|
||||
<HBar value={calls} max={maxCalls} width={bw} />
|
||||
<Text> {fit(server, nw)}</Text>
|
||||
<Text>{String(calls).padStart(6)}</Text>
|
||||
</Text>
|
||||
<Text key={server} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(server, nw)}</Text><Text>{String(calls).padStart(6)}</Text></Text>
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
|
|
@ -445,29 +431,16 @@ function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: nu
|
|||
|
||||
function BashBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
const bashTotals: Record<string, number> = {}
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const [cmd, data] of Object.entries(session.bashBreakdown)) {
|
||||
bashTotals[cmd] = (bashTotals[cmd] ?? 0) + data.calls
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const project of projects) { for (const session of project.sessions) { for (const [cmd, data] of Object.entries(session.bashBreakdown)) { bashTotals[cmd] = (bashTotals[cmd] ?? 0) + data.calls } } }
|
||||
const sorted = Object.entries(bashTotals).sort(([, a], [, b]) => b - a)
|
||||
if (sorted.length === 0) {
|
||||
return <Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}><Text dimColor>No shell commands</Text></Panel>
|
||||
}
|
||||
if (sorted.length === 0) return <Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}><Text dimColor>No shell commands</Text></Panel>
|
||||
const maxCalls = sorted[0]?.[1] ?? 0
|
||||
const nw = Math.max(6, pw - bw - 15)
|
||||
|
||||
return (
|
||||
<Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
|
||||
{sorted.slice(0, 10).map(([cmd, calls]) => (
|
||||
<Text key={cmd} wrap="truncate-end">
|
||||
<HBar value={calls} max={maxCalls} width={bw} />
|
||||
<Text> {fit(cmd, nw)}</Text>
|
||||
<Text>{String(calls).padStart(7)}</Text>
|
||||
</Text>
|
||||
<Text key={cmd} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(cmd, nw)}</Text><Text>{String(calls).padStart(7)}</Text></Text>
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
|
|
@ -481,16 +454,9 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
}
|
||||
function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name }
|
||||
|
||||
function getProviderDisplayName(name: string): string {
|
||||
return PROVIDER_DISPLAY_NAMES[name] ?? name
|
||||
}
|
||||
|
||||
function PeriodTabs({ active, providerName, showProvider }: {
|
||||
active: Period
|
||||
providerName?: string
|
||||
showProvider?: boolean
|
||||
}) {
|
||||
function PeriodTabs({ active, providerName, showProvider }: { active: Period; providerName?: string; showProvider?: boolean }) {
|
||||
return (
|
||||
<Box justifyContent="space-between" paddingX={1}>
|
||||
<Box gap={1}>
|
||||
|
|
@ -501,41 +467,82 @@ function PeriodTabs({ active, providerName, showProvider }: {
|
|||
))}
|
||||
</Box>
|
||||
{showProvider && providerName && (
|
||||
<Box>
|
||||
<Text color={DIM}>| </Text>
|
||||
<Text color={ORANGE} bold>[p]</Text>
|
||||
<Text bold color={PROVIDER_COLORS[providerName] ?? ORANGE}> {getProviderDisplayName(providerName)}</Text>
|
||||
</Box>
|
||||
<Box><Text color={DIM}>| </Text><Text color={ORANGE} bold>[p]</Text><Text bold color={PROVIDER_COLORS[providerName] ?? ORANGE}> {getProviderDisplayName(providerName)}</Text></Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBar({ width, showProvider }: { width: number; showProvider?: boolean }) {
|
||||
function FindingAction({ action }: { action: WasteAction }) {
|
||||
const lines = action.type === 'file-content' ? action.content.split('\n') : action.type === 'command' ? action.text.split('\n') : [action.text]
|
||||
return (<><Text dimColor>{action.label}</Text>{lines.map((line, i) => <Text key={i} color="#5BF5E0"> {line}</Text>)}</>)
|
||||
}
|
||||
|
||||
function FindingPanel({ index, finding, costRate, width }: { index: number; finding: WasteFinding; costRate: number; width: number }) {
|
||||
const costSaved = finding.tokensSaved * costRate
|
||||
const color = IMPACT_PANEL_COLORS[finding.impact] ?? DIM
|
||||
const label = finding.impact.charAt(0).toUpperCase() + finding.impact.slice(1)
|
||||
const trendBadge = finding.trend === 'improving' ? ' improving \u2193' : ''
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text bold>{index}. {finding.title}</Text>
|
||||
<Text> </Text>
|
||||
<Text color={color}>{label}</Text>
|
||||
{trendBadge && <Text color="#5BF5A0">{trendBadge}</Text>}
|
||||
</Text>
|
||||
<Text dimColor wrap="wrap">{finding.explanation}</Text>
|
||||
<Text color={GOLD}>Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)})</Text>
|
||||
<Text> </Text>
|
||||
<FindingAction action={finding.fix} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const GRADE_COLORS: Record<string, string> = { A: '#5BF5A0', B: '#5BF5A0', C: GOLD, D: ORANGE, F: '#F55B5B' }
|
||||
|
||||
function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string }) {
|
||||
const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0)
|
||||
const totalCost = totalTokens * costRate
|
||||
const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0
|
||||
const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1)
|
||||
const gradeColor = GRADE_COLORS[healthGrade] ?? DIM
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={width}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text bold color={ORANGE}>CodeBurn Optimize</Text>
|
||||
<Text dimColor> {label} Setup: </Text>
|
||||
<Text bold color={gradeColor}>{healthGrade}</Text>
|
||||
<Text dimColor> ({healthScore}/100)</Text>
|
||||
</Text>
|
||||
<Text color="#5BF5A0" wrap="truncate-end">Savings: ~{formatTokens(totalTokens)} tokens (~{formatCost(totalCost)}, ~{pct}% of spend)</Text>
|
||||
</Box>
|
||||
{findings.map((f, i) => <FindingPanel key={i} index={i + 1} finding={f} costRate={costRate} width={width} />)}
|
||||
<Box paddingX={1} width={width}><Text dimColor>Token estimates are approximate.</Text></Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean }) {
|
||||
const isOptimize = view === 'optimize'
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={DIM} width={width} justifyContent="center" paddingX={1}>
|
||||
<Text>
|
||||
<Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text>
|
||||
<Text dimColor> switch </Text>
|
||||
<Text color={ORANGE} bold>q</Text>
|
||||
<Text dimColor> quit </Text>
|
||||
<Text color={ORANGE} bold>1</Text>
|
||||
<Text dimColor> today </Text>
|
||||
<Text color={ORANGE} bold>2</Text>
|
||||
<Text dimColor> week </Text>
|
||||
<Text color={ORANGE} bold>3</Text>
|
||||
<Text dimColor> 30 days </Text>
|
||||
<Text color={ORANGE} bold>4</Text>
|
||||
<Text dimColor> month </Text>
|
||||
<Text color={ORANGE} bold>5</Text>
|
||||
<Text dimColor> all time</Text>
|
||||
{showProvider && (
|
||||
<>
|
||||
<Text dimColor> </Text>
|
||||
<Text color={ORANGE} bold>p</Text>
|
||||
<Text dimColor> provider</Text>
|
||||
</>
|
||||
{isOptimize
|
||||
? <><Text color={ORANGE} bold>b</Text><Text dimColor> back </Text></>
|
||||
: <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> switch </Text></>}
|
||||
<Text color={ORANGE} bold>q</Text><Text dimColor> quit </Text>
|
||||
<Text color={ORANGE} bold>1</Text><Text dimColor> today </Text>
|
||||
<Text color={ORANGE} bold>2</Text><Text dimColor> week </Text>
|
||||
<Text color={ORANGE} bold>3</Text><Text dimColor> 30 days </Text>
|
||||
<Text color={ORANGE} bold>4</Text><Text dimColor> month </Text>
|
||||
<Text color={ORANGE} bold>5</Text><Text dimColor> all time</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></>
|
||||
)}
|
||||
{showProvider && (<><Text dimColor> </Text><Text color={ORANGE} bold>p</Text><Text dimColor> provider</Text></>)}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
|
|
@ -546,58 +553,34 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
|
|||
return <>{children}</>
|
||||
}
|
||||
|
||||
function DashboardContent({ projects, period, columns, activeProvider }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string }) {
|
||||
function DashboardContent({ projects, period, columns, activeProvider, budgets }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget> }) {
|
||||
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
|
||||
const isCursor = activeProvider === 'cursor'
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<Panel title="CodeBurn" color={ORANGE} width={dashWidth}>
|
||||
<Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel>
|
||||
const pw = wide ? halfWidth : dashWidth
|
||||
// undefined = no cutoff (show all days); 31 for month/30-day ranges; 14 for shorter periods
|
||||
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} />
|
||||
|
||||
<Row wide={wide} width={dashWidth}>
|
||||
<DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} />
|
||||
<ProjectBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
</Row>
|
||||
|
||||
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
|
||||
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
|
||||
|
||||
<Row wide={wide} width={dashWidth}>
|
||||
<ActivityBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
<ModelBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
</Row>
|
||||
|
||||
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
|
||||
{isCursor ? (
|
||||
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
|
||||
) : (
|
||||
<>
|
||||
<Row wide={wide} width={dashWidth}>
|
||||
<ToolBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
<BashBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
</Row>
|
||||
<McpBreakdown projects={projects} pw={dashWidth} bw={barWidth} />
|
||||
</>
|
||||
<><Row wide={wide} width={dashWidth}><ToolBreakdown projects={projects} pw={pw} bw={barWidth} /><BashBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><McpBreakdown projects={projects} pw={dashWidth} bw={barWidth} /></>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds }: {
|
||||
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds, projectFilter, excludeFilter }: {
|
||||
initialProjects: ProjectSummary[]
|
||||
initialPeriod: Period
|
||||
initialProvider: string
|
||||
refreshSeconds?: number
|
||||
projectFilter?: string[]
|
||||
excludeFilter?: string[]
|
||||
}) {
|
||||
const { exit } = useApp()
|
||||
const [period, setPeriod] = useState<Period>(initialPeriod)
|
||||
|
|
@ -605,34 +588,64 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
const [loading, setLoading] = useState(false)
|
||||
const [activeProvider, setActiveProvider] = useState(initialProvider)
|
||||
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
|
||||
const [view, setView] = useState<View>('dashboard')
|
||||
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
|
||||
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
|
||||
const { columns } = useWindowSize()
|
||||
const { dashWidth } = getLayout(columns)
|
||||
const multipleProviders = detectedProviders.length > 1
|
||||
const optimizeAvailable = activeProvider === 'all' || activeProvider === 'claude'
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const findingCount = optimizeResult?.findings.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function detect() {
|
||||
const found: string[] = []
|
||||
const allProviders = await getAllProviders()
|
||||
for (const p of allProviders) {
|
||||
const sessions = await p.discoverSessions()
|
||||
if (sessions.length > 0) found.push(p.name)
|
||||
}
|
||||
if (!cancelled) {
|
||||
setDetectedProviders(found)
|
||||
}
|
||||
for (const p of await getAllProviders()) { const s = await p.discoverSessions(); if (s.length > 0) found.push(p.name) }
|
||||
if (!cancelled) setDetectedProviders(found)
|
||||
}
|
||||
detect()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadBudgets() {
|
||||
const 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 (!cancelled) setProjectBudgets(budgets)
|
||||
}
|
||||
loadBudgets()
|
||||
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, getDateRange(period))
|
||||
if (!cancelled) setOptimizeResult(result)
|
||||
}
|
||||
scan()
|
||||
return () => { cancelled = true }
|
||||
}, [projects, period, optimizeAvailable])
|
||||
|
||||
const reloadData = useCallback(async (p: Period, prov: string) => {
|
||||
setLoading(true)
|
||||
setOptimizeResult(null)
|
||||
const range = getDateRange(p)
|
||||
const data = await parseAllSessions(range, prov)
|
||||
const data = filterProjectsByName(await parseAllSessions(range, prov), projectFilter, excludeFilter)
|
||||
setProjects(data)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
}, [projectFilter, excludeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshSeconds || refreshSeconds <= 0) return
|
||||
|
|
@ -640,46 +653,34 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
return () => clearInterval(id)
|
||||
}, [refreshSeconds, period, activeProvider, reloadData])
|
||||
|
||||
const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const switchPeriod = useCallback((newPeriod: Period) => {
|
||||
if (newPeriod === period) return
|
||||
setPeriod(newPeriod)
|
||||
const switchPeriod = useCallback((np: Period) => {
|
||||
if (np === period) return
|
||||
setPeriod(np); setView('dashboard')
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
reloadData(newPeriod, activeProvider)
|
||||
}, 600)
|
||||
debounceRef.current = setTimeout(() => { reloadData(np, activeProvider) }, 600)
|
||||
}, [period, activeProvider, reloadData])
|
||||
|
||||
const switchPeriodImmediate = useCallback(async (newPeriod: Period) => {
|
||||
if (newPeriod === period) return
|
||||
setPeriod(newPeriod)
|
||||
const switchPeriodImmediate = useCallback(async (np: Period) => {
|
||||
if (np === period) return
|
||||
setPeriod(np); setView('dashboard')
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
await reloadData(newPeriod, activeProvider)
|
||||
await reloadData(np, activeProvider)
|
||||
}, [period, activeProvider, reloadData])
|
||||
|
||||
useInput((input, key) => {
|
||||
if (input === 'q') {
|
||||
exit()
|
||||
return
|
||||
}
|
||||
|
||||
if (input === 'q') { exit(); return }
|
||||
if (input === 'o' && findingCount > 0 && view === 'dashboard' && optimizeAvailable) { setView('optimize'); return }
|
||||
if ((input === 'b' || key.escape) && view === 'optimize') { setView('dashboard'); return }
|
||||
if (input === 'p' && multipleProviders) {
|
||||
const options = ['all', ...detectedProviders]
|
||||
const idx = options.indexOf(activeProvider)
|
||||
const next = options[(idx + 1) % options.length]
|
||||
setActiveProvider(next)
|
||||
const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length]
|
||||
setActiveProvider(next); setView('dashboard')
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
reloadData(period, next)
|
||||
return
|
||||
reloadData(period, next); return
|
||||
}
|
||||
|
||||
const idx = PERIODS.indexOf(period)
|
||||
if (key.leftArrow) {
|
||||
switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length])
|
||||
} else if (key.rightArrow || key.tab) {
|
||||
switchPeriod(PERIODS[(idx + 1) % PERIODS.length])
|
||||
} else if (input === '1') switchPeriodImmediate('today')
|
||||
if (key.leftArrow && view === 'dashboard') switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length])
|
||||
else if ((key.rightArrow || key.tab) && view === 'dashboard') switchPeriod(PERIODS[(idx + 1) % PERIODS.length])
|
||||
else if (input === '1') switchPeriodImmediate('today')
|
||||
else if (input === '2') switchPeriodImmediate('week')
|
||||
else if (input === '3') switchPeriodImmediate('30days')
|
||||
else if (input === '4') switchPeriodImmediate('month')
|
||||
|
|
@ -690,10 +691,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders} />
|
||||
<Panel title="CodeBurn" color={ORANGE} width={dashWidth}>
|
||||
<Text dimColor>Loading {PERIOD_LABELS[period]}...</Text>
|
||||
</Panel>
|
||||
<StatusBar width={dashWidth} showProvider={multipleProviders} />
|
||||
<Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {PERIOD_LABELS[period]}...</Text></Panel>
|
||||
<StatusBar width={dashWidth} showProvider={multipleProviders} view="dashboard" findingCount={0} optimizeAvailable={false} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -701,8 +700,10 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders} />
|
||||
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} />
|
||||
<StatusBar width={dashWidth} showProvider={multipleProviders} />
|
||||
{view === 'optimize' && optimizeResult
|
||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} />
|
||||
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} />}
|
||||
<StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -718,23 +719,18 @@ function StaticDashboard({ projects, period, activeProvider }: { projects: Proje
|
|||
)
|
||||
}
|
||||
|
||||
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number): Promise<void> {
|
||||
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null): Promise<void> {
|
||||
await loadPricing()
|
||||
const range = getDateRange(period)
|
||||
const projects = await parseAllSessions(range, provider)
|
||||
|
||||
const range = customRange ?? getDateRange(period)
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
|
||||
const isTTY = process.stdin.isTTY && process.stdout.isTTY
|
||||
|
||||
if (isTTY) {
|
||||
const { waitUntilExit } = render(
|
||||
<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} />
|
||||
<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
|
||||
)
|
||||
await waitUntilExit()
|
||||
} else {
|
||||
const { unmount } = render(
|
||||
<StaticDashboard projects={projects} period={period} activeProvider={provider} />,
|
||||
{ patchConsole: false }
|
||||
)
|
||||
const { unmount } = render(<StaticDashboard projects={projects} period={period} activeProvider={provider} />, { patchConsole: false })
|
||||
unmount()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
142
src/day-aggregator.ts
Normal file
142
src/day-aggregator.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import type { DailyEntry } from './daily-cache.js'
|
||||
import type { PeriodData } from './menubar-json.js'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
|
||||
function emptyEntry(date: string): DailyEntry {
|
||||
return {
|
||||
date,
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
editTurns: 0,
|
||||
oneShotTurns: 0,
|
||||
models: {},
|
||||
categories: {},
|
||||
providers: {},
|
||||
}
|
||||
}
|
||||
|
||||
function dateKey(iso: string): string {
|
||||
return iso.slice(0, 10)
|
||||
}
|
||||
|
||||
export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] {
|
||||
const byDate = new Map<string, DailyEntry>()
|
||||
const ensure = (date: string): DailyEntry => {
|
||||
let d = byDate.get(date)
|
||||
if (!d) { d = emptyEntry(date); byDate.set(date, d) }
|
||||
return d
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
const sessionDate = dateKey(session.firstTimestamp)
|
||||
ensure(sessionDate).sessions += 1
|
||||
|
||||
for (const turn of session.turns) {
|
||||
if (turn.assistantCalls.length === 0) continue
|
||||
const turnDate = dateKey(turn.assistantCalls[0]!.timestamp)
|
||||
const turnDay = ensure(turnDate)
|
||||
|
||||
const editTurns = turn.hasEdits ? 1 : 0
|
||||
const oneShotTurns = turn.hasEdits && turn.retries === 0 ? 1 : 0
|
||||
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
|
||||
|
||||
turnDay.editTurns += editTurns
|
||||
turnDay.oneShotTurns += oneShotTurns
|
||||
|
||||
const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
|
||||
cat.turns += 1
|
||||
cat.cost += turnCost
|
||||
cat.editTurns += editTurns
|
||||
cat.oneShotTurns += oneShotTurns
|
||||
turnDay.categories[turn.category] = cat
|
||||
|
||||
for (const call of turn.assistantCalls) {
|
||||
const callDate = dateKey(call.timestamp)
|
||||
const callDay = ensure(callDate)
|
||||
|
||||
callDay.cost += call.costUSD
|
||||
callDay.calls += 1
|
||||
callDay.inputTokens += call.usage.inputTokens
|
||||
callDay.outputTokens += call.usage.outputTokens
|
||||
callDay.cacheReadTokens += call.usage.cacheReadInputTokens
|
||||
callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
|
||||
|
||||
const model = callDay.models[call.model] ?? {
|
||||
calls: 0, cost: 0,
|
||||
inputTokens: 0, outputTokens: 0,
|
||||
cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
}
|
||||
model.calls += 1
|
||||
model.cost += call.costUSD
|
||||
model.inputTokens += call.usage.inputTokens
|
||||
model.outputTokens += call.usage.outputTokens
|
||||
model.cacheReadTokens += call.usage.cacheReadInputTokens
|
||||
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
|
||||
callDay.models[call.model] = model
|
||||
|
||||
const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0 }
|
||||
provider.calls += 1
|
||||
provider.cost += call.costUSD
|
||||
callDay.providers[call.provider] = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date))
|
||||
}
|
||||
|
||||
export function buildPeriodDataFromDays(days: DailyEntry[], label: string): PeriodData {
|
||||
let cost = 0, calls = 0, sessions = 0
|
||||
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
|
||||
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
const modelTotals: Record<string, { calls: number; cost: number }> = {}
|
||||
|
||||
for (const d of days) {
|
||||
cost += d.cost
|
||||
calls += d.calls
|
||||
sessions += d.sessions
|
||||
inputTokens += d.inputTokens
|
||||
outputTokens += d.outputTokens
|
||||
cacheReadTokens += d.cacheReadTokens
|
||||
cacheWriteTokens += d.cacheWriteTokens
|
||||
|
||||
for (const [name, m] of Object.entries(d.models)) {
|
||||
const acc = modelTotals[name] ?? { calls: 0, cost: 0 }
|
||||
acc.calls += m.calls
|
||||
acc.cost += m.cost
|
||||
modelTotals[name] = acc
|
||||
}
|
||||
for (const [cat, c] of Object.entries(d.categories)) {
|
||||
const acc = catTotals[cat] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
|
||||
acc.turns += c.turns
|
||||
acc.cost += c.cost
|
||||
acc.editTurns += c.editTurns
|
||||
acc.oneShotTurns += c.oneShotTurns
|
||||
catTotals[cat] = acc
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
cost,
|
||||
calls,
|
||||
sessions,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
categories: Object.entries(catTotals)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
|
||||
models: Object.entries(modelTotals)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([name, d]) => ({ name, ...d })),
|
||||
}
|
||||
}
|
||||
311
src/export.ts
311
src/export.ts
|
|
@ -1,8 +1,8 @@
|
|||
import { writeFile } from 'fs/promises'
|
||||
import { resolve } from 'path'
|
||||
import { writeFile, mkdir, readdir, stat, rm } from 'fs/promises'
|
||||
import { dirname, join, resolve } from 'path'
|
||||
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { getCostColumnHeader, convertCost } from './currency.js'
|
||||
import { getCurrency, convertCost } from './currency.js'
|
||||
|
||||
function escCsv(s: string): string {
|
||||
const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s
|
||||
|
|
@ -12,15 +12,47 @@ function escCsv(s: string): string {
|
|||
return sanitized
|
||||
}
|
||||
|
||||
function buildDailyRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
|
||||
const daily: Record<string, { cost: number; calls: number; input: number; output: number; cacheRead: number; cacheWrite: number }> = {}
|
||||
type Row = Record<string, string | number>
|
||||
|
||||
function rowsToCsv(rows: Row[]): string {
|
||||
if (rows.length === 0) return ''
|
||||
const headers = Object.keys(rows[0])
|
||||
const lines = [headers.map(escCsv).join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(','))
|
||||
}
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function pct(n: number, total: number): number {
|
||||
return total > 0 ? round2((n / total) * 100) : 0
|
||||
}
|
||||
|
||||
type DailyAgg = {
|
||||
cost: number
|
||||
calls: number
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
sessions: Set<string>
|
||||
}
|
||||
|
||||
function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
|
||||
const daily: Record<string, DailyAgg> = {}
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const turn of session.turns) {
|
||||
if (!turn.timestamp) continue
|
||||
const day = turn.timestamp.slice(0, 10)
|
||||
if (!daily[day]) daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
||||
if (!daily[day]) {
|
||||
daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: new Set() }
|
||||
}
|
||||
daily[day].sessions.add(session.sessionId)
|
||||
for (const call of turn.assistantCalls) {
|
||||
daily[day].cost += call.costUSD
|
||||
daily[day].calls++
|
||||
|
|
@ -32,11 +64,13 @@ function buildDailyRows(projects: ProjectSummary[]): Array<Record<string, string
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { code } = getCurrency()
|
||||
return Object.entries(daily).sort().map(([date, d]) => ({
|
||||
Period: period,
|
||||
Date: date,
|
||||
[getCostColumnHeader()]: convertCost(d.cost),
|
||||
[`Cost (${code})`]: round2(convertCost(d.cost)),
|
||||
'API Calls': d.calls,
|
||||
Sessions: d.sessions.size,
|
||||
'Input Tokens': d.input,
|
||||
'Output Tokens': d.output,
|
||||
'Cache Read Tokens': d.cacheRead,
|
||||
|
|
@ -44,7 +78,7 @@ function buildDailyRows(projects: ProjectSummary[]): Array<Record<string, string
|
|||
}))
|
||||
}
|
||||
|
||||
function buildActivityRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
|
||||
function buildActivityRows(projects: ProjectSummary[], period: string): Row[] {
|
||||
const catTotals: Record<string, { turns: number; cost: number }> = {}
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
|
|
@ -55,40 +89,53 @@ function buildActivityRows(projects: ProjectSummary[]): Array<Record<string, str
|
|||
}
|
||||
}
|
||||
}
|
||||
const totalCost = Object.values(catTotals).reduce((s, d) => s + d.cost, 0)
|
||||
const { code } = getCurrency()
|
||||
return Object.entries(catTotals)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([cat, d]) => ({
|
||||
Period: period,
|
||||
Activity: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
|
||||
[getCostColumnHeader()]: convertCost(d.cost),
|
||||
[`Cost (${code})`]: round2(convertCost(d.cost)),
|
||||
'Share (%)': pct(d.cost, totalCost),
|
||||
Turns: d.turns,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildModelRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
|
||||
const modelTotals: Record<string, { calls: number; cost: number; input: number; output: number }> = {}
|
||||
function buildModelRows(projects: ProjectSummary[], period: string): Row[] {
|
||||
const modelTotals: Record<string, { calls: number; cost: number; input: number; output: number; cacheRead: number; cacheWrite: number }> = {}
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const [model, d] of Object.entries(session.modelBreakdown)) {
|
||||
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, input: 0, output: 0 }
|
||||
if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
||||
modelTotals[model].calls += d.calls
|
||||
modelTotals[model].cost += d.costUSD
|
||||
modelTotals[model].input += d.tokens.inputTokens
|
||||
modelTotals[model].output += d.tokens.outputTokens
|
||||
modelTotals[model].cacheRead += d.tokens.cacheReadInputTokens ?? 0
|
||||
modelTotals[model].cacheWrite += d.tokens.cacheCreationInputTokens ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalCost = Object.values(modelTotals).reduce((s, d) => s + d.cost, 0)
|
||||
const { code } = getCurrency()
|
||||
return Object.entries(modelTotals)
|
||||
.filter(([name]) => name !== '<synthetic>')
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([model, d]) => ({
|
||||
Period: period,
|
||||
Model: model,
|
||||
[getCostColumnHeader()]: convertCost(d.cost),
|
||||
[`Cost (${code})`]: round2(convertCost(d.cost)),
|
||||
'Share (%)': pct(d.cost, totalCost),
|
||||
'API Calls': d.calls,
|
||||
'Input Tokens': d.input,
|
||||
'Output Tokens': d.output,
|
||||
'Cache Read Tokens': d.cacheRead,
|
||||
'Cache Write Tokens': d.cacheWrite,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildToolRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
|
||||
function buildToolRows(projects: ProjectSummary[]): Row[] {
|
||||
const toolTotals: Record<string, number> = {}
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
|
|
@ -97,12 +144,17 @@ function buildToolRows(projects: ProjectSummary[]): Array<Record<string, string
|
|||
}
|
||||
}
|
||||
}
|
||||
const total = Object.values(toolTotals).reduce((s, n) => s + n, 0)
|
||||
return Object.entries(toolTotals)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([tool, calls]) => ({ Tool: tool, Calls: calls }))
|
||||
.map(([tool, calls]) => ({
|
||||
Tool: tool,
|
||||
Calls: calls,
|
||||
'Share (%)': pct(calls, total),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildBashRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
|
||||
function buildBashRows(projects: ProjectSummary[]): Row[] {
|
||||
const bashTotals: Record<string, number> = {}
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
|
|
@ -111,28 +163,48 @@ function buildBashRows(projects: ProjectSummary[]): Array<Record<string, string
|
|||
}
|
||||
}
|
||||
}
|
||||
const total = Object.values(bashTotals).reduce((s, n) => s + n, 0)
|
||||
return Object.entries(bashTotals)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([cmd, calls]) => ({ Command: cmd, Calls: calls }))
|
||||
.map(([cmd, calls]) => ({
|
||||
Command: cmd,
|
||||
Calls: calls,
|
||||
'Share (%)': pct(calls, total),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildProjectRows(projects: ProjectSummary[]): Array<Record<string, string | number>> {
|
||||
return projects.map(p => ({
|
||||
Project: p.projectPath,
|
||||
[getCostColumnHeader()]: convertCost(p.totalCostUSD),
|
||||
'API Calls': p.totalApiCalls,
|
||||
Sessions: p.sessions.length,
|
||||
}))
|
||||
function buildProjectRows(projects: ProjectSummary[]): Row[] {
|
||||
const { code } = getCurrency()
|
||||
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
return projects
|
||||
.slice()
|
||||
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
|
||||
.map(p => ({
|
||||
Project: p.projectPath,
|
||||
[`Cost (${code})`]: round2(convertCost(p.totalCostUSD)),
|
||||
[`Avg/Session (${code})`]: p.sessions.length > 0 ? round2(convertCost(p.totalCostUSD / p.sessions.length)) : '',
|
||||
'Share (%)': pct(p.totalCostUSD, total),
|
||||
'API Calls': p.totalApiCalls,
|
||||
Sessions: p.sessions.length,
|
||||
}))
|
||||
}
|
||||
|
||||
function rowsToCsv(rows: Array<Record<string, string | number>>): string {
|
||||
if (rows.length === 0) return ''
|
||||
const headers = Object.keys(rows[0])
|
||||
const lines = [headers.map(escCsv).join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(','))
|
||||
function buildSessionRows(projects: ProjectSummary[]): Row[] {
|
||||
const { code } = getCurrency()
|
||||
const rows: Row[] = []
|
||||
for (const p of projects) {
|
||||
for (const s of p.sessions) {
|
||||
rows.push({
|
||||
Project: p.projectPath,
|
||||
'Session ID': s.sessionId,
|
||||
'Started At': s.firstTimestamp ?? '',
|
||||
[`Cost (${code})`]: round2(convertCost(s.totalCostUSD)),
|
||||
'API Calls': s.apiCalls,
|
||||
Turns: s.turns.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
return rows.sort((a, b) => (b[`Cost (${code})`] as number) - (a[`Cost (${code})`] as number))
|
||||
}
|
||||
|
||||
export type PeriodExport = {
|
||||
|
|
@ -140,77 +212,140 @@ export type PeriodExport = {
|
|||
projects: ProjectSummary[]
|
||||
}
|
||||
|
||||
function buildSummaryRow(period: PeriodExport): Record<string, string | number> {
|
||||
const cost = period.projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
const calls = period.projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
||||
const sessions = period.projects.reduce((s, p) => s + p.sessions.length, 0)
|
||||
return { Period: period.label, [getCostColumnHeader()]: convertCost(cost), 'API Calls': calls, Sessions: sessions }
|
||||
function buildSummaryRows(periods: PeriodExport[]): Row[] {
|
||||
const { code } = getCurrency()
|
||||
return periods.map(p => {
|
||||
const cost = p.projects.reduce((s, proj) => s + proj.totalCostUSD, 0)
|
||||
const calls = p.projects.reduce((s, proj) => s + proj.totalApiCalls, 0)
|
||||
const sessions = p.projects.reduce((s, proj) => s + proj.sessions.length, 0)
|
||||
const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0).length
|
||||
return {
|
||||
Period: p.label,
|
||||
[`Cost (${code})`]: round2(convertCost(cost)),
|
||||
'API Calls': calls,
|
||||
Sessions: sessions,
|
||||
Projects: projectCount,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildReadme(periods: PeriodExport[]): string {
|
||||
const { code } = getCurrency()
|
||||
const generated = new Date().toISOString()
|
||||
const lines = [
|
||||
'CodeBurn Usage Export',
|
||||
'====================',
|
||||
'',
|
||||
`Generated: ${generated}`,
|
||||
`Currency: ${code}`,
|
||||
`Periods: ${periods.map(p => p.label).join(', ')}`,
|
||||
'',
|
||||
'Files',
|
||||
'-----',
|
||||
' summary.csv One row per period. Headline totals.',
|
||||
' daily.csv Day-by-day breakdown, Period column distinguishes the window.',
|
||||
' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).',
|
||||
' models.csv Spend per model with token totals and cache usage.',
|
||||
' projects.csv Spend per project folder (30-day window).',
|
||||
' sessions.csv One row per session (30-day window) with session IDs and costs.',
|
||||
' tools.csv Tool invocations and share (30-day window).',
|
||||
' shell-commands.csv Shell commands executed via Bash tool (30-day window).',
|
||||
'',
|
||||
'Notes',
|
||||
'-----',
|
||||
' Every cost column is already converted to the active currency. Tokens are raw integer',
|
||||
' counts from provider telemetry. Share (%) is relative to the period/table total.',
|
||||
'',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/// Sentinel file dropped into every folder we create so we can safely overwrite an older
|
||||
/// codeburn export without ever deleting a user's unrelated files by accident.
|
||||
const EXPORT_MARKER_FILE = '.codeburn-export'
|
||||
|
||||
async function isCodeburnExportFolder(path: string): Promise<boolean> {
|
||||
const markerStat = await stat(join(path, EXPORT_MARKER_FILE)).catch(() => null)
|
||||
return markerStat?.isFile() ?? false
|
||||
}
|
||||
|
||||
async function clearCodeburnExportFolder(path: string): Promise<void> {
|
||||
const entries = await readdir(path)
|
||||
for (const entry of entries) {
|
||||
await rm(join(path, entry), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a folder of one-table-per-file CSVs. The outputPath is treated as a directory. If it
|
||||
/// ends in `.csv` the extension is stripped to form the folder name. Refuses to delete a
|
||||
/// pre-existing file or a non-codeburn folder, so a typo like `-o ~/.ssh/id_ed25519` can't
|
||||
/// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally).
|
||||
export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise<string> {
|
||||
const allProjects = periods.find(p => p.label === '30 Days')?.projects
|
||||
?? periods[periods.length - 1].projects
|
||||
const thirtyDays = periods.find(p => p.label === '30 Days')
|
||||
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
parts.push('# Summary')
|
||||
parts.push(rowsToCsv(periods.map(buildSummaryRow)))
|
||||
parts.push('')
|
||||
|
||||
for (const period of periods) {
|
||||
parts.push(`# Daily - ${period.label}`)
|
||||
parts.push(rowsToCsv(buildDailyRows(period.projects)))
|
||||
parts.push('')
|
||||
|
||||
parts.push(`# Activity - ${period.label}`)
|
||||
parts.push(rowsToCsv(buildActivityRows(period.projects)))
|
||||
parts.push('')
|
||||
|
||||
parts.push(`# Models - ${period.label}`)
|
||||
parts.push(rowsToCsv(buildModelRows(period.projects)))
|
||||
parts.push('')
|
||||
let folder = resolve(outputPath)
|
||||
if (folder.toLowerCase().endsWith('.csv')) {
|
||||
folder = folder.slice(0, -4)
|
||||
}
|
||||
|
||||
parts.push('# Tools - All')
|
||||
parts.push(rowsToCsv(buildToolRows(allProjects)))
|
||||
parts.push('')
|
||||
const existingStat = await stat(folder).catch(() => null)
|
||||
if (existingStat?.isFile()) {
|
||||
throw new Error(`Refusing to overwrite existing file at ${folder}. Pass a directory path instead.`)
|
||||
}
|
||||
if (existingStat?.isDirectory()) {
|
||||
if (!(await isCodeburnExportFolder(folder))) {
|
||||
throw new Error(
|
||||
`Refusing to reuse non-empty directory ${folder}: no ${EXPORT_MARKER_FILE} marker. ` +
|
||||
`Delete it manually or pick a different -o path.`
|
||||
)
|
||||
}
|
||||
await clearCodeburnExportFolder(folder)
|
||||
}
|
||||
await mkdir(folder, { recursive: true })
|
||||
await writeFile(join(folder, EXPORT_MARKER_FILE), '', 'utf-8')
|
||||
|
||||
parts.push('# Shell Commands - All')
|
||||
parts.push(rowsToCsv(buildBashRows(allProjects)))
|
||||
parts.push('')
|
||||
const dailyRows = periods.flatMap(p => buildDailyRows(p.projects, p.label))
|
||||
const activityRows = periods.flatMap(p => buildActivityRows(p.projects, p.label))
|
||||
const modelRows = periods.flatMap(p => buildModelRows(p.projects, p.label))
|
||||
|
||||
parts.push('# Projects - All')
|
||||
parts.push(rowsToCsv(buildProjectRows(allProjects)))
|
||||
parts.push('')
|
||||
await writeFile(join(folder, 'README.txt'), buildReadme(periods), 'utf-8')
|
||||
await writeFile(join(folder, 'summary.csv'), rowsToCsv(buildSummaryRows(periods)), 'utf-8')
|
||||
await writeFile(join(folder, 'daily.csv'), rowsToCsv(dailyRows), 'utf-8')
|
||||
await writeFile(join(folder, 'activity.csv'), rowsToCsv(activityRows), 'utf-8')
|
||||
await writeFile(join(folder, 'models.csv'), rowsToCsv(modelRows), 'utf-8')
|
||||
await writeFile(join(folder, 'projects.csv'), rowsToCsv(buildProjectRows(thirtyDayProjects)), 'utf-8')
|
||||
await writeFile(join(folder, 'sessions.csv'), rowsToCsv(buildSessionRows(thirtyDayProjects)), 'utf-8')
|
||||
await writeFile(join(folder, 'tools.csv'), rowsToCsv(buildToolRows(thirtyDayProjects)), 'utf-8')
|
||||
await writeFile(join(folder, 'shell-commands.csv'), rowsToCsv(buildBashRows(thirtyDayProjects)), 'utf-8')
|
||||
|
||||
const fullPath = resolve(outputPath)
|
||||
await writeFile(fullPath, parts.join('\n'), 'utf-8')
|
||||
return fullPath
|
||||
return folder
|
||||
}
|
||||
|
||||
export async function exportJson(periods: PeriodExport[], outputPath: string): Promise<string> {
|
||||
const allProjects = periods.find(p => p.label === '30 Days')?.projects
|
||||
?? periods[periods.length - 1].projects
|
||||
|
||||
const periodData: Record<string, unknown> = {}
|
||||
for (const period of periods) {
|
||||
periodData[period.label] = {
|
||||
summary: buildSummaryRow(period),
|
||||
daily: buildDailyRows(period.projects),
|
||||
activity: buildActivityRows(period.projects),
|
||||
models: buildModelRows(period.projects),
|
||||
}
|
||||
}
|
||||
const thirtyDays = periods.find(p => p.label === '30 Days')
|
||||
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
|
||||
const { code, rate, symbol } = getCurrency()
|
||||
|
||||
const data = {
|
||||
schema: 'codeburn.export.v2',
|
||||
generated: new Date().toISOString(),
|
||||
periods: periodData,
|
||||
tools: buildToolRows(allProjects),
|
||||
shellCommands: buildBashRows(allProjects),
|
||||
projects: buildProjectRows(allProjects),
|
||||
currency: { code, rate, symbol },
|
||||
summary: buildSummaryRows(periods),
|
||||
periods: periods.map(p => ({
|
||||
label: p.label,
|
||||
daily: buildDailyRows(p.projects, p.label),
|
||||
activity: buildActivityRows(p.projects, p.label),
|
||||
models: buildModelRows(p.projects, p.label),
|
||||
})),
|
||||
projects: buildProjectRows(thirtyDayProjects),
|
||||
sessions: buildSessionRows(thirtyDayProjects),
|
||||
tools: buildToolRows(thirtyDayProjects),
|
||||
shellCommands: buildBashRows(thirtyDayProjects),
|
||||
}
|
||||
|
||||
const fullPath = resolve(outputPath)
|
||||
await writeFile(fullPath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
return fullPath
|
||||
const target = resolve(outputPath.toLowerCase().endsWith('.json') ? outputPath : `${outputPath}.json`)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await writeFile(target, JSON.stringify(data, null, 2), 'utf-8')
|
||||
return target
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,20 @@ export function formatTokens(n: number): string {
|
|||
return n.toString()
|
||||
}
|
||||
|
||||
/// Returns YYYY-MM-DD for the given date in the process-local timezone. Cheaper than shelling
|
||||
/// out to Intl.DateTimeFormat for every turn in a loop and avoids the UTC drift that bites
|
||||
/// `Date.toISOString().slice(0,10)` whenever the user runs this between local midnight and
|
||||
/// UTC midnight.
|
||||
function localDateString(d: Date): string {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export function renderStatusBar(projects: ProjectSummary[]): string {
|
||||
const now = new Date()
|
||||
const today = now.toISOString().slice(0, 10)
|
||||
const today = localDateString(now)
|
||||
const monthStart = `${today.slice(0, 7)}-01`
|
||||
|
||||
let todayCost = 0, todayCalls = 0, monthCost = 0, monthCalls = 0
|
||||
|
|
@ -24,7 +35,11 @@ export function renderStatusBar(projects: ProjectSummary[]): string {
|
|||
for (const session of project.sessions) {
|
||||
for (const turn of session.turns) {
|
||||
if (!turn.timestamp) continue
|
||||
const day = turn.timestamp.slice(0, 10)
|
||||
// Bucket by the session timestamp's local date so the user's "today" and "this month"
|
||||
// match the wall clock on their machine. Session timestamps are stored as UTC ISO
|
||||
// strings; naively slicing `timestamp.slice(0,10)` bucketed them by UTC date, which
|
||||
// showed `Today $0` during the UTC-midnight-to-local-midnight window.
|
||||
const day = localDateString(new Date(turn.timestamp))
|
||||
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
|
||||
const turnCalls = turn.assistantCalls.length
|
||||
if (day === today) { todayCost += turnCost; todayCalls += turnCalls }
|
||||
|
|
|
|||
93
src/fs-utils.ts
Normal file
93
src/fs-utils.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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.
|
||||
export const MAX_SESSION_FILE_BYTES = 128 * 1024 * 1024
|
||||
export const STREAM_THRESHOLD_BYTES = 8 * 1024 * 1024
|
||||
|
||||
function verbose(): boolean {
|
||||
return process.env.CODEBURN_VERBOSE === '1'
|
||||
}
|
||||
|
||||
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 {
|
||||
size = (await stat(filePath)).size
|
||||
} catch (err) {
|
||||
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (size > MAX_SESSION_FILE_BYTES) {
|
||||
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
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'}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readSessionFileSync(filePath: string): string | null {
|
||||
let size: number
|
||||
try {
|
||||
size = statSync(filePath).size
|
||||
} catch (err) {
|
||||
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (size > MAX_SESSION_FILE_BYTES) {
|
||||
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return readFileSync(filePath, 'utf-8')
|
||||
} catch (err) {
|
||||
warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function* readSessionLines(filePath: string): AsyncGenerator<string> {
|
||||
let size: number
|
||||
try {
|
||||
size = (await stat(filePath)).size
|
||||
} catch (err) {
|
||||
warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (size > MAX_SESSION_FILE_BYTES) {
|
||||
warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`)
|
||||
return
|
||||
}
|
||||
|
||||
const stream = createReadStream(filePath, { encoding: 'utf-8' })
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity })
|
||||
try {
|
||||
for await (const line of rl) yield line
|
||||
} catch (err) {
|
||||
warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
|
||||
}
|
||||
}
|
||||
173
src/menubar-installer.ts
Normal file
173
src/menubar-installer.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { spawn } from 'node:child_process'
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rename, rm, stat } 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'
|
||||
const APP_BUNDLE_NAME = 'CodeBurnMenubar.app'
|
||||
const ASSET_PATTERN = /^CodeBurnMenubar-.*\.zip$/
|
||||
const APP_PROCESS_NAME = 'CodeBurnMenubar'
|
||||
const SUPPORTED_OS = 'darwin'
|
||||
const MIN_MACOS_MAJOR = 14
|
||||
|
||||
export type InstallResult = { installedPath: string; launched: boolean }
|
||||
|
||||
type ReleaseAsset = { name: string; browser_download_url: string }
|
||||
type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
|
||||
|
||||
function userApplicationsDir(): string {
|
||||
return join(homedir(), 'Applications')
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSupportedPlatform(): Promise<void> {
|
||||
if (platform() !== SUPPORTED_OS) {
|
||||
throw new Error(`The menubar app is macOS only (detected: ${platform()}).`)
|
||||
}
|
||||
const major = Number((process.env.CODEBURN_FORCE_MACOS_MAJOR ?? '')
|
||||
|| (await sysProductVersion()).split('.')[0])
|
||||
if (!Number.isFinite(major) || major < MIN_MACOS_MAJOR) {
|
||||
throw new Error(`macOS ${MIN_MACOS_MAJOR}+ required (detected ${major}).`)
|
||||
}
|
||||
}
|
||||
|
||||
async function sysProductVersion(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('/usr/bin/sw_vers', ['-productVersion'])
|
||||
let out = ''
|
||||
proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
|
||||
proc.on('error', reject)
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) reject(new Error(`sw_vers exited with ${code}`))
|
||||
else resolve(out.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchLatestReleaseAsset(): Promise<ReleaseAsset> {
|
||||
const response = await fetch(RELEASE_API, {
|
||||
headers: {
|
||||
// Identify the installer so GitHub's abuse heuristics treat us as a known client.
|
||||
'User-Agent': 'codeburn-menubar-installer',
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
|
||||
}
|
||||
const body = await response.json() as ReleaseResponse
|
||||
const asset = body.assets.find(a => ASSET_PATTERN.test(a.name))
|
||||
if (!asset) {
|
||||
throw new Error(
|
||||
`No ${APP_BUNDLE_NAME} zip found in release ${body.tag_name}. ` +
|
||||
`Check https://github.com/getagentseal/codeburn/releases.`
|
||||
)
|
||||
}
|
||||
return asset
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, destPath: string): Promise<void> {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'codeburn-menubar-installer' },
|
||||
redirect: 'follow',
|
||||
})
|
||||
if (!response.ok || response.body === null) {
|
||||
throw new Error(`Download failed: HTTP ${response.status}`)
|
||||
}
|
||||
// fetch's ReadableStream needs to be wrapped for Node streams.
|
||||
const nodeStream = Readable.fromWeb(response.body as never)
|
||||
await pipeline(nodeStream, createWriteStream(destPath))
|
||||
}
|
||||
|
||||
async function runCommand(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { stdio: 'inherit' })
|
||||
proc.on('error', reject)
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) resolve()
|
||||
else reject(new Error(`${command} exited with status ${code}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function isAppRunning(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME])
|
||||
proc.on('close', (code) => resolve(code === 0))
|
||||
proc.on('error', () => resolve(false))
|
||||
})
|
||||
}
|
||||
|
||||
async function killRunningApp(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
const proc = spawn('/usr/bin/pkill', ['-f', APP_PROCESS_NAME])
|
||||
proc.on('close', () => resolve())
|
||||
proc.on('error', () => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
export async function installMenubarApp(options: { force?: boolean } = {}): Promise<InstallResult> {
|
||||
await ensureSupportedPlatform()
|
||||
|
||||
const appsDir = userApplicationsDir()
|
||||
const targetPath = join(appsDir, APP_BUNDLE_NAME)
|
||||
const alreadyInstalled = await exists(targetPath)
|
||||
|
||||
if (alreadyInstalled && !options.force) {
|
||||
if (!(await isAppRunning())) {
|
||||
await runCommand('/usr/bin/open', [targetPath])
|
||||
}
|
||||
return { installedPath: targetPath, launched: true }
|
||||
}
|
||||
|
||||
console.log('Looking up the latest CodeBurn Menubar release...')
|
||||
const asset = await fetchLatestReleaseAsset()
|
||||
|
||||
const stagingDir = await mkdtemp(join(tmpdir(), 'codeburn-menubar-'))
|
||||
try {
|
||||
const archivePath = join(stagingDir, asset.name)
|
||||
console.log(`Downloading ${asset.name}...`)
|
||||
await downloadToFile(asset.browser_download_url, archivePath)
|
||||
|
||||
console.log('Unpacking...')
|
||||
await runCommand('/usr/bin/unzip', ['-q', archivePath, '-d', stagingDir])
|
||||
|
||||
const unpackedApp = join(stagingDir, APP_BUNDLE_NAME)
|
||||
if (!(await exists(unpackedApp))) {
|
||||
throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`)
|
||||
}
|
||||
|
||||
// Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the
|
||||
// "cannot verify developer" prompt even for a signed + notarized app when the bundle
|
||||
// was delivered via curl/fetch instead of the Mac App Store.
|
||||
await runCommand('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', unpackedApp]).catch(() => {})
|
||||
|
||||
await mkdir(appsDir, { recursive: true })
|
||||
if (alreadyInstalled) {
|
||||
// Kill the running copy before replacing its bundle so `mv` can proceed cleanly and the
|
||||
// user ends up on the new version.
|
||||
await killRunningApp()
|
||||
await rm(targetPath, { recursive: true, force: true })
|
||||
}
|
||||
await rename(unpackedApp, targetPath)
|
||||
|
||||
console.log('Launching CodeBurn Menubar...')
|
||||
await runCommand('/usr/bin/open', [targetPath])
|
||||
return { installedPath: targetPath, launched: true }
|
||||
} finally {
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
182
src/menubar-json.ts
Normal file
182
src/menubar-json.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/// Rollup of one time window (today / 7 days / 30 days / month / all) used as the canonical
|
||||
/// input to the menubar payload. Built inside the CLI and also consumed by the day-aggregator
|
||||
/// when hydrating per-day cache entries.
|
||||
export type PeriodData = {
|
||||
label: string
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
|
||||
models: Array<{ name: string; cost: number; calls: number }>
|
||||
}
|
||||
|
||||
export type ProviderCost = {
|
||||
name: string
|
||||
cost: number
|
||||
}
|
||||
import type { OptimizeResult } from './optimize.js'
|
||||
|
||||
const TOP_ACTIVITIES_LIMIT = 20
|
||||
const TOP_MODELS_LIMIT = 20
|
||||
const TOP_FINDINGS_LIMIT = 10
|
||||
const HISTORY_DAYS_LIMIT = 365
|
||||
const SYNTHETIC_MODEL_NAME = '<synthetic>'
|
||||
|
||||
export type DailyModelBreakdown = {
|
||||
name: string
|
||||
cost: number
|
||||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
}
|
||||
|
||||
export type DailyHistoryEntry = {
|
||||
date: string
|
||||
cost: number
|
||||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
topModels: DailyModelBreakdown[]
|
||||
}
|
||||
|
||||
export type MenubarPayload = {
|
||||
generated: string
|
||||
current: {
|
||||
label: string
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
oneShotRate: number | null
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheHitPercent: number
|
||||
topActivities: Array<{
|
||||
name: string
|
||||
cost: number
|
||||
turns: number
|
||||
oneShotRate: number | null
|
||||
}>
|
||||
topModels: Array<{
|
||||
name: string
|
||||
cost: number
|
||||
calls: number
|
||||
}>
|
||||
providers: Record<string, number>
|
||||
}
|
||||
optimize: {
|
||||
findingCount: number
|
||||
savingsUSD: number
|
||||
topFindings: Array<{
|
||||
title: string
|
||||
impact: 'high' | 'medium' | 'low'
|
||||
savingsUSD: number
|
||||
}>
|
||||
}
|
||||
history: {
|
||||
daily: DailyHistoryEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {
|
||||
if (editTurns === 0) return null
|
||||
return oneShotTurns / editTurns
|
||||
}
|
||||
|
||||
function aggregateOneShotRate(categories: PeriodData['categories']): number | null {
|
||||
let edits = 0
|
||||
let oneShots = 0
|
||||
for (const cat of categories) {
|
||||
edits += cat.editTurns
|
||||
oneShots += cat.oneShotTurns
|
||||
}
|
||||
if (edits === 0) return null
|
||||
return oneShots / edits
|
||||
}
|
||||
|
||||
function cacheHitPercent(inputTokens: number, cacheReadTokens: number): number {
|
||||
const denom = inputTokens + cacheReadTokens
|
||||
if (denom === 0) return 0
|
||||
return (cacheReadTokens / denom) * 100
|
||||
}
|
||||
|
||||
function buildTopActivities(categories: PeriodData['categories']): MenubarPayload['current']['topActivities'] {
|
||||
return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({
|
||||
name: cat.name,
|
||||
cost: cat.cost,
|
||||
turns: cat.turns,
|
||||
oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
|
||||
return models
|
||||
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
|
||||
.slice(0, TOP_MODELS_LIMIT)
|
||||
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls }))
|
||||
}
|
||||
|
||||
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
|
||||
if (!optimize || optimize.findings.length === 0) {
|
||||
return { findingCount: 0, savingsUSD: 0, topFindings: [] }
|
||||
}
|
||||
const { findings, costRate } = optimize
|
||||
const totalSavingsUSD = findings.reduce((s, f) => s + f.tokensSaved * costRate, 0)
|
||||
const topFindings = findings.slice(0, TOP_FINDINGS_LIMIT).map(f => ({
|
||||
title: f.title,
|
||||
impact: f.impact,
|
||||
savingsUSD: f.tokensSaved * costRate,
|
||||
}))
|
||||
return {
|
||||
findingCount: findings.length,
|
||||
savingsUSD: totalSavingsUSD,
|
||||
topFindings,
|
||||
}
|
||||
}
|
||||
|
||||
function buildProviders(providers: ProviderCost[]): Record<string, number> {
|
||||
const map: Record<string, number> = {}
|
||||
for (const p of providers) {
|
||||
if (p.cost < 0) continue
|
||||
map[p.name.toLowerCase()] = p.cost
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['history'] {
|
||||
if (!daily || daily.length === 0) return { daily: [] }
|
||||
const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
|
||||
const trimmed = sorted.slice(-HISTORY_DAYS_LIMIT)
|
||||
return { daily: trimmed }
|
||||
}
|
||||
|
||||
export function buildMenubarPayload(
|
||||
current: PeriodData,
|
||||
providers: ProviderCost[],
|
||||
optimize: OptimizeResult | null,
|
||||
dailyHistory?: DailyHistoryEntry[],
|
||||
): MenubarPayload {
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
current: {
|
||||
label: current.label,
|
||||
cost: current.cost,
|
||||
calls: current.calls,
|
||||
sessions: current.sessions,
|
||||
oneShotRate: aggregateOneShotRate(current.categories),
|
||||
inputTokens: current.inputTokens,
|
||||
outputTokens: current.outputTokens,
|
||||
cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
|
||||
topActivities: buildTopActivities(current.categories),
|
||||
topModels: buildTopModels(current.models),
|
||||
providers: buildProviders(providers),
|
||||
},
|
||||
optimize: buildOptimize(optimize),
|
||||
history: buildHistory(dailyHistory),
|
||||
}
|
||||
}
|
||||
318
src/menubar.ts
318
src/menubar.ts
|
|
@ -1,318 +0,0 @@
|
|||
import { execFileSync, execSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { chmod, mkdir, unlink, writeFile } from 'fs/promises'
|
||||
import { homedir, platform } from 'os'
|
||||
import { join } from 'path'
|
||||
import { formatCost, formatTokens } from './format.js'
|
||||
import { getCurrency } from './currency.js'
|
||||
|
||||
const PLUGIN_REFRESH = '5m'
|
||||
const SWIFTBAR_PREFERENCES_DOMAIN = 'com.ameba.SwiftBar'
|
||||
const SWIFTBAR_PLUGIN_DIRECTORY_KEY = 'PluginDirectory'
|
||||
|
||||
function getSwiftBarPluginDir(): string {
|
||||
return join(homedir(), 'Library', 'Application Support', 'SwiftBar', 'plugins')
|
||||
}
|
||||
|
||||
function getXbarPluginDir(): string {
|
||||
return join(homedir(), 'Library', 'Application Support', 'xbar', 'plugins')
|
||||
}
|
||||
|
||||
export function parsePluginDirectoryPreference(value: string): string | undefined {
|
||||
const pluginDir = value.trim()
|
||||
if (!pluginDir) return undefined
|
||||
if (pluginDir === '~') return homedir()
|
||||
if (pluginDir.startsWith('~/')) return join(homedir(), pluginDir.slice(2))
|
||||
return pluginDir
|
||||
}
|
||||
|
||||
function getConfiguredSwiftBarPluginDir(): string | undefined {
|
||||
if (platform() !== 'darwin') return undefined
|
||||
|
||||
try {
|
||||
return parsePluginDirectoryPreference(execFileSync('defaults', [
|
||||
'read',
|
||||
SWIFTBAR_PREFERENCES_DOMAIN,
|
||||
SWIFTBAR_PLUGIN_DIRECTORY_KEY,
|
||||
], { encoding: 'utf-8' }))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getSwiftBarPluginDirs(): string[] {
|
||||
const dirs = [getConfiguredSwiftBarPluginDir(), getSwiftBarPluginDir()]
|
||||
return dirs.filter((dir, index): dir is string => dir !== undefined && dirs.indexOf(dir) === index)
|
||||
}
|
||||
|
||||
export function chooseMenubarPluginDir(
|
||||
swiftBarPluginDirs: string[],
|
||||
xbarPluginDir: string,
|
||||
pathExists: (path: string) => boolean,
|
||||
): { pluginDir: string; appName: string } {
|
||||
const preferredSwiftBarDir = swiftBarPluginDirs[0] ?? getSwiftBarPluginDir()
|
||||
|
||||
for (const pluginDir of swiftBarPluginDirs) {
|
||||
if (pathExists(pluginDir)) return { pluginDir, appName: 'SwiftBar' }
|
||||
}
|
||||
|
||||
if (pathExists(xbarPluginDir)) return { pluginDir: xbarPluginDir, appName: 'xbar' }
|
||||
|
||||
return { pluginDir: preferredSwiftBarDir, appName: 'SwiftBar' }
|
||||
}
|
||||
|
||||
function getCodeburnBin(): string {
|
||||
try {
|
||||
return execSync('which codeburn', { encoding: 'utf-8' }).trim()
|
||||
} catch {
|
||||
return 'npx --yes codeburn'
|
||||
}
|
||||
}
|
||||
|
||||
function generatePlugin(bin: string): string {
|
||||
const home = homedir()
|
||||
return `#!/bin/bash
|
||||
# <xbar.title>CodeBurn</xbar.title>
|
||||
# <xbar.version>v0.1.0</xbar.version>
|
||||
# <xbar.author>AgentSeal</xbar.author>
|
||||
# <xbar.author.github>agentseal</xbar.author.github>
|
||||
# <xbar.desc>See where your AI coding tokens burn. Tracks cost, activity, and model usage across Claude Code, Cursor, and Codex by task type, tool, MCP server, and project.</xbar.desc>
|
||||
# <xbar.image>file://${home}/codeburn/assets/logo.png</xbar.image>
|
||||
# <xbar.abouturl>https://github.com/agentseal/codeburn</xbar.abouturl>
|
||||
# <xbar.dependencies>node</xbar.dependencies>
|
||||
|
||||
export PATH="/usr/local/bin:/opt/homebrew/bin:$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
${bin} status --format menubar 2>/dev/null || echo "-- | sfimage=flame.fill"
|
||||
`
|
||||
}
|
||||
|
||||
function miniBar(value: number, max: number, width: number = 10): string {
|
||||
if (max === 0) return '·'.repeat(width)
|
||||
const filled = Math.round((value / max) * width)
|
||||
return '█'.repeat(Math.min(filled, width)) + '·'.repeat(Math.max(width - filled, 0))
|
||||
}
|
||||
|
||||
export type PeriodData = {
|
||||
label: string
|
||||
cost: number
|
||||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
|
||||
models: Array<{ name: string; cost: number; calls: number }>
|
||||
}
|
||||
|
||||
export type ProviderCost = {
|
||||
name: string
|
||||
cost: number
|
||||
}
|
||||
|
||||
export function renderMenubarFormat(
|
||||
today: PeriodData,
|
||||
week: PeriodData,
|
||||
thirtyDays: PeriodData,
|
||||
month: PeriodData,
|
||||
todayProviders?: ProviderCost[],
|
||||
): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(`${formatCost(today.cost)} | sfimage=flame.fill color=#FF8C42`)
|
||||
lines.push('---')
|
||||
|
||||
lines.push(`CodeBurn | size=15 color=#FF8C42`)
|
||||
lines.push(`AI Coding Cost Tracker | size=11`)
|
||||
if (todayProviders && todayProviders.length > 1) {
|
||||
for (const p of todayProviders) {
|
||||
lines.push(` ${p.name.padEnd(10)} ${formatCost(p.cost).padStart(10)} | font=Menlo size=11`)
|
||||
}
|
||||
}
|
||||
lines.push('---')
|
||||
|
||||
lines.push(`Today ${formatCost(today.cost)} ${today.calls.toLocaleString()} calls | size=14`)
|
||||
lines.push('---')
|
||||
|
||||
const maxCat = Math.max(...today.categories.map(c => c.cost), 0.01)
|
||||
lines.push(`Activity - Today | size=12 color=#FF8C42`)
|
||||
for (const cat of today.categories.slice(0, 8)) {
|
||||
const bar = miniBar(cat.cost, maxCat)
|
||||
const name = cat.name.padEnd(14)
|
||||
lines.push(`${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
|
||||
}
|
||||
lines.push('---')
|
||||
|
||||
const maxModel = Math.max(...today.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
|
||||
lines.push(`Models - Today | size=12 color=#FF8C42`)
|
||||
for (const model of today.models.slice(0, 5)) {
|
||||
if (model.name === '<synthetic>') continue
|
||||
const bar = miniBar(model.cost, maxModel)
|
||||
const name = model.name.padEnd(14)
|
||||
lines.push(`${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
|
||||
}
|
||||
|
||||
const cacheHit = today.inputTokens + today.cacheReadTokens > 0
|
||||
? ((today.cacheReadTokens / (today.inputTokens + today.cacheReadTokens)) * 100).toFixed(0)
|
||||
: '0'
|
||||
lines.push(`Tokens: ${formatTokens(today.inputTokens)} in · ${formatTokens(today.outputTokens)} out · ${cacheHit}% cache hit | font=Menlo size=10`)
|
||||
lines.push('---')
|
||||
|
||||
lines.push(`7 Days ${formatCost(week.cost)} ${week.calls.toLocaleString()} calls | size=14`)
|
||||
const weekMaxCat = Math.max(...week.categories.map(c => c.cost), 0.01)
|
||||
const weekMaxModel = Math.max(...week.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
|
||||
lines.push(`--Activity | size=12 color=#FF8C42`)
|
||||
for (const cat of week.categories.slice(0, 8)) {
|
||||
const bar = miniBar(cat.cost, weekMaxCat)
|
||||
const name = cat.name.padEnd(14)
|
||||
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
|
||||
}
|
||||
lines.push(`-----`)
|
||||
lines.push(`--Models | size=12 color=#FF8C42`)
|
||||
for (const model of week.models.slice(0, 5)) {
|
||||
if (model.name === '<synthetic>') continue
|
||||
const bar = miniBar(model.cost, weekMaxModel)
|
||||
const name = model.name.padEnd(14)
|
||||
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
|
||||
}
|
||||
|
||||
lines.push(`30 Days ${formatCost(thirtyDays.cost)} ${thirtyDays.calls.toLocaleString()} calls | size=14`)
|
||||
const tdMaxCat = Math.max(...thirtyDays.categories.map(c => c.cost), 0.01)
|
||||
const tdMaxModel = Math.max(...thirtyDays.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
|
||||
lines.push(`--Activity | size=12 color=#FF8C42`)
|
||||
for (const cat of thirtyDays.categories.slice(0, 8)) {
|
||||
const bar = miniBar(cat.cost, tdMaxCat)
|
||||
const name = cat.name.padEnd(14)
|
||||
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
|
||||
}
|
||||
lines.push(`-----`)
|
||||
lines.push(`--Models | size=12 color=#FF8C42`)
|
||||
for (const model of thirtyDays.models.slice(0, 5)) {
|
||||
if (model.name === '<synthetic>') continue
|
||||
const bar = miniBar(model.cost, tdMaxModel)
|
||||
const name = model.name.padEnd(14)
|
||||
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
|
||||
}
|
||||
|
||||
lines.push(`Month ${formatCost(month.cost)} ${month.calls.toLocaleString()} calls | size=14`)
|
||||
const monthMaxCat = Math.max(...month.categories.map(c => c.cost), 0.01)
|
||||
const monthMaxModel = Math.max(...month.models.filter(m => m.name !== '<synthetic>').map(m => m.cost), 0.01)
|
||||
lines.push(`--Activity | size=12 color=#FF8C42`)
|
||||
for (const cat of month.categories.slice(0, 8)) {
|
||||
const bar = miniBar(cat.cost, monthMaxCat)
|
||||
const name = cat.name.padEnd(14)
|
||||
lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
|
||||
}
|
||||
lines.push(`-----`)
|
||||
lines.push(`--Models | size=12 color=#FF8C42`)
|
||||
for (const model of month.models.slice(0, 5)) {
|
||||
if (model.name === '<synthetic>') continue
|
||||
const bar = miniBar(model.cost, monthMaxModel)
|
||||
const name = model.name.padEnd(14)
|
||||
lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
|
||||
}
|
||||
|
||||
lines.push('---')
|
||||
const home = process.env.HOME ?? '~'
|
||||
const bin = getCodeburnBin()
|
||||
// Invoke the resolved `codeburn` binary directly. SwiftBar/xbar deliver
|
||||
// each `paramN=` value as its own argv entry, so there's no shell
|
||||
// quoting involved — and we don't ship the user to a `~/codeburn`
|
||||
// checkout that only exists when running from a dev clone (#32).
|
||||
lines.push(`Open Full Report | terminal=true shell=${bin} param1=report`)
|
||||
lines.push(`Export CSV to Desktop | terminal=false shell=${bin} param1=export param2=-o param3=${home}/Desktop/codeburn-report.csv`)
|
||||
|
||||
// Currency submenu -- common currencies as clickable items.
|
||||
// Clicking one runs 'codeburn currency XXX' and refreshes the plugin.
|
||||
const activeCurrency = getCurrency().code
|
||||
const currencies = [
|
||||
{ code: 'USD', name: 'US Dollar' },
|
||||
{ code: 'GBP', name: 'British Pound' },
|
||||
{ code: 'EUR', name: 'Euro' },
|
||||
{ code: 'AUD', name: 'Australian Dollar' },
|
||||
{ code: 'CAD', name: 'Canadian Dollar' },
|
||||
{ code: 'NZD', name: 'New Zealand Dollar' },
|
||||
{ code: 'JPY', name: 'Japanese Yen' },
|
||||
{ code: 'CHF', name: 'Swiss Franc' },
|
||||
{ code: 'INR', name: 'Indian Rupee' },
|
||||
{ code: 'BRL', name: 'Brazilian Real' },
|
||||
{ code: 'SEK', name: 'Swedish Krona' },
|
||||
{ code: 'SGD', name: 'Singapore Dollar' },
|
||||
{ code: 'HKD', name: 'Hong Kong Dollar' },
|
||||
{ code: 'KRW', name: 'South Korean Won' },
|
||||
{ code: 'MXN', name: 'Mexican Peso' },
|
||||
{ code: 'ZAR', name: 'South African Rand' },
|
||||
{ code: 'DKK', name: 'Danish Krone' },
|
||||
]
|
||||
lines.push(`Currency: ${activeCurrency} | size=14`)
|
||||
for (const { code, name } of currencies) {
|
||||
const check = code === activeCurrency ? ' *' : ''
|
||||
// The real CLI subcommand is `codeburn currency [code]` (with `--reset`
|
||||
// for USD), not `codeburn config currency` — the latter doesn't exist
|
||||
// and silently fails when SwiftBar runs it. Fixes #27.
|
||||
if (code === 'USD') {
|
||||
lines.push(`--${name} (${code})${check} | terminal=false refresh=true shell=${bin} param1=currency param2=--reset`)
|
||||
} else {
|
||||
lines.push(`--${name} (${code})${check} | terminal=false refresh=true shell=${bin} param1=currency param2=${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`Refresh | refresh=true`)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export async function installMenubar(): Promise<string> {
|
||||
if (platform() !== 'darwin') {
|
||||
return 'Menu bar integration is only available on macOS. Use `codeburn watch` or `codeburn status` instead.'
|
||||
}
|
||||
|
||||
const bin = getCodeburnBin()
|
||||
const pluginContent = generatePlugin(bin)
|
||||
|
||||
const { pluginDir, appName } = chooseMenubarPluginDir(getSwiftBarPluginDirs(), getXbarPluginDir(), existsSync)
|
||||
|
||||
if (!existsSync(pluginDir)) {
|
||||
await mkdir(pluginDir, { recursive: true })
|
||||
}
|
||||
|
||||
const pluginPath = join(pluginDir, `codeburn.${PLUGIN_REFRESH}.sh`)
|
||||
await writeFile(pluginPath, pluginContent, 'utf-8')
|
||||
await chmod(pluginPath, 0o755)
|
||||
|
||||
const swiftbarInstalled = existsSync('/Applications/SwiftBar.app') || existsSync(join(homedir(), 'Applications', 'SwiftBar.app'))
|
||||
const xbarInstalled = existsSync('/Applications/xbar.app') || existsSync(join(homedir(), 'Applications', 'xbar.app'))
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(`\n Plugin installed to: ${pluginPath}`)
|
||||
|
||||
if (swiftbarInstalled || xbarInstalled) {
|
||||
lines.push(` ${appName} detected - plugin should appear in your menu bar shortly.`)
|
||||
lines.push(` If not, open ${appName} and refresh plugins.\n`)
|
||||
} else {
|
||||
lines.push(`\n To see CodeBurn in your menu bar, install SwiftBar:`)
|
||||
lines.push(` brew install --cask swiftbar`)
|
||||
lines.push(`\n Then launch SwiftBar - the plugin will load automatically.\n`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export async function uninstallMenubar(): Promise<string> {
|
||||
const paths = [
|
||||
...getSwiftBarPluginDirs().map(dir => join(dir, `codeburn.${PLUGIN_REFRESH}.sh`)),
|
||||
join(getXbarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
|
||||
]
|
||||
|
||||
let removed = false
|
||||
for (const p of paths) {
|
||||
if (existsSync(p)) {
|
||||
await unlink(p)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
|
||||
return removed
|
||||
? '\n Menu bar plugin removed.\n'
|
||||
: '\n No menu bar plugin found.\n'
|
||||
}
|
||||
1134
src/optimize.ts
Normal file
1134
src/optimize.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
import { readdir, readFile } from 'fs/promises'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { readSessionFile } from './fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from './models.js'
|
||||
import { discoverAllSessions, getProvider } from './providers/index.js'
|
||||
import type { ParsedProviderCall } from './providers/types.js'
|
||||
|
|
@ -168,11 +169,11 @@ function buildSessionSummary(
|
|||
project: string,
|
||||
turns: ClassifiedTurn[],
|
||||
): SessionSummary {
|
||||
const modelBreakdown: SessionSummary['modelBreakdown'] = {}
|
||||
const toolBreakdown: SessionSummary['toolBreakdown'] = {}
|
||||
const mcpBreakdown: SessionSummary['mcpBreakdown'] = {}
|
||||
const bashBreakdown: SessionSummary['bashBreakdown'] = {}
|
||||
const categoryBreakdown: SessionSummary['categoryBreakdown'] = {} as SessionSummary['categoryBreakdown']
|
||||
const modelBreakdown: SessionSummary['modelBreakdown'] = Object.create(null)
|
||||
const toolBreakdown: SessionSummary['toolBreakdown'] = Object.create(null)
|
||||
const mcpBreakdown: SessionSummary['mcpBreakdown'] = Object.create(null)
|
||||
const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null)
|
||||
const categoryBreakdown: SessionSummary['categoryBreakdown'] = Object.create(null)
|
||||
|
||||
let totalCost = 0
|
||||
let totalInput = 0
|
||||
|
|
@ -265,12 +266,17 @@ async function parseSessionFile(
|
|||
seenMsgIds: Set<string>,
|
||||
dateRange?: DateRange,
|
||||
): Promise<SessionSummary | null> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(filePath, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
// Skip files whose mtime is older than the range start. A session file
|
||||
// can only contain entries up to its last-modified time; if that predates
|
||||
// the requested range, nothing in this file can match.
|
||||
if (dateRange) {
|
||||
try {
|
||||
const s = await stat(filePath)
|
||||
if (s.mtimeMs < dateRange.start.getTime()) return null
|
||||
} catch { /* fall through to normal read; missing stat shouldn't break parsing */ }
|
||||
}
|
||||
const content = await readSessionFile(filePath)
|
||||
if (content === null) return null
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
const entries: JournalEntry[] = []
|
||||
|
||||
|
|
@ -391,6 +397,12 @@ async function parseProviderSources(
|
|||
const sessionMap = new Map<string, { project: string; turns: ClassifiedTurn[] }>()
|
||||
|
||||
for (const source of sources) {
|
||||
if (dateRange) {
|
||||
try {
|
||||
const s = await stat(source.path)
|
||||
if (s.mtimeMs < dateRange.start.getTime()) continue
|
||||
} catch { /* fall through; treat unknown stat as "may contain data" */ }
|
||||
}
|
||||
const parser = provider.createSessionParser(
|
||||
{ path: source.path, project: source.project, provider: providerName },
|
||||
seenKeys,
|
||||
|
|
@ -462,6 +474,31 @@ function cachePut(key: string, data: ProjectSummary[]) {
|
|||
sessionCache.set(key, { data, ts: now })
|
||||
}
|
||||
|
||||
export function filterProjectsByName(
|
||||
projects: ProjectSummary[],
|
||||
include?: string[],
|
||||
exclude?: string[],
|
||||
): ProjectSummary[] {
|
||||
let result = projects
|
||||
if (include && include.length > 0) {
|
||||
const patterns = include.map(s => s.toLowerCase())
|
||||
result = result.filter(p => {
|
||||
const name = p.project.toLowerCase()
|
||||
const path = p.projectPath.toLowerCase()
|
||||
return patterns.some(pat => name.includes(pat) || path.includes(pat))
|
||||
})
|
||||
}
|
||||
if (exclude && exclude.length > 0) {
|
||||
const patterns = exclude.map(s => s.toLowerCase())
|
||||
result = result.filter(p => {
|
||||
const name = p.project.toLowerCase()
|
||||
const path = p.projectPath.toLowerCase()
|
||||
return !patterns.some(pat => name.includes(pat) || path.includes(pat))
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise<ProjectSummary[]> {
|
||||
const key = cacheKey(dateRange, providerFilter)
|
||||
const cached = sessionCache.get(key)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost } from '../models.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
|
|
@ -65,10 +66,11 @@ function sanitizeProject(cwd: string): string {
|
|||
}
|
||||
|
||||
async function readFirstLine(filePath: string): Promise<CodexEntry | null> {
|
||||
const content = await readSessionFile(filePath)
|
||||
if (content === null) return null
|
||||
const line = content.split('\n')[0]
|
||||
if (!line?.trim()) return null
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const line = content.split('\n')[0]
|
||||
if (!line?.trim()) return null
|
||||
return JSON.parse(line) as CodexEntry
|
||||
} catch {
|
||||
return null
|
||||
|
|
@ -139,13 +141,8 @@ function resolveModel(info: CodexEntry['payload'], sessionModel?: string): strin
|
|||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(source.path, 'utf-8')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const content = await readSessionFile(source.path)
|
||||
if (content === null) return
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
let sessionModel: string | undefined
|
||||
let sessionId = ''
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost } from '../models.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
|
|
@ -85,13 +86,8 @@ function parseCwd(yaml: string): string | null {
|
|||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(source.path, 'utf-8')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const content = await readSessionFile(source.path)
|
||||
if (content === null) return
|
||||
const sessionId = basename(dirname(source.path))
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
let currentModel = ''
|
||||
|
|
@ -177,11 +173,11 @@ async function discoverSessionsInDir(sessionStateDir: string): Promise<SessionSo
|
|||
if (!s?.isFile()) continue
|
||||
|
||||
let project = sessionId
|
||||
try {
|
||||
const yaml = await readFile(join(sessionStateDir, sessionId, 'workspace.yaml'), 'utf-8')
|
||||
const yaml = await readSessionFile(join(sessionStateDir, sessionId, 'workspace.yaml'))
|
||||
if (yaml !== null) {
|
||||
const cwd = parseCwd(yaml)
|
||||
if (cwd) project = basename(cwd)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
sources.push({ path: eventsPath, project, provider: 'copilot' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
|
@ -60,10 +61,11 @@ function getOmpSessionsDir(override?: string): string {
|
|||
}
|
||||
|
||||
async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
|
||||
const content = await readSessionFile(filePath)
|
||||
if (content === null) return null
|
||||
const line = content.split('\n')[0]
|
||||
if (!line?.trim()) return null
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const line = content.split('\n')[0]
|
||||
if (!line?.trim()) return null
|
||||
return JSON.parse(line) as PiEntry
|
||||
} catch {
|
||||
return null
|
||||
|
|
@ -112,13 +114,8 @@ async function discoverSessionsInDir(sessionsDir: string, providerName: string):
|
|||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(source.path, 'utf-8')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const content = await readSessionFile(source.path)
|
||||
if (content === null) return
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
let sessionId = basename(source.path, '.jsonl')
|
||||
let pendingUserMessage = ''
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
/// Thin SQLite read-only wrapper over Node's built-in `node:sqlite` module (stable in
|
||||
/// Node 24, experimental in Node 22 / 23). Replaces the earlier `better-sqlite3` binding
|
||||
/// so the dependency graph no longer pulls in the deprecated `prebuild-install` package
|
||||
/// (issue #75). Works across Cursor and OpenCode session DBs, both of which we only read.
|
||||
|
||||
const requireForSqlite = createRequire(import.meta.url)
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
|
||||
|
|
@ -9,21 +14,64 @@ export type SqliteDatabase = {
|
|||
close(): void
|
||||
}
|
||||
|
||||
let BetterSqlite3: unknown = null
|
||||
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => {
|
||||
prepare(sql: string): { all(...params: unknown[]): Row[] }
|
||||
close(): void
|
||||
}
|
||||
|
||||
let DatabaseSync: DatabaseSyncCtor | null = null
|
||||
let loadAttempted = false
|
||||
let loadError: string | null = null
|
||||
|
||||
/// Lazily imports `node:sqlite`. On Node 22/23 it emits an ExperimentalWarning the first
|
||||
/// time the module is loaded; we silence that specific warning once so dashboards aren't
|
||||
/// preceded by a scary stderr line every run. Any other warnings (including future
|
||||
/// non-SQLite ones) are left untouched.
|
||||
function loadDriver(): boolean {
|
||||
if (loadAttempted) return BetterSqlite3 !== null
|
||||
if (loadAttempted) return DatabaseSync !== null
|
||||
loadAttempted = true
|
||||
|
||||
const origEmit = process.emit.bind(process)
|
||||
let restored = false
|
||||
const restore = () => {
|
||||
if (restored) return
|
||||
restored = true
|
||||
process.emit = origEmit
|
||||
}
|
||||
|
||||
// Node's `process.emit` signature is overloaded; we intercept the 'warning' channel
|
||||
// only and proxy everything else through unchanged. The `any` cast avoids chasing the
|
||||
// overload union which isn't worth its verbosity for a single-purpose shim.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.emit = function patchedEmit(this: NodeJS.Process, event: string, ...args: any[]): boolean {
|
||||
if (event === 'warning') {
|
||||
const warning = args[0] as { name?: string; message?: string } | undefined
|
||||
if (
|
||||
warning?.name === 'ExperimentalWarning' &&
|
||||
typeof warning.message === 'string' &&
|
||||
/SQLite/i.test(warning.message)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (origEmit as any).call(this, event, ...args)
|
||||
} as typeof process.emit
|
||||
|
||||
try {
|
||||
BetterSqlite3 = require('better-sqlite3')
|
||||
const mod = requireForSqlite('node:sqlite') as { DatabaseSync: DatabaseSyncCtor }
|
||||
DatabaseSync = mod.DatabaseSync
|
||||
return true
|
||||
} catch {
|
||||
loadError = 'SQLite-based providers (Cursor, OpenCode) require the better-sqlite3 package.\n' +
|
||||
'Install it with: npm install -g better-sqlite3\n' +
|
||||
'Then run codeburn again.'
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
loadError =
|
||||
'SQLite-based providers (Cursor, OpenCode) need Node 22+ with the node:sqlite module.\n' +
|
||||
`Current Node: ${process.version}.\n` +
|
||||
'Upgrade Node (https://nodejs.org) and run codeburn again.\n' +
|
||||
`(underlying error: ${message})`
|
||||
return false
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -36,16 +84,11 @@ export function getSqliteLoadError(): string {
|
|||
}
|
||||
|
||||
export function openDatabase(path: string): SqliteDatabase {
|
||||
if (!loadDriver()) {
|
||||
if (!loadDriver() || DatabaseSync === null) {
|
||||
throw new Error(getSqliteLoadError())
|
||||
}
|
||||
|
||||
const Database = BetterSqlite3 as new (path: string, options?: Record<string, unknown>) => {
|
||||
prepare(sql: string): { all(...params: unknown[]): Row[] }
|
||||
close(): void
|
||||
}
|
||||
|
||||
const db = new Database(path, { readonly: true, fileMustExist: true })
|
||||
const db = new DatabaseSync(path, { readOnly: true })
|
||||
|
||||
return {
|
||||
query<T extends Row = Row>(sql: string, params: unknown[] = []): T[] {
|
||||
|
|
|
|||
189
tests/daily-cache.test.ts
Normal file
189
tests/daily-cache.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { readFile, rm } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import {
|
||||
addNewDays,
|
||||
DAILY_CACHE_VERSION,
|
||||
type DailyCache,
|
||||
type DailyEntry,
|
||||
getDaysInRange,
|
||||
loadDailyCache,
|
||||
saveDailyCache,
|
||||
withDailyCacheLock,
|
||||
} from '../src/daily-cache.js'
|
||||
|
||||
function emptyDay(date: string, cost = 0, calls = 0): DailyEntry {
|
||||
return {
|
||||
date,
|
||||
cost,
|
||||
calls,
|
||||
sessions: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
editTurns: 0,
|
||||
oneShotTurns: 0,
|
||||
models: {},
|
||||
categories: {},
|
||||
providers: {},
|
||||
}
|
||||
}
|
||||
|
||||
const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-cache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env['CODEBURN_CACHE_DIR']
|
||||
if (existsSync(TMP_CACHE_ROOT)) {
|
||||
await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('loadDailyCache', () => {
|
||||
it('returns an empty cache when the file does not exist', async () => {
|
||||
const cache = await loadDailyCache()
|
||||
expect(cache.version).toBe(DAILY_CACHE_VERSION)
|
||||
expect(cache.lastComputedDate).toBeNull()
|
||||
expect(cache.days).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty cache when the file contains invalid JSON', async () => {
|
||||
const { writeFile, mkdir } = await import('fs/promises')
|
||||
await mkdir(TMP_CACHE_ROOT, { recursive: true })
|
||||
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'not valid json{{', 'utf-8')
|
||||
const cache = await loadDailyCache()
|
||||
expect(cache.days).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty cache when the version does not match', async () => {
|
||||
const saved: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION - 999,
|
||||
lastComputedDate: '2026-04-10',
|
||||
days: [emptyDay('2026-04-10', 10)],
|
||||
}
|
||||
const { writeFile, mkdir } = await import('fs/promises')
|
||||
await mkdir(TMP_CACHE_ROOT, { recursive: true })
|
||||
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
|
||||
const cache = await loadDailyCache()
|
||||
expect(cache.days).toEqual([])
|
||||
expect(cache.lastComputedDate).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips a valid cache through save and load', async () => {
|
||||
const saved: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
lastComputedDate: '2026-04-10',
|
||||
days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)],
|
||||
}
|
||||
await saveDailyCache(saved)
|
||||
const loaded = await loadDailyCache()
|
||||
expect(loaded).toEqual(saved)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveDailyCache', () => {
|
||||
it('writes atomically so no temp file is left after a successful save', async () => {
|
||||
const saved: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
lastComputedDate: '2026-04-10',
|
||||
days: [emptyDay('2026-04-10', 5)],
|
||||
}
|
||||
await saveDailyCache(saved)
|
||||
const { readdir } = await import('fs/promises')
|
||||
const files = await readdir(TMP_CACHE_ROOT)
|
||||
const tempLeftovers = files.filter(f => f.endsWith('.tmp'))
|
||||
expect(tempLeftovers).toEqual([])
|
||||
const finalFile = await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'utf-8')
|
||||
expect(JSON.parse(finalFile)).toEqual(saved)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addNewDays', () => {
|
||||
it('returns a new cache with the added days sorted ascending by date', () => {
|
||||
const base: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
lastComputedDate: '2026-04-08',
|
||||
days: [emptyDay('2026-04-07', 3), emptyDay('2026-04-08', 5)],
|
||||
}
|
||||
const updated = addNewDays(base, [emptyDay('2026-04-10', 9), emptyDay('2026-04-09', 7)], '2026-04-10')
|
||||
expect(updated.days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09', '2026-04-10'])
|
||||
expect(updated.lastComputedDate).toBe('2026-04-10')
|
||||
})
|
||||
|
||||
it('skips days already present in the cache (first write wins)', () => {
|
||||
const base: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
lastComputedDate: '2026-04-08',
|
||||
days: [emptyDay('2026-04-08', 5)],
|
||||
}
|
||||
const updated = addNewDays(base, [emptyDay('2026-04-08', 99)], '2026-04-08')
|
||||
const aprilEight = updated.days.find(d => d.date === '2026-04-08')!
|
||||
expect(aprilEight.cost).toBe(5)
|
||||
})
|
||||
|
||||
it('does not regress lastComputedDate if incoming newestDate is older', () => {
|
||||
const base: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
lastComputedDate: '2026-04-10',
|
||||
days: [emptyDay('2026-04-10', 5)],
|
||||
}
|
||||
const updated = addNewDays(base, [emptyDay('2026-04-05', 3)], '2026-04-05')
|
||||
expect(updated.lastComputedDate).toBe('2026-04-10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDaysInRange', () => {
|
||||
const cache: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
lastComputedDate: '2026-04-10',
|
||||
days: [
|
||||
emptyDay('2026-04-05', 1),
|
||||
emptyDay('2026-04-06', 2),
|
||||
emptyDay('2026-04-07', 3),
|
||||
emptyDay('2026-04-08', 4),
|
||||
emptyDay('2026-04-09', 5),
|
||||
emptyDay('2026-04-10', 6),
|
||||
],
|
||||
}
|
||||
|
||||
it('returns inclusive start and end range', () => {
|
||||
const days = getDaysInRange(cache, '2026-04-07', '2026-04-09')
|
||||
expect(days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09'])
|
||||
})
|
||||
|
||||
it('returns empty when range is entirely outside cache', () => {
|
||||
expect(getDaysInRange(cache, '2026-03-01', '2026-03-10')).toEqual([])
|
||||
expect(getDaysInRange(cache, '2026-05-01', '2026-05-10')).toEqual([])
|
||||
})
|
||||
|
||||
it('clips to available cache days when range extends beyond', () => {
|
||||
const days = getDaysInRange(cache, '2026-04-09', '2026-04-20')
|
||||
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('withDailyCacheLock', () => {
|
||||
it('serializes concurrent operations', async () => {
|
||||
const sequence: string[] = []
|
||||
const op = async (tag: string): Promise<void> => {
|
||||
await withDailyCacheLock(async () => {
|
||||
sequence.push(`start-${tag}`)
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
sequence.push(`end-${tag}`)
|
||||
})
|
||||
}
|
||||
await Promise.all([op('a'), op('b'), op('c')])
|
||||
for (let i = 0; i < sequence.length; i += 2) {
|
||||
expect(sequence[i]?.startsWith('start-')).toBe(true)
|
||||
expect(sequence[i + 1]?.startsWith('end-')).toBe(true)
|
||||
expect(sequence[i]!.slice(6)).toBe(sequence[i + 1]!.slice(4))
|
||||
}
|
||||
})
|
||||
})
|
||||
57
tests/date-range-filter.test.ts
Normal file
57
tests/date-range-filter.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { parseDateRangeFlags } from '../src/cli-date.js'
|
||||
|
||||
describe('parseDateRangeFlags', () => {
|
||||
it('returns null when neither flag is provided', () => {
|
||||
expect(parseDateRangeFlags(undefined, undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('parses a symmetric range in local time', () => {
|
||||
const range = parseDateRangeFlags('2026-04-07', '2026-04-10')
|
||||
expect(range).not.toBeNull()
|
||||
expect(range!.start.getFullYear()).toBe(2026)
|
||||
expect(range!.start.getMonth()).toBe(3)
|
||||
expect(range!.start.getDate()).toBe(7)
|
||||
expect(range!.start.getHours()).toBe(0)
|
||||
expect(range!.end.getDate()).toBe(10)
|
||||
expect(range!.end.getHours()).toBe(23)
|
||||
expect(range!.end.getMinutes()).toBe(59)
|
||||
expect(range!.end.getSeconds()).toBe(59)
|
||||
})
|
||||
|
||||
it('accepts --from alone (open-ended to today 23:59:59)', () => {
|
||||
const range = parseDateRangeFlags('2026-04-01', undefined)
|
||||
expect(range).not.toBeNull()
|
||||
expect(range!.start.getDate()).toBe(1)
|
||||
expect(range!.end.getHours()).toBe(23)
|
||||
})
|
||||
|
||||
it('accepts --to alone (start = epoch)', () => {
|
||||
const range = parseDateRangeFlags(undefined, '2026-04-10')
|
||||
expect(range).not.toBeNull()
|
||||
expect(range!.start.getTime()).toBe(new Date(0).getTime())
|
||||
expect(range!.end.getDate()).toBe(10)
|
||||
})
|
||||
|
||||
it('throws when --from > --to', () => {
|
||||
expect(() => parseDateRangeFlags('2026-04-10', '2026-04-07'))
|
||||
.toThrow('--from must not be after --to')
|
||||
})
|
||||
|
||||
it('throws on a non-ISO string', () => {
|
||||
expect(() => parseDateRangeFlags('April 7', undefined))
|
||||
.toThrow('Invalid date format')
|
||||
})
|
||||
|
||||
it('throws on wrong digit count', () => {
|
||||
expect(() => parseDateRangeFlags('26-4-7', undefined))
|
||||
.toThrow('Invalid date format')
|
||||
})
|
||||
|
||||
it('same day is valid (start midnight, end 23:59:59)', () => {
|
||||
const range = parseDateRangeFlags('2026-04-10', '2026-04-10')
|
||||
expect(range).not.toBeNull()
|
||||
expect(range!.start.getDate()).toBe(10)
|
||||
expect(range!.end.getDate()).toBe(10)
|
||||
})
|
||||
})
|
||||
258
tests/day-aggregator.test.ts
Normal file
258
tests/day-aggregator.test.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
|
||||
function makeProject(overrides: Partial<ProjectSummary> & { sessions: ProjectSummary['sessions'] }): ProjectSummary {
|
||||
return {
|
||||
project: 'p',
|
||||
projectPath: '/p',
|
||||
totalCostUSD: overrides.sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
|
||||
totalApiCalls: overrides.sessions.reduce((s, sess) => s + sess.apiCalls, 0),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provider = 'claude') {
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 50,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
},
|
||||
costUSD,
|
||||
tools: [],
|
||||
mcpTools: [],
|
||||
hasAgentSpawn: false,
|
||||
hasPlanMode: false,
|
||||
speed: 'standard' as const,
|
||||
timestamp,
|
||||
bashCommands: [],
|
||||
deduplicationKey: `dk-${timestamp}-${costUSD}`,
|
||||
}
|
||||
}
|
||||
|
||||
describe('aggregateProjectsIntoDays', () => {
|
||||
it('buckets api calls by calendar date derived from timestamp', () => {
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: '2026-04-09T10:00:00Z',
|
||||
lastTimestamp: '2026-04-10T08:00:00Z',
|
||||
totalCostUSD: 10,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 2,
|
||||
turns: [
|
||||
{
|
||||
userMessage: 'hi',
|
||||
timestamp: '2026-04-09T10:00:00Z',
|
||||
sessionId: 's1',
|
||||
category: 'coding',
|
||||
retries: 0,
|
||||
hasEdits: true,
|
||||
assistantCalls: [
|
||||
makeCall('2026-04-09T10:00:00Z', 4),
|
||||
makeCall('2026-04-10T08:00:00Z', 6),
|
||||
],
|
||||
},
|
||||
],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {} as never,
|
||||
}],
|
||||
}),
|
||||
]
|
||||
|
||||
const days = aggregateProjectsIntoDays(projects)
|
||||
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
|
||||
expect(days[0]!.cost).toBe(4)
|
||||
expect(days[0]!.calls).toBe(1)
|
||||
expect(days[1]!.cost).toBe(6)
|
||||
expect(days[1]!.calls).toBe(1)
|
||||
})
|
||||
|
||||
it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => {
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: '2026-04-09T10:00:00Z',
|
||||
lastTimestamp: '2026-04-09T10:05:00Z',
|
||||
totalCostUSD: 3,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [
|
||||
{
|
||||
userMessage: 'hi',
|
||||
timestamp: '2026-04-09T10:00:00Z',
|
||||
sessionId: 's1',
|
||||
category: 'coding',
|
||||
retries: 0,
|
||||
hasEdits: true,
|
||||
assistantCalls: [makeCall('2026-04-09T10:00:00Z', 3)],
|
||||
},
|
||||
],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {} as never,
|
||||
}],
|
||||
}),
|
||||
]
|
||||
const days = aggregateProjectsIntoDays(projects)
|
||||
const day = days[0]!
|
||||
expect(day.editTurns).toBe(1)
|
||||
expect(day.oneShotTurns).toBe(1)
|
||||
expect(day.categories['coding']).toEqual({
|
||||
turns: 1,
|
||||
cost: 3,
|
||||
editTurns: 1,
|
||||
oneShotTurns: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts a session under its firstTimestamp date', () => {
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: '2026-04-09T23:59:00Z',
|
||||
lastTimestamp: '2026-04-10T00:10:00Z',
|
||||
totalCostUSD: 1,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
||||
apiCalls: 0,
|
||||
turns: [],
|
||||
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
||||
categoryBreakdown: {} as never,
|
||||
}],
|
||||
}),
|
||||
]
|
||||
const days = aggregateProjectsIntoDays(projects)
|
||||
expect(days[0]!.date).toBe('2026-04-09')
|
||||
expect(days[0]!.sessions).toBe(1)
|
||||
})
|
||||
|
||||
it('aggregates per-model and per-provider totals inside each day', () => {
|
||||
const projects: ProjectSummary[] = [
|
||||
makeProject({
|
||||
sessions: [{
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: '2026-04-10T10:00:00Z',
|
||||
lastTimestamp: '2026-04-10T10:00:00Z',
|
||||
totalCostUSD: 10,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
||||
apiCalls: 2,
|
||||
turns: [
|
||||
{
|
||||
userMessage: 'x', timestamp: '2026-04-10T10:00:00Z', 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'),
|
||||
],
|
||||
},
|
||||
],
|
||||
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
||||
categoryBreakdown: {} as never,
|
||||
}],
|
||||
}),
|
||||
]
|
||||
const days = aggregateProjectsIntoDays(projects)
|
||||
const day = days[0]!
|
||||
expect(day.models['Opus 4.7']).toEqual({
|
||||
calls: 1, cost: 7,
|
||||
inputTokens: 100, outputTokens: 200,
|
||||
cacheReadTokens: 50, cacheWriteTokens: 0,
|
||||
})
|
||||
expect(day.models['gpt-5']).toEqual({
|
||||
calls: 1, cost: 3,
|
||||
inputTokens: 100, outputTokens: 200,
|
||||
cacheReadTokens: 50, cacheWriteTokens: 0,
|
||||
})
|
||||
expect(day.providers['claude']).toEqual({ calls: 1, cost: 7 })
|
||||
expect(day.providers['codex']).toEqual({ calls: 1, cost: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPeriodDataFromDays', () => {
|
||||
function makeDay(date: string, cost: number) {
|
||||
return {
|
||||
date,
|
||||
cost,
|
||||
calls: 10,
|
||||
sessions: 2,
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 300,
|
||||
cacheWriteTokens: 0,
|
||||
editTurns: 3,
|
||||
oneShotTurns: 2,
|
||||
models: {
|
||||
'Opus 4.7': { calls: 8, cost: cost * 0.8, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
'Haiku 4.5': { calls: 2, cost: cost * 0.2, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
},
|
||||
categories: { 'coding': { turns: 2, cost: cost * 0.5, editTurns: 2, oneShotTurns: 1 } },
|
||||
providers: { 'claude': { calls: 10, cost } },
|
||||
}
|
||||
}
|
||||
|
||||
it('sums cost, calls, sessions, tokens across days', () => {
|
||||
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
|
||||
const pd = buildPeriodDataFromDays(days, '7 Days')
|
||||
expect(pd.label).toBe('7 Days')
|
||||
expect(pd.cost).toBe(30)
|
||||
expect(pd.calls).toBe(20)
|
||||
expect(pd.sessions).toBe(4)
|
||||
expect(pd.inputTokens).toBe(200)
|
||||
expect(pd.outputTokens).toBe(400)
|
||||
expect(pd.cacheReadTokens).toBe(600)
|
||||
})
|
||||
|
||||
it('merges per-model totals across days and sorts by cost desc', () => {
|
||||
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
|
||||
const pd = buildPeriodDataFromDays(days, 'Today')
|
||||
expect(pd.models[0]!.name).toBe('Opus 4.7')
|
||||
expect(pd.models[0]!.cost).toBeCloseTo(24)
|
||||
expect(pd.models[1]!.name).toBe('Haiku 4.5')
|
||||
expect(pd.models[1]!.cost).toBeCloseTo(6)
|
||||
})
|
||||
|
||||
it('merges per-category totals and keeps editTurns + oneShotTurns per category', () => {
|
||||
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
|
||||
const pd = buildPeriodDataFromDays(days, 'Today')
|
||||
const coding = pd.categories.find(c => c.name === 'Coding')!
|
||||
expect(coding.turns).toBe(4)
|
||||
expect(coding.editTurns).toBe(4)
|
||||
expect(coding.oneShotTurns).toBe(2)
|
||||
expect(coding.cost).toBeCloseTo(15)
|
||||
})
|
||||
|
||||
it('returns empty period totals when no days supplied', () => {
|
||||
const pd = buildPeriodDataFromDays([], 'Today')
|
||||
expect(pd.cost).toBe(0)
|
||||
expect(pd.calls).toBe(0)
|
||||
expect(pd.sessions).toBe(0)
|
||||
expect(pd.categories).toEqual([])
|
||||
expect(pd.models).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -120,8 +120,15 @@ describe('exportCsv', () => {
|
|||
]
|
||||
|
||||
const outputPath = join(tmpDir, 'report.csv')
|
||||
await exportCsv(periods, outputPath)
|
||||
const content = await readFile(outputPath, 'utf-8')
|
||||
const folder = await exportCsv(periods, outputPath)
|
||||
// exportCsv now writes a folder of clean one-table-per-file CSVs, so the formula-prefix
|
||||
// guard is scattered across files. Concatenate them for the assertion surface.
|
||||
const [projects, models, shell] = await Promise.all([
|
||||
readFile(join(folder, 'projects.csv'), 'utf-8'),
|
||||
readFile(join(folder, 'models.csv'), 'utf-8'),
|
||||
readFile(join(folder, 'shell-commands.csv'), 'utf-8'),
|
||||
])
|
||||
const content = projects + models + shell
|
||||
|
||||
expect(content).toContain("\"'=cmd,calc\"")
|
||||
expect(content).toContain("'+danger-model")
|
||||
|
|
|
|||
1
tests/fixtures/security/proto-bash.jsonl
vendored
Normal file
1
tests/fixtures/security/proto-bash.jsonl
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-bash","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"command":"/x/__proto__"}}],"usage":{"input_tokens":1,"output_tokens":1}}}
|
||||
1
tests/fixtures/security/proto-model.jsonl
vendored
Normal file
1
tests/fixtures/security/proto-model.jsonl
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-model","type":"message","role":"assistant","model":"__proto__","content":[{"type":"text","text":"x"}],"usage":{"input_tokens":1,"output_tokens":1}}}
|
||||
1
tests/fixtures/security/proto-tool.jsonl
vendored
Normal file
1
tests/fixtures/security/proto-tool.jsonl
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-tool","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"t1","name":"__proto__","input":{}}],"usage":{"input_tokens":1,"output_tokens":1}}}
|
||||
63
tests/fs-utils.test.ts
Normal file
63
tests/fs-utils.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { mkdtemp, writeFile, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import {
|
||||
MAX_SESSION_FILE_BYTES,
|
||||
STREAM_THRESHOLD_BYTES,
|
||||
readSessionFile,
|
||||
} from '../src/fs-utils.js'
|
||||
|
||||
describe('readSessionFile', () => {
|
||||
const tmpDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.CODEBURN_VERBOSE
|
||||
while (tmpDirs.length > 0) {
|
||||
const d = tmpDirs.pop()
|
||||
if (d) await rm(d, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function tmpPath(content: string | Buffer): Promise<string> {
|
||||
const base = await mkdtemp(join(tmpdir(), 'codeburn-fs-'))
|
||||
tmpDirs.push(base)
|
||||
const p = join(base, 'x.jsonl')
|
||||
await writeFile(p, content)
|
||||
return p
|
||||
}
|
||||
|
||||
it('returns content for small files via readFile fast path', async () => {
|
||||
const p = await tmpPath('hello\nworld\n')
|
||||
expect(await readSessionFile(p)).toBe('hello\nworld\n')
|
||||
})
|
||||
|
||||
it('returns content for files at the stream threshold via stream path', async () => {
|
||||
const p = await tmpPath(Buffer.alloc(STREAM_THRESHOLD_BYTES, 'a'))
|
||||
const got = await readSessionFile(p)
|
||||
expect(got).not.toBeNull()
|
||||
expect(got!.length).toBe(STREAM_THRESHOLD_BYTES)
|
||||
})
|
||||
|
||||
it('returns null and skips files over the cap', async () => {
|
||||
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'b'))
|
||||
expect(await readSessionFile(p)).toBeNull()
|
||||
})
|
||||
|
||||
it('emits stderr warning under CODEBURN_VERBOSE=1 for skipped file', async () => {
|
||||
process.env.CODEBURN_VERBOSE = '1'
|
||||
const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'c'))
|
||||
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
await readSessionFile(p)
|
||||
expect(spy).toHaveBeenCalled()
|
||||
const msg = (spy.mock.calls[0][0] as string)
|
||||
expect(msg).toContain('codeburn')
|
||||
expect(msg).toContain('oversize')
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null on stat failure without throwing', async () => {
|
||||
expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull()
|
||||
})
|
||||
})
|
||||
234
tests/menubar-json.test.ts
Normal file
234
tests/menubar-json.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildMenubarPayload, type PeriodData, type ProviderCost } from '../src/menubar-json.js'
|
||||
import type { OptimizeResult } from '../src/optimize.js'
|
||||
|
||||
function emptyPeriod(label: string): PeriodData {
|
||||
return {
|
||||
label,
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
categories: [],
|
||||
models: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildMenubarPayload', () => {
|
||||
it('emits the full schema with current-period metrics and iso timestamp', () => {
|
||||
const period: PeriodData = {
|
||||
label: '7 Days',
|
||||
cost: 1248.01,
|
||||
calls: 11231,
|
||||
sessions: 97,
|
||||
inputTokens: 19100,
|
||||
outputTokens: 675600,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
categories: [],
|
||||
models: [],
|
||||
}
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
|
||||
expect(payload.generated).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
||||
expect(payload.current.label).toBe('7 Days')
|
||||
expect(payload.current.cost).toBe(1248.01)
|
||||
expect(payload.current.calls).toBe(11231)
|
||||
expect(payload.current.sessions).toBe(97)
|
||||
expect(payload.current.inputTokens).toBe(19100)
|
||||
expect(payload.current.outputTokens).toBe(675600)
|
||||
})
|
||||
|
||||
it('computes per-category oneShotRate from editTurns and skips categories without edits', () => {
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
cost: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
categories: [
|
||||
{ name: 'Coding', cost: 15.83, turns: 7, editTurns: 7, oneShotTurns: 6 },
|
||||
{ name: 'Conversation', cost: 16.69, turns: 47, editTurns: 0, oneShotTurns: 0 },
|
||||
],
|
||||
models: [],
|
||||
}
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
|
||||
const coding = payload.current.topActivities.find(a => a.name === 'Coding')!
|
||||
expect(coding.oneShotRate).toBeCloseTo(6 / 7)
|
||||
|
||||
const conv = payload.current.topActivities.find(a => a.name === 'Conversation')!
|
||||
expect(conv.oneShotRate).toBeNull()
|
||||
})
|
||||
|
||||
it('computes aggregate oneShotRate across categories with edits', () => {
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
cost: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
categories: [
|
||||
{ name: 'Coding', cost: 1, turns: 7, editTurns: 10, oneShotTurns: 8 },
|
||||
{ name: 'Debugging', cost: 1, turns: 5, editTurns: 10, oneShotTurns: 6 },
|
||||
{ name: 'Conversation', cost: 1, turns: 40, editTurns: 0, oneShotTurns: 0 },
|
||||
],
|
||||
models: [],
|
||||
}
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
expect(payload.current.oneShotRate).toBeCloseTo((8 + 6) / (10 + 10))
|
||||
})
|
||||
|
||||
it('returns null aggregate oneShotRate when no categories have editTurns', () => {
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
cost: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
categories: [{ name: 'Conversation', cost: 1, turns: 5, editTurns: 0, oneShotTurns: 0 }],
|
||||
models: [],
|
||||
}
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
expect(payload.current.oneShotRate).toBeNull()
|
||||
})
|
||||
|
||||
it('filters out the synthetic model and caps topModels at 20 so multi-model users see all their models', () => {
|
||||
const models = Array.from({ length: 30 }, (_, i) => ({
|
||||
name: `Model${i}`, cost: 30 - i, calls: 100,
|
||||
}))
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
cost: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
categories: [],
|
||||
models: [{ name: '<synthetic>', cost: 99, calls: 0 }, ...models],
|
||||
}
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
expect(payload.current.topModels.find(m => m.name === '<synthetic>')).toBeUndefined()
|
||||
expect(payload.current.topModels).toHaveLength(20)
|
||||
expect(payload.current.topModels[0].name).toBe('Model0')
|
||||
})
|
||||
|
||||
it('caps topActivities at 20 so all task categories can surface', () => {
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
cost: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
categories: Array.from({ length: 25 }, (_, i) => ({
|
||||
name: `Cat${i}`, cost: 1, turns: 1, editTurns: 1, oneShotTurns: 1,
|
||||
})),
|
||||
models: [],
|
||||
}
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
expect(payload.current.topActivities).toHaveLength(20)
|
||||
})
|
||||
|
||||
it('computes cacheHitPercent from cache reads over input plus cache reads', () => {
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
cost: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 900,
|
||||
cacheWriteTokens: 0,
|
||||
categories: [],
|
||||
models: [],
|
||||
}
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
expect(payload.current.cacheHitPercent).toBeCloseTo(90)
|
||||
})
|
||||
|
||||
it('returns zero cacheHitPercent when there is no input or cache traffic', () => {
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
|
||||
expect(payload.current.cacheHitPercent).toBe(0)
|
||||
})
|
||||
|
||||
it('handles null optimize as empty findings block', () => {
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
|
||||
expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] })
|
||||
})
|
||||
|
||||
it('converts tokensSaved to savingsUSD via costRate and caps topFindings at 10', () => {
|
||||
const findings = Array.from({ length: 15 }, (_, i) => ({
|
||||
title: `F${i}`, explanation: '', impact: 'low' as const, tokensSaved: 1000,
|
||||
fix: { type: 'paste' as const, label: '', text: '' },
|
||||
}))
|
||||
const optimize: OptimizeResult = {
|
||||
findings,
|
||||
costRate: 0.00002,
|
||||
healthScore: 60,
|
||||
healthGrade: 'C',
|
||||
}
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), [], optimize)
|
||||
|
||||
expect(payload.optimize.findingCount).toBe(15)
|
||||
expect(payload.optimize.topFindings).toHaveLength(10)
|
||||
expect(payload.optimize.topFindings[0].title).toBe('F0')
|
||||
expect(payload.optimize.topFindings[0].savingsUSD).toBeCloseTo(1000 * 0.00002)
|
||||
expect(payload.optimize.savingsUSD).toBeCloseTo(15 * 1000 * 0.00002)
|
||||
})
|
||||
|
||||
it('maps providers into a lowercased dict inside the current-period block', () => {
|
||||
const providers: ProviderCost[] = [
|
||||
{ name: 'Claude Code', cost: 76.45 },
|
||||
{ name: 'Cursor', cost: 2.18 },
|
||||
{ name: 'Codex', cost: 1.5 },
|
||||
]
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
|
||||
expect(payload.current.providers).toEqual({ 'claude code': 76.45, cursor: 2.18, codex: 1.5 })
|
||||
})
|
||||
|
||||
it('keeps zero-cost providers in the dict so installed-but-unused providers still render as tabs', () => {
|
||||
const providers: ProviderCost[] = [
|
||||
{ name: 'Claude', cost: 76.45 },
|
||||
{ name: 'Codex', cost: 0 },
|
||||
{ name: 'Cursor', cost: 2.18 },
|
||||
]
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
|
||||
expect(payload.current.providers).toEqual({ claude: 76.45, codex: 0, cursor: 2.18 })
|
||||
})
|
||||
|
||||
it('includes up to 365 daily history entries sorted ascending by date', () => {
|
||||
const history = Array.from({ length: 400 }, (_, i) => {
|
||||
const d = new Date(2025, 0, 1)
|
||||
d.setDate(d.getDate() + i)
|
||||
return {
|
||||
date: d.toISOString().slice(0, 10),
|
||||
cost: i,
|
||||
calls: i * 10,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
topModels: [],
|
||||
}
|
||||
})
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history)
|
||||
expect(payload.history.daily).toHaveLength(365)
|
||||
expect(payload.history.daily[0]!.date < payload.history.daily[364]!.date).toBe(true)
|
||||
expect(payload.history.daily[364]!.date).toBe(history[399]!.date)
|
||||
})
|
||||
|
||||
it('preserves token fields in dailyHistory entries', () => {
|
||||
const history = [
|
||||
{ date: '2026-04-15', cost: 10, calls: 50, inputTokens: 100, outputTokens: 200, cacheReadTokens: 5000, cacheWriteTokens: 800, topModels: [{ name: 'Opus 4.7', cost: 8, calls: 40, inputTokens: 80, outputTokens: 160 }] },
|
||||
{ date: '2026-04-16', cost: 20, calls: 75, inputTokens: 150, outputTokens: 350, cacheReadTokens: 8000, cacheWriteTokens: 1200, topModels: [] },
|
||||
]
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history)
|
||||
expect(payload.history.daily[0]).toEqual(history[0])
|
||||
expect(payload.history.daily[1]).toEqual(history[1])
|
||||
})
|
||||
|
||||
it('returns empty history when none supplied', () => {
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
|
||||
expect(payload.history.daily).toEqual([])
|
||||
})
|
||||
|
||||
it('drops providers with negative cost defensively', () => {
|
||||
const providers: ProviderCost[] = [
|
||||
{ name: 'Claude', cost: 76.45 },
|
||||
{ name: 'Broken', cost: -1 },
|
||||
]
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
|
||||
expect(payload.current.providers).toEqual({ claude: 76.45 })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { chooseMenubarPluginDir, parsePluginDirectoryPreference } from '../src/menubar.js'
|
||||
|
||||
describe('parsePluginDirectoryPreference', () => {
|
||||
it('trims defaults output and preserves spaces in paths', () => {
|
||||
expect(parsePluginDirectoryPreference('/Users/test/Documents/Tech stuff/swiftbar_plugins\n')).toBe('/Users/test/Documents/Tech stuff/swiftbar_plugins')
|
||||
})
|
||||
|
||||
it('expands tilde paths', () => {
|
||||
expect(parsePluginDirectoryPreference('~/swiftbar_plugins')).toBe(join(homedir(), 'swiftbar_plugins'))
|
||||
})
|
||||
|
||||
it('ignores blank preference values', () => {
|
||||
expect(parsePluginDirectoryPreference(' \n')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chooseMenubarPluginDir', () => {
|
||||
const configuredSwiftBarDir = '/Users/test/Documents/Tech stuff/swiftbar_plugins'
|
||||
const defaultSwiftBarDir = '/Users/test/Library/Application Support/SwiftBar/plugins'
|
||||
const xbarDir = '/Users/test/Library/Application Support/xbar/plugins'
|
||||
|
||||
it('uses SwiftBar configured plugin directory before the default directory', () => {
|
||||
const existing = new Set([configuredSwiftBarDir, defaultSwiftBarDir])
|
||||
const result = chooseMenubarPluginDir(
|
||||
[configuredSwiftBarDir, defaultSwiftBarDir],
|
||||
xbarDir,
|
||||
path => existing.has(path),
|
||||
)
|
||||
|
||||
expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
|
||||
})
|
||||
|
||||
it('falls back to xbar when no SwiftBar plugin directory exists', () => {
|
||||
const existing = new Set([xbarDir])
|
||||
const result = chooseMenubarPluginDir(
|
||||
[defaultSwiftBarDir],
|
||||
xbarDir,
|
||||
path => existing.has(path),
|
||||
)
|
||||
|
||||
expect(result).toEqual({ pluginDir: xbarDir, appName: 'xbar' })
|
||||
})
|
||||
|
||||
it('creates the preferred SwiftBar directory when no plugin directory exists', () => {
|
||||
const result = chooseMenubarPluginDir(
|
||||
[configuredSwiftBarDir, defaultSwiftBarDir],
|
||||
xbarDir,
|
||||
() => false,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
|
||||
})
|
||||
})
|
||||
402
tests/optimize-fs.test.ts
Normal file
402
tests/optimize-fs.test.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os')
|
||||
const fs = await vi.importActual<typeof import('fs')>('fs')
|
||||
const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/codeburn-home-')
|
||||
fs.mkdirSync(fakeHome + '/.claude', { recursive: true })
|
||||
process.env['CODEBURN_TEST_FAKE_HOME'] = fakeHome
|
||||
return { ...actual, homedir: () => fakeHome }
|
||||
})
|
||||
|
||||
const FAKE_HOME_FOR_MOCK = process.env['CODEBURN_TEST_FAKE_HOME']!
|
||||
|
||||
import {
|
||||
detectBloatedClaudeMd,
|
||||
detectUnusedMcp,
|
||||
detectBashBloat,
|
||||
detectGhostAgents,
|
||||
detectGhostSkills,
|
||||
detectGhostCommands,
|
||||
loadMcpConfigs,
|
||||
scanJsonlFile,
|
||||
scanAndDetect,
|
||||
type ToolCall,
|
||||
} from '../src/optimize.js'
|
||||
import {
|
||||
estimateContextBudget,
|
||||
discoverProjectCwd,
|
||||
} from '../src/context-budget.js'
|
||||
|
||||
// ============================================================================
|
||||
// Helpers for filesystem fixtures
|
||||
// ============================================================================
|
||||
|
||||
const FIXTURE_ROOTS: string[] = [FAKE_HOME_FOR_MOCK]
|
||||
|
||||
function makeFixtureRoot(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeburn-test-'))
|
||||
FIXTURE_ROOTS.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function writeFile(path: string, content: string): void {
|
||||
mkdirSync(join(path, '..'), { recursive: true })
|
||||
writeFileSync(path, content)
|
||||
}
|
||||
|
||||
function touchOld(path: string, daysAgo: number): void {
|
||||
const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000)
|
||||
utimesSync(path, past, past)
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of FIXTURE_ROOTS) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// detectBloatedClaudeMd (including @-import expansion)
|
||||
// ============================================================================
|
||||
|
||||
describe('detectBloatedClaudeMd', () => {
|
||||
it('flags a CLAUDE.md with more than 200 lines', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
const content = Array.from({ length: 300 }, (_, i) => `line ${i}`).join('\n')
|
||||
writeFile(join(projectDir, 'CLAUDE.md'), content)
|
||||
const finding = detectBloatedClaudeMd(new Set([projectDir]))
|
||||
expect(finding).not.toBeNull()
|
||||
})
|
||||
|
||||
it('expands @-imports and counts transitive load', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(
|
||||
join(projectDir, 'CLAUDE.md'),
|
||||
'line 1\nline 2\n@./rules.md\n@./conventions.md\n',
|
||||
)
|
||||
writeFile(join(projectDir, 'rules.md'), Array.from({ length: 120 }, (_, i) => `rule ${i}`).join('\n'))
|
||||
writeFile(join(projectDir, 'conventions.md'), Array.from({ length: 120 }, (_, i) => `conv ${i}`).join('\n'))
|
||||
const finding = detectBloatedClaudeMd(new Set([projectDir]))
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.explanation).toContain('2 @-imports')
|
||||
})
|
||||
|
||||
it('does not flag a lean CLAUDE.md under 200 lines with no imports', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, 'CLAUDE.md'), 'just a few\nlines\nhere\n')
|
||||
expect(detectBloatedClaudeMd(new Set([projectDir]))).toBeNull()
|
||||
})
|
||||
|
||||
it('does not recurse infinitely on circular @-imports', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, 'CLAUDE.md'), '@./a.md\n')
|
||||
writeFile(join(projectDir, 'a.md'), '@./b.md\n')
|
||||
writeFile(join(projectDir, 'b.md'), '@./a.md\n')
|
||||
expect(() => detectBloatedClaudeMd(new Set([projectDir]))).not.toThrow()
|
||||
})
|
||||
|
||||
it('ignores @ tokens that are not paths (emails, npm scopes)', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(
|
||||
join(projectDir, 'CLAUDE.md'),
|
||||
Array.from({ length: 250 }, (_, i) =>
|
||||
i === 10 ? '@user@example.com' :
|
||||
i === 20 ? '@org/package' :
|
||||
`line ${i}`
|
||||
).join('\n'),
|
||||
)
|
||||
const finding = detectBloatedClaudeMd(new Set([projectDir]))
|
||||
expect(finding).not.toBeNull()
|
||||
// "with N @-imports" suffix appears only when non-zero imports were resolved
|
||||
expect(finding!.explanation).not.toMatch(/with \d+ @-import/)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// loadMcpConfigs + detectUnusedMcp
|
||||
// ============================================================================
|
||||
|
||||
describe('loadMcpConfigs', () => {
|
||||
it('returns empty map when no configs exist', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const servers = loadMcpConfigs([root])
|
||||
expect(servers.size).toBe(0)
|
||||
})
|
||||
|
||||
it('reads servers from project .mcp.json', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { foo: { command: 'foo' }, bar: { command: 'bar' } },
|
||||
}))
|
||||
const servers = loadMcpConfigs([projectDir])
|
||||
expect(servers.has('foo')).toBe(true)
|
||||
expect(servers.has('bar')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes server names by replacing colons with underscores', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { 'plugin:context7:context7': { command: 'ctx' } },
|
||||
}))
|
||||
const servers = loadMcpConfigs([projectDir])
|
||||
expect(servers.has('plugin_context7_context7')).toBe(true)
|
||||
expect(servers.get('plugin_context7_context7')!.original).toBe('plugin:context7:context7')
|
||||
})
|
||||
|
||||
it('handles malformed JSON without crashing', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, '.mcp.json'), '{ not valid json')
|
||||
expect(() => loadMcpConfigs([projectDir])).not.toThrow()
|
||||
expect(loadMcpConfigs([projectDir]).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectUnusedMcp', () => {
|
||||
it('flags servers configured but never called', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { ghost: { command: 'x' } },
|
||||
}))
|
||||
const configFile = join(projectDir, '.mcp.json')
|
||||
touchOld(configFile, 30)
|
||||
const finding = detectUnusedMcp([], [], new Set([projectDir]))
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.explanation).toContain('ghost')
|
||||
})
|
||||
|
||||
it('does not flag servers configured within 24 hours', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { freshly_added: { command: 'x' } },
|
||||
}))
|
||||
expect(detectUnusedMcp([], [], new Set([projectDir]))).toBeNull()
|
||||
})
|
||||
|
||||
it('does not flag servers that were called', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { used: { command: 'x' } },
|
||||
}))
|
||||
touchOld(join(projectDir, '.mcp.json'), 30)
|
||||
const calls: ToolCall[] = [
|
||||
{ name: 'mcp__used__some_tool', input: {}, sessionId: 's1', project: 'p1' },
|
||||
]
|
||||
expect(detectUnusedMcp(calls, [], new Set([projectDir]))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// detectBashBloat
|
||||
// ============================================================================
|
||||
|
||||
describe('detectBashBloat', () => {
|
||||
const originalEnv = process.env['BASH_MAX_OUTPUT_LENGTH']
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env['BASH_MAX_OUTPUT_LENGTH']
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (originalEnv !== undefined) process.env['BASH_MAX_OUTPUT_LENGTH'] = originalEnv
|
||||
})
|
||||
|
||||
it('flags when env var is unset (uses default 30K)', () => {
|
||||
const finding = detectBashBloat()
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.impact).toBe('medium')
|
||||
})
|
||||
|
||||
it('does not flag when env var is at recommended 15K', () => {
|
||||
process.env['BASH_MAX_OUTPUT_LENGTH'] = '15000'
|
||||
expect(detectBashBloat()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not flag when env var is below recommended', () => {
|
||||
process.env['BASH_MAX_OUTPUT_LENGTH'] = '10000'
|
||||
expect(detectBashBloat()).toBeNull()
|
||||
})
|
||||
|
||||
it('flags when env var is above 15K', () => {
|
||||
process.env['BASH_MAX_OUTPUT_LENGTH'] = '50000'
|
||||
const finding = detectBashBloat()
|
||||
expect(finding).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// detectGhostCommands (the pure-function ghost detector)
|
||||
// ============================================================================
|
||||
|
||||
describe('detectGhostCommands', () => {
|
||||
it('returns null when no commands are defined', async () => {
|
||||
expect(await detectGhostCommands([])).toBeNull()
|
||||
})
|
||||
|
||||
it('does not match /tmp or /usr or other path prefixes as command usage', async () => {
|
||||
const messages = [
|
||||
'check /tmp/debug.log',
|
||||
'look at /usr/local/bin',
|
||||
'rm -rf /var/cache',
|
||||
]
|
||||
expect(await detectGhostCommands(messages)).toBeNull()
|
||||
})
|
||||
|
||||
it('matches <command-name> tags in user messages', async () => {
|
||||
const messages = ['<command-name>review</command-name>']
|
||||
expect(await detectGhostCommands(messages)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// scanJsonlFile
|
||||
// ============================================================================
|
||||
|
||||
describe('scanJsonlFile', () => {
|
||||
it('returns empty result for nonexistent file', async () => {
|
||||
const result = await scanJsonlFile('/nonexistent/path.jsonl', 'p1', undefined)
|
||||
expect(result.calls).toEqual([])
|
||||
expect(result.cwds).toEqual([])
|
||||
expect(result.apiCalls).toEqual([])
|
||||
expect(result.userMessages).toEqual([])
|
||||
})
|
||||
|
||||
it('parses tool_use blocks from assistant entries', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const filePath = join(root, 'session.jsonl')
|
||||
const now = new Date().toISOString()
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: now,
|
||||
message: {
|
||||
content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/x/foo.ts' } }],
|
||||
},
|
||||
}),
|
||||
]
|
||||
writeFile(filePath, lines.join('\n'))
|
||||
const result = await scanJsonlFile(filePath, 'p1', undefined)
|
||||
expect(result.calls).toHaveLength(1)
|
||||
expect(result.calls[0].name).toBe('Read')
|
||||
})
|
||||
|
||||
it('skips malformed JSONL lines without crashing', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const filePath = join(root, 'session.jsonl')
|
||||
writeFile(filePath, 'this is not json\n{broken\n{"type":"assistant","message":{"content":[]}}\n')
|
||||
const result = await scanJsonlFile(filePath, 'p1', undefined)
|
||||
expect(result.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('respects date-range filter for assistant entries', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const filePath = join(root, 'session.jsonl')
|
||||
const old = '2020-01-01T00:00:00Z'
|
||||
const now = new Date().toISOString()
|
||||
writeFile(filePath, [
|
||||
JSON.stringify({
|
||||
type: 'assistant', timestamp: old,
|
||||
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/old' } }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant', timestamp: now,
|
||||
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/new' } }] },
|
||||
}),
|
||||
].join('\n'))
|
||||
const today = new Date()
|
||||
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
const result = await scanJsonlFile(filePath, 'p1', { start, end: today })
|
||||
expect(result.calls).toHaveLength(1)
|
||||
expect((result.calls[0].input as Record<string, unknown>).file_path).toBe('/new')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// scanAndDetect (top-level integration)
|
||||
// ============================================================================
|
||||
|
||||
describe('scanAndDetect', () => {
|
||||
it('returns healthy result for empty projects', async () => {
|
||||
const result = await scanAndDetect([])
|
||||
expect(result.findings).toEqual([])
|
||||
expect(result.healthScore).toBe(100)
|
||||
expect(result.healthGrade).toBe('A')
|
||||
expect(result.costRate).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// context-budget
|
||||
// ============================================================================
|
||||
|
||||
describe('estimateContextBudget', () => {
|
||||
it('returns only system base when project has no config', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const budget = await estimateContextBudget(root)
|
||||
expect(budget.total).toBeGreaterThan(0)
|
||||
expect(budget.mcpTools.count).toBe(0)
|
||||
expect(budget.skills.count).toBe(0)
|
||||
})
|
||||
|
||||
it('includes MCP tools from project .mcp.json', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { a: { command: 'x' }, b: { command: 'x' } },
|
||||
}))
|
||||
const budget = await estimateContextBudget(root)
|
||||
expect(budget.mcpTools.count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('includes memory file tokens from CLAUDE.md', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
writeFile(join(root, 'CLAUDE.md'), 'Project context for Claude.\n')
|
||||
const budget = await estimateContextBudget(root)
|
||||
expect(budget.memory.count).toBeGreaterThan(0)
|
||||
expect(budget.memory.tokens).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('discoverProjectCwd', () => {
|
||||
it('returns null for empty directory', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
expect(await discoverProjectCwd(root)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for directory with no jsonl files', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
writeFile(join(root, 'readme.txt'), 'hi')
|
||||
expect(await discoverProjectCwd(root)).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts cwd from the first jsonl entry', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const entry = JSON.stringify({ type: 'assistant', cwd: '/Users/test/project', timestamp: new Date().toISOString() })
|
||||
writeFile(join(root, 'session.jsonl'), entry + '\n')
|
||||
expect(await discoverProjectCwd(root)).toBe('/Users/test/project')
|
||||
})
|
||||
})
|
||||
301
tests/optimize.test.ts
Normal file
301
tests/optimize.test.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
detectJunkReads,
|
||||
detectDuplicateReads,
|
||||
detectLowReadEditRatio,
|
||||
detectCacheBloat,
|
||||
detectBloatedClaudeMd,
|
||||
computeHealth,
|
||||
computeTrend,
|
||||
type ToolCall,
|
||||
type ApiCallMeta,
|
||||
type WasteFinding,
|
||||
} from '../src/optimize.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
|
||||
function call(name: string, input: Record<string, unknown>, sessionId = 's1', project = 'p1'): ToolCall {
|
||||
return { name, input, sessionId, project }
|
||||
}
|
||||
|
||||
function emptyProjects(): ProjectSummary[] {
|
||||
return []
|
||||
}
|
||||
|
||||
describe('detectJunkReads', () => {
|
||||
it('returns null below minimum threshold', () => {
|
||||
const calls = [
|
||||
call('Read', { file_path: '/x/node_modules/a.js' }),
|
||||
call('Read', { file_path: '/x/node_modules/b.js' }),
|
||||
]
|
||||
expect(detectJunkReads(calls)).toBeNull()
|
||||
})
|
||||
|
||||
it('flags when threshold is met', () => {
|
||||
const calls = [
|
||||
call('Read', { file_path: '/x/node_modules/a.js' }),
|
||||
call('Read', { file_path: '/x/node_modules/b.js' }),
|
||||
call('Read', { file_path: '/x/.git/config' }),
|
||||
]
|
||||
const finding = detectJunkReads(calls)
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.impact).toBe('low')
|
||||
})
|
||||
|
||||
it('scales impact with read count', () => {
|
||||
const make = (n: number) => Array.from({ length: n }, (_, i) =>
|
||||
call('Read', { file_path: `/x/node_modules/file-${i}.js` })
|
||||
)
|
||||
expect(detectJunkReads(make(25))!.impact).toBe('high')
|
||||
expect(detectJunkReads(make(10))!.impact).toBe('medium')
|
||||
})
|
||||
|
||||
it('ignores non-junk paths', () => {
|
||||
const calls = [
|
||||
call('Read', { file_path: '/x/src/a.ts' }),
|
||||
call('Read', { file_path: '/x/src/b.ts' }),
|
||||
call('Read', { file_path: '/x/README.md' }),
|
||||
]
|
||||
expect(detectJunkReads(calls)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores non-read tools', () => {
|
||||
const calls = [
|
||||
call('Edit', { file_path: '/x/node_modules/a.js' }),
|
||||
call('Bash', { command: 'ls node_modules' }),
|
||||
call('Grep', { pattern: 'test', path: '/x/node_modules' }),
|
||||
]
|
||||
expect(detectJunkReads(calls)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles missing file_path gracefully', () => {
|
||||
const calls = [
|
||||
call('Read', {}),
|
||||
call('Read', { file_path: null as unknown as string }),
|
||||
]
|
||||
expect(detectJunkReads(calls)).toBeNull()
|
||||
})
|
||||
|
||||
it('suggests CLAUDE.md advice listing detected and common junk dirs', () => {
|
||||
const calls = Array.from({ length: 5 }, () => call('Read', { file_path: '/x/node_modules/a.js' }))
|
||||
const finding = detectJunkReads(calls)!
|
||||
expect(finding.fix.type).toBe('paste')
|
||||
if (finding.fix.type === 'paste') {
|
||||
expect(finding.fix.text).toContain('node_modules')
|
||||
}
|
||||
expect(finding.fix.label).toContain('CLAUDE.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectDuplicateReads', () => {
|
||||
it('counts same file read multiple times in same session', () => {
|
||||
const calls = [
|
||||
...Array.from({ length: 4 }, () => call('Read', { file_path: '/src/a.ts' }, 's1')),
|
||||
...Array.from({ length: 4 }, () => call('Read', { file_path: '/src/b.ts' }, 's1')),
|
||||
]
|
||||
const finding = detectDuplicateReads(calls)
|
||||
expect(finding).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not count across sessions', () => {
|
||||
const calls = [
|
||||
call('Read', { file_path: '/src/a.ts' }, 's1'),
|
||||
call('Read', { file_path: '/src/a.ts' }, 's2'),
|
||||
call('Read', { file_path: '/src/a.ts' }, 's3'),
|
||||
]
|
||||
expect(detectDuplicateReads(calls)).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes junk directory reads', () => {
|
||||
const calls = Array.from({ length: 10 }, () =>
|
||||
call('Read', { file_path: '/x/node_modules/foo.js' }, 's1')
|
||||
)
|
||||
expect(detectDuplicateReads(calls)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for single reads', () => {
|
||||
const calls = [
|
||||
call('Read', { file_path: '/src/a.ts' }, 's1'),
|
||||
call('Read', { file_path: '/src/b.ts' }, 's1'),
|
||||
]
|
||||
expect(detectDuplicateReads(calls)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectLowReadEditRatio', () => {
|
||||
it('returns null below minimum edit count', () => {
|
||||
const calls = [
|
||||
call('Edit', {}),
|
||||
call('Edit', {}),
|
||||
call('Read', {}),
|
||||
]
|
||||
expect(detectLowReadEditRatio(calls)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when ratio is healthy', () => {
|
||||
const calls = [
|
||||
...Array.from({ length: 40 }, () => call('Read', {})),
|
||||
...Array.from({ length: 10 }, () => call('Edit', {})),
|
||||
]
|
||||
expect(detectLowReadEditRatio(calls)).toBeNull()
|
||||
})
|
||||
|
||||
it('flags when edits outpace reads', () => {
|
||||
const calls = [
|
||||
...Array.from({ length: 5 }, () => call('Read', {})),
|
||||
...Array.from({ length: 10 }, () => call('Edit', {})),
|
||||
]
|
||||
const finding = detectLowReadEditRatio(calls)
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.impact).toBe('high')
|
||||
})
|
||||
|
||||
it('counts Grep and Glob as reads for ratio', () => {
|
||||
const calls = [
|
||||
...Array.from({ length: 40 }, () => call('Grep', {})),
|
||||
...Array.from({ length: 10 }, () => call('Edit', {})),
|
||||
]
|
||||
expect(detectLowReadEditRatio(calls)).toBeNull()
|
||||
})
|
||||
|
||||
it('counts Write as edit', () => {
|
||||
const calls = [
|
||||
...Array.from({ length: 15 }, () => call('Read', {})),
|
||||
...Array.from({ length: 10 }, () => call('Write', {})),
|
||||
]
|
||||
const finding = detectLowReadEditRatio(calls)
|
||||
expect(finding).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectCacheBloat', () => {
|
||||
it('returns null below minimum api calls', () => {
|
||||
const apiCalls: ApiCallMeta[] = [
|
||||
{ cacheCreationTokens: 80000, version: '2.1.100' },
|
||||
{ cacheCreationTokens: 80000, version: '2.1.100' },
|
||||
]
|
||||
expect(detectCacheBloat(apiCalls, emptyProjects())).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when median is close to baseline', () => {
|
||||
const apiCalls: ApiCallMeta[] = Array.from({ length: 20 }, () => ({
|
||||
cacheCreationTokens: 50000,
|
||||
version: '2.1.98',
|
||||
}))
|
||||
expect(detectCacheBloat(apiCalls, emptyProjects())).toBeNull()
|
||||
})
|
||||
|
||||
it('flags when median exceeds 1.4x baseline', () => {
|
||||
const apiCalls: ApiCallMeta[] = Array.from({ length: 20 }, () => ({
|
||||
cacheCreationTokens: 80000,
|
||||
version: '2.1.100',
|
||||
}))
|
||||
const finding = detectCacheBloat(apiCalls, emptyProjects())
|
||||
expect(finding).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectBloatedClaudeMd', () => {
|
||||
it('returns null when no projects have CLAUDE.md', () => {
|
||||
const result = detectBloatedClaudeMd(new Set(['/nonexistent/path']))
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty project set', () => {
|
||||
const result = detectBloatedClaudeMd(new Set())
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeHealth', () => {
|
||||
it('returns A with 100 for no findings', () => {
|
||||
const { score, grade } = computeHealth([])
|
||||
expect(score).toBe(100)
|
||||
expect(grade).toBe('A')
|
||||
})
|
||||
|
||||
function mockFinding(impact: 'high' | 'medium' | 'low'): WasteFinding {
|
||||
return {
|
||||
title: 't', explanation: 'e', impact, tokensSaved: 1000,
|
||||
fix: { type: 'paste', label: 'l', text: 't' },
|
||||
}
|
||||
}
|
||||
|
||||
it('one low finding stays at A', () => {
|
||||
const { score, grade } = computeHealth([mockFinding('low')])
|
||||
expect(score).toBe(97)
|
||||
expect(grade).toBe('A')
|
||||
})
|
||||
|
||||
it('two high findings drop to C', () => {
|
||||
const { score, grade } = computeHealth([mockFinding('high'), mockFinding('high')])
|
||||
expect(score).toBe(70)
|
||||
expect(grade).toBe('C')
|
||||
})
|
||||
|
||||
it('caps penalty at 80 to prevent score below 20', () => {
|
||||
const findings = Array.from({ length: 20 }, () => mockFinding('high'))
|
||||
const { score } = computeHealth(findings)
|
||||
expect(score).toBe(20)
|
||||
})
|
||||
|
||||
it('progresses grades predictably', () => {
|
||||
expect(computeHealth([mockFinding('low')]).grade).toBe('A')
|
||||
expect(computeHealth([mockFinding('medium')]).grade).toBe('A')
|
||||
expect(computeHealth([mockFinding('medium'), mockFinding('medium')]).grade).toBe('B')
|
||||
expect(computeHealth([mockFinding('high'), mockFinding('high'), mockFinding('high')]).grade).toBe('C')
|
||||
expect(computeHealth([mockFinding('high'), mockFinding('high'), mockFinding('high'), mockFinding('high'), mockFinding('high')]).grade).toBe('F')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTrend', () => {
|
||||
const window = 48 * 60 * 60 * 1000
|
||||
const baselineWindow = 5 * 24 * 60 * 60 * 1000
|
||||
|
||||
it('returns active when no recent activity detected', () => {
|
||||
const trend = computeTrend({
|
||||
recentCount: 0, recentWindowMs: window,
|
||||
baselineCount: 100, baselineWindowMs: baselineWindow,
|
||||
hasRecentActivity: false,
|
||||
})
|
||||
expect(trend).toBe('active')
|
||||
})
|
||||
|
||||
it('returns resolved when recent activity exists but zero waste in it', () => {
|
||||
const trend = computeTrend({
|
||||
recentCount: 0, recentWindowMs: window,
|
||||
baselineCount: 100, baselineWindowMs: baselineWindow,
|
||||
hasRecentActivity: true,
|
||||
})
|
||||
expect(trend).toBe('resolved')
|
||||
})
|
||||
|
||||
it('returns improving when recent rate is less than half of baseline rate', () => {
|
||||
const trend = computeTrend({
|
||||
recentCount: 5, recentWindowMs: window,
|
||||
baselineCount: 100, baselineWindowMs: baselineWindow,
|
||||
hasRecentActivity: true,
|
||||
})
|
||||
expect(trend).toBe('improving')
|
||||
})
|
||||
|
||||
it('returns active when recent rate matches baseline rate', () => {
|
||||
const recentRate = 100 / baselineWindow
|
||||
const recentCount = Math.ceil(recentRate * window)
|
||||
const trend = computeTrend({
|
||||
recentCount, recentWindowMs: window,
|
||||
baselineCount: 100, baselineWindowMs: baselineWindow,
|
||||
hasRecentActivity: true,
|
||||
})
|
||||
expect(trend).toBe('active')
|
||||
})
|
||||
|
||||
it('returns active when baseline is empty (new finding)', () => {
|
||||
const trend = computeTrend({
|
||||
recentCount: 10, recentWindowMs: window,
|
||||
baselineCount: 0, baselineWindowMs: baselineWindow,
|
||||
hasRecentActivity: true,
|
||||
})
|
||||
expect(trend).toBe('active')
|
||||
})
|
||||
})
|
||||
82
tests/parser-filter.test.ts
Normal file
82
tests/parser-filter.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { filterProjectsByName } from '../src/parser.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
|
||||
function makeProject(project: string, projectPath = project): ProjectSummary {
|
||||
return {
|
||||
project,
|
||||
projectPath,
|
||||
sessions: [],
|
||||
totalCostUSD: 0,
|
||||
totalApiCalls: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('filterProjectsByName', () => {
|
||||
const projects = [
|
||||
makeProject('codeburn', '/Users/alice/codeburn'),
|
||||
makeProject('AgentSeal', '/Users/alice/projects/AgentSeal'),
|
||||
makeProject('dashboard', '/Users/alice/AgentSeal/dashboard'),
|
||||
makeProject('sandbox', '/tmp/sandbox'),
|
||||
]
|
||||
|
||||
it('returns all projects when no filters given', () => {
|
||||
expect(filterProjectsByName(projects)).toEqual(projects)
|
||||
expect(filterProjectsByName(projects, [], [])).toEqual(projects)
|
||||
expect(filterProjectsByName(projects, undefined, undefined)).toEqual(projects)
|
||||
})
|
||||
|
||||
it('include matches project name (case-insensitive substring)', () => {
|
||||
const result = filterProjectsByName(projects, ['codeburn'])
|
||||
expect(result.map(p => p.project)).toEqual(['codeburn'])
|
||||
})
|
||||
|
||||
it('include is case-insensitive', () => {
|
||||
const result = filterProjectsByName(projects, ['AGENTSEAL'])
|
||||
expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
|
||||
})
|
||||
|
||||
it('include matches substring in path when name does not match', () => {
|
||||
const result = filterProjectsByName(projects, ['alice/projects'])
|
||||
expect(result.map(p => p.project)).toEqual(['AgentSeal'])
|
||||
})
|
||||
|
||||
it('include uses OR semantics across patterns', () => {
|
||||
const result = filterProjectsByName(projects, ['codeburn', 'sandbox'])
|
||||
expect(result.map(p => p.project).sort()).toEqual(['codeburn', 'sandbox'])
|
||||
})
|
||||
|
||||
it('exclude removes matching projects (AND-negation across patterns)', () => {
|
||||
const result = filterProjectsByName(projects, undefined, ['codeburn', 'sandbox'])
|
||||
expect(result.map(p => p.project).sort()).toEqual(['AgentSeal', 'dashboard'])
|
||||
})
|
||||
|
||||
it('exclude matches path substring', () => {
|
||||
const result = filterProjectsByName(projects, undefined, ['/tmp'])
|
||||
expect(result.map(p => p.project)).not.toContain('sandbox')
|
||||
})
|
||||
|
||||
it('exclude is applied after include', () => {
|
||||
const result = filterProjectsByName(projects, ['AgentSeal'], ['dashboard'])
|
||||
expect(result.map(p => p.project)).toEqual(['AgentSeal'])
|
||||
})
|
||||
|
||||
it('returns empty array when no project matches include', () => {
|
||||
expect(filterProjectsByName(projects, ['does-not-exist'])).toEqual([])
|
||||
})
|
||||
|
||||
it('empty-string pattern matches every project', () => {
|
||||
const resultInclude = filterProjectsByName(projects, [''])
|
||||
expect(resultInclude).toHaveLength(projects.length)
|
||||
const resultExclude = filterProjectsByName(projects, undefined, [''])
|
||||
expect(resultExclude).toEqual([])
|
||||
})
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [makeProject('a'), makeProject('b')]
|
||||
const snapshot = [...input]
|
||||
filterProjectsByName(input, ['a'], ['b'])
|
||||
expect(input).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
|
@ -29,7 +29,7 @@ function createTestDb(dir: string): string {
|
|||
mkdirSync(ocDir, { recursive: true })
|
||||
const dbPath = join(ocDir, 'opencode.db')
|
||||
|
||||
const Database = require('better-sqlite3')
|
||||
const { DatabaseSync: Database } = require('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
|
|
@ -57,7 +57,7 @@ function createTestDb(dir: string): string {
|
|||
}
|
||||
|
||||
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
|
||||
const Database = require('better-sqlite3')
|
||||
const { DatabaseSync: Database } = require('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
fn(db)
|
||||
db.close()
|
||||
|
|
@ -221,7 +221,7 @@ skipUnlessSqlite('opencode provider - session discovery', () => {
|
|||
const ocDir = join(tmpDir, 'opencode')
|
||||
await mkdir(ocDir, { recursive: true })
|
||||
|
||||
const Database = require('better-sqlite3')
|
||||
const { DatabaseSync: Database } = require('node:sqlite')
|
||||
for (const file of ['opencode.db', 'opencode-dev.db']) {
|
||||
const dbPath = join(ocDir, file)
|
||||
const db = new Database(dbPath)
|
||||
|
|
|
|||
77
tests/security/prototype-pollution.test.ts
Normal file
77
tests/security/prototype-pollution.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, cp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { parseAllSessions } from '../../src/parser.js'
|
||||
import type { DateRange } from '../../src/types.js'
|
||||
|
||||
// Fixtures carry timestamp 2026-04-16T00:00:00Z. The range below must stay
|
||||
// wide enough to include that date; if the fixtures move, move FIXTURE_DAY too.
|
||||
const FIXTURE_DAY = Date.UTC(2026, 3, 16) // month index 3 = April (Date.UTC is 0-indexed)
|
||||
const RANGE_BEFORE_MS = FIXTURE_DAY - 24 * 60 * 60 * 1000
|
||||
const RANGE_AFTER_MS = FIXTURE_DAY + 24 * 60 * 60 * 1000
|
||||
const PROJECT_NAME = 'codeburn-poc-testing'
|
||||
|
||||
function makeRange(offsetMs: number): DateRange {
|
||||
return {
|
||||
start: new Date(RANGE_BEFORE_MS + offsetMs),
|
||||
end: new Date(RANGE_AFTER_MS + offsetMs),
|
||||
}
|
||||
}
|
||||
|
||||
// Hermeticity note: the Claude provider also scans a fixed Desktop sessions
|
||||
// dir independent of CLAUDE_CONFIG_DIR. The narrow dateRange above excludes
|
||||
// any real sessions in practice, but these tests are not strictly isolated
|
||||
// on a machine with April 2026 Claude Desktop activity. A stricter fix
|
||||
// belongs in a follow-up to discoverSessions itself.
|
||||
|
||||
describe('HIGH-1 prototype pollution via unchecked bracket-assign', () => {
|
||||
const tmpDirs: string[] = []
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfigDir = process.env['CLAUDE_CONFIG_DIR']
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete (Object.prototype as Record<string, unknown>).calls
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
} else {
|
||||
process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir
|
||||
}
|
||||
while (tmpDirs.length > 0) {
|
||||
const d = tmpDirs.pop()
|
||||
if (d) await rm(d, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function setupPoc(fixture: string): Promise<string> {
|
||||
const base = await mkdtemp(join(tmpdir(), 'codeburn-sec-'))
|
||||
tmpDirs.push(base)
|
||||
const projectDir = join(base, 'projects', PROJECT_NAME)
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
await cp(join(__dirname, '..', 'fixtures', 'security', fixture), join(projectDir, 'pwn.jsonl'))
|
||||
process.env['CLAUDE_CONFIG_DIR'] = base
|
||||
return base
|
||||
}
|
||||
|
||||
it('does not pollute Object.prototype when session contains tool_use name "__proto__"', async () => {
|
||||
await setupPoc('proto-tool.jsonl')
|
||||
await expect(parseAllSessions(makeRange(0), 'claude')).resolves.not.toThrow()
|
||||
expect(({} as Record<string, unknown>).calls).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not pollute Object.prototype when bash command basename is "__proto__"', async () => {
|
||||
await setupPoc('proto-bash.jsonl')
|
||||
await expect(parseAllSessions(makeRange(1), 'claude')).resolves.not.toThrow()
|
||||
expect(({} as Record<string, unknown>).calls).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not pollute Object.prototype when model name is "__proto__"', async () => {
|
||||
await setupPoc('proto-model.jsonl')
|
||||
await expect(parseAllSessions(makeRange(2), 'claude')).resolves.not.toThrow()
|
||||
expect(({} as Record<string, unknown>).calls).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue