chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
# CodeBurn Architecture
|
||||
|
||||
A map of the codebase. Read this once before opening a non-trivial PR.
|
||||
|
||||
## Three Surfaces
|
||||
|
||||
CodeBurn is one Node.js CLI plus two GUI clients that shell out to it.
|
||||
|
||||
```
|
||||
+----------------------+ +-----------------+
|
||||
| mac/ (Swift) | ---> | |
|
||||
+----------------------+ | src/cli.ts |
|
||||
| gnome/ (JavaScript) | ---> | (the CLI) |
|
||||
+----------------------+ | |
|
||||
| status |
|
||||
| --format |
|
||||
| menubar-json |
|
||||
+-----------------+
|
||||
|
|
||||
v
|
||||
+----------------------------+
|
||||
| session files on disk |
|
||||
| (JSONL, SQLite, protobuf) |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
The macOS menubar (`mac/`) and the GNOME extension (`gnome/`) both invoke `codeburn status --format menubar-json --period <p>` and parse the JSON. They do not share code with the CLI; they only depend on its output contract.
|
||||
|
||||
## CLI (`src/`)
|
||||
|
||||
`src/cli.ts` is the Commander.js entry point. The bin field in `package.json` points at `dist/cli.js`. Twelve commands are registered:
|
||||
|
||||
| Command | Line | Purpose |
|
||||
|---|---|---|
|
||||
| `report` | 274 | Default. Interactive Ink TUI dashboard. |
|
||||
| `status` | 358 | Compact text status, plus `--format menubar-json` for clients. |
|
||||
| `today` | 524 | Today-only view of `report`. |
|
||||
| `month` | 542 | Month-only view of `report`. |
|
||||
| `export` | 560 | CSV or JSON dump of usage data. |
|
||||
| `menubar` | 621 | Downloads and launches the macOS menubar bundle. |
|
||||
| `currency` | 636 | Sets display currency. |
|
||||
| `model-alias` | 687 | Maps an unknown model name to a known one for pricing. |
|
||||
| `plan` | 737 | Configures a subscription plan for overage tracking. |
|
||||
| `optimize` | 857 | Runs all 14 waste detectors. |
|
||||
| `compare` | 870 | Compares two models side by side. |
|
||||
| `yield` | 882 | Tracks which sessions shipped to main vs. were reverted (experimental). |
|
||||
|
||||
### Pipeline
|
||||
|
||||
```
|
||||
provider.discoverSessions()
|
||||
|
|
||||
v
|
||||
provider.createSessionParser(source, seenKeys)
|
||||
|
|
||||
v yields ParsedProviderCall (see src/providers/types.ts)
|
||||
|
|
||||
v
|
||||
src/parser.ts: parseAllSessions()
|
||||
|
|
||||
v aggregates into ProjectSummary[]
|
||||
|
|
||||
v
|
||||
src/daily-cache.ts: aggregate per day, persist
|
||||
|
|
||||
v
|
||||
output formatter (Ink TUI, JSON, or menubar-json)
|
||||
```
|
||||
|
||||
`src/parser.ts` is the central aggregator. Public exports: `parseAllSessions`, `filterProjectsByName`, `extractMcpInventory`. It owns the dedup `Set` (`seenKeys`) that is passed into every provider parser so a turn that surfaces in two providers (Claude logs vs. Cursor mirror, for instance) is counted once.
|
||||
|
||||
### Cache Layers
|
||||
|
||||
Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`):
|
||||
|
||||
| File | Owner | Invalidation |
|
||||
|---|---|---|
|
||||
| `codex-results.json` | `src/codex-cache.ts` | `mtimeMs + sizeBytes` per Codex `.jsonl`. |
|
||||
| `cursor-results.json` | `src/cursor-cache.ts` | `mtimeMs + sizeBytes` of the Cursor SQLite db. |
|
||||
| `daily-cache.json` | `src/daily-cache.ts` | Tracks `lastComputedDate`; new days are backfilled, old days are reused. |
|
||||
|
||||
All three use atomic write (temp file + `rename`) and write with mode `0o600`. All three carry a numeric `version` field; bumping it forces a recompute next run.
|
||||
|
||||
### Optimize Detectors
|
||||
|
||||
`src/optimize.ts` exports 14 detectors. Each returns a `WasteFinding | null`. They are composed by `runOptimize()` which collects findings, ranks them by impact, and returns them with `WasteAction` objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config).
|
||||
|
||||
| Detector | Line | What it catches |
|
||||
|---|---|---|
|
||||
| `detectJunkReads` | 428 | Reads into `node_modules`, `.git`, `dist`, etc. |
|
||||
| `detectDuplicateReads` | 477 | Re-reads of the same file in a session. |
|
||||
| `detectMcpToolCoverage` | 795 | MCP servers with many tools but low usage. |
|
||||
| `detectUnusedMcp` | 855 | MCP servers configured but never invoked. |
|
||||
| `detectBloatedClaudeMd` | 944 | `CLAUDE.md` files past a healthy size. |
|
||||
| `detectLowReadEditRatio` | 987 | Edit-heavy sessions with too few prior reads. |
|
||||
| `detectCacheBloat` | 1048 | High `cache_creation_input_tokens`. |
|
||||
| `detectGhostAgents` | 1124 | Defined but never-invoked Claude agents. |
|
||||
| `detectGhostSkills` | 1154 | Defined but never-invoked skills. |
|
||||
| `detectGhostCommands` | 1184 | Defined but never-invoked slash commands. |
|
||||
| `detectBashBloat` | 1228 | Shell output limit set above the recommended 15K chars. |
|
||||
| `detectLowWorthSessions` | 1405 | Sessions with cost but no edits or git delivery. |
|
||||
| `detectContextBloat` | 1512 | Input:output token ratio above 25:1. |
|
||||
| `detectSessionOutliers` | 1558 | Sessions costing more than 2x the project average. |
|
||||
|
||||
### Output Formats
|
||||
|
||||
| Command | `--format` choices | Default |
|
||||
|---|---|---|
|
||||
| `report`, `today`, `month` | `tui`, `json` | `tui` |
|
||||
| `status` | `terminal`, `menubar-json`, `json` | `terminal` |
|
||||
| `export` | `csv`, `json` | `csv` |
|
||||
| `plan` | `text`, `json` | `text` |
|
||||
|
||||
The macOS menubar and GNOME extension consume `menubar-json`. `src/menubar-json.ts` defines the contract; `tests/menubar-json.test.ts` pins it.
|
||||
|
||||
## Providers (`src/providers/`)
|
||||
|
||||
Every provider implements the `Provider` interface in `src/providers/types.ts`:
|
||||
|
||||
```ts
|
||||
type Provider = {
|
||||
name: string
|
||||
displayName: string
|
||||
modelDisplayName(model: string): string
|
||||
toolDisplayName(rawTool: string): string
|
||||
discoverSessions(): Promise<SessionSource[]>
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser
|
||||
}
|
||||
```
|
||||
|
||||
`src/providers/index.ts` registers twenty-one providers across two tiers:
|
||||
|
||||
- **Eager**: `claude`, `cline`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load.
|
||||
- **Lazy**: `antigravity`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf) do not touch users who do not have those tools installed.
|
||||
|
||||
Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run.
|
||||
|
||||
`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `cline`, `ibm-bob`, `kilo-code`, and `roo-code`. It is not registered as a provider on its own.
|
||||
|
||||
For the per-provider data location, storage format, parser quirks, and test coverage, see `docs/providers/`.
|
||||
|
||||
## macOS Menubar (`mac/`)
|
||||
|
||||
Swift package (`mac/Package.swift`), targets macOS 14, strict concurrency on. Layout under `mac/Sources/CodeBurnMenubar/`:
|
||||
|
||||
- `CodeBurnApp.swift` boots the SwiftUI `App` and the `NSStatusItem`.
|
||||
- `AppStore.swift` is the single source of truth for UI state.
|
||||
- `Data/` holds models, the CLI client, credential stores, and subscription services.
|
||||
- `DataClient.swift` spawns the CLI and decodes `MenubarPayload`. See file-level comment for why we never route through `/bin/zsh -c`.
|
||||
- `MenubarPayload.swift` mirrors the JSON the CLI emits; keep it in sync with `src/menubar-json.ts`.
|
||||
- `Security/CodeburnCLI.swift` resolves the CLI binary (env override `CODEBURN_BIN`, fallback `codeburn`), validates each argv entry against an allowlist regex, and augments PATH for Homebrew and npm-global installs. The Process is launched via `/usr/bin/env`, never via a shell.
|
||||
- `Theme/` holds color and typography constants and the dark/light state.
|
||||
- `Views/` are the SwiftUI components rendered inside `NSPopover`.
|
||||
|
||||
Tests live in `mac/Tests/CodeBurnMenubarTests/` (currently `CapacityEstimatorTests.swift`).
|
||||
|
||||
The build artifact is a zipped `.app` bundle produced by `mac/Scripts/package-app.sh`. See `RELEASING.md` for how the GitHub Actions workflow uses it.
|
||||
|
||||
## GNOME Extension (`gnome/`)
|
||||
|
||||
Plain JavaScript, no bundler. Targets GNOME Shell 45-50 (`metadata.json`).
|
||||
|
||||
- `extension.js` is the entry point. On `enable()` it constructs a `CodeBurnIndicator` and adds it to the panel.
|
||||
- `indicator.js` is the popover. It owns the period selector, the insight tabs, and the provider filter.
|
||||
- `dataClient.js` wraps `Gio.Subprocess` to call the CLI. It validates argv against the same allowlist pattern as the macOS client and augments PATH with `~/.local/bin`, `~/.npm-global/bin`, `~/.volta/bin`, `~/.bun/bin`, `~/.cargo/bin`, `~/.asdf/shims`, and a few others. Results are cached for 300 seconds.
|
||||
- `prefs.js` is the settings dialog backed by `schemas/org.gnome.shell.extensions.codeburn.gschema.xml`.
|
||||
- `install.sh` copies the extension into `~/.local/share/gnome-shell/extensions/`.
|
||||
|
||||
## Build (`scripts/`, `tsup.config.ts`)
|
||||
|
||||
`npm run build` is two steps:
|
||||
|
||||
1. `node scripts/bundle-litellm.mjs` fetches the latest litellm pricing JSON and writes `src/data/litellm-snapshot.json`. The bundle script keeps a manual override for MiniMax variants. Direct (un-prefixed) entries win over prefixed ones. The result is checked in so the build is reproducible.
|
||||
2. `tsup` reads `tsup.config.ts` and emits a single ESM bundle at `dist/cli.js` with a Node shebang banner. No source maps in publish builds; sourcemaps on for development.
|
||||
|
||||
The `prepublishOnly` hook in `package.json` runs `npm run build` so `npm publish` always ships fresh code.
|
||||
|
||||
## Tests
|
||||
|
||||
`npm test` runs vitest. Forty-two test files live under `tests/`:
|
||||
|
||||
- `tests/` root (27 files) covers CLI, parser, optimize, cache, format, models, plans.
|
||||
- `tests/security/` (1 file) covers prototype-pollution guards.
|
||||
- `tests/providers/` (15 files) covers per-provider parsing.
|
||||
- `tests/fixtures/` holds redacted real-world session data.
|
||||
|
||||
Five providers ship without dedicated test files today: `antigravity`, `claude`, `gemini`, `goose`, `qwen`. Closing this gap is a standing good-first-issue.
|
||||
|
||||
CI runs Semgrep against `.semgrep/rules/no-bracket-assign-hot-paths.yml` over `src/providers/` and `src/parser.ts` (`.github/workflows/ci.yml`). It does not run vitest in CI today; tests run locally before publish.
|
||||
@@ -0,0 +1,755 @@
|
||||
# CodeBurn MCP Server — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `codeburn mcp` stdio MCP server exposing CodeBurn's usage/cost data to AI agents via two tools (`get_usage`, `get_savings`), each returning a markdown table plus typed structured JSON.
|
||||
|
||||
**Architecture:** Extract the existing `status --format menubar-json` aggregation into one reusable `buildMenubarPayloadForRange(periodInfo, opts)` (with an `optimize` boolean — the only expensive call, `scanAndDetect`, is skipped for `get_usage`). A long-lived in-process `McpServer` registers the two tools, injects the aggregator for testability, coalesces concurrent calls, hashes project names by default, and relies on the existing 180 s parser cache for warm reuse.
|
||||
|
||||
**Tech Stack:** TypeScript (ESM, `type: module`, node ≥ 22.13), commander, `@modelcontextprotocol/sdk@^1.29` (v1), zod, tsup, vitest.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create `src/usage-aggregator.ts`** — owns `buildPeriodData` (moved from `main.ts`) and the extracted `buildMenubarPayloadForRange(periodInfo, opts): Promise<MenubarPayload>`. Single responsibility: turn a resolved date range + filters into a `MenubarPayload`.
|
||||
- **Create `src/mcp/redact.ts`** — `pseudonym()` + `redactProjectNames(payload, include)`. Privacy only.
|
||||
- **Create `src/mcp/tables.ts`** — markdown renderers (`renderSummaryTable`, `renderBreakdownTable`, `renderSavingsTable`). Presentation only.
|
||||
- **Create `src/mcp/server.ts`** — `createServer(deps)` (tool registration + handlers, aggregator injectable) and `startStdioServer(version)` (loadPricing + pre-warm + stdio transport).
|
||||
- **Modify `src/main.ts`** — import `buildPeriodData`/`buildMenubarPayloadForRange` from the aggregator; refactor the `status` menubar branch to call the aggregator; add `.command('mcp')`.
|
||||
- **Modify `package.json`** — add `@modelcontextprotocol/sdk` + `zod` deps.
|
||||
- **Modify `tsup.config.ts`** — `external: ['@modelcontextprotocol/sdk', 'zod']`.
|
||||
- **Create tests** — `tests/usage-aggregator.test.ts`, `tests/mcp-redact.test.ts`, `tests/mcp-tables.test.ts`, `tests/mcp-server.test.ts`.
|
||||
|
||||
Internal MCP period names map to CodeBurn's: `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all` (≈6 months).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add dependencies and externalize them in the bundle
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (dependencies)
|
||||
- Modify: `tsup.config.ts`
|
||||
|
||||
- [ ] **Step 1: Add runtime deps**
|
||||
|
||||
Run: `npm install @modelcontextprotocol/sdk@^1.29.0 zod@^3.25.0 --save-exact=false`
|
||||
Expected: both appear under `"dependencies"` in `package.json`; `node_modules/@modelcontextprotocol/sdk` and `node_modules/zod` exist.
|
||||
|
||||
- [ ] **Step 2: Externalize them so they are not inlined into `dist/main.js`**
|
||||
|
||||
Edit `tsup.config.ts` to:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/main.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
dts: false,
|
||||
external: ['@modelcontextprotocol/sdk', 'zod'],
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and confirm the SDK is external (not bundled)**
|
||||
|
||||
Run: `npm run build && node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); if(/from'@modelcontextprotocol\/sdk|require\(.@modelcontextprotocol\/sdk./.test(s.replace(/\s/g,''))||!/McpServer/.test(s)){} console.log('built, bytes', s.length)"`
|
||||
Expected: build succeeds, prints `built, bytes <n>`. (The SDK isn't imported anywhere yet, so this just verifies the config builds.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json tsup.config.ts
|
||||
git commit -m "build(mcp): add @modelcontextprotocol/sdk + zod, externalize in tsup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Move `buildPeriodData` into a shared module
|
||||
|
||||
`buildPeriodData` is currently a private function in `main.ts:410`. The aggregator needs it, so move it to the new module and import it back into `main.ts`.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/usage-aggregator.ts`
|
||||
- Modify: `src/main.ts:410` (remove local def), import section
|
||||
|
||||
- [ ] **Step 1: Create the module with `buildPeriodData` moved verbatim**
|
||||
|
||||
Create `src/usage-aggregator.ts`. Move the entire `function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { ... }` body from `main.ts` into it, exported, with imports it needs:
|
||||
|
||||
```ts
|
||||
import type { ProjectSummary } from './types.js'
|
||||
import { type PeriodData } from './menubar-json.js'
|
||||
|
||||
export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
// ... exact body moved from main.ts:410 ...
|
||||
}
|
||||
```
|
||||
|
||||
(Copy the body unchanged. Add any imports the body references — e.g. `getShortModelName` from `./models.js` — until `npx tsc --noEmit` is clean.)
|
||||
|
||||
- [ ] **Step 2: Update `main.ts` to import it and delete the local copy**
|
||||
|
||||
Remove the `function buildPeriodData(...) {...}` at `main.ts:410`. Add to the import block near the top:
|
||||
|
||||
```ts
|
||||
import { buildPeriodData } from './usage-aggregator.js'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck + run the full suite (parity guard)**
|
||||
|
||||
Run: `npx tsc --noEmit && npm test`
|
||||
Expected: typecheck clean; all existing tests pass (the `status` paths still use `buildPeriodData`, now imported).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/usage-aggregator.ts src/main.ts
|
||||
git commit -m "refactor: move buildPeriodData into usage-aggregator module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extract `buildMenubarPayloadForRange`
|
||||
|
||||
Move the `status` menubar-json aggregation block (`main.ts:485–759`, everything after `periodInfo`/`now` are computed, ending at the `console.log`) into the aggregator as a function that **returns** the payload instead of printing it.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/usage-aggregator.ts` (add the function)
|
||||
- Modify: `src/main.ts:476–761` (call it)
|
||||
- Test: existing `tests/cli-status-menubar.test.ts` is the parity guard
|
||||
|
||||
- [ ] **Step 1: Add the function signature + types to `src/usage-aggregator.ts`**
|
||||
|
||||
```ts
|
||||
import { homedir } from 'node:os'
|
||||
import type { ProjectSummary, DateRange } from './types.js'
|
||||
import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, buildMenubarPayload } from './menubar-json.js'
|
||||
import { parseAllSessions, getAllProviders, filterProjectsByName, filterProjectsByDays } from './parser.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { scanAndDetect } from './optimize.js'
|
||||
import { hydrateCache, loadDailyCache, getDaysInRange, toDateString, BACKFILL_DAYS, type DailyCache } from './daily-cache.js'
|
||||
|
||||
export type PeriodInfo = { range: DateRange; label: string }
|
||||
export type AggregateOpts = {
|
||||
provider?: string
|
||||
project?: string[]
|
||||
exclude?: string[]
|
||||
daysSelection?: { range: DateRange; label: string; days: Set<string> } | null
|
||||
optimize?: boolean
|
||||
}
|
||||
|
||||
export async function buildMenubarPayloadForRange(
|
||||
periodInfo: PeriodInfo,
|
||||
opts: AggregateOpts = {},
|
||||
): Promise<MenubarPayload> {
|
||||
const pf = opts.provider ?? 'all'
|
||||
const daysSelection = opts.daysSelection ?? null
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? [])
|
||||
// ... moved block from main.ts:485–757 (now/todayStart/... through breakdowns) ...
|
||||
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
|
||||
return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns)
|
||||
}
|
||||
```
|
||||
|
||||
Move lines `main.ts:485–757` (from `const now = new Date()` through the `breakdowns` IIFE) into the body unchanged. The block already references only the symbols imported above plus `homedir()`. `hydrateCache` is imported from `./daily-cache.js` (same module as `loadDailyCache`); if tsc reports a different source, follow the import it suggests.
|
||||
|
||||
- [ ] **Step 2: Precondition note — pricing**
|
||||
|
||||
The block assumes `loadPricing()` already ran. The `status` action calls it at `main.ts:473`; the MCP server will call it at startup (Task 6). Do **not** call `loadPricing()` inside the function.
|
||||
|
||||
- [ ] **Step 3: Refactor the `status` menubar branch to call it**
|
||||
|
||||
Replace `main.ts:485–760` (the inline block + `console.log` + `return`) with:
|
||||
|
||||
```ts
|
||||
const payload = await buildMenubarPayloadForRange(periodInfo, {
|
||||
provider: pf,
|
||||
project: opts.project,
|
||||
exclude: opts.exclude,
|
||||
daysSelection,
|
||||
optimize: opts.optimize !== false,
|
||||
})
|
||||
console.log(JSON.stringify(payload))
|
||||
return
|
||||
```
|
||||
|
||||
Keep `main.ts:477–484` (the `daysSelection`/`customRange`/`daySelection`/`periodInfo` resolution) — `periodInfo` and `daysSelection` are the inputs now passed in. Add `buildMenubarPayloadForRange` to the existing import from `./usage-aggregator.js`.
|
||||
|
||||
- [ ] **Step 4: Typecheck + parity test**
|
||||
|
||||
Run: `npx tsc --noEmit && npm test -- cli-status-menubar`
|
||||
Expected: clean typecheck; `tests/cli-status-menubar.test.ts` passes — i.e. `status --format menubar-json` output is unchanged (parity).
|
||||
|
||||
- [ ] **Step 5: Add a direct unit test for the aggregator**
|
||||
|
||||
Create `tests/usage-aggregator.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js'
|
||||
import { getDateRange } from '../src/cli-date.js'
|
||||
|
||||
describe('buildMenubarPayloadForRange', () => {
|
||||
it('returns a zero payload with no data and skips optimize when optimize:false', async () => {
|
||||
const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false })
|
||||
expect(payload.current.cost).toBe(0)
|
||||
expect(payload.current.calls).toBe(0)
|
||||
expect(payload.optimize.findingCount).toBe(0)
|
||||
expect(Array.isArray(payload.current.topProjects)).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Run: `npm test -- usage-aggregator`
|
||||
Expected: PASS (uses the empty real environment; `scanAndDetect` not called because `optimize:false`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/usage-aggregator.ts src/main.ts tests/usage-aggregator.test.ts
|
||||
git commit -m "refactor: extract buildMenubarPayloadForRange for reuse by MCP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Project-name redaction
|
||||
|
||||
**Files:**
|
||||
- Create: `src/mcp/redact.ts`
|
||||
- Test: `tests/mcp-redact.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/mcp-redact.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pseudonym, redactProjectNames } from '../src/mcp/redact.js'
|
||||
import type { MenubarPayload } from '../src/menubar-json.js'
|
||||
|
||||
function payload(): MenubarPayload {
|
||||
const base = {
|
||||
name: 'secret-client-repo', cost: 5, sessions: 2, avgCostPerSession: 2.5, sessionDetails: [],
|
||||
}
|
||||
return {
|
||||
generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, history: { daily: [] },
|
||||
current: {
|
||||
label: 'Today', cost: 5, calls: 10, sessions: 2, oneShotRate: null, inputTokens: 0, outputTokens: 0,
|
||||
cacheHitPercent: 0, topActivities: [], topModels: [], providers: {},
|
||||
topProjects: [base], modelEfficiency: [],
|
||||
topSessions: [{ project: 'secret-client-repo', cost: 5, calls: 10, date: '2026-06-01' }],
|
||||
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
|
||||
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
} as MenubarPayload
|
||||
}
|
||||
|
||||
describe('redact', () => {
|
||||
it('pseudonym is stable and path-free', () => {
|
||||
expect(pseudonym('a')).toBe(pseudonym('a'))
|
||||
expect(pseudonym('secret-client-repo')).toMatch(/^project-[0-9a-f]{6}$/)
|
||||
})
|
||||
it('hashes project names by default, preserves numbers', () => {
|
||||
const out = redactProjectNames(payload(), false)
|
||||
expect(out.current.topProjects[0]!.name).toMatch(/^project-[0-9a-f]{6}$/)
|
||||
expect(out.current.topSessions[0]!.project).toMatch(/^project-[0-9a-f]{6}$/)
|
||||
expect(out.current.topProjects[0]!.cost).toBe(5)
|
||||
})
|
||||
it('keeps real names when include=true', () => {
|
||||
const out = redactProjectNames(payload(), true)
|
||||
expect(out.current.topProjects[0]!.name).toBe('secret-client-repo')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `npm test -- mcp-redact`
|
||||
Expected: FAIL ("Cannot find module '../src/mcp/redact.js'").
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `src/mcp/redact.ts`:
|
||||
|
||||
```ts
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
|
||||
export function pseudonym(name: string): string {
|
||||
return `project-${createHash('sha256').update(name).digest('hex').slice(0, 6)}`
|
||||
}
|
||||
|
||||
export function redactProjectNames(payload: MenubarPayload, includeNames: boolean): MenubarPayload {
|
||||
if (includeNames) return payload
|
||||
return {
|
||||
...payload,
|
||||
current: {
|
||||
...payload.current,
|
||||
topProjects: payload.current.topProjects.map(p => ({ ...p, name: pseudonym(p.name) })),
|
||||
topSessions: payload.current.topSessions.map(s => ({ ...s, project: pseudonym(s.project) })),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npm test -- mcp-redact`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mcp/redact.ts tests/mcp-redact.test.ts
|
||||
git commit -m "feat(mcp): hash project names by default with opt-in reveal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Markdown table renderers
|
||||
|
||||
**Files:**
|
||||
- Create: `src/mcp/tables.ts`
|
||||
- Test: `tests/mcp-tables.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/mcp-tables.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderSummaryTable, renderBreakdownTable, renderSavingsTable } from '../src/mcp/tables.js'
|
||||
import type { MenubarPayload } from '../src/menubar-json.js'
|
||||
|
||||
function payload(): MenubarPayload {
|
||||
return {
|
||||
generated: '', optimize: { findingCount: 1, savingsUSD: 2.5, topFindings: [{ title: 'Trim system prompt', impact: 'high', savingsUSD: 2.5 }] }, history: { daily: [] },
|
||||
current: {
|
||||
label: 'Last 7 Days', cost: 12.5, calls: 100, sessions: 4, oneShotRate: 0.5, inputTokens: 1000, outputTokens: 500,
|
||||
cacheHitPercent: 80, topActivities: [{ name: 'feature', cost: 8, turns: 30, oneShotRate: 0.6 }],
|
||||
topModels: [{ name: 'Opus 4.8', cost: 10, calls: 60 }], providers: { 'claude code': 12.5 },
|
||||
topProjects: [{ name: 'project-abc123', cost: 12.5, sessions: 4, avgCostPerSession: 3.125, sessionDetails: [] }],
|
||||
modelEfficiency: [], topSessions: [],
|
||||
retryTax: { totalUSD: 1.2, retries: 4, editTurns: 20, byModel: [{ name: 'Opus 4.8', taxUSD: 1.2, retries: 4, retriesPerEdit: 0.2 }] },
|
||||
routingWaste: { totalSavingsUSD: 3, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [{ name: 'Opus 4.8', costPerEdit: 0.05, editTurns: 20, actualUSD: 1, counterfactualUSD: 0.2, savingsUSD: 0.8 }] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
} as MenubarPayload
|
||||
}
|
||||
|
||||
describe('tables', () => {
|
||||
it('summary shows headline cost and top models', () => {
|
||||
const t = renderSummaryTable(payload())
|
||||
expect(t).toContain('Last 7 Days')
|
||||
expect(t).toContain('Opus 4.8')
|
||||
expect(t).toContain('| Model | Cost | Calls |')
|
||||
})
|
||||
it('breakdown by provider lists providers', () => {
|
||||
expect(renderBreakdownTable(payload(), 'provider', 20)).toContain('claude code')
|
||||
})
|
||||
it('breakdown handles empty dimension gracefully', () => {
|
||||
const p = payload(); p.current.topActivities = []
|
||||
expect(renderBreakdownTable(p, 'task', 20)).toContain('no data')
|
||||
})
|
||||
it('savings shows retry tax and routing waste', () => {
|
||||
const t = renderSavingsTable(payload())
|
||||
expect(t).toContain('Retry tax')
|
||||
expect(t).toContain('Routing waste')
|
||||
expect(t).toContain('Trim system prompt')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `npm test -- mcp-tables`
|
||||
Expected: FAIL ("Cannot find module '../src/mcp/tables.js'").
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `src/mcp/tables.ts`:
|
||||
|
||||
```ts
|
||||
import { formatCost, formatTokens } from '../format.js'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
|
||||
export type BreakdownBy = 'project' | 'model' | 'task' | 'provider'
|
||||
|
||||
function mdTable(headers: string[], rows: string[][]): string {
|
||||
const head = `| ${headers.join(' | ')} |`
|
||||
const sep = `| ${headers.map(() => '---').join(' | ')} |`
|
||||
if (rows.length === 0) return `${head}\n${sep}\n| _(no data)_ |${' |'.repeat(headers.length - 1)}`
|
||||
return [head, sep, ...rows.map(r => `| ${r.join(' | ')} |`)].join('\n')
|
||||
}
|
||||
|
||||
const pct = (n: number) => `${Math.round(n)}%`
|
||||
const oneShot = (r: number | null) => (r === null ? 'n/a' : pct(r * 100))
|
||||
|
||||
export function renderSummaryTable(p: MenubarPayload): string {
|
||||
const c = p.current
|
||||
return [
|
||||
`**${c.label}** — ${formatCost(c.cost)} · ${c.calls} calls · ${c.sessions} sessions`,
|
||||
`cache hit ${pct(c.cacheHitPercent)} · one-shot ${oneShot(c.oneShotRate)} · in ${formatTokens(c.inputTokens)} / out ${formatTokens(c.outputTokens)}`,
|
||||
'',
|
||||
'_Top models_',
|
||||
mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])),
|
||||
'',
|
||||
'_Top projects_',
|
||||
mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string {
|
||||
const c = p.current
|
||||
if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)]))
|
||||
if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)]))
|
||||
if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)]))
|
||||
return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)]))
|
||||
}
|
||||
|
||||
export function renderSavingsTable(p: MenubarPayload): string {
|
||||
const c = p.current
|
||||
const findings = mdTable(['Finding', 'Impact', 'Saves'], p.optimize.topFindings.slice(0, 10).map(f => [f.title, f.impact, formatCost(f.savingsUSD)]))
|
||||
const retry = mdTable(['Model', 'Retry tax', 'Retries'], c.retryTax.byModel.map(m => [m.name, formatCost(m.taxUSD), String(m.retries)]))
|
||||
const routing = mdTable(['Model', 'Overpaid', 'vs baseline'], c.routingWaste.byModel.map(m => [m.name, formatCost(m.savingsUSD), c.routingWaste.baselineModel]))
|
||||
return [
|
||||
`**Savings — ${c.label}**`,
|
||||
`Optimize findings: ${p.optimize.findingCount} (≈ ${formatCost(p.optimize.savingsUSD)})`,
|
||||
findings, '',
|
||||
`_Retry tax_ — ${formatCost(c.retryTax.totalUSD)} on ${c.retryTax.retries} retries`,
|
||||
retry, '',
|
||||
`_Routing waste_ — ${formatCost(c.routingWaste.totalSavingsUSD)} vs ${c.routingWaste.baselineModel || 'n/a'}`,
|
||||
routing,
|
||||
].join('\n')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npm test -- mcp-tables`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mcp/tables.ts tests/mcp-tables.test.ts
|
||||
git commit -m "feat(mcp): markdown table renderers for usage and savings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: MCP server (tools, schemas, handlers, coalescing)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/mcp/server.ts`
|
||||
- Test: `tests/mcp-server.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test (in-memory transport, injected aggregator)**
|
||||
|
||||
Create `tests/mcp-server.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { createServer } from '../src/mcp/server.js'
|
||||
import type { MenubarPayload } from '../src/menubar-json.js'
|
||||
|
||||
function fakePayload(calls = 100): MenubarPayload {
|
||||
return {
|
||||
generated: '', optimize: { findingCount: 1, savingsUSD: 2, topFindings: [{ title: 'X', impact: 'high', savingsUSD: 2 }] }, history: { daily: [] },
|
||||
current: {
|
||||
label: 'Today', cost: 9, calls, sessions: 1, oneShotRate: 0.5, inputTokens: 10, outputTokens: 5, cacheHitPercent: 50,
|
||||
topActivities: [{ name: 'feature', cost: 9, turns: 5, oneShotRate: 0.5 }], topModels: [{ name: 'Opus 4.8', cost: 9, calls }],
|
||||
providers: { 'claude code': 9 }, topProjects: [{ name: 'real-repo', cost: 9, sessions: 1, avgCostPerSession: 9, sessionDetails: [] }],
|
||||
modelEfficiency: [], topSessions: [{ project: 'real-repo', cost: 9, calls, date: '2026-06-01' }],
|
||||
retryTax: { totalUSD: 1, retries: 2, editTurns: 5, byModel: [{ name: 'Opus 4.8', taxUSD: 1, retries: 2, retriesPerEdit: 0.4 }] },
|
||||
routingWaste: { totalSavingsUSD: 1, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
} as MenubarPayload
|
||||
}
|
||||
|
||||
async function connect(aggregate: any) {
|
||||
const server = createServer({ version: 'test', aggregate })
|
||||
const [a, b] = InMemoryTransport.createLinkedPair()
|
||||
const client = new Client({ name: 'test', version: '1' })
|
||||
await Promise.all([server.connect(a), client.connect(b)])
|
||||
return client
|
||||
}
|
||||
|
||||
describe('mcp server', () => {
|
||||
it('exposes exactly two read-only tools', async () => {
|
||||
const client = await connect(async () => fakePayload())
|
||||
const { tools } = await client.listTools()
|
||||
expect(tools.map(t => t.name).sort()).toEqual(['get_savings', 'get_usage'])
|
||||
expect(tools.find(t => t.name === 'get_usage')!.annotations?.readOnlyHint).toBe(true)
|
||||
})
|
||||
it('get_usage hashes project names by default', async () => {
|
||||
const client = await connect(async () => fakePayload())
|
||||
const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project' } })
|
||||
expect(JSON.stringify(res)).not.toContain('real-repo')
|
||||
expect(JSON.stringify(res)).toMatch(/project-[0-9a-f]{6}/)
|
||||
expect(res.isError).toBeFalsy()
|
||||
})
|
||||
it('get_usage reveals names when opted in', async () => {
|
||||
const client = await connect(async () => fakePayload())
|
||||
const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project', include_project_names: true } })
|
||||
expect(JSON.stringify(res)).toContain('real-repo')
|
||||
})
|
||||
it('empty data returns a friendly message, not a zero table', async () => {
|
||||
const client = await connect(async () => fakePayload(0))
|
||||
const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today' } })
|
||||
expect(res.content[0].text.toLowerCase()).toContain('no usage')
|
||||
})
|
||||
it('aggregator failure surfaces as isError', async () => {
|
||||
const client = await connect(async () => { throw new Error('boom') })
|
||||
const res: any = await client.callTool({ name: 'get_savings', arguments: {} })
|
||||
expect(res.isError).toBe(true)
|
||||
expect(res.content[0].text).toContain('boom')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `npm test -- mcp-server`
|
||||
Expected: FAIL ("Cannot find module '../src/mcp/server.js'").
|
||||
|
||||
- [ ] **Step 3: Implement the server**
|
||||
|
||||
Create `src/mcp/server.ts`:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { getDateRange } from '../cli-date.js'
|
||||
import { loadPricing } from '../models.js'
|
||||
import { buildMenubarPayloadForRange, type PeriodInfo } from '../usage-aggregator.js'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
import { redactProjectNames } from './redact.js'
|
||||
import { renderSummaryTable, renderBreakdownTable, renderSavingsTable, type BreakdownBy } from './tables.js'
|
||||
|
||||
const PERIOD = { today: 'today', last_7_days: 'week', last_30_days: '30days', month_to_date: 'month', last_6_months: 'all' } as const
|
||||
type McpPeriod = keyof typeof PERIOD
|
||||
const periodSchema = z.enum(['today', 'last_7_days', 'last_30_days', 'month_to_date', 'last_6_months'])
|
||||
|
||||
type Aggregate = (periodInfo: PeriodInfo, opts: { provider?: string; optimize?: boolean }) => Promise<MenubarPayload>
|
||||
|
||||
const INSTRUCTIONS =
|
||||
'CodeBurn exposes local AI-coding spend data. Use get_usage for spend/usage and breakdowns (fast); ' +
|
||||
'use get_savings to find cost reductions (slower — runs a deeper analysis). Project names are pseudonymized ' +
|
||||
'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' +
|
||||
'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.'
|
||||
|
||||
export function createServer(deps: { version: string; aggregate?: Aggregate }): McpServer {
|
||||
const aggregate = deps.aggregate ?? buildMenubarPayloadForRange
|
||||
const inflight = new Map<string, Promise<MenubarPayload>>()
|
||||
|
||||
const getPayload = (period: McpPeriod, optimize: boolean): Promise<MenubarPayload> => {
|
||||
const key = `${optimize ? 'sav' : 'use'}:${period}`
|
||||
const existing = inflight.get(key)
|
||||
if (existing) return existing
|
||||
const { range, label } = getDateRange(PERIOD[period])
|
||||
const p = aggregate({ range, label }, { provider: 'all', optimize }).finally(() => inflight.delete(key))
|
||||
inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: 'codeburn', version: deps.version }, { instructions: INSTRUCTIONS })
|
||||
|
||||
server.registerTool(
|
||||
'get_usage',
|
||||
{
|
||||
title: 'CodeBurn — usage & cost',
|
||||
description:
|
||||
'Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break ' +
|
||||
'it down by project, model, task, or provider (Claude Code / Cursor / Codex). Fast. Local to this machine.',
|
||||
inputSchema: {
|
||||
period: periodSchema.default('today'),
|
||||
by: z.enum(['project', 'model', 'task', 'provider']).optional(),
|
||||
limit: z.number().int().min(1).max(100).default(20),
|
||||
include_project_names: z.boolean().default(false),
|
||||
},
|
||||
outputSchema: {
|
||||
period: z.string(),
|
||||
empty: z.boolean(),
|
||||
totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }),
|
||||
breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(),
|
||||
},
|
||||
annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
||||
},
|
||||
async ({ period, by, limit, include_project_names }): Promise<CallToolResult> => {
|
||||
try {
|
||||
const payload = redactProjectNames(await getPayload(period, false), include_project_names)
|
||||
const c = payload.current
|
||||
const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate }
|
||||
if (c.calls === 0) {
|
||||
return { content: [{ type: 'text', text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }], structuredContent: { period: c.label, empty: true, totals, breakdown: null } }
|
||||
}
|
||||
const text = by ? renderBreakdownTable(payload, by as BreakdownBy, limit) : renderSummaryTable(payload)
|
||||
const breakdown = by ? breakdownRows(payload, by as BreakdownBy, limit) : null
|
||||
return { content: [{ type: 'text', text }], structuredContent: { period: c.label, empty: false, totals, breakdown } }
|
||||
} catch (err) {
|
||||
return { content: [{ type: 'text', text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }], isError: true }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
'get_savings',
|
||||
{
|
||||
title: 'CodeBurn — savings opportunities',
|
||||
description:
|
||||
'Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing ' +
|
||||
'work), and routing waste (what you would have saved on a cheaper model). Slower than get_usage.',
|
||||
inputSchema: { period: periodSchema.default('last_7_days'), include_project_names: z.boolean().default(false) },
|
||||
outputSchema: {
|
||||
period: z.string(),
|
||||
optimize: z.object({ findingCount: z.number(), savingsUSD: z.number(), topFindings: z.array(z.object({ title: z.string(), impact: z.string(), savingsUSD: z.number() })) }),
|
||||
retryTaxUSD: z.number(),
|
||||
routingWasteUSD: z.number(),
|
||||
},
|
||||
annotations: { title: 'CodeBurn — savings opportunities', readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
||||
},
|
||||
async ({ period, include_project_names }): Promise<CallToolResult> => {
|
||||
try {
|
||||
const payload = redactProjectNames(await getPayload(period, true), include_project_names)
|
||||
const c = payload.current
|
||||
return {
|
||||
content: [{ type: 'text', text: renderSavingsTable(payload) }],
|
||||
structuredContent: { period: c.label, optimize: payload.optimize, retryTaxUSD: c.retryTax.totalUSD, routingWasteUSD: c.routingWaste.totalSavingsUSD },
|
||||
}
|
||||
} catch (err) {
|
||||
return { content: [{ type: 'text', text: `codeburn: failed to compute savings — ${err instanceof Error ? err.message : String(err)}` }], isError: true }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> {
|
||||
const c = p.current
|
||||
if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost }))
|
||||
if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost }))
|
||||
if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost }))
|
||||
return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost }))
|
||||
}
|
||||
|
||||
export async function startStdioServer(version: string): Promise<void> {
|
||||
await loadPricing()
|
||||
const server = createServer({ version })
|
||||
// Pre-warm the parser cache for the common case; ignore failures.
|
||||
void buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }).catch(() => {})
|
||||
await server.connect(new StdioServerTransport())
|
||||
}
|
||||
```
|
||||
|
||||
> If the installed SDK rejects the raw-shape `inputSchema`/`outputSchema` (object of zod validators), wrap each in `z.object({ ... })` — the in-memory test in Step 4 will surface this immediately. Likewise, if `InMemoryTransport`'s import path differs, it is exported from `@modelcontextprotocol/sdk/inMemory.js` in v1.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npm test -- mcp-server`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mcp/server.ts tests/mcp-server.test.ts
|
||||
git commit -m "feat(mcp): get_usage + get_savings tools with annotations, schemas, coalescing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Wire the `codeburn mcp` command
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main.ts` (add command + stdout guard)
|
||||
|
||||
- [ ] **Step 1: Add the command**
|
||||
|
||||
In `src/main.ts`, alongside the other `.command(...)` registrations (e.g. after the `status` block), add:
|
||||
|
||||
```ts
|
||||
program
|
||||
.command('mcp')
|
||||
.description('Run a Model Context Protocol server (stdio) exposing usage + savings to AI agents')
|
||||
.action(async () => {
|
||||
// stdout MUST carry only JSON-RPC; route stray logs to stderr.
|
||||
console.log = ((...args: unknown[]) => process.stderr.write(args.join(' ') + '\n')) as typeof console.log
|
||||
const { startStdioServer } = await import('./mcp/server.js')
|
||||
await startStdioServer(version)
|
||||
})
|
||||
```
|
||||
|
||||
(`version` is already in scope at `main.ts:30`.)
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: succeeds. `src/mcp/server.ts` is reachable from the `main.ts` import graph (via the dynamic import), so tsup bundles it; the `@modelcontextprotocol/sdk` and `zod` imports stay external (Task 1).
|
||||
|
||||
- [ ] **Step 3: Smoke-test the built server over stdio**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | node dist/cli.js mcp 2>/dev/null | head -2
|
||||
```
|
||||
Expected: two JSON-RPC result lines on stdout; the second lists `get_usage` and `get_savings`. No non-JSON noise on stdout (warnings, if any, went to stderr).
|
||||
|
||||
- [ ] **Step 4: Verify the SDK is external in the bundle**
|
||||
|
||||
Run: `node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); console.log('McpServer source inlined?', /class McpServer/.test(s))"`
|
||||
Expected: `McpServer source inlined? false` (it's imported from node_modules at runtime, not bundled).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main.ts
|
||||
git commit -m "feat(mcp): add 'codeburn mcp' stdio command with stdout guard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full suite + final verification
|
||||
|
||||
**Files:** none (verification)
|
||||
|
||||
- [ ] **Step 1: Run everything**
|
||||
|
||||
Run: `npx tsc --noEmit && npm test && npm run build`
|
||||
Expected: typecheck clean; all tests pass (incl. the pre-existing suite — parity preserved); build succeeds.
|
||||
|
||||
- [ ] **Step 2: Manual end-to-end against real data (optional but recommended)**
|
||||
|
||||
Run: `node dist/cli.js mcp` then, in another shell, point a local MCP client (or the smoke command from Task 7) at it and call `get_usage {"period":"today"}` and `get_savings {"period":"last_7_days"}`. Confirm tables render and project names are hashed.
|
||||
|
||||
- [ ] **Step 3: Commit any cleanup**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "chore(mcp): final verification" || echo "nothing to commit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deferred (not in v1 — see spec §10 and note below)
|
||||
|
||||
- **Per-provider graceful degradation (`degraded[]`).** The spec (§5/§8) called for `allSettled` per-provider isolation. That requires changing the shared `parseAllSessions` loop (`parser.ts:2133`), which every command uses — out of scope for v1 to avoid destabilizing the parser. v1 handles failures at the tool boundary (`isError: true` with the message). A malformed provider aborting a scan is a **pre-existing** behavior shared with the `status`/menubar path, not introduced here. *This is the one intentional deviation from the committed spec.*
|
||||
- HTTP/SSE transport, `compare_periods`, MCP prompts/resources, README + registry listings.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** in-process stdio server (Task 6/7) ✓; cheap vs optimize split via `optimize` flag (Task 3, refined — `scanAndDetect` is the only expensive call) ✓; 2 tools with annotations + outputSchema + isError (Task 6) ✓; hash-by-default redaction (Task 4) ✓; in-flight coalescing + pre-warm (Task 6) ✓; tsup external + pinned deps (Task 1) ✓; stdout guard (Task 7) ✓; period-enum rename + `last_6_months` semantics (Task 6 + instructions) ✓; empty-state message (Task 6) ✓; token discipline via `limit` + summarized history (Task 6, no daily array returned) ✓. Deviation: per-provider `degraded[]` deferred (documented above).
|
||||
- **Placeholders:** none — every code step has full source; the only "move verbatim" steps (Tasks 2–3) are mechanical relocations of existing, cited code, gated by the existing parity test.
|
||||
- **Type consistency:** `buildMenubarPayloadForRange(PeriodInfo, AggregateOpts)`, `redactProjectNames(MenubarPayload, boolean)`, `renderBreakdownTable(payload, BreakdownBy, limit)`, `createServer({version, aggregate})` — names/signatures consistent across tasks and tests.
|
||||
@@ -0,0 +1,125 @@
|
||||
# CodeBurn MCP Server — Design Spec
|
||||
|
||||
- **Date:** 2026-06-02
|
||||
- **Status:** Approved design (pre-implementation)
|
||||
- **Author:** brainstormed + validator/devil's-advocate hardened
|
||||
|
||||
## 1. Context & Goal
|
||||
|
||||
CodeBurn already aggregates rich AI-coding usage/cost data (by task, model, project, provider; retry tax; routing waste; optimize findings; 365-day history). This spec adds an **MCP server** that exposes that data to AI agents (Claude Code, Cursor, Claude Desktop) so an agent can answer "where did my tokens go?" and "how do I spend less?" mid-conversation.
|
||||
|
||||
**Why (and why not):** This is a **product / differentiation** play, not a downloads play. We measured that MCP is a niche *download* lever (`@ccusage/mcp` ≈ 1.1% of ccusage's downloads; competitor tokscale ships no MCP), and the real download lever is npx-first CLI positioning — tracked separately. The MCP's value is agent-facing utility on data competitors don't expose (retry tax, routing waste, one-shot rate, task attribution).
|
||||
|
||||
## 2. Decisions (from brainstorming)
|
||||
|
||||
1. **Use case:** unified — serves both live self-optimization and historical analysis from one tool set.
|
||||
2. **Output contract:** every tool returns a ready-to-display **markdown table** *and* the same data as **structured JSON**.
|
||||
3. **Architecture:** Approach A — a `codeburn mcp` subcommand on the existing CLI (no second package), reusing the existing aggregation; run as a **long-lived in-process stdio server**.
|
||||
4. **Tool surface:** **2 tools** — `get_usage` and `get_savings`. (`compare_periods` cut — no reusable backend, overlap-incoherent.)
|
||||
5. **Privacy:** project/session names **hashed by default** (`project-<6hex>`); real names only when `include_project_names: true`. Absolute paths never exposed.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### Process model
|
||||
- `codeburn mcp` starts a **resident, in-process** MCP server over **stdio** (`StdioServerTransport`). Not exec-per-call — a resident process is required so the existing in-process session cache (180s TTL, `src/parser.ts`) amortizes across tool calls. Measured cost otherwise: `--period all` is ~17.6s **even warm** when the process exits between calls.
|
||||
- **First line of the `mcp` action:** `console.log = console.error`. The aggregation path is stdout-clean today (writes go to stderr), but the global `preAction` hook and `runOptimize` contain stdout `console.log`s; reassigning immunizes the JSON-RPC stream against any present or future stdout write. (Verified: `scanAndDetect`, `parseAllSessions`, providers, caches, `buildMenubarPayload` do not write to stdout.)
|
||||
- Pre-warm `today` usage on boot so the first interactive call is fast.
|
||||
|
||||
### Modules
|
||||
- **`src/usage-aggregator.ts`** *(new)* — extracted from the inline logic in the `status` handler (`src/main.ts` ~476–760):
|
||||
- `buildUsage(period, opts): Promise<MenubarPayload>` — **cheap path**, no optimize pass.
|
||||
- `buildSavings(period, opts): Promise<MenubarPayload>` — adds `scanAndDetect` (optimize findings + retry tax + routing waste).
|
||||
- `opts: { provider?: string; project?: string[]; exclude?: string[]; range?: ResolvedRange }`. The `provider` and project/range filters are **required** for parity — the `status` body forks on `isAllProviders` and resolves a range from `day/days/from/to` before `period`. `status --format menubar-json` is refactored to call these (one shared path).
|
||||
- **`src/mcp/server.ts`** *(new)* — builds `McpServer`, registers the 2 tools, connects `StdioServerTransport`, owns the in-flight coalescing map and the empty-state messages.
|
||||
- **`src/mcp/tables.ts`** *(new)* — compact markdown renderers per slice, reusing `format.ts` and `models-report.ts` where they already render tables.
|
||||
- **`src/mcp/redact.ts`** *(new)* — stable pseudonym hashing for project/session names; applied unless the caller passes `include_project_names: true`.
|
||||
- **`src/main.ts`** — add `.command('mcp')` that dynamic-`import()`s `./mcp/server.js` and starts it.
|
||||
|
||||
### Dependencies & build
|
||||
- Add to **`dependencies`**: `@modelcontextprotocol/sdk@^1.29.0` (v1 line; import paths `@modelcontextprotocol/sdk/server/mcp.js` and `/server/stdio.js`) and `zod@^3.25` (NOT transitive — it's a peer dep of the SDK and absent from the lockfile today).
|
||||
- Add to **`tsup.config.ts`**: `external: ['@modelcontextprotocol/sdk', 'zod']`. The current config bundles all deps (`splitting: false`, no `external`), so without this a dynamic import would inline the SDK (~MBs) into `dist/main.js`. Externalizing keeps `dist` small and makes the dynamic import a real lazy load from `node_modules`. (`files: ["dist"]` means externalized deps must be runtime `dependencies`.)
|
||||
- Pin the SDK major: a separately-named v2 package exists with different import paths; `^1.29.0` keeps the v1 paths valid.
|
||||
|
||||
## 4. Tool Surface
|
||||
|
||||
Period enum (LLM-clear names, mapped internally): `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all`. Documented: `last_6_months` is the maximum window (codeburn's "all" = ~6 months); history is summarized, not dumped.
|
||||
|
||||
Both tools carry annotations `{ readOnlyHint: true, openWorldHint: false, idempotentHint: true, title }`, a zod `inputSchema` and `outputSchema`, and return `{ content: [{ type:'text', text: <markdown table> }], structuredContent: <object matching outputSchema> }`.
|
||||
|
||||
### 4.1 `get_usage`
|
||||
- **title:** "CodeBurn — usage & cost"
|
||||
- **description (agent-facing):** "Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break it down by project, model, task, or provider. Fast (does not run the deeper savings analysis). Data is local to this machine and current as of the last scan."
|
||||
- **inputSchema:**
|
||||
- `period?: enum` (default `today`)
|
||||
- `by?: "project" | "model" | "task" | "provider"`
|
||||
- `limit?: number` (default 20, max 100) — row cap for breakdowns
|
||||
- `include_project_names?: boolean` (default `false`)
|
||||
- **Behavior:** no `by` → headline (cost, calls, sessions, input/output tokens, cache-hit %, one-shot rate) + top-N models/projects/tasks. With `by` → one ranked table for that dimension (`project→topProjects`, `model→topModels`, `task→topActivities`, `provider→providers` cost map). Uses `buildUsage` (cheap path).
|
||||
- **outputSchema:** the relevant subset of `MenubarPayload.current` (typed).
|
||||
|
||||
### 4.2 `get_savings`
|
||||
- **title:** "CodeBurn — savings opportunities"
|
||||
- **description (agent-facing):** "Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing work), and routing waste (what you'd have saved on a cheaper model). Runs a deeper analysis, so it is slower than get_usage."
|
||||
- **inputSchema:** `period?: enum` (default `last_7_days` — never default to the slow `last_6_months`), `include_project_names?: boolean` (default `false`).
|
||||
- **Behavior:** runs `buildSavings`; returns optimize findings (title, impact, $ saved), retry tax (total + by model), routing waste (total savings, baseline model, by model).
|
||||
- **outputSchema:** `{ optimize, retryTax, routingWaste }` (typed).
|
||||
|
||||
### 4.3 Server `instructions`
|
||||
> "CodeBurn exposes local AI-coding spend data. Use `get_usage` for spend/usage and breakdowns (fast); use `get_savings` to find cost reductions (slower). Project names are pseudonymized unless you pass `include_project_names: true`. All data is read locally from this machine; `last_6_months` is the widest window. Numbers reflect the most recent scan and may lag the current in-flight session by a short interval."
|
||||
|
||||
## 5. Data Flow
|
||||
|
||||
```
|
||||
agent tool call
|
||||
→ zod inputSchema validation (invalid params → protocol error, auto)
|
||||
→ in-flight coalesce on {kind, period, opts-hash} (parallel callers share one scan)
|
||||
→ buildUsage / buildSavings(period, {provider:'all', ...})
|
||||
→ parseAllSessions with PER-PROVIDER allSettled isolation → degraded[]
|
||||
→ existing 180s in-process session cache amortizes
|
||||
→ redact project/session names unless include_project_names
|
||||
→ render markdown table + build structuredContent (validated vs outputSchema)
|
||||
→ return { content:[{type:'text',text}], structuredContent } (isError:true on failure)
|
||||
```
|
||||
|
||||
## 6. Privacy / Redaction
|
||||
- Name-bearing fields — `topProjects[].name`, `topSessions[].project`, `topProjects[].sessionDetails` — are replaced with stable pseudonyms `project-<6hex(sha256(name))>` unless `include_project_names: true`.
|
||||
- Absolute paths are never emitted (they aren't in the payload today; the MCP must not enrich with them).
|
||||
- Rationale: an MCP is an egress surface to possibly-remote/cloud agents; codeburn's brand is "data never leaves your machine." Hashing keeps breakdowns coherent (stable pseudonyms) without leaking identity; local users who want real names opt in per call.
|
||||
|
||||
## 7. Performance
|
||||
- **Two paths:** `get_usage` uses the cheap aggregation (`--no-optimize` seam already exists; ~3s `today`, ~5s `all`). `get_savings` runs the optimize pass (~+13s) — isolated to the one tool that needs it. Without this split, every tool paid the 13s tax.
|
||||
- **Resident process** + existing 180s session cache → warm calls are cheap.
|
||||
- **In-flight coalescing:** concurrent calls for the same `{kind, period, opts}` await a single scan (agents fire tools in parallel).
|
||||
- **Pre-warm** `today` usage on boot.
|
||||
- **Token discipline:** breakdowns capped at `limit` (default 20); history is summarized (totals + top-N), never the full 365-day array; tables are compact.
|
||||
|
||||
## 8. Error Handling
|
||||
- Invalid params → zod → MCP protocol error (auto).
|
||||
- No data for period (fresh install) → `isError: false` with a friendly "no usage recorded for <period> yet — run some coding sessions" message (not a zero-filled table).
|
||||
- One provider fails to parse → isolate via `allSettled`, return partial data, list skipped providers in `degraded[]` and a table footer note.
|
||||
- Aggregation throw / payload-shape mismatch → `isError: true` with a clear message (incl. version-skew hint); never hang the transport.
|
||||
|
||||
## 9. Testing
|
||||
- **Parity:** `buildUsage('today', {provider:'all'})` deep-equals current `status --format menubar-json --period today` (plus one more period). Guards the extraction.
|
||||
- **Per-tool:** fixture payload → expected markdown table (snapshot) + structuredContent keys; `by` each dimension; redaction on/off (no real names/paths leak when off; pseudonyms stable); empty-state message.
|
||||
- **MCP protocol smoke:** in-memory transport — `listTools` returns the 2 tools with annotations + outputSchema; call each; assert result shape (`content` + `structuredContent`) and `isError` paths; concurrent identical calls coalesce to one scan.
|
||||
- **Build:** assert SDK + zod are externalized (absent from `dist`) and declared in `dependencies`.
|
||||
|
||||
## 10. Out of Scope (v1 — YAGNI)
|
||||
- HTTP/SSE transport (stdio only).
|
||||
- `compare_periods` (agent can diff two `get_usage` calls; backend is net-new and overlap-incoherent).
|
||||
- Write/mutating tools, auth, multi-machine/remote data.
|
||||
- MCP **prompts** and **resources** primitives (tools-only v1; revisit if a "cost-review" prompt template proves useful).
|
||||
- README "MCP" section and MCP-registry listings (lobehub/Smithery/mcp.so) — follow-up tasks, not code.
|
||||
|
||||
## 11. Provenance — review findings incorporated
|
||||
Hardened by a validator + devil's-advocate pass:
|
||||
- **BLOCKER** in-process resident server (caches don't help exec-per-call) — §3, §7.
|
||||
- **BLOCKER** split cheap vs optimize path (optimize ≈ 70% of cost) — §7.
|
||||
- **BLOCKER** redact names by default (raw repo names + dated spend would egress) — §6.
|
||||
- **MAJOR** `buildUsage/buildSavings` signature carries provider/project/range for parity — §3.
|
||||
- **MAJOR** tsup `external` + deps in `dependencies`; pin SDK `^1.29.0`; add `zod` explicitly — §3.
|
||||
- **MAJOR** per-provider `allSettled` isolation + `degraded[]` — §5, §8.
|
||||
- **MAJOR** in-flight coalescing — §5, §7.
|
||||
- **MAJOR** drop `compare_periods`; rename period enums; default `today` — §4, §10.
|
||||
- **MINOR** `console.log→console.error` stdout guard; friendly empty-state; run compiled `dist` not `tsx`; validate payload shape; `last_6_months` is the real max — §3, §8, §4.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Provider Docs
|
||||
|
||||
One file per provider integration. If you are fixing a bug or adding a feature scoped to a single provider, read the file for that provider first; it tells you which file to edit, where on disk the source data lives, and what edge cases the test suite already covers.
|
||||
|
||||
For the architectural picture, see `../architecture.md`.
|
||||
|
||||
## Provider Index
|
||||
|
||||
### Eager (always loaded)
|
||||
|
||||
| Provider | Storage | Source | Test |
|
||||
|---|---|---|---|
|
||||
| [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) |
|
||||
| [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` |
|
||||
| [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` |
|
||||
| [Copilot](copilot.md) | JSONL + SQLite (OTel) + Nitrite .db (JetBrains) | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` |
|
||||
| [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.test.ts` |
|
||||
| [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` |
|
||||
| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none |
|
||||
| [Hermes Agent](hermes.md) | SQLite | `src/providers/hermes.ts` | `tests/providers/hermes.test.ts` |
|
||||
| [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` |
|
||||
| [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` |
|
||||
| [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` |
|
||||
| [Kimi](kimi.md) | JSONL | `src/providers/kimi.ts` | `tests/providers/kimi.test.ts` |
|
||||
| [LingTai TUI](lingtai-tui.md) | JSONL | `src/providers/lingtai-tui.ts` | `tests/providers/lingtai-tui.test.ts` |
|
||||
| [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` |
|
||||
| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` |
|
||||
| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` |
|
||||
| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` |
|
||||
| [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none |
|
||||
| [Roo Code](roo-code.md) | JSON | `src/providers/roo-code.ts` | `tests/providers/roo-code.test.ts` |
|
||||
| [Zerostack](zerostack.md) | JSON | `src/providers/zerostack.ts` | `tests/providers/zerostack.test.ts` |
|
||||
| [Grok Build](grok.md) | JSON/JSONL | `src/providers/grok.ts` | `tests/providers/grok.test.ts` |
|
||||
|
||||
### Lazy (loaded on first call)
|
||||
|
||||
| Provider | Storage | Source | Test |
|
||||
|---|---|---|---|
|
||||
| [Antigravity](antigravity.md) | protobuf over RPC | `src/providers/antigravity.ts` | none |
|
||||
| [Crush](crush.md) | SQLite (per-project) | `src/providers/crush.ts` | `tests/providers/crush.test.ts` |
|
||||
| [Forge](forge.md) | SQLite | `src/providers/forge.ts` | `tests/providers/forge.test.ts` |
|
||||
| [Cursor](cursor.md) | SQLite | `src/providers/cursor.ts` | `tests/providers/cursor.test.ts` |
|
||||
| [Cursor Agent](cursor-agent.md) | text / JSONL | `src/providers/cursor-agent.ts` | `tests/providers/cursor-agent.test.ts` |
|
||||
| [Goose](goose.md) | SQLite | `src/providers/goose.ts` | none |
|
||||
| [OpenCode](opencode.md) | SQLite | `src/providers/opencode.ts` | `tests/providers/opencode.test.ts` |
|
||||
| [Warp](warp.md) | SQLite | `src/providers/warp.ts` | `tests/providers/warp.test.ts` |
|
||||
| [Vercel AI Gateway](vercel-gateway.md) | REST API | `src/providers/vercel-gateway.ts` | `tests/providers/vercel-gateway.test.ts` |
|
||||
| [ZCode](zcode.md) | SQLite | `src/providers/zcode.ts` | `tests/providers/zcode.test.ts` |
|
||||
|
||||
### Shared
|
||||
|
||||
| Helper | Used by | Source |
|
||||
|---|---|---|
|
||||
| [vscode-cline-parser](vscode-cline-parser.md) | `cline`, `ibm-bob`, `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` |
|
||||
|
||||
## File Format
|
||||
|
||||
Each provider doc has the same structure:
|
||||
|
||||
1. **One-line summary** of what the provider integrates.
|
||||
2. **Where it reads from** on disk (or over RPC).
|
||||
3. **Storage format** and validation rules.
|
||||
4. **Caching** (which cache layer, if any).
|
||||
5. **Deduplication key** so you understand cross-provider dedup.
|
||||
6. **Quirks** that have bitten us before.
|
||||
7. **When fixing a bug here** as a checklist.
|
||||
|
||||
If you add a new provider, copy `claude.md` as a template and fill in your provider's specifics. Update this index, and prefer adding a real test fixture under `tests/providers/`.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Antigravity
|
||||
|
||||
Google Antigravity (CLI and IDE). CodeBurn discovers session files on disk and queries the local language-server RPC endpoint to parse them.
|
||||
|
||||
- **Source:** `src/providers/antigravity.ts`
|
||||
- **Loading:** lazy via `src/providers/index.ts`. Lazy because the protobuf dependency is heavy.
|
||||
- **Test:** focused helper coverage in `tests/providers/antigravity.test.ts`.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
CodeBurn discovers Antigravity sessions from local directories on disk, then queries the live language-server process (if running) to fetch detailed trajectory generator metadata:
|
||||
|
||||
1. **Session Discovery:** It scans the following folders for `.pb` or `.db` files:
|
||||
- **Antigravity CLI:** `%USERPROFILE%\.gemini\antigravity-cli\conversations` (and `implicit`)
|
||||
- **Antigravity App/older path:** `%USERPROFILE%\.gemini\antigravity\conversations`
|
||||
- **Antigravity IDE (VSCode-based):** `%USERPROFILE%\.gemini\antigravity-ide\conversations` (and `implicit`)
|
||||
2. **Language Server RPC Query:** It locates the active language-server process via `ps` on POSIX or `Get-CimInstance Win32_Process` on Windows. It extracts the port and CSRF token from the process arguments, and queries the local HTTPS RPC endpoint `GetCascadeTrajectoryGeneratorMetadata` to parse the session.
|
||||
3. **Cache Fallback:** If the language server is not running, it falls back to the local results cache.
|
||||
|
||||
Antigravity exposes slightly different process flags across platforms:
|
||||
POSIX builds have used `--https_server_port` and `--csrf_token`; Windows
|
||||
builds can expose `--extension_server_port` and
|
||||
`--extension_server_csrf_token`. Both space-separated and `--flag=value`
|
||||
forms are supported. The parser identifies the target app type using the `--app-data-dir` flag (e.g., `antigravity`, `antigravity-cli`, or `antigravity-ide`).
|
||||
|
||||
For Antigravity CLI (`agy`), CodeBurn can also install an opt-in status line
|
||||
hook with `codeburn antigravity-hook install`. The hook records the CLI's
|
||||
sanitized `context_window.current_usage` payload while `agy` is still alive,
|
||||
without prompts or local working-directory paths. It also attempts a best-effort
|
||||
RPC snapshot for full response metadata. The installed command points at a
|
||||
persistent `codeburn` binary from PATH rather than a local build artifact, and
|
||||
running `codeburn antigravity-hook install` again repairs older CodeBurn-owned
|
||||
statusLine commands that used stale absolute paths. Remove it with `codeburn
|
||||
antigravity-hook uninstall`; if `--force` replaced an existing statusLine
|
||||
command, uninstall restores that previous command.
|
||||
|
||||
## Storage format
|
||||
|
||||
Protobuf. Cascade and response objects map to `ParsedProviderCall` directly.
|
||||
|
||||
## Caching
|
||||
|
||||
Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<cascadeId>:<responseId>` for RPC data. The status line fallback collapses
|
||||
repeated identical usage snapshots, ignores singleton intermediate snapshots
|
||||
when a later stabilized usage total is observed for the same conversation, and
|
||||
uses positive deltas for monotonic snapshots so cumulative counters are not
|
||||
double-counted.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Antigravity is the only provider that requires a live process.** A user who closes Antigravity loses the most-recent data until next launch (the cache covers older runs).
|
||||
- **Antigravity CLI has a shorter capture window than the desktop app.** `agy`
|
||||
exposes its language server only while the CLI session is active. The status
|
||||
line hook closes that gap for future sessions; older CLI `.pb` files still
|
||||
cannot be priced exactly unless an RPC snapshot was captured.
|
||||
- The 16 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine.
|
||||
- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens`. Thinking is billed at output rate.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproducing the full provider path requires Antigravity running locally.
|
||||
The unit tests cover process flag parsing and wrapped/unwrapped RPC response
|
||||
extraction, but they do not stand up a live Antigravity RPC endpoint.
|
||||
2. Before any change, capture a sample protobuf response (anonymized) so future regressions can be tested against a recording.
|
||||
3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling is the place to look.
|
||||
4. If the bug is "stale data", check whether the RPC is reachable; the cache fallback can mask connectivity issues.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Claude
|
||||
|
||||
Anthropic Claude Code CLI and Claude Desktop's local agent mode.
|
||||
|
||||
- **Source:** `src/providers/claude.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:1`)
|
||||
- **Test:** none directly. Coverage comes from `tests/parser-claude-cwd.test.ts`, `tests/parser-filter.test.ts`, and `tests/parser-mcp-inventory.test.ts`, which exercise `src/parser.ts` end-to-end against fixture session files.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Source | Path |
|
||||
|---|---|
|
||||
| Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` |
|
||||
| Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` |
|
||||
| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` |
|
||||
| Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` |
|
||||
|
||||
For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL, one event per line, per session file. Sessions live under `<project>/<sessionId>.jsonl`.
|
||||
|
||||
## Parser
|
||||
|
||||
`createSessionParser` returns an empty async generator (`claude.ts:101-105`). Claude is a special case: `src/parser.ts` reads Claude JSONL files directly with full turn grouping, dedup of streaming message IDs, and MCP tool inventory extraction. The provider object exists only so `discoverSessions` can return Claude session sources alongside the others.
|
||||
|
||||
## Pricing
|
||||
|
||||
Claude Code reports total cache-write tokens in `usage.cache_creation_input_tokens`.
|
||||
When available, it also splits those writes by duration in
|
||||
`usage.cache_creation.ephemeral_5m_input_tokens` and
|
||||
`usage.cache_creation.ephemeral_1h_input_tokens`. CodeBurn keeps the existing
|
||||
aggregate cache-write token total for reports, but prices the 1-hour portion at
|
||||
2x base input cost (1.6x the 5-minute cache-write rate exposed by LiteLLM).
|
||||
If the split fields are missing, the parser falls back to the legacy behavior
|
||||
and prices every cache write at the 5-minute rate.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level. The daily aggregation cache (`src/daily-cache.ts`) reuses prior computed days.
|
||||
|
||||
## Quirks
|
||||
|
||||
- The parser is in `src/parser.ts`, not in `src/providers/claude.ts`. Anything that changes Claude parsing belongs in `parser.ts`.
|
||||
- Streaming responses produce duplicate message IDs across resumed sessions; `parser.ts` strips them via the global `seenMsgIds` Set.
|
||||
- Model display names are mapped in `claude.ts:7-20`; add new versions there when Anthropic releases them.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm whether the bug is in **discovery** (sessions not picked up) or **parsing** (sessions found but data wrong).
|
||||
2. Discovery bugs live in `claude.ts:78-99`. Verify the directory layout you expect actually matches what Claude writes today.
|
||||
3. Parsing bugs live in `src/parser.ts`. Look for `parseSessionFile`, `groupIntoTurns`, and `dedupeStreamingMessageIds`.
|
||||
4. Add a fixture under `tests/fixtures/` and a test under `tests/parser-claude-cwd.test.ts` (or a new file). Do not mock the filesystem.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Cline
|
||||
|
||||
Cline VS Code extension and Cline home-data task storage.
|
||||
|
||||
- **Source:** `src/providers/cline.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:2`)
|
||||
- **Test:** `tests/providers/cline.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Two task roots are scanned:
|
||||
|
||||
1. VS Code extension globalStorage for `saoudrizwan.claude-dev`.
|
||||
2. Cline's home-data root at `~/.cline/data`.
|
||||
|
||||
Both roots are expected to contain a `tasks/` child directory. Discovery is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`, so a task is only included when it has a `ui_messages.json` file.
|
||||
|
||||
## Storage format
|
||||
|
||||
Per-task directories with:
|
||||
|
||||
```
|
||||
tasks/<taskId>/
|
||||
ui_messages.json
|
||||
api_conversation_history.json
|
||||
task_metadata.json
|
||||
```
|
||||
|
||||
`ui_messages.json` provides the `api_req_started` usage entries. `api_conversation_history.json` is used for model extraction. See [`vscode-cline-parser`](vscode-cline-parser.md) for the full schema description.
|
||||
`task_metadata.json` is part of Cline's task layout but is not read by CodeBurn today.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level; delegates to the shared helper and normal parser/cache layers.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Discovery deduplicates by task id across the two Cline roots so a migrated task is not scanned twice. If the same task id exists in multiple roots, the one with the newest `ui_messages.json` wins. Parsing still uses the shared per-call key: `<providerName>:<taskId>:<index>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- This provider is intentionally a thin wrapper over the shared Cline-family parser.
|
||||
- Cline can keep data in both VS Code globalStorage and `~/.cline/data`, depending on version and workflow.
|
||||
- If Cline changes the JSON shape, fix `vscode-cline-parser.ts` only if Roo Code and KiloCode still pass. Branch provider-specific parsing rather than duplicating the whole parser.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce with a minimal task directory containing `ui_messages.json` and `api_conversation_history.json`.
|
||||
2. Run `tests/providers/cline.test.ts`, plus `tests/providers/roo-code.test.ts` and `tests/providers/kilo-code.test.ts` if the shared parser changes.
|
||||
3. Keep the provider name `cline`; downstream filters and dedup keys depend on it.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Codex
|
||||
|
||||
OpenAI Codex CLI.
|
||||
|
||||
- **Source:** `src/providers/codex.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:2`)
|
||||
- **Test:** `tests/providers/codex.test.ts` (374 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$CODEX_HOME` if set, otherwise `~/.codex`. Sessions are nested by date:
|
||||
|
||||
```
|
||||
~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl
|
||||
```
|
||||
|
||||
The discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on each path component.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL. The first line must be a `session_meta` entry with `payload.originator` starting with `codex` (case-insensitive). Files that fail this check are silently skipped.
|
||||
|
||||
The first line read is capped at 1 MB (`FIRST_LINE_READ_CAP`). Codex CLI 0.128+ embeds the full system prompt in `session_meta`, which can run 20-27 KB; the cap leaves headroom while bounding memory if a corrupt file has no newline.
|
||||
|
||||
## Caching
|
||||
|
||||
`src/codex-cache.ts` writes `~/.cache/codeburn/codex-results.json` (or `$CODEBURN_CACHE_DIR/codex-results.json`). Each entry is keyed by absolute file path and validated against `mtimeMs + sizeBytes`. Cached entries are returned wholesale.
|
||||
|
||||
A session that yielded zero parseable lines does **not** write to the cache (`codex.ts:419`); this prevents a transient read failure from pinning an empty result against a fingerprint.
|
||||
|
||||
## Deduplication
|
||||
|
||||
`codex:<sessionId>:<timestamp>:<cumulativeTotal>` for accounted events, plus `codex:<sessionId>:<timestamp>:est<n>` for estimated events that fall back to char-counting.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Codex CLI emits both `last_token_usage` (per turn) and `total_token_usage` (cumulative). The parser handles three modes:
|
||||
1. `last_token_usage` present: use it directly.
|
||||
2. Only cumulative: compute deltas against the prior turn.
|
||||
3. Neither: estimate from message text length (`CHARS_PER_TOKEN = 4`).
|
||||
- `prevCumulativeTotal` is initialized to `null`, not `0`. A session whose first event reports `total = 0` would otherwise be dropped as a "duplicate" of the initial state.
|
||||
- `prev*` token counters are advanced on **every** event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes.
|
||||
- OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`.
|
||||
2. If the bug is "zero tokens reported", first check whether the file is being skipped by `isValidCodexSession`.
|
||||
3. If the bug is "tokens counted twice", look at `prevCumulativeTotal` and the prev-counter advancement.
|
||||
4. If you change the dedup key shape, run `tests/providers/codex.test.ts` and `tests/parser-filter.test.ts` together; cross-provider dedup happens via the global `seenKeys` Set.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Copilot
|
||||
|
||||
GitHub Copilot Chat (CLI, VS Code core chat sessions, VS Code extension transcripts, and JetBrains IDE sessions).
|
||||
|
||||
- **Source:** `src/providers/copilot.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:3`)
|
||||
- **Test:** `tests/providers/copilot.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Three JSONL locations plus an optional OpenTelemetry SQLite source (see below). OTel is
|
||||
preferred when present; chatSessions are only discovered when no OTel source is found.
|
||||
Other discovered sources are walked on every run; results merge and dedupe.
|
||||
|
||||
1. **Legacy CLI sessions:** `~/.copilot/session-state/`
|
||||
2. **VS Code core chat sessions:** `~/Library/Application Support/Code/User/workspaceStorage/<hash>/chatSessions/*.jsonl` plus `~/Library/Application Support/Code/User/globalStorage/emptyWindowChatSessions/*.jsonl` and equivalents on Windows / Linux
|
||||
3. **VS Code transcripts:** `~/Library/Application Support/Code/User/workspaceStorage/<hash>/GitHub.copilot-chat/transcripts/` and equivalents on Windows / Linux
|
||||
4. **OTel SQLite store:** VS Code Copilot Chat's `agent-traces.db` (see the OTel section). Preferred when present because it carries full input / output / cache token counts; legacy JSONL sources only record output tokens.
|
||||
5. **JetBrains IDE sessions:** `~/.config/github-copilot/<ide>/<kind>/<storeId>/copilot-*-nitrite.db` (see the JetBrains section). Covers IntelliJ IDEA, PyCharm, RubyMine, etc.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL in the first three locations (schemas differ; the parser switches by source type / event shape), a SQLite DB for the OTel source, and a Nitrite (H2 MVStore) `.db` for the JetBrains source. VS Code core chat sessions use a delta journal: `kind:0` sets the root object, `kind:1` writes a value at path `k`, and `kind:2` appends items to an array path.
|
||||
|
||||
## OpenTelemetry (OTel) source
|
||||
|
||||
When VS Code Copilot Chat's `agent-traces.db` exists, the parser reads per-LLM-call token
|
||||
breakdowns (input, output, cache-read, cache-creation) from it, which the JSONL sources do
|
||||
not record. Discovery is skipped with `CODEBURN_COPILOT_DISABLE_OTEL=1`, and the DB path
|
||||
can be overridden with `CODEBURN_COPILOT_OTEL_DB`.
|
||||
|
||||
If OTel discovery finds at least one source, workspace `chatSessions/*.jsonl` and
|
||||
`emptyWindowChatSessions/*.jsonl` are skipped. Those journals can mirror the same Copilot
|
||||
turns under IDs that do not match OTel turn IDs, so CodeBurn prefers the richer OTel data
|
||||
instead of trying to dedupe across stores.
|
||||
|
||||
- **Requires Node 22+.** The OTel source uses the built-in `node:sqlite` module (the same
|
||||
backend as Cursor / OpenCode). On Node 20, or if the DB is missing / locked / corrupt /
|
||||
wrong-schema, OTel is skipped and the JSONL/transcript sources are used as a fallback.
|
||||
- **Durable cache (monotonic totals).** Copilot is marked `durableSources`: OTel-derived
|
||||
cache entries are never evicted when VS Code prunes old spans from the DB, so
|
||||
month-to-date totals do not drop as the DB rotates. Entries age out after 90 days.
|
||||
- **Upgrade note.** The first run after upgrading to the OTel version bumps the copilot
|
||||
parse version, which discards the prior copilot cache. Spans already pruned from the DB
|
||||
before the upgrade cannot be recovered, so monotonicity starts from the upgrade point,
|
||||
not retroactively.
|
||||
|
||||
## JetBrains IDEs (IntelliJ, PyCharm, …)
|
||||
|
||||
The JetBrains Copilot plugin does **not** write to any of the VS Code or CLI
|
||||
locations above. It persists chat/agent sessions under the shared GitHub Copilot
|
||||
config root, in one store directory per session store:
|
||||
|
||||
```
|
||||
~/.config/github-copilot/<ide>/<kind>/<storeId>/
|
||||
copilot-*-nitrite.db # Nitrite (H2 MVStore) — the session content
|
||||
blobs/
|
||||
```
|
||||
|
||||
`<ide>` is a per-IDE dir (`iu` for IntelliJ IDEA Ultimate, `intellij` for the
|
||||
community edition, `PyCharm2025.2`, …). `<kind>` ∈ `chat-agent-sessions`,
|
||||
`chat-sessions`, `chat-edit-sessions` (agent / ask / edit mode). The root follows
|
||||
XDG rules: `$XDG_CONFIG_HOME/github-copilot` when set, else
|
||||
`~/.config/github-copilot` (macOS / Linux) or `%LOCALAPPDATA%\github-copilot`
|
||||
(Windows).
|
||||
|
||||
**Storage: the Nitrite `.db`.** An H2 MVStore file (header
|
||||
`H:2,block:9,…format:3`) of Java-serialized Nitrite documents (`NtAgentSession`,
|
||||
`NtAgentTurn`). It is read as `latin1` (byte-offset-stable, lossless) and scanned
|
||||
— no Java deserializer, no new deps, and it is **not** SQLite so `node:sqlite` is
|
||||
not used. Each assistant reply is a `{"__first__":{"type":"Subgraph",…}}` blob.
|
||||
`extractResponseText` recovers the reply by unescaping one level at a time and,
|
||||
at the first depth where the record markers appear bare, reading the reply
|
||||
**structurally** (the payload is parsed as a delimited JSON-string literal, so a
|
||||
reply containing its own quotes is never truncated).
|
||||
|
||||
**Two turn shapes, both handled** (a blob is one or the other — verified across
|
||||
every observed store that they never coexist):
|
||||
|
||||
- **Ask mode** — the reply is a `Markdown` record's `text`.
|
||||
- **Agent / plan mode** (agent sessions, `/plan …`, e.g. in PyCharm) — the reply
|
||||
is the `reply` field of an `AgentRound` record; here the `Markdown` records
|
||||
hold the *user's* prompt instead. The mode is decided by the **presence** of an
|
||||
`AgentRound` record, and only its `reply` is read — so an agent turn with an
|
||||
empty reply (a failed turn or a pure tool-call round) is billed **$0** rather
|
||||
than falling back to the prompt. A multi-round blob contributes every non-empty
|
||||
round's reply.
|
||||
|
||||
Sidecar records that plan/agent mode also writes — `Thinking` (chain-of-thought),
|
||||
`PendingChanges` (proposed code diff, stored under `content` not `data`),
|
||||
`AskQuestion`, `Notification`, `SubTurn`, and file-read `text` results — are
|
||||
**not** billable assistant output and are deliberately skipped. User prompts are
|
||||
the simpler `{"<uuid>":{"type":"Value",…}}` value-maps.
|
||||
|
||||
**Old plugin format (≤1.5.x, e.g. 1.5.59-243).** Older plugins do not write
|
||||
per-turn `__first__`/Subgraph blobs at all — they store the whole session as ONE
|
||||
binary-framed outer Nitrite document of UUID-keyed `Value` entries, with the
|
||||
`AgentRound` records one escaping level deeper. When the Subgraph scan finds no
|
||||
turns but the raw file contains `AgentRound` text, a fallback locates that outer
|
||||
document (`extractJetBrainsDbTurns`), runs it through the same
|
||||
`extractResponseText` depth-unescape, and emits **one session-level call** per
|
||||
document (all rounds' replies joined). Cost and tokens are correct; only the
|
||||
per-turn call-count granularity is coarser than the new format — an accepted
|
||||
tradeoff for legacy data. The fallback is gated on the new-format scan yielding
|
||||
nothing, so current sessions are never affected or double-counted.
|
||||
|
||||
(Store dirs may also contain a legacy `00000000000.xd` Xodus log from older
|
||||
plugin versions. On every installation observed it is either empty or shadowed
|
||||
by the `.db`, so CodeBurn reads only the `.db`. If a real `.xd`-only session ever
|
||||
surfaces, add a reader with a captured fixture.)
|
||||
|
||||
- **No token accounting.** No store records token counts. Output tokens are
|
||||
**estimated** from the reply text via `estimateTokens` (`CHARS_PER_TOKEN = 4`,
|
||||
as for Cursor and legacy Copilot JSONL); input tokens are 0; every JetBrains
|
||||
call is marked `costIsEstimated: true`.
|
||||
- **Errored turns.** A failed generation ("Sorry, an error occurred …") is stored
|
||||
as an assistant blob with an error status and no reply text; it is detected and
|
||||
billed **$0** (not conflated with an empty success). In agent mode a failed turn
|
||||
has an empty `AgentRound` reply — the parser does not fall back to the prompt
|
||||
`Markdown`, so the user's words are never billed as the assistant's output.
|
||||
- **Per-turn model.** The model varies per turn within one `.db`. It is recovered
|
||||
from inside the assistant blob when present, else a store-wide default, else a
|
||||
generic Copilot bucket. Dotted Claude names are normalised to canonical ids
|
||||
(`claude-opus-4.5` → `claude-opus-4-5`); GPT/Gemini names kept verbatim.
|
||||
- **Duplicates.** The store keeps several byte-copies of each reply (original,
|
||||
lowercased, revisions); assistant turns are de-duplicated by reply content.
|
||||
- **One `.db` holds many chat tabs.** A single store `.db` contains multiple
|
||||
conversations, each with an internal GUID and an evolving title
|
||||
(`New Agent Session` → auto-name → final title). CodeBurn recovers the
|
||||
`GUID → title` map (`extractJetBrainsConversations`, keeping the latest
|
||||
non-default title), attributes each turn to the nearest preceding conversation
|
||||
GUID, and emits **one session per conversation** (not one per `.db`). Reply
|
||||
content is de-duplicated per conversation.
|
||||
- **Project.** Resolved in three tiers, most authoritative first:
|
||||
1. **`projectName` field (plugin 1.12+).** Recent plugins serialize the repo
|
||||
label directly on the session doc (`extractJetBrainsProjectName`) — the
|
||||
JetBrains analogue of the OTel source's `github.copilot.git.repository`.
|
||||
**Cross-kind join:** the billable turns live in `chat-agent-sessions`, but
|
||||
the `projectName` is usually written only into the sibling
|
||||
`chat-sessions` / `chat-edit-sessions` store. Discovery
|
||||
(`resolveJetBrainsProjectNames`) joins them by **store id** so the agent
|
||||
session inherits the label from whichever store recorded it. Read
|
||||
length-prefixed (Java `TC_STRING`) so an embedded quote/newline can't
|
||||
truncate it.
|
||||
2. **`.git` walk-up (older plugins / no `projectName`).** For each `file://`
|
||||
URI a chat referenced, walk UP the real filesystem to the nearest ancestor
|
||||
containing a `.git` and use that repo's basename (e.g. `pinot`).
|
||||
3. **`copilot-jetbrains`** bucket when neither signal exists (chat referenced
|
||||
no files and no `projectName` was recorded, or the repo no longer exists on
|
||||
disk).
|
||||
|
||||
The conversation **title** is a chat-thread name, NOT a project — it is the
|
||||
session label (`userMessage`) and deliberately kept out of `project` so it does
|
||||
not pollute the By-Project view. Note that `bg-agent-sessions/` (a newer kind
|
||||
dir holding `copilot-agent-snapshots.db` / `copilot-session-metadata.db`) is
|
||||
**not** scanned: those DBs carry file snapshots and metadata, not billable
|
||||
turns, and the same session's turns are already read from
|
||||
`chat-agent-sessions`.
|
||||
- **Override the root** with `CODEBURN_COPILOT_JETBRAINS_DIR`.
|
||||
|
||||
## Caching
|
||||
|
||||
None for the JSONL sources. The OTel source uses a durable cache (see above).
|
||||
|
||||
## Deduplication
|
||||
|
||||
Legacy JSONL and transcript sessions dedupe per `messageId`. Core chat sessions dedupe per `copilot-chatsession:<sessionId>:<requestId>`, and are not discovered when an OTel source is present. JetBrains `.db` turns dedupe per `copilot:jb:<conversationId>:<turnIndex>` (a per-conversation index, plus reply-content dedup within each conversation). These sources otherwise touch disjoint locations from the VS Code / CLI sources.
|
||||
|
||||
If a workspace hash contains at least one `chatSessions/*.jsonl` file, the provider skips that hash's legacy `GitHub.copilot-chat/transcripts/` directory. The core chat session journal is the modern token-bearing source for the same conversations, so reading both would inflate call counts.
|
||||
|
||||
## Model inference
|
||||
|
||||
Copilot does not always tag the model on each message. The parser infers it from the tool-call ID prefix:
|
||||
|
||||
| Prefix | Inferred model family |
|
||||
|---|---|
|
||||
| `toolu_bdrk_`, `toolu_vrtx_`, `tooluse_`, `toolu_` | Anthropic |
|
||||
| `call_` | OpenAI |
|
||||
|
||||
See `copilot.ts:176-213`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- `toolRequests` can be missing or non-array on older sessions; the parser guards against that (`copilot.ts:126`, `:260`).
|
||||
- When `outputTokens` is missing the parser falls back to char-counting (`CHARS_PER_TOKEN = 4`, `copilot.ts:252-254`).
|
||||
- A single chat may be mirrored across both legacy and transcript paths if the user upgraded; the dedup `messageId` collision handles this.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Determine which schema reproduces the bug. The two parsers share little code on purpose; do not unify them unless you understand both formats.
|
||||
2. If the model is misidentified, look at the tool-call ID prefix list and consider whether a new prefix should be added.
|
||||
3. New fixtures go under `tests/fixtures/copilot/` and are referenced from `tests/providers/copilot.test.ts`.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Crush
|
||||
|
||||
Charmbracelet's Crush TUI coding agent.
|
||||
|
||||
- **Source:** `src/providers/crush.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts`). Lazy because Crush ships per-project SQLite databases and we use `node:sqlite` to read them.
|
||||
- **Test:** `tests/providers/crush.test.ts` (10 tests, fixture-based)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Crush keeps a global registry that lists every project it has touched, and a separate SQLite database **per project**.
|
||||
|
||||
| File | Path |
|
||||
|---|---|
|
||||
| Registry (project list) | `$CRUSH_GLOBAL_DATA/projects.json`, otherwise `$XDG_DATA_HOME/crush/projects.json`, otherwise `~/.local/share/crush/projects.json` (Linux/macOS) or `%LOCALAPPDATA%/crush/projects.json` (Windows). |
|
||||
| Per-project db | `<project.path>/<project.data_dir>/crush.db` where `data_dir` defaults to `.crush`. |
|
||||
|
||||
The registry shape is an object keyed by project id (modern Crush) or an array (older builds and tokscale's sample fixtures). The parser accepts both.
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite. Schema verified against `charmbracelet/crush` v0.66.1 (`internal/db/migrations/20250424200609_initial.sql` plus subsequent additive migrations).
|
||||
|
||||
Two tables matter for codeburn:
|
||||
|
||||
```sql
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost REAL NOT NULL DEFAULT 0.0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
...
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
parts TEXT NOT NULL DEFAULT '[]',
|
||||
model TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `crush:<sessionId>` (`crush.ts`).
|
||||
|
||||
## What we extract
|
||||
|
||||
| codeburn field | Crush source |
|
||||
|---|---|
|
||||
| `inputTokens` | `sessions.prompt_tokens` |
|
||||
| `outputTokens` | `sessions.completion_tokens` |
|
||||
| `costUSD` | `sessions.cost` (already in dollars) |
|
||||
| `model` | dominant value of `messages.model` for the session, picked by `GROUP BY model ORDER BY COUNT(*) DESC LIMIT 1`. Falls back to `unknown`. |
|
||||
| `timestamp` | `sessions.updated_at` if set, otherwise `created_at` |
|
||||
|
||||
Cache tokens, reasoning tokens, web-search counts, tools, and bash commands are all left as zero / empty. Crush does not record per-message token data, so per-turn attribution is not available.
|
||||
|
||||
## Quirks worth knowing
|
||||
|
||||
- **Timestamps are seconds, not milliseconds.** The Crush schema *comments* in the upstream migration claim millisecond timestamps, but every actual `INSERT`/`UPDATE` in `internal/db/sql/{sessions,messages}.sql` uses `strftime('%s', 'now')`, which returns Unix seconds. The parser multiplies by 1000 before constructing a `Date`. **Tokscale's parser (junhoyeo/tokscale#346) gets this wrong and is off by 1000x.** Confirmed against Crush v0.66.1.
|
||||
- **Cost is stored in dollars as a `REAL`.** No conversion needed.
|
||||
- **Child sessions are skipped.** Only rows with `parent_session_id IS NULL` are surfaced. Crush sub-agents inherit cost into the parent.
|
||||
- **Zero-spend rows are filtered.** Discovery skips sessions with `cost = 0 AND prompt_tokens = 0 AND completion_tokens = 0`.
|
||||
- **Optimize detectors that depend on tools (`detectJunkReads`, `detectDuplicateReads`, `detectLowReadEditRatio`) will not flag Crush sessions.** That is correct: Crush does not log per-tool calls in a way we can read today.
|
||||
- **`detectLowWorthSessions` may flag Crush sessions** because it looks for cost without edits. That is a known false positive; if it becomes noisy, we can branch the detector on provider.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm the issue against a real Crush install (`brew install charmbracelet/tap/crush`) before assuming the schema has changed. Migrations in the last six months have only added columns to `sessions`/`messages`, never removed any of the ones we read.
|
||||
2. If the bug is "Crush sessions show timestamps from 1970-something", check whether someone "fixed" the seconds-vs-milliseconds handling by removing the `* 1000`. The schema comment is wrong; the data is in seconds.
|
||||
3. If the bug is "Crush model column shows `unknown`", the session has no messages with a non-null `model`. Some early Crush builds did not record provider on every message; add `LIKE` matching against `provider` if you want a stronger fallback.
|
||||
4. If the bug is "no sessions discovered", the registry path probably has not been verified for the user's setup. Print `getRegistryPath()` and have them confirm the file exists at that location.
|
||||
5. New fixtures go under the inline schema in `tests/providers/crush.test.ts`; keep the `CREATE TABLE` literal and synchronized with the upstream migration.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Cursor Agent
|
||||
|
||||
Cursor's background agent transcripts (separate from the regular chat).
|
||||
|
||||
- **Source:** `src/providers/cursor-agent.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:62-87`)
|
||||
- **Test:** `tests/providers/cursor-agent.test.ts` (243 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.cursor/projects/<projectId>/agent-transcripts/`. Inside each project, two layouts coexist:
|
||||
|
||||
1. **Legacy:** `*.txt` flat files.
|
||||
2. **Composer 2:** UUID-named subdirectories, each containing JSONL.
|
||||
|
||||
Subagents (delegated runs) live in `subagents/` subdirectories under the parent (`cursor-agent.ts:479-490`). They are picked up too.
|
||||
|
||||
## Storage format
|
||||
|
||||
- Legacy: free-form text transcripts. The parser does line-based heuristic parsing (`cursor-agent.ts:219-314`).
|
||||
- Composer 2: JSONL (`cursor-agent.ts:167-217`).
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level. Conversation metadata is read from the same Cursor SQLite db (`state.vscdb`), specifically the `conversation_summaries` table (`cursor-agent.ts:46-50`). If the summary is missing, file mtime is used as the timestamp.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<provider>:<conversationId>:<turnIndex>` (`cursor-agent.ts:379`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- A file with a UUID-shaped name is treated as the conversation ID directly (`cursor-agent.ts:142-143`); other names are derived from the parent directory.
|
||||
- Token counts are estimated from char count (`CHARS_PER_TOKEN = 4`, `cursor-agent.ts:35`, `:81-84`). The legacy text format never reports real tokens.
|
||||
- The text parser is regex-driven and brittle. It is easier to fix a Composer 2 (JSONL) bug than a legacy (text) bug.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Check which format the failing transcript uses before opening a fix.
|
||||
2. For text-format bugs, copy the redacted transcript verbatim into `tests/fixtures/cursor-agent/` so the regex change can be regression-tested.
|
||||
3. If the bug is "wrong project", look at `cursor-agent.ts:46-50` and whether a `conversation_summaries` row exists for the conversation.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Cursor
|
||||
|
||||
Cursor IDE chat history.
|
||||
|
||||
- **Source:** `src/providers/cursor.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:44-57`). The `node:sqlite` import is the heavy dependency that justifies lazy loading.
|
||||
- **Test:** `tests/providers/cursor.test.ts` (77 lines), `tests/providers/cursor-bubble-dedup.test.ts` (176 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
A single SQLite database per platform:
|
||||
|
||||
| Platform | Path |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` |
|
||||
| Windows | `%APPDATA%/Cursor/User/globalStorage/state.vscdb` |
|
||||
| Linux | `~/.config/Cursor/User/globalStorage/state.vscdb` |
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite. Two parallel sources within the same db:
|
||||
|
||||
1. **Bubbles** (`cursor.ts:201-331`): per-message rows. The richer source.
|
||||
2. **agentKv** (`cursor.ts:350-460`): per-conversation key-value blobs. The fallback for older sessions.
|
||||
|
||||
The parser tries both and dedupes via `seenKeys`.
|
||||
|
||||
## Caching
|
||||
|
||||
`src/cursor-cache.ts` writes `~/.cache/codeburn/cursor-results.json` (override with `$CODEBURN_CACHE_DIR`). The fingerprint is `dbMtimeMs + dbSizeBytes` of `state.vscdb`. Atomic write via temp + rename.
|
||||
|
||||
## Deduplication
|
||||
|
||||
- Bubbles: per `bubbleId` (`cursor.ts:282`).
|
||||
- agentKv: per `requestId` (`cursor.ts:429`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **180-day lookback.** The bubbles query bounds itself to the trailing 180 days (`cursor.ts:205`). Older history is ignored. If a user reports "Cursor data missing", confirm the date range first.
|
||||
- **250 000 bubble cap.** Power users with massive history are capped to prevent unbounded memory. If you need to raise this, also raise the cache size budget.
|
||||
- **Per-conversation user-message queue.** The parser caches the user-message stream per conversation to avoid an O(n) shift on every turn (`cursor.ts:171-191`).
|
||||
- **agentKv has no per-message timestamp.** The DB file's mtime is used as the timestamp for every agentKv-derived call (`cursor.ts:358-363`). This is wrong but consistent.
|
||||
- **Cursor v3 reports zero token counts.** The parser falls back to char-counting (`CHARS_PER_TOKEN = 4`) for those rows (`cursor.ts:265-272`).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. **Always reproduce against a fixture, not a real db.** SQLite over the live db is racy; the user might be using Cursor while you read.
|
||||
2. If the bug is "tokens are zero", check whether the row is a v3 zero-token bubble, in which case the char-fallback should kick in.
|
||||
3. If the bug is "duplicate counts", check both `bubbleId` dedup and the cross-provider `seenKeys` dedup.
|
||||
4. Cache poisoning is the most common failure mode after a Cursor schema change. Bump `CURSOR_CACHE_VERSION` in `src/cursor-cache.ts` so old caches are invalidated.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Devin
|
||||
|
||||
Cognition Devin CLI local usage tracking.
|
||||
|
||||
- **Source:** `src/providers/devin.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/devin.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Devin CLI data lives under:
|
||||
|
||||
```text
|
||||
~/.local/share/devin/cli/
|
||||
```
|
||||
|
||||
The MVP usage source is transcript JSON:
|
||||
|
||||
```text
|
||||
~/.local/share/devin/cli/transcripts/*.json
|
||||
```
|
||||
|
||||
The provider also reads:
|
||||
|
||||
```text
|
||||
~/.local/share/devin/cli/sessions.db
|
||||
```
|
||||
|
||||
`sessions.db` is enrichment only. It supplies project path/name, model fallback,
|
||||
timestamp fallback, and hidden-session filtering. It is not the source of usage
|
||||
or billing.
|
||||
|
||||
## Configuration
|
||||
|
||||
Devin reports spend in ACUs. CodeBurn reports provider cost through `costUSD`,
|
||||
so Devin stays disabled until a positive finite ACU-to-USD rate is configured:
|
||||
|
||||
```json
|
||||
{
|
||||
"devin": {
|
||||
"acuUsdRate": 2.25
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The config file is:
|
||||
|
||||
```text
|
||||
~/.config/codeburn/config.json
|
||||
```
|
||||
|
||||
The macOS Settings window writes this value from the Devin tab. There is no
|
||||
environment-variable override and no default rate. Do not hardcode a universal
|
||||
ACU price; Devin ACU pricing is account/contract dependent.
|
||||
|
||||
When the rate is missing or invalid, `discoverSessions()` returns `[]` and the
|
||||
parser yields no calls. Devin remains registered as a provider, but it does not
|
||||
appear in CLI/UI results until configured.
|
||||
|
||||
## Storage format
|
||||
|
||||
Transcript root is a JSON object following the [ATIF-v1.7 trajectory schema][atif],
|
||||
with Devin-specific additions such as per-step `metadata` and `extra`. The
|
||||
parser does not validate `schema_version`; it only requires a parseable object
|
||||
with `steps[]`.
|
||||
|
||||
Core fields include `session_id`, `agent.model_name`, `agent.extra` (Devin
|
||||
backend/permission info), `final_metrics`, and `steps[]`.
|
||||
|
||||
Steps now support two metric sources. The parser checks `step.metrics` first
|
||||
(the standard ATIF location) and falls back to `step.metadata.metrics` (the
|
||||
legacy Devin location). Similarly, ACU cost is read from
|
||||
`step.metadata.committed_acu_cost` first, falling back to
|
||||
`step.extra.committed_acu_cost`.
|
||||
|
||||
Messages can be a plain string or an array of `ContentPart` objects (text or
|
||||
image), following the ATIF v1.6+ multimodal content model. The parser
|
||||
normalises both forms when extracting user messages.
|
||||
|
||||
Each counted step can provide:
|
||||
|
||||
- `step_id`
|
||||
- `metadata.committed_acu_cost` (or `extra.committed_acu_cost`)
|
||||
- `metrics.prompt_tokens` (or `metadata.metrics.input_tokens`)
|
||||
- `metrics.completion_tokens` (or `metadata.metrics.output_tokens`)
|
||||
- `metrics.extra.cache_creation_input_tokens` (or `metadata.metrics.cache_creation_tokens`)
|
||||
- `metrics.cached_tokens` (or `metadata.metrics.cache_read_tokens`)
|
||||
- `metadata.created_at`
|
||||
- `metadata.generation_model` (or `extra.generation_model`)
|
||||
- `metadata.request_id`
|
||||
- `tool_calls[].function_name`
|
||||
- `observation.results[]` (tool output; not parsed for usage)
|
||||
|
||||
User-input steps (`metadata.is_user_input === true`) are skipped. Non-user
|
||||
steps are included only if they have positive ACU usage or positive token usage.
|
||||
|
||||
## Pricing
|
||||
|
||||
ACU cost is per step, not cumulative. The provider reads
|
||||
`metadata.committed_acu_cost` first, falling back to
|
||||
`extra.committed_acu_cost`, then converts with:
|
||||
|
||||
```text
|
||||
costUSD = committed_acu_cost * devin.acuUsdRate
|
||||
```
|
||||
|
||||
Token-only steps are still included when they have positive token metrics, but
|
||||
their `costUSD` is `0` if `committed_acu_cost` is absent from both locations.
|
||||
|
||||
`src/parser.ts` preserves Devin's provider-supplied `costUSD` instead of
|
||||
re-pricing it through LiteLLM.
|
||||
|
||||
## sessions.db enrichment
|
||||
|
||||
The provider currently reads these columns from `sessions`:
|
||||
|
||||
| Column | Use |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | join key with transcript `session_id` during parsing; discovery uses the transcript filename before `.json` |
|
||||
| `working_directory` | `projectPath` and derived project name |
|
||||
| `model` | model fallback |
|
||||
| `title` | project name fallback |
|
||||
| `created_at` | timestamp fallback |
|
||||
| `last_activity_at` | preferred session timestamp fallback |
|
||||
| `hidden` | skip hidden sessions |
|
||||
|
||||
`message_nodes`, `prompt_history`, and `tool_call_state` are not parsed yet.
|
||||
|
||||
## Timestamps
|
||||
|
||||
Step timestamps come from `metadata.created_at`, falling back to
|
||||
`sessions.last_activity_at`, then `sessions.created_at`.
|
||||
|
||||
Transcript step timestamps are passed through as ATIF string timestamps.
|
||||
Numeric normalization is only applied to `sessions.db` timestamps:
|
||||
|
||||
- less than `10_000_000_000`: seconds
|
||||
- otherwise: milliseconds
|
||||
|
||||
## Model Resolution
|
||||
|
||||
Model names resolve in this order:
|
||||
|
||||
1. `step.metadata.generation_model`
|
||||
2. `step.model_name`
|
||||
3. `transcript.agent.model_name`
|
||||
4. `sessions.model`
|
||||
5. `devin`
|
||||
|
||||
## Caching
|
||||
|
||||
No provider-level cache.
|
||||
|
||||
The normal session cache stores parsed provider calls, but Devin is always
|
||||
reparsed by `src/parser.ts` because `sessions.db` can change without the
|
||||
transcript JSON fingerprint changing.
|
||||
|
||||
## Deduplication
|
||||
|
||||
`devin:<sessionId>:<step.step_id>`
|
||||
|
||||
The provider name is part of the key via the `devin:` prefix.
|
||||
|
||||
## Quirks
|
||||
|
||||
- The transcript directory has usage; `sessions.db` is enrichment only.
|
||||
- `committed_acu_cost` is per-generation/per-step ACU usage. Never treat it as cumulative. It can appear in `metadata` (legacy) or `extra` (ATIF v1.7); the provider checks both.
|
||||
- Token metrics can live in `step.metrics` (standard ATIF) or `step.metadata.metrics` (legacy Devin). The provider checks `step.metrics` first, falling back to `metadata`.
|
||||
- Step messages can be a plain string or an array of `ContentPart` objects (text/image). The parser normalises both when extracting user messages.
|
||||
- There is no default ACU-to-USD rate. Missing config intentionally hides Devin.
|
||||
- Hidden sessions from `sessions.db` are skipped in discovery and parsing.
|
||||
- Tool names come directly from `tool_calls[].function_name`; the provider assumes valid ATIF tool-call records.
|
||||
- If SQLite is unavailable or `sessions.db` cannot be opened, the provider still parses transcripts without enrichment.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. First check whether `~/.config/codeburn/config.json` contains a valid
|
||||
`devin.acuUsdRate`. Without it, no Devin sessions should appear.
|
||||
2. For usage total bugs, compare against (ACU cost can live in `metadata` or `extra`):
|
||||
|
||||
```bash
|
||||
jq '[.steps[] | select(.metadata.is_user_input != true) | (.metadata.committed_acu_cost // .extra.committed_acu_cost // 0)] | add' ~/.local/share/devin/cli/transcripts/<session>.json
|
||||
```
|
||||
|
||||
3. If project/model/timestamp metadata is wrong, inspect `sessions.db`, not the transcript.
|
||||
4. If a hidden session appears, check the `hidden` column. Discovery can only
|
||||
hide sessions whose transcript filename matches `sessions.id`; parsing uses
|
||||
the transcript `session_id` when present.
|
||||
5. Run `tests/providers/devin.test.ts` after parser changes. It covers ACU conversion, disabled-until-configured behavior, timestamp parsing, deduplication, hidden sessions, `sessions.db` enrichment, ATIF v1.7 multimodal messages, `step.metrics` vs `metadata.metrics` priority, and `extra.committed_acu_cost` fallback.
|
||||
|
||||
[atif]: https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md
|
||||
@@ -0,0 +1,36 @@
|
||||
# Droid
|
||||
|
||||
Factory's Droid CLI.
|
||||
|
||||
- **Source:** `src/providers/droid.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:4`)
|
||||
- **Test:** `tests/providers/droid.test.ts` (148 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$FACTORY_DIR` if set, otherwise `~/.factory/sessions/<subdir>/*.jsonl`.
|
||||
|
||||
The parser ignores the `.factory/` directory itself (`droid.ts:293-296`); some installs nest it accidentally.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `messageId` within a session (`droid.ts:253`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Token totals are session-level only.** Droid does not report per-message tokens. The parser reads `settings.tokenUsage` once per session and **splits it evenly** across all assistant calls, with the remainder added to the last call (`droid.ts:223-251`). This is approximate but consistent.
|
||||
- Project name is derived from the session's `cwd`. If the cwd contains `projects/<name>`, that name is preferred over the basename (`droid.ts:299-319`).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If the bug is "tokens unevenly attributed", that is by design. The session-level total is the only signal Droid emits.
|
||||
2. If the bug is "no sessions found", confirm the user does not have `$FACTORY_DIR` pointing somewhere unexpected.
|
||||
3. New fixtures go under `tests/fixtures/droid/`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Forge
|
||||
|
||||
Forge agent CLI.
|
||||
|
||||
- **Source:** `src/providers/forge.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:48`)
|
||||
- **Test:** `tests/providers/forge.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.forge/.forge.db` (`forge.ts:31`).
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite (`forge.ts:225-252`). CodeBurn reads the `conversations` table and parses JSON from `context.messages` (`forge.ts:154-171`).
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<provider>:<conversation_id>:<tool_call_id-or-message-index>` (`forge.ts:193`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- `workspace_id` is cast to text in SQL because Forge can store values larger than JavaScript's safe integer range (`forge.ts:35`, `forge.ts:155`, `forge.ts:238`).
|
||||
- Forge reports prompt tokens inclusive of cached tokens. CodeBurn subtracts cached tokens from prompt tokens before pricing (`forge.ts:185-188`).
|
||||
- One CodeBurn call is emitted per assistant message with usage; zero-token assistant messages are skipped (`forge.ts:183-190`).
|
||||
- Project names come from the conversation title, falling back to `workspace_id` (`forge.ts:244`).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If discovery returns no sessions, confirm the SQLite schema still has `conversations` with `conversation_id`, `workspace_id`, `context`, `created_at`, and `updated_at`.
|
||||
2. If Node SQLite throws on real data, check whether a numeric field needs `CAST(... AS TEXT)` like `workspace_id`.
|
||||
3. If costs look too high, verify cached tokens are still subtracted from prompt tokens before calling `calculateCost`.
|
||||
4. If duplicate rows appear, inspect whether `tool_calls[].call_id` is missing and the parser is falling back to message index.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Gemini
|
||||
|
||||
Google Gemini CLI.
|
||||
|
||||
- **Source:** `src/providers/gemini.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:5`)
|
||||
- **Test:** none. Adding a fixture-based test is a known good first issue.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.gemini/tmp/<project>/chats/session-*.json` and `session-*.jsonl` (`gemini.ts:218-252`).
|
||||
|
||||
## Storage format
|
||||
|
||||
Either a single JSON document per session or JSONL, depending on Gemini CLI version. The parser sniffs the first non-whitespace character to decide (`gemini.ts:197-206`).
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `sessionId` (`gemini.ts:72`). Gemini sessions are aggregated to a single call per session.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Cached tokens are a subset of input.** Gemini reports cached tokens included inside `promptTokenCount`. The parser subtracts them so callers see Anthropic semantics (cached are separate).
|
||||
- **Thoughts are billed at output rate** (`gemini.ts:125`).
|
||||
- Each session collapses to one `ParsedProviderCall`. If you need per-turn data, the upstream format does not support it without re-parsing the prompt history.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. The lack of a test file is a hazard. **Add a fixture and a test before changing parsing logic** so future regressions are caught.
|
||||
2. If the bug involves a new Gemini version's schema, sniff with the same first-character heuristic; do not call `JSON.parse` on the whole file.
|
||||
3. If the bug is "Gemini sessions report less than expected", check whether the cached-token subtraction is over-correcting.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Goose
|
||||
|
||||
Block's Goose CLI.
|
||||
|
||||
- **Source:** `src/providers/goose.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:29-42`)
|
||||
- **Test:** none. Adding a fixture-based test is a known good first issue.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
A SQLite database. Path resolution honors `XDG_DATA_HOME` and a `GOOSE_PATH_ROOT` override:
|
||||
|
||||
| Platform | Default path |
|
||||
|---|---|
|
||||
| macOS / Linux | `~/.local/share/goose/sessions/sessions.db` |
|
||||
| Windows | `%APPDATA%/Block/goose/sessions/sessions.db` |
|
||||
|
||||
See `goose.ts:52-62`.
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `sessionId` (`goose.ts:174`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- Source paths are encoded as `<dbPath>:<sessionId>` so a single db can yield many session sources. The discovery code splits on the last colon (`goose.ts:148-150`).
|
||||
- Tool inventory comes from the `messages` table queried with `LIKE '%toolRequest%'` (`goose.ts:90`). This will miss tools whose payloads are encoded differently in a future Goose version.
|
||||
- Tokens are read directly from `accumulated_input_tokens` and `accumulated_output_tokens`. No estimation.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Add a fixture-based test before changing logic. `tests/providers/goose.test.ts` does not exist yet; create it and use a small SQLite file under `tests/fixtures/goose/`.
|
||||
2. If the bug is "no sessions", check `XDG_DATA_HOME` and `GOOSE_PATH_ROOT` first; users on non-default Linux setups will not match the default path.
|
||||
3. The `LIKE '%toolRequest%'` query is fragile. If Goose changes the message envelope, this is where it will break.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Grok Build
|
||||
|
||||
Grok Build, xAI's coding CLI. Sessions use the `grok-build` model by default.
|
||||
|
||||
- **Source:** `src/providers/grok.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/grok.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$GROK_HOME/sessions/` (or `~/.grok/sessions/`), one directory per session:
|
||||
`sessions/<url-encoded-cwd>/<uuid>/`. The parser reads `summary.json`, `signals.json`, and `updates.jsonl` from each session directory.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSON + JSONL. `summary.json` holds the session id, cwd, timestamps, and `current_model_id`. `signals.json` holds `modelsUsed`, `toolsUsed`, and `contextTokensUsed`. `updates.jsonl` is the ACP log: each streamed chunk carries `params._meta.totalTokens` (running context size) and `params._meta.promptId` (one per turn).
|
||||
|
||||
## Token model
|
||||
|
||||
**Estimated.** Grok does not log billable input/output tokens. It only records the running context fill (`totalTokens` per chunk, and `contextTokensUsed` in signals). The parser reconstructs a rough estimate from the per-turn `totalTokens` curve: input is the context entering each turn, output is the context growth during it. The result is flagged `costIsEstimated` and re-priced with `calculateCost`.
|
||||
|
||||
## Pricing
|
||||
|
||||
`grok-build` is aliased to `grok-build-0.1` in `src/models.ts`, so it prices off the bundled LiteLLM fallback. Note that xAI's published API rate and the LiteLLM fallback figure differ, so treat the cost as an estimate and verify against your xAI usage console.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `grok:<session-dir>:<updated_at>:<id>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **No cache or output/tool-token split.** Only context fill is available, so cache fields are `0` and the cost is an estimate (likely an upper bound, since re-sent context is cached server-side and not exposed in the session files).
|
||||
- **No bash-command capture.** Tool names come from `signals.toolsUsed`; per-command bash text is not extracted, so `bashCommands` is empty.
|
||||
- **Whole-session timestamp.** Spend is attributed to `updated_at`, since the context curve is cumulative.
|
||||
- **Subscription vs API.** Grok Build runs via either a metered xAI API account (tiered) or a SuperGrok subscription; the session files do not record which.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Discovery: check the `sessions/<cwd>/<uuid>/` walk and the `GROK_HOME` resolution.
|
||||
2. Token estimate: see `estimateTokens` (groups `updates.jsonl` by `promptId`).
|
||||
3. Add a fixture-format session under `tests/providers/grok.test.ts`; do not mock the filesystem.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Hermes Agent
|
||||
|
||||
Hermes Agent CLI profiles.
|
||||
|
||||
- **Source:** `src/providers/hermes.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/hermes.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Source | Path |
|
||||
|---|---|
|
||||
| Default Hermes profile | `$HERMES_HOME/state.db` if set, otherwise `~/.hermes/state.db` |
|
||||
| Named Hermes profiles | `$HERMES_HOME/profiles/<profile>/state.db` |
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite. The provider reads Hermes' aggregate `sessions` token/cost counters and the matching `messages` rows for user prompt and tool-call context.
|
||||
|
||||
## Parser
|
||||
|
||||
Hermes stores durable token accounting at the session level, so CodeBurn emits one parsed call per Hermes session instead of one call per LLM API request. The call contains the aggregate session totals:
|
||||
|
||||
- input tokens
|
||||
- output tokens
|
||||
- cache-read tokens
|
||||
- cache-write tokens
|
||||
- reasoning tokens
|
||||
- actual or estimated cost when Hermes recorded one
|
||||
|
||||
If Hermes recorded no positive cost, CodeBurn falls back to its normal model pricing table.
|
||||
|
||||
## Project grouping
|
||||
|
||||
Discovery groups sessions by Hermes profile (`default`, `coder`, `analytics`, etc.). When a session message includes a clean `Current working directory: /path` line, parsing can attach that project path so CodeBurn can canonicalize worktrees. The parser deliberately ignores quoted or escaped prompt text that merely contains the phrase `Current working directory:`.
|
||||
|
||||
## Tool mapping
|
||||
|
||||
Hermes `tool_calls` are normalized to CodeBurn display names where possible:
|
||||
|
||||
- `terminal` -> `Bash`
|
||||
- `read_file` -> `Read`
|
||||
- `write_file` -> `Write`
|
||||
- `patch` -> `Edit`
|
||||
- `search_files` -> `Grep`
|
||||
- browser tools -> `Browser`
|
||||
- web tools -> `WebSearch` / `WebFetch`
|
||||
- skill tools -> `Skill`
|
||||
|
||||
Terminal command arguments are exposed as `bashCommands` for CodeBurn's command breakdowns.
|
||||
|
||||
## Caching
|
||||
|
||||
The shared session cache fingerprints Hermes state DB files. `HERMES_HOME` is included in the provider environment fingerprint so changing the runtime home invalidates stale cached results.
|
||||
|
||||
## Quirks
|
||||
|
||||
- The provider is aggregate-first because Hermes' stable accounting lives in `sessions`. Do not infer per-turn usage from message text.
|
||||
- Source paths are encoded as `<dbPath>#hermes-session=<sessionId>` so SQLite paths containing `:` remain safe.
|
||||
- SQLite schema checks are intentionally light: if the expected `sessions` or `messages` columns are absent, the DB is skipped.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce against a real Hermes `state.db` or a minimal SQLite fixture.
|
||||
2. Run `npm test -- tests/providers/hermes.test.ts --run`.
|
||||
3. For local smoke testing, use an isolated cache directory, for example:
|
||||
`CODEBURN_CACHE_DIR=/tmp/codeburn-hermes-cache node --import tsx -e "import { parseAllSessions } from './src/parser.ts'; console.log(await parseAllSessions(undefined, 'hermes'))"`.
|
||||
@@ -0,0 +1,55 @@
|
||||
# IBM Bob
|
||||
|
||||
IBM Bob IDE task history.
|
||||
|
||||
- **Source:** `src/providers/ibm-bob.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/ibm-bob.test.ts`
|
||||
|
||||
## Where It Reads From
|
||||
|
||||
IBM Bob stores IDE task history below `User/globalStorage/ibm.bob-code/tasks/` in the application data directory.
|
||||
|
||||
Default paths checked:
|
||||
|
||||
| Platform | Paths |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/IBM Bob/User/globalStorage/ibm.bob-code/`, `~/Library/Application Support/Bob-IDE/User/globalStorage/ibm.bob-code/` |
|
||||
| Windows | `%APPDATA%/IBM Bob/User/globalStorage/ibm.bob-code/`, `%APPDATA%/Bob-IDE/User/globalStorage/ibm.bob-code/` |
|
||||
| Linux | `$XDG_CONFIG_HOME/IBM Bob/User/globalStorage/ibm.bob-code/`, `$XDG_CONFIG_HOME/Bob-IDE/User/globalStorage/ibm.bob-code/` with `~/.config` fallback |
|
||||
|
||||
The `Bob-IDE` paths cover the preview-era app name that some installs used before the GA `IBM Bob` directory.
|
||||
|
||||
## Storage Format
|
||||
|
||||
Each task is a directory under `tasks/<task-id>/` and must contain `ui_messages.json`.
|
||||
|
||||
CodeBurn parses the same Cline-family UI event format used by Roo Code and KiloCode:
|
||||
|
||||
- `ui_messages.json` entries with `type: "say"` and `say: "api_req_started"` contain serialized token/cost metrics.
|
||||
- `ui_messages.json` user text entries seed the turn's first user message.
|
||||
- `api_conversation_history.json` is optional and is used to extract the selected model from `<model>...</model>` environment details when present.
|
||||
- `task_metadata.json` may exist upstream, but CodeBurn does not need it for usage math today.
|
||||
|
||||
If no model tag is present, the parser uses `ibm-bob-auto`, which is priced through the same conservative Sonnet fallback used for Cline-family auto modes.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<providerName>:<taskId>:<apiRequestIndex>` via `vscode-cline-parser.ts`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- IBM Bob has shipped under both `IBM Bob` and `Bob-IDE` application data folder names.
|
||||
- This provider intentionally covers the IDE task-history format. Bob Shell's `~/.bob` checkpoint data is a separate storage surface and is not parsed until we have a stable usage schema fixture.
|
||||
- The shared Cline parser does not currently extract individual tool names from UI messages, so tool breakdowns are empty for IBM Bob just like Roo Code and KiloCode.
|
||||
|
||||
## When Fixing A Bug Here
|
||||
|
||||
1. Check whether the install uses `IBM Bob` or `Bob-IDE` as the application data directory.
|
||||
2. Confirm the task folder still contains `ui_messages.json` and `api_conversation_history.json`.
|
||||
3. If the UI message schema changed, add a focused fixture to `tests/providers/ibm-bob.test.ts`.
|
||||
4. If the change also affects Roo Code or KiloCode, update `src/providers/vscode-cline-parser.ts` and run all three provider test files.
|
||||
@@ -0,0 +1,34 @@
|
||||
# KiloCode
|
||||
|
||||
KiloCode VS Code extension.
|
||||
|
||||
- **Source:** `src/providers/kilo-code.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:6`)
|
||||
- **Test:** `tests/providers/kilo-code.test.ts` (62 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
VS Code extension globalStorage for `kilocode.kilo-code` (extension ID set at `kilo-code.ts:4`). The actual walk is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`.
|
||||
|
||||
## Storage format
|
||||
|
||||
Per-task directories with `ui_messages.json` and `api_conversation_history.json`. See [`vscode-cline-parser`](vscode-cline-parser.md) for the full schema description.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level; delegates to the shared helper.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Delegated. Per `<providerName>:<taskId>:<index>` (handled in `vscode-cline-parser.ts:109`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- This file is a thin wrapper. Almost every bug for KiloCode actually lives in `vscode-cline-parser.ts`.
|
||||
- The VS Code extension wrappers using the Cline-family parser differ **only** by extension ID.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If the bug is "Cline, KiloCode, and Roo Code all broken in the same way", fix it in `vscode-cline-parser.ts`.
|
||||
2. If the bug is "KiloCode broken, Roo Code fine", the difference is upstream (KiloCode's emitted JSON differs slightly). Reproduce with a fixture and consider whether the cline parser needs to branch on extension ID.
|
||||
3. Read [`vscode-cline-parser.md`](vscode-cline-parser.md) before editing.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Kimi
|
||||
|
||||
Kimi Code CLI session parser.
|
||||
|
||||
- **Source:** `src/providers/kimi.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/kimi.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$KIMI_SHARE_DIR/sessions/` if set, otherwise `~/.kimi/sessions/`.
|
||||
|
||||
Kimi stores sessions by work-directory hash:
|
||||
|
||||
```text
|
||||
~/.kimi/
|
||||
kimi.json
|
||||
config.toml
|
||||
sessions/
|
||||
<workdir-md5>/
|
||||
<session-id>/
|
||||
context.jsonl
|
||||
wire.jsonl
|
||||
state.json
|
||||
subagents/
|
||||
<agent-id>/
|
||||
context.jsonl
|
||||
wire.jsonl
|
||||
```
|
||||
|
||||
`kimi.json` maps each work-directory hash back to the original working path. CodeBurn uses that to display the project basename; if the metadata file is missing, the hash directory name is used.
|
||||
|
||||
## Storage Format
|
||||
|
||||
CodeBurn reads `wire.jsonl`. Each data line is a persisted wire record:
|
||||
|
||||
```json
|
||||
{"timestamp":1776162403,"message":{"type":"StatusUpdate","payload":{"message_id":"msg-1","token_usage":{"input_other":100,"input_cache_read":25,"input_cache_creation":10,"output":40}}}}
|
||||
```
|
||||
|
||||
`TurnBegin` / `SteerInput` provide the user prompt, `ToolCall` / `ToolCallRequest` provide tool names and shell commands, and `StatusUpdate.token_usage` provides the billable token counts.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `kimi:<session-id>:<message_id>`, falling back to the status-update line index if the message id is absent.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Kimi's official `TokenUsage` separates `input_other`, `input_cache_read`, `input_cache_creation`, and `output`. CodeBurn maps those directly into input, cache read, cache write, and output.
|
||||
- The current Kimi wire schema does not persist the model on every usage update. CodeBurn uses `KIMI_MODEL_NAME` when set, then the active `~/.kimi/config.toml` default model, then `kimi-auto`.
|
||||
- `kimi-auto`, `kimi-code`, and `kimi-for-coding` are priced as `kimi-k2-thinking` so managed Kimi Code sessions do not show as `$0` when the exact backend model is hidden.
|
||||
- Subagent sessions are discovered from `subagents/<agent-id>/wire.jsonl` and parsed as separate Kimi sessions under the same project.
|
||||
|
||||
## When Fixing A Bug Here
|
||||
|
||||
1. Reproduce with a tiny `wire.jsonl` fixture in `tests/providers/kimi.test.ts`.
|
||||
2. If token totals look wrong, inspect `StatusUpdate.token_usage` first; `context.jsonl` only stores context checkpoints and cumulative counts, not per-step billing detail.
|
||||
3. If tools are missing, check whether Kimi emitted `ToolCall`, `ToolCallRequest`, or nested `SubagentEvent`; CodeBurn intentionally counts subagent wire files separately to avoid double-counting parent mirrors.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Kiro
|
||||
|
||||
Kiro IDE chat history.
|
||||
|
||||
- **Source:** `src/providers/kiro.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:7`)
|
||||
- **Test:** `tests/providers/kiro.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
VS Code-style globalStorage at `kiro.kiroagent`:
|
||||
|
||||
| Platform | Path |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent` |
|
||||
| Windows | `%APPDATA%/Kiro/User/globalStorage/kiro.kiroagent` |
|
||||
| Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` |
|
||||
|
||||
Sessions are under hash-named workspace subdirectories. Discovery keeps backward compatibility with legacy `.chat` files and also scans the post-February 2026 extensionless format:
|
||||
|
||||
- `<workspace-hash>/<execution-id>.chat` legacy session files
|
||||
- `<workspace-hash>/<session-hash>` extensionless session index files
|
||||
- `<workspace-hash>/<session-hash>/<execution-hash>` extensionless execution files inside session directories
|
||||
|
||||
## Storage format
|
||||
|
||||
Kiro has two known JSON formats:
|
||||
|
||||
- Legacy `.chat` files with `{ chat, metadata, executionId }`
|
||||
- Modern extensionless execution files with identifiers/timestamps at the top level plus conversation fields such as `messages`, `conversation`, `chat`, `transcript`, `entries`, `events`, or direct prompt/response fields
|
||||
|
||||
Session index files with `{ executions: [...] }` are discovered but skipped during parsing because they do not contain conversation content.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Modern files deduplicate per session/execution pair. Legacy `.chat` files deduplicate per workflow/execution pair.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Workspace hash resolution** is non-trivial. The parser tries `workspace.json` first; if that fails, it base64-decodes the directory name to recover the workspace path.
|
||||
- **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot. Add new versions here when Kiro ships them.
|
||||
- **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `<tool_use><name>...</name>` or expose structured `toolCalls` / `tool_calls` / `tools` entries.
|
||||
- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If the bug is "wrong workspace", check the base64 fallback path. Some users name their workspaces with characters that are not valid base64.
|
||||
2. If the bug is "missing model in pricing", add the model to the normalization map and verify against `tests/providers/kiro.test.ts`.
|
||||
3. If the bug is "tools missing", check both text-envelope extraction and structured tool-call extraction. Kiro changes its envelope occasionally.
|
||||
@@ -0,0 +1,88 @@
|
||||
# LingTai TUI
|
||||
|
||||
LingTai TUI per-agent token ledger integration.
|
||||
|
||||
- **Source:** `src/providers/lingtai-tui.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/lingtai-tui.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Source | Path |
|
||||
|---|---|
|
||||
| Explicit LingTai homes | `$LINGTAI_HOME` or `$LINGTAI_TUI_HOME` if set; path-list values are supported |
|
||||
| Default LingTai home | `~/.lingtai` |
|
||||
| Project LingTai homes | `<project>/.lingtai` for projects registered in `~/.lingtai-tui/registry.jsonl` and `~/.lingtai-tui/brief/projects/*/meta.json` |
|
||||
| Current worktree home | `.lingtai` in the current directory or any parent directory |
|
||||
| Agent ledgers | `<lingtai-home>/<agent>/logs/token_ledger.jsonl` |
|
||||
|
||||
Daemon ledgers nested under `<agent>/daemons/...` are deliberately not discovered during normal scanning. LingTai mirrors daemon usage into the parent agent ledger with `source`, `em_id`, and `run_id` tags, so reading nested ledgers too would double count spend.
|
||||
|
||||
## Storage format
|
||||
|
||||
Append-only JSONL. Each valid ledger line may include:
|
||||
|
||||
- `source`
|
||||
- `em_id`
|
||||
- `run_id`
|
||||
- `ts`
|
||||
- `input`
|
||||
- `output`
|
||||
- `thinking`
|
||||
- `cached`
|
||||
- `model`
|
||||
- `endpoint`
|
||||
|
||||
Malformed lines and zero-token entries are skipped. Missing `model` falls back to the agent `.agent.json` `llm.model`, then `unknown`.
|
||||
|
||||
## Parser
|
||||
|
||||
CodeBurn emits one parsed call per ledger entry. LingTai records provider-normalized total input plus a separate `cached` counter, so the provider maps:
|
||||
|
||||
- `input - cached` -> fresh input tokens
|
||||
- `cached` -> cache-read tokens
|
||||
- `output` -> output tokens
|
||||
- `thinking` -> reasoning tokens
|
||||
|
||||
Costs are calculated from CodeBurn's normal model pricing table.
|
||||
|
||||
## Activity mapping
|
||||
|
||||
LingTai's token ledger is an accounting source, not full chat history, so it does not include the original user prompt or per-tool transcript. CodeBurn maps the ledger `source` field conservatively:
|
||||
|
||||
| LingTai `source` | CodeBurn activity |
|
||||
|---|---|
|
||||
| `main` and unknown sources | Conversation |
|
||||
| `tc_wake` and other task-coordinator wake sources | Delegation |
|
||||
| `daemon` | Delegation |
|
||||
| `summarize_apriori` | Planning |
|
||||
|
||||
This keeps the menubar and dashboard **By Activity** view from collapsing all LingTai usage into Conversation while avoiding invented feature/debug/refactor semantics that the ledger cannot prove.
|
||||
|
||||
## Project grouping
|
||||
|
||||
Discovery reads `<agent>/.agent.json` and groups by `nickname`, `agent_name`, `address`, then the directory name. Project-local homes are prefixed with the project directory name, for example `sample-project-Project Agent`, so same-named agents from different LingTai projects do not collapse together. The parsed call also carries the agent directory as `projectPath`.
|
||||
|
||||
## Caching
|
||||
|
||||
The shared session cache fingerprints each `token_ledger.jsonl`. `LINGTAI_HOME`, `LINGTAI_TUI_HOME`, and `LINGTAI_TUI_GLOBAL_DIR` are part of the provider environment fingerprint so changing homes invalidates stale cached results.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Dedup keys include provider name, ledger path, line number, timestamp, model, endpoint, LingTai source tags, and token counts:
|
||||
|
||||
`lingtai-tui:<ledger-path>:<line>:<timestamp>:<model>:...`
|
||||
|
||||
The ledger is append-only, so line number is stable for normal operation.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Tool calls are not reconstructed from chat history. The token ledger is the stable accounting source and does not include tool metadata.
|
||||
- Older ledger entries may not include `source`; those are labeled `main`.
|
||||
- `cached` is treated as cache read. LingTai does not expose a separate cache creation counter in the ledger.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Prefer a minimal redacted `token_ledger.jsonl` fixture over full `chat_history.jsonl`.
|
||||
2. Check whether a daemon entry is already mirrored into the parent ledger before adding new discovery paths.
|
||||
3. Run `npm test -- tests/providers/lingtai-tui.test.ts --run`.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Mistral Vibe
|
||||
|
||||
Mistral Vibe CLI.
|
||||
|
||||
- **Source:** `src/providers/mistral-vibe.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/mistral-vibe.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$VIBE_HOME/logs/session/` when `VIBE_HOME` is set, otherwise `~/.vibe/logs/session/`.
|
||||
|
||||
## Storage format
|
||||
|
||||
Vibe 2.x stores each session as a directory:
|
||||
|
||||
- `meta.json` contains session metadata, cumulative token totals, active model config, model prices, timestamps, working directory, and available tools.
|
||||
- `messages.jsonl` contains non-system messages and assistant `tool_calls`.
|
||||
|
||||
Subagent traces are stored under a parent session's `agents/` folder with the same `meta.json` / `messages.jsonl` shape, so CodeBurn scans those one level down as separate sessions.
|
||||
|
||||
## Caching
|
||||
|
||||
Current Vibe local logs do not expose cache-read/cache-write token fields, so
|
||||
CodeBurn reports cache token counts as `0`. When `meta.json.stats.session_cost`
|
||||
is present, CodeBurn uses that session total instead of re-estimating from
|
||||
prompt/completion token prices because it is the best cache-aware cost signal
|
||||
available in the local log shape.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `mistral-vibe:<session_id>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Usage is cumulative per session.** Vibe does not write per-assistant-message token usage into `messages.jsonl`; token counts come from `meta.json.stats.session_prompt_tokens` and `session_completion_tokens`. CodeBurn splits assistant-message tools into their user turns for classification and distributes the cumulative token/cost totals across those assistant calls so session totals remain unchanged.
|
||||
- **Cost prefers Vibe's own session total.** `meta.json.stats.session_cost` is used first. If it is missing, `meta.json.stats.input_price_per_million` and `output_price_per_million` are used with the active model config as a fallback. LiteLLM pricing is only used when Vibe provides no price data.
|
||||
- **Project names come from metadata.** Discovery uses `meta.json.environment.working_directory` and falls back to the session directory name if that field is missing.
|
||||
- **Tool calls come from messages.** Assistant `tool_calls[*].function.name` is normalized to the standard CodeBurn names (`bash` to `Bash`, `search_replace` to `Edit`, etc.). Bash commands are extracted from `function.arguments.command`.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce with a fixture that has both `meta.json` and `messages.jsonl`; both files are required for current Vibe sessions.
|
||||
2. If the bug is "wrong total", check `meta.json.stats` first. `messages.jsonl` is only for prompts and tool calls.
|
||||
3. If a future Vibe release adds per-turn usage, add tests before changing the one-record-per-session behavior so historical sessions continue to parse correctly.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Mux
|
||||
|
||||
[coder/mux](https://github.com/coder/mux) — Coder's desktop/CLI app for parallel agentic development. Mux makes its own LLM API calls (via the Vercel AI SDK) and records per-turn token usage natively, so codeburn reads it directly.
|
||||
|
||||
- **Source:** `src/providers/mux.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:13`)
|
||||
- **Test:** `tests/providers/mux.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Order | Path |
|
||||
|---|---|
|
||||
| 1 | `$CODEBURN_MUX_DIR` (codeburn-only override) |
|
||||
| 2 | `$MUX_ROOT` (mux's own override) |
|
||||
| 3 | `~/.mux` (default) |
|
||||
|
||||
Session files: `<root>/sessions/<workspaceId>/chat.jsonl`, plus each spawned sub-agent's own transcript at `<root>/sessions/<workspaceId>/subagent-transcripts/<childTaskId>/chat.jsonl` (see Quirks). Dev builds of mux use `~/.mux-dev` and an older install may use `~/.cmux` (migrated to `~/.mux`); point `CODEBURN_MUX_DIR`/`MUX_ROOT` at those if needed.
|
||||
|
||||
The human-readable project name is resolved from `<root>/config.json` (shape: `{ projects: Array<[projectPath, { workspaces: [{ id }] }] > }`); the directory under `sessions/` is the `workspaceId`, matched against `workspace.id`. Falls back to the raw `workspaceId` when there's no mapping.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL, one `MuxMessage` per line. Token usage rides on each assistant message's `metadata.usage` (`{ inputTokens, outputTokens, reasoningTokens, cachedInputTokens }`, typed `z.any()` upstream so it's read defensively). Tool calls and the user prompt are `parts[]` entries (`dynamic-tool` / `text`).
|
||||
|
||||
## Pricing
|
||||
|
||||
Cost is recomputed locally via `calculateCost` (mux and codeburn both price from the LiteLLM snapshot). On-disk model strings are `provider:modelId` (e.g. `anthropic:claude-opus-4-8`); the `provider:` prefix is stripped **at parse time** so the stored model is the bare LiteLLM id. This matters because codeburn's canonicalizer (`getCanonicalName`) only strips slash-style prefixes, and `parser.ts` re-prices from the stored model after the cache round-trip — a colon-prefixed model would price at $0 and render raw in the breakdown.
|
||||
|
||||
Token decomposition matches mux's own `displayUsage.ts` (AI SDK v6 semantics):
|
||||
|
||||
- `inputTokens` is **inclusive** of cache-read and cache-creation → `input = inputTokens − cachedInputTokens − cacheCreation`.
|
||||
- `outputTokens` is **inclusive** of reasoning → `output = outputTokens − reasoning`; reasoning bills at the output rate, so it's folded back into the output arg of `calculateCost`.
|
||||
- Cache-creation tokens are provider-specific: read from `metadata.providerMetadata.anthropic.cacheCreationInputTokens`.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `mux:<workspaceId>:<message.id>`. `message.id` is required by mux's schema; for corrupt id-less lines it falls back to the (unique) line index.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **No double-counting with `claude`.** Mux calls model APIs itself and stores usage under `~/.mux`; it does not shell out to Claude Code or read `~/.claude`.
|
||||
- **Sub-agents.** A spawned sub-agent is its own LLM-client session, but mux writes it to `sessions/<workspaceId>/subagent-transcripts/<childTaskId>/chat.jsonl` — *nested under the parent workspace*, not as a top-level `sessions/<id>` dir. `discoverSessions` walks these explicitly and attributes them to the parent's project; missing them undercounts real sessions substantially (sub-agent calls are routinely the majority of a session's turns). No double-count: the dedup key is derived from the `<childTaskId>` directory name, which is disjoint from every workspace id.
|
||||
- **Reasoning vs. output decomposition.** `output = outputTokens − reasoningTokens` assumes `outputTokens` is reasoning-inclusive, which holds for every record carrying `outputTokenDetails` (text + reasoning). A small fraction of Google `gemini-3-*-preview` records report `reasoningTokens > outputTokens`; for those the text component clamps to 0 (reasoning is still billed at the output rate). This matches mux's own `displayUsage.ts` and the token/cost effect is negligible.
|
||||
- **Read only `chat.jsonl`.** `session-usage.json` (pre-aggregated per model) and `analytics/analytics.db` (DuckDB, derived from `chat.jsonl`) describe the *same* usage; summing them would double-count.
|
||||
- **No web-search field.** Mux records no web-search/server-tool request count, so `webSearchRequests` is always `0`.
|
||||
- **Remote runtimes.** Mux can run workspaces on SSH/Docker, but the desktop app is the LLM client and writes `sessions/*/chat.jsonl` on the machine running mux — so usage is captured locally regardless of where commands execute.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm whether the bug is in **discovery** (sessions not picked up — `discoverSessions`/`loadProjectMap`) or **parsing** (`createParser`).
|
||||
2. `metadata.usage` is untyped upstream and its exact field set varies by provider family (anthropic/openai/google/xai/deepseek). Validate against a real `chat.jsonl` sample and cross-check the parsed per-model totals against the sibling `session-usage.json` `byModel` — they should match.
|
||||
3. Add a fixture-driven case to `tests/providers/mux.test.ts`. Do not mock the filesystem; write a temp `~/.mux` layout like the existing tests.
|
||||
@@ -0,0 +1,34 @@
|
||||
# OMP
|
||||
|
||||
OMP CLI. Same parser as Pi, different data directory.
|
||||
|
||||
- **Source:** `src/providers/pi.ts` (the `omp` export)
|
||||
- **Loading:** eager (`src/providers/index.ts:9`)
|
||||
- **Test:** `tests/providers/omp.test.ts` (225 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.omp/agent/sessions/` (`pi.ts:59-61`).
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL, identical schema to Pi.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Identical to Pi: `<provider>:<path>:<responseId>` with timestamp / line-index fallbacks (`pi.ts:164`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- OMP and Pi share the **same** `createParser` function. The provider object differs only in name, displayName, and the discovery directory.
|
||||
- If OMP and Pi diverge in a future release, do **not** copy-paste the parser. Add a discriminator to `createParser` and branch.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Check if the bug also reproduces against Pi. If yes, fix both with one change; the parser is shared.
|
||||
2. If the bug is OMP-specific, the right fix is usually to pass an option into `createParser` rather than to fork the file.
|
||||
3. Read [`pi.md`](pi.md) for the parser-level details.
|
||||
@@ -0,0 +1,41 @@
|
||||
# OpenClaw
|
||||
|
||||
OpenClaw, plus the older Clawdbot / Moltbot / Moldbot lineage.
|
||||
|
||||
- **Source:** `src/providers/openclaw.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:8`)
|
||||
- **Test:** `tests/providers/openclaw.test.ts` (192 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Four directories, all checked on every run (`openclaw.ts:62-70`):
|
||||
|
||||
- `~/.openclaw/agents`
|
||||
- `~/.clawdbot/agents`
|
||||
- `~/.moltbot/agents`
|
||||
- `~/.moldbot/agents`
|
||||
|
||||
The legacy directories are kept for users who upgraded from older builds.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL (`openclaw.ts:242`). Each agents directory has a `sessions.json` index file plus per-session `.jsonl` files. The parser reads the index when present and falls back to a directory scan if it is missing or stale (`openclaw.ts:220-247`).
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<sessionId>:<dedupId>` (`openclaw.ts:169`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Cost is preferred from the provider when reported.** OpenClaw emits `costUSD` in `message.usage`; the parser uses it directly when present (`openclaw.ts:174-177`) and only computes from tokens when it is missing.
|
||||
- Tokens are reported across `input`, `output`, `cacheRead`, and `cacheWrite`. Anthropic semantics throughout, no normalization needed.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If the bug is "session not found", check the four legacy dirs. A user might have a stray `~/.moltbot/` that the parser is reading instead of the real `~/.openclaw/`.
|
||||
2. If the bug is "wrong cost", confirm whether `costUSD` is present in the source data; the parser trusts it over its own calculation.
|
||||
3. The `sessions.json` index can drift when the user crashes mid-session. Make sure the directory-scan fallback triggers in those cases.
|
||||
@@ -0,0 +1,47 @@
|
||||
# OpenCode
|
||||
|
||||
OpenCode (sst/opencode).
|
||||
|
||||
- **Source:** `src/providers/opencode.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:59-75`)
|
||||
- **Test:** `tests/providers/opencode.test.ts` (676 lines, the largest provider test)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Default `~/.local/share/opencode/` or `$XDG_DATA_HOME/opencode/`. The discovery walk picks up `opencode*.db` files (`opencode.ts:71-88`).
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<sessionId>:<messageId>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects. This is the right behavior; do not silently swallow these.
|
||||
- Source paths are encoded as `<dbPath>:<sessionId>`.
|
||||
- Discovery only emits root sessions (`parent_id IS NULL`) to avoid double
|
||||
counting. Parsing a root session walks the unarchived `session.parent_id`
|
||||
subtree, so child and grandchild agent sessions contribute their message,
|
||||
token, and tool usage back to the root session.
|
||||
- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness.
|
||||
- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics.
|
||||
- Assistant messages with missing router usage are kept as zero-cost calls
|
||||
when their parts contain non-empty text or tool activity. Empty zero-usage
|
||||
assistant placeholders are still skipped.
|
||||
- External MCP tools are stored as `<server>_<tool>` names (for example
|
||||
`clickup_clickup_get_task`). The provider normalizes those to CodeBurn's
|
||||
canonical `mcp__<server>__<tool>` names before aggregation so shared MCP
|
||||
panels and `optimize` findings count OpenCode usage.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. The 558-line test suite catches a lot. Run `npm test -- tests/providers/opencode.test.ts` before and after any change.
|
||||
2. If the bug is "missing table" warning, do not catch and silence it. Either upgrade the version expectation in the parser or document the breaking schema change.
|
||||
3. If the bug is "reasoning tokens off by one", check the parts index ordering.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Pi
|
||||
|
||||
Pi agent CLI.
|
||||
|
||||
- **Source:** `src/providers/pi.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:9`)
|
||||
- **Test:** `tests/providers/pi.test.ts` (336 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.pi/agent/sessions/` (`pi.ts:55-57`).
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL (`pi.ts:98`).
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<provider>:<path>:<responseId>` when a response ID is present, falling back to the entry timestamp, and finally to a line index (`pi.ts:164`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- Undefined token fields in `message.usage` are coerced to `0` (`pi.ts:156-159`); never `undefined`.
|
||||
- The provider name is taken from `source.provider` (`pi.ts:182`), not hard-coded. This matters because `pi.ts` is the parser for **both** Pi and OMP; see [`omp.md`](omp.md).
|
||||
- Tool-call content type is extracted from the message envelope (`pi.ts:169-176`).
|
||||
- Pi/OMP have no dedicated skill tool: a native skill load is a `read` whose path points at a skill's `SKILL.md` (or a `skill://<name>` URI in newer OMP builds). The parser surfaces these as the `Skill` tool and records the name in `skills` (mirroring the Claude parser) instead of counting a `Read`, so the shared classifier tags the turn `general` and the Skills & Agents breakdown picks it up (`skillLoadName`, issue #588).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If you change parsing logic, also run `tests/providers/omp.test.ts` because OMP shares this code.
|
||||
2. If the bug is "tokens are NaN", look at the coercion at `pi.ts:156-159`. A regression on this is silent and easy to miss.
|
||||
3. If the bug is specific to the dedup behavior, decide which of the three fallback keys was used by adding a temporary log; the keys collide differently for old vs. new Pi versions.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Qwen
|
||||
|
||||
Qwen Code CLI.
|
||||
|
||||
- **Source:** `src/providers/qwen.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:10`)
|
||||
- **Test:** none. Adding a fixture-based test is a known good first issue.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$QWEN_DATA_DIR` if set, otherwise `~/.qwen/projects/<project>/chats/*.jsonl` (`qwen.ts:52-54`).
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<sessionId>:<uuid>` (`qwen.ts:110`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Project name comes from the last path component** (`qwen.ts:56-59`), not from any in-file field. If a user puts the same project under two different paths, they will appear as two projects.
|
||||
- **Thought parts are filtered out** before token accounting (`qwen.ts:97`). Qwen reports `thoughtsTokenCount` separately from `candidatesTokenCount`; this parser counts both as output but does not double-count thoughts in the main message.
|
||||
- **Tool calls** are extracted from a fixed envelope shape (`qwen.ts:61-76`). If Qwen restructures its tool-call format in a future release, this is where it will break first.
|
||||
- Tokens come from `usageMetadata`: `promptTokenCount`, `candidatesTokenCount`, `thoughtsTokenCount`, `cachedContentTokenCount`.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Add a fixture and a test before changing logic. The lack of `tests/providers/qwen.test.ts` makes regressions invisible.
|
||||
2. If the bug is "tools missing", look at the function-call extraction loop at `qwen.ts:61-76`.
|
||||
3. If the bug is "duplicate counts", confirm `<sessionId>:<uuid>` actually uniquely identifies a turn in your reproducer; some Qwen builds repeat UUIDs across resumed sessions.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Roo Code
|
||||
|
||||
Roo Code VS Code extension.
|
||||
|
||||
- **Source:** `src/providers/roo-code.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:11`)
|
||||
- **Test:** `tests/providers/roo-code.test.ts` (247 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
VS Code extension globalStorage for `rooveterinaryinc.roo-cline` (extension ID set at `roo-code.ts:4`). The actual walk is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`.
|
||||
|
||||
## Storage format
|
||||
|
||||
Per-task directories with `ui_messages.json` and `api_conversation_history.json`. See [`vscode-cline-parser`](vscode-cline-parser.md) for the schema.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level; delegates to the shared helper.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Delegated. Per `<providerName>:<taskId>:<index>` (in `vscode-cline-parser.ts:109`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- Thin wrapper. Almost every Roo Code bug actually lives in `vscode-cline-parser.ts`.
|
||||
- The VS Code extension wrappers using the Cline-family parser differ **only** by extension ID.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If the bug also reproduces against Cline or KiloCode, fix it in `vscode-cline-parser.ts`.
|
||||
2. If the bug is Roo Code-specific, the difference is upstream JSON shape. Reproduce with a fixture and consider whether the cline parser needs to branch on extension ID.
|
||||
3. Read [`vscode-cline-parser.md`](vscode-cline-parser.md) before editing.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Vercel AI Gateway
|
||||
|
||||
Cloud usage for [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) via the reporting API.
|
||||
|
||||
- **Source:** `src/providers/vercel-gateway.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/vercel-gateway.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Not local disk. CodeBurn calls:
|
||||
|
||||
```
|
||||
GET https://ai-gateway.vercel.sh/v1/report?start_date=...&end_date=...&date_part=day&group_by=model
|
||||
```
|
||||
|
||||
See [Custom Reporting](https://vercel.com/docs/ai-gateway/capabilities/custom-reporting).
|
||||
|
||||
## Authentication
|
||||
|
||||
Set one of:
|
||||
|
||||
- `AI_GATEWAY_API_KEY`
|
||||
- `VERCEL_OIDC_TOKEN` (from `vercel env pull` when using `vercel dev`)
|
||||
|
||||
## Caching
|
||||
|
||||
None. Each parse issues one API request for the requested date range.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `vercel-gateway:<day>:<model>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Requires Pro/Enterprise Custom Reporting on your Vercel account.
|
||||
- Data can lag by a few minutes after requests complete.
|
||||
- Rows are daily aggregates per model, not per chat session.
|
||||
- `total_cost` is used as `costUSD`; token fields map directly when present.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm env vars are set in the same shell running `codeburn`.
|
||||
2. Reproduce with `codeburn report --provider vercel-gateway -p week --format json`.
|
||||
3. Compare totals to the Vercel dashboard AI Gateway usage view.
|
||||
@@ -0,0 +1,50 @@
|
||||
# vscode-cline-parser (Shared Helper)
|
||||
|
||||
Shared discovery and parsing for Cline and VS Code extensions descended from Cline.
|
||||
|
||||
- **Source:** `src/providers/vscode-cline-parser.ts`
|
||||
- **Loading:** not a provider; imported by `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`.
|
||||
- **Test:** none directly. Coverage comes from `tests/providers/cline.test.ts`, `tests/providers/ibm-bob.test.ts`, `tests/providers/kilo-code.test.ts`, and `tests/providers/roo-code.test.ts`.
|
||||
|
||||
## What it does
|
||||
|
||||
Two responsibilities:
|
||||
|
||||
1. `discoverClineTasks(extensionId)` walks a base directory's `tasks/` child and returns one source per task that has a `ui_messages.json` file (`vscode-cline-parser.ts:25-50`). Without an override directory it uses VS Code's `globalStorage/<extensionId>/` path.
|
||||
2. `discoverClineTasksInBaseDirs(baseDirs)` does the same for non-VS Code apps with compatible task storage, such as IBM Bob.
|
||||
3. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model, tools, and token counts, and yields `ParsedProviderCall` objects.
|
||||
|
||||
## Storage layout
|
||||
|
||||
Per task directory:
|
||||
|
||||
```
|
||||
<baseDir>/tasks/<taskId>/
|
||||
ui_messages.json # event stream
|
||||
api_conversation_history.json # full prompt history with model tags
|
||||
```
|
||||
|
||||
## Model resolution
|
||||
|
||||
The model is extracted from `api_conversation_history.json` by searching user message content blocks for a `<model>...</model>` tag. Falls back to the provider-supplied auto model (`cline-auto` by default) if no tag is found.
|
||||
|
||||
## Token extraction
|
||||
|
||||
From `api_req_started` entries inside `ui_messages.json`. Each such entry's `text` field is JSON-parsed; the parsed object holds `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, and (optionally) `cost`.
|
||||
|
||||
If `cost` is present, it is used directly. If not, `calculateCost` from `src/models.ts` computes it from tokens.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<providerName>:<taskId>:<index>` where `index` is the position of the `api_req_started` entry within `ui_messages.json`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Only the **first** user message is emitted as `userMessage` in the `ParsedProviderCall`. Subsequent user turns are accounted but not surfaced.
|
||||
- The model regex looks inside content blocks, not at top-level fields. Some Cline-derivative extensions emit the model elsewhere; if you add support for one, branch on extension ID rather than rewriting the regex.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. A change here ripples to Cline, IBM Bob, KiloCode, and Roo Code. Run all four provider test files before opening a PR.
|
||||
2. If you find that one of the extensions emits a different shape, branch on the extension ID parameter that the discovery function already takes; do not duplicate the parser.
|
||||
3. If you add support for another Cline-family task store, register it as a thin wrapper file in the same shape as `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Warp
|
||||
|
||||
Warp Oz agent sessions from Warp's local SQLite database.
|
||||
|
||||
- **Source:** `src/providers/warp.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/warp.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
A SQLite database in Warp's group container.
|
||||
|
||||
| Channel | Default path |
|
||||
|---|---|
|
||||
| Stable | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Stable/warp.sqlite` |
|
||||
| Preview | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Preview/warp.sqlite` |
|
||||
|
||||
Override with `WARP_DB_PATH` when needed.
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite. The parser requires these tables:
|
||||
|
||||
- `agent_conversations`
|
||||
- `ai_queries`
|
||||
- `blocks`
|
||||
|
||||
## Caching
|
||||
|
||||
No provider-specific cache. Standard CodeBurn session cache applies.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per exchange: `warp:<conversationId>:<exchangeId>`.
|
||||
|
||||
## What we extract
|
||||
|
||||
| codeburn field | Warp source |
|
||||
|---|---|
|
||||
| `sessionId` | `agent_conversations.conversation_id` |
|
||||
| `timestamp` | `ai_queries.start_ts` |
|
||||
| `userMessage` | `ai_queries.input[0].Query.text` (when present) |
|
||||
| `project` / `projectPath` | `ai_queries.working_directory` |
|
||||
| `model` | `ai_queries.model_id` with fallback to dominant `conversation_data.token_usage[*].model_id` |
|
||||
| `tools` / `bashCommands` | `blocks.stylized_command` grouped onto nearest preceding exchange |
|
||||
| `inputTokens` | Estimated from prompt-size weighting, normalized to conversation token totals |
|
||||
|
||||
`outputTokens` are set to `0` because Warp does not expose a reliable per-exchange input/output split in these tables.
|
||||
|
||||
## Quirks
|
||||
|
||||
- `ai_queries.output_status` may be stored as a JSON-quoted string (for example `"Completed"`). The parser normalizes this before filtering.
|
||||
- Warp auto model IDs (`auto-efficient`, `auto-powerful`) are resolved using dominant conversation model information when available.
|
||||
- Token and cost attribution is estimated. Calls are emitted with `costIsEstimated: true`.
|
||||
- Command blocks are attached to the closest preceding exchange in the same conversation based on `start_ts`.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce with a fixture SQLite in `tests/providers/warp.test.ts` before changing parser logic.
|
||||
2. Verify schema compatibility first (`agent_conversations`, `ai_queries`, `blocks`) before debugging parse behavior.
|
||||
3. If model/cost looks wrong, inspect both `ai_queries.model_id` and `conversation_data.conversation_usage_metadata.token_usage`.
|
||||
4. If command attribution is wrong, compare `blocks.start_ts` ordering against `ai_queries.start_ts`; attribution is timestamp-based.
|
||||
@@ -0,0 +1,89 @@
|
||||
# ZCode
|
||||
|
||||
ZCode CLI coding agent (z.ai), running GLM-5.2 over the z.ai start-plan.
|
||||
|
||||
- **Source:** `src/providers/zcode.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts`). Lazy because we read ZCode's SQLite database with `node:sqlite`.
|
||||
- **Test:** `tests/providers/zcode.test.ts` (3 tests, fixture-based)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
ZCode keeps a single global SQLite database for the CLI.
|
||||
|
||||
| Source | Path |
|
||||
|---|---|
|
||||
| ZCode CLI db | `~/.zcode/cli/db/db.sqlite` |
|
||||
|
||||
The desktop app dir (`~/Library/Application Support/ZCode`) only holds Electron runtime state, and the JSONL activity log (`~/.zcode/cli/log/*.jsonl`) redacts token counts, so neither is used.
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite. Schema verified against CLI db v0.14.8. Three tables matter:
|
||||
|
||||
```sql
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
directory TEXT NOT NULL,
|
||||
...
|
||||
);
|
||||
|
||||
CREATE TABLE model_usage (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
turn_id TEXT,
|
||||
model_id TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
started_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
...
|
||||
);
|
||||
|
||||
CREATE TABLE tool_usage (
|
||||
session_id TEXT NOT NULL,
|
||||
turn_id TEXT,
|
||||
tool_name TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `zcode:<model_usage.id>` (`zcode.ts`). `model_usage.id` is the row primary key, unique per request.
|
||||
|
||||
## What we extract
|
||||
|
||||
| codeburn field | ZCode source |
|
||||
|---|---|
|
||||
| `inputTokens` | `model_usage.input_tokens` minus cached + created (see quirks) |
|
||||
| `outputTokens` | `model_usage.output_tokens` |
|
||||
| `reasoningTokens` | `model_usage.reasoning_tokens` |
|
||||
| `cacheCreationInputTokens` | `model_usage.cache_creation_input_tokens` |
|
||||
| `cacheReadInputTokens` | `model_usage.cache_read_input_tokens` |
|
||||
| `costUSD` | computed by `calculateCost` (ZCode stores no cost) |
|
||||
| `model` | `model_usage.model_id` (e.g. `GLM-5.2`) |
|
||||
| `timestamp` | `model_usage.completed_at` if set, otherwise `started_at` (epoch ms) |
|
||||
| `tools` | `tool_usage.tool_name` for the turn, attached to one request per turn |
|
||||
|
||||
## Quirks worth knowing
|
||||
|
||||
- **Cached tokens are folded into `input_tokens` (OpenAI-style).** The row's `input_tokens` is the full prompt size including cache reads/writes, and `provider_total_tokens = input_tokens + output_tokens`. The parser subtracts `cache_read_input_tokens` and `cache_creation_input_tokens` from `input_tokens` so fresh input bills at the input rate and cached at the cache-read rate. Confirmed against the nested Anthropic usage in `provider_metadata_json` (e.g. 100 input = 36 fresh + 64 cached).
|
||||
- **No cost is stored anywhere.** GLM-5.2 runs on z.ai's `start-plan` subscription, so ZCode logs tokens only. CodeBurn computes a notional cost from the pricing table.
|
||||
- **GLM-5.2 is priced via an alias.** LiteLLM does not list GLM-5.2 yet, so `GLM-5.2` maps to `glm-5p1` (GLM-5.1) in `BUILTIN_ALIASES` (`src/models.ts`). Reports therefore show the model as `glm-5p1`, the same way any aliased model displays as its priced-as target. Drop the alias once LiteLLM adds GLM-5.2.
|
||||
- **Timestamps are milliseconds.** Unlike Crush (seconds), ZCode stores epoch ms; the parser passes them straight to `Date`.
|
||||
- **Tools are attached per turn, not per request.** `tool_usage` links to a turn, not a specific `model_usage` row, so each turn's tools are attached to its first request to avoid double-counting. Bash command text is not stored, so `bashCommands` is always empty.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm the schema against a real ZCode install; copy `~/.zcode/cli/db/db.sqlite` to a temp file before querying so you do not lock the live db.
|
||||
2. If costs are $0, check that `GLM-5.2` (or the current model id) still resolves through `BUILTIN_ALIASES` to a priced model.
|
||||
3. If tokens look ~8x too high, someone likely removed the cache-subtraction in the input normalization; the row's `input_tokens` already includes cached tokens.
|
||||
4. New fixtures go under the inline schema in `tests/providers/zcode.test.ts`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Zed
|
||||
|
||||
Zed's built-in AI agent.
|
||||
|
||||
- **Source:** `src/providers/zed.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:177`)
|
||||
- **Test:** `tests/providers/zed.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
One SQLite database with one row per agent thread (`zed.ts:19`):
|
||||
|
||||
- macOS: `~/Library/Application Support/Zed/threads/threads.db`
|
||||
- Linux: `~/.local/share/zed/threads/threads.db`
|
||||
- Windows: `%LOCALAPPDATA%\Zed\threads\threads.db`
|
||||
|
||||
## Storage format
|
||||
|
||||
The `threads` table stores each thread's `data` BLOB as zstd-compressed JSON (`data_type = "zstd"`; legacy rows may be uncompressed `"json"`, both are read, `zed.ts:117-127`). Decompression uses Node's built-in `zlib.zstdDecompressSync` (`zed.ts:17`), no extra dependency.
|
||||
|
||||
The decompressed thread JSON carries:
|
||||
|
||||
- `model`: `{ "provider": ..., "model": ... }`
|
||||
- `request_token_usage`: map of user-message id to `{ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens }` (zero-valued fields are omitted)
|
||||
- `cumulative_token_usage`: same shape, whole-thread totals
|
||||
|
||||
Token semantics match Anthropic's (separate cache-creation and cache-read fields), so pricing maps directly onto the LiteLLM engine. Shapes verified against Zed's serialization source (`crates/agent/src/db.rs`: `DbThread`, `TokenUsage`, `SerializedLanguageModel`, `DataType`) and a real store.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `zed:<threadId>:<requestKey>` (`zed.ts:96`), where `requestKey` is the user-message id from `request_token_usage` or the synthetic `cumulative-remainder`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- `request_token_usage` is keyed by user message and does not cover every request a thread made (verified on a real thread: cumulative was ~3x the map sum). One remainder entry per thread tops usage up to the exact `cumulative_token_usage` (`zed.ts:133-153`), so totals always match the store.
|
||||
- The per-request map carries no timestamps, so every call in a thread uses the thread's `updated_at`; day-level attribution inside long-running threads is approximate.
|
||||
- Node's zlib gained zstd in 22.15. On older Nodes the provider skips with a notice instead of failing (`zed.ts:14-17`).
|
||||
- All Zed usage currently lands under a single `zed` project; `folder_paths` is not yet mapped to per-project attribution.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If discovery returns no sessions, confirm `threads.db` exists at the platform path and the `threads` table still has `id`, `summary`, `updated_at`, `data_type`, `data`.
|
||||
2. If threads are skipped, check `data_type` values on disk; only `zstd` and `json` are read.
|
||||
3. If totals disagree with the store, compare against `cumulative_token_usage` per thread; the remainder logic must bring each thread exactly to it.
|
||||
4. If model names stop pricing, inspect `model.model` strings in a real thread and add aliases if Zed introduces new hosted-model ids.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Zerostack
|
||||
|
||||
Zerostack (gi-dellav/zerostack) — a minimal Rust coding agent. Wraps OpenRouter (default), OpenAI, Anthropic, Gemini, and Ollama.
|
||||
|
||||
- **Source:** `src/providers/zerostack.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/zerostack.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
The platform data dir + `zerostack/sessions/`, mirroring Rust's `dirs::data_dir` (`src/session/storage.rs` in zerostack):
|
||||
|
||||
| OS | Path |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/zerostack/sessions/` |
|
||||
| Linux | `$XDG_DATA_HOME/zerostack/sessions/` or `~/.local/share/zerostack/sessions/` |
|
||||
|
||||
`ZS_DATA_DIR` overrides the whole data dir (sessions live directly under it). A directory argument to `createZerostackProvider(dir)` overrides the sessions dir outright (used by tests).
|
||||
|
||||
## Storage format
|
||||
|
||||
JSON — one `<uuid>.json` file per session. Each file is a single `Session` object: a `messages[]` array (`role`, `content`, `estimated_tokens`) plus session-level metadata.
|
||||
|
||||
## Token model
|
||||
|
||||
**Cumulative, not per-call.** Real billable tokens exist only as session totals — `total_input_tokens`, `total_output_tokens` — alongside `model`, `provider`, and `working_dir`. Individual messages carry only a rough `estimated_tokens`. So the parser emits **one `ParsedProviderCall` per session** from the totals; cost is recomputed with `calculateCost` (LiteLLM), not taken from the session's own `total_cost`.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `zerostack:<path>:<updated_at>:<id>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **No cache breakdown.** zerostack's `Usage` only carries `input_tokens` and `output_tokens` (`src/agent/runner.rs`); it folds any cached prompt tokens into the input count and discards the split before writing the session. So cache fields are always `0` and the cache % reads 0. Cost is re-priced at LiteLLM's standard input rate, which can slightly overestimate when caching was active — consistent with zerostack's own flat `input_token_cost`.
|
||||
- **No tool data.** zerostack persists only final assistant text, not tool-call or bash records, so `tools` and `bashCommands` are always empty.
|
||||
- **OpenRouter model ids are prefixed** (e.g. `deepseek/deepseek-v4-pro`). `modelDisplayName` strips the route prefix before resolving; `calculateCost` resolves the prefixed id via LiteLLM's canonical-name handling.
|
||||
- **Whole-session timestamp.** All spend is attributed to `updated_at`, since cumulative totals can't be split across days.
|
||||
- Unknown/local models (Ollama, custom) price at `$0`, which is expected.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm whether the bug is in **discovery** (session files not picked up — check the data-dir resolution against `src/session/storage.rs`) or **parsing** (totals mapped wrong).
|
||||
2. The session struct is `Session` in zerostack's `src/session/mod.rs`. If a field is renamed upstream, update `ZerostackSession` to match.
|
||||
3. Add a fixture-format session to `tests/providers/zerostack.test.ts`; do not mock the filesystem.
|
||||
Reference in New Issue
Block a user