Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Added
- **Overview marks Cursor dollars that are local estimates, not the bill.** When a period still holds local Cursor or Cursor Agent usage that no dashboard import covers, `codeburn overview` prints one line naming how much of the total is priced from the IDE's local files (`includes $X of Cursor priced from local files, not Cursor's bill`) and points at `codeburn import cursor <file.csv>`, instead of letting the estimate read as the dashboard bill — on the account that reported #1545 the local figure was about 2% of what Cursor actually charged. Imported events and days an import covers never trigger the line, since covered local calls are dropped at serve time and the imported rows carry Cursor's own billed amounts. (#1545)
- **Amp support.** CodeBurn reads Sourcegraph Amp's local thread mirror at `~/.local/share/amp/threads/` (`AMP_DATA_DIR` honored, comma-separated): one JSON document per thread with a per-request usage ledger (`tokens.input`/`output`, cache tokens joined from the billed message through `toMessageId`) and a per-message usage fallback for older threads. Every call is priced from the underlying model's token rates and marked estimated; Amp's own credits field is not converted; thread files carry no cwd, so sessions group under the provider name. Reported in #1572.
- **`codeburn import cursor <file.csv>` replaces the local Cursor estimates with Cursor's own usage export.** The local database has no per-request token counts and none of the cache reads Cursor re-sends, so on one real month CodeBurn saw about 7M Cursor tokens where the dashboard showed 644M. Export CSV at cursor.com/dashboard/usage and import it: for the time the export covers, its events replace the local Cursor, Cursor Agent and (when the export holds Grok Bot events) Grok Bot estimates, and outside it the estimates stay. Dollar amounts in the export are kept as billed; included usage is priced at API rates like every other local call. Pass `--from`/`--to` with the range you exported, re-import overlapping exports without double counting, and `--remove` to go back. Stored locally, nothing is uploaded. Reported in #1545.

Expand Down
2 changes: 1 addition & 1 deletion docs/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The parser tries both and dedupes via `seenKeys`.

## Importing Cursor's own usage export

The local database carries no per-turn token counts and none of the cache reads Cursor re-sends on every request, so local figures are estimates and run far below Cursor's dashboard. Cursor's dashboard exports every usage event with the token split it billed:
The local database carries no per-turn token counts and none of the cache reads Cursor re-sends on every request, so local figures are estimates and run far below Cursor's dashboard. When a period still holds local Cursor or Cursor Agent dollars that no import covers, `codeburn overview` says so in one dim line under the bottom line (`includes $X of Cursor priced from local files, not Cursor's bill`) instead of letting the estimate read as the bill (#1545). Cursor's dashboard exports every usage event with the token split it billed:

```
codeburn import cursor ~/Downloads/usage-events-2026-09-25.csv --from 2026-08-27 --to 2026-09-25
Expand Down
2 changes: 1 addition & 1 deletion src/cursor-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const CURSOR_CSV_HEADER = [
'Input (w/ Cache Write)', 'Input (w/o Cache Write)', 'Cache Read', 'Output Tokens', 'Total Tokens', 'Cost',
] as const

const CURSOR_IMPORT_KEY_PREFIX = 'cursor-import:'
export const CURSOR_IMPORT_KEY_PREFIX = 'cursor-import:'
const CURSOR_IMPORT_PROJECT = 'Cursor (imported)'
const GROK_BOT_IMPORT_PROJECT = 'Grok Bot (imported)'

Expand Down
22 changes: 22 additions & 0 deletions src/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { markEstimated, excludedGatewayNote } from './format.js'
import { AGGREGATE_ONLY_PROVIDER } from './parser.js'
import { maxOf } from './math-utils.js'
import { formatSessionCount, SESSION_COUNT_HELP, type SessionCountBasis } from './session-count-label.js'
import { CURSOR_IMPORT_KEY_PREFIX } from './cursor-import.js'
import { normalizeAbsProjectPathKey } from './parser.js'
import { dateKey } from './day-aggregator.js'
import type { DailyEntry } from './daily-cache.js'
Expand Down Expand Up @@ -73,6 +74,13 @@ type OverviewBudget = {
inProgress: boolean
}

// Local Cursor rows are priced from IDE composer bubbles, which never see the
// context Cursor re-sends on every request, so their dollars are a local
// estimate that can sit far below the dashboard bill (#1545). Imported events
// are Cursor's own numbers; covered local rows are already dropped at serve
// time, so any non-imported Cursor call that survives is an uncovered estimate.
const CURSOR_LOCAL_PROVIDERS = new Set(['cursor', 'cursor-agent'])

// Visible width, ignoring ANSI color codes, so padding stays aligned.
function vlen(s: string): number {
// eslint-disable-next-line no-control-regex
Expand Down Expand Up @@ -147,6 +155,7 @@ export function renderOverview(

let cost = 0, savings = 0, calls = 0, sessions = 0
let inTok = 0, outTok = 0, cacheR = 0, cacheW = 0
let cursorLocalEstimateUSD = 0
const byProvider = new Map<string, { cost: number; tokens: number }>()
const byModel = new Map<string, { cost: number; calls: number; tokens: number; estimatedCost: number }>()
const byCat = new Map<string, { cost: number; turns: number }>()
Expand Down Expand Up @@ -203,6 +212,10 @@ export function renderOverview(
pv.cost += call.costUSD
pv.tokens += tk
byProvider.set(call.provider, pv)
if (CURSOR_LOCAL_PROVIDERS.has(call.provider)
&& !(call.deduplicationKey ?? '').startsWith(CURSOR_IMPORT_KEY_PREFIX)) {
cursorLocalEstimateUSD += call.costUSD
}
if (day) {
const dd = byDay.get(day) ?? { cost: 0, tokens: 0, providers: new Set<string>() }
dd.cost += call.costUSD
Expand Down Expand Up @@ -416,5 +429,14 @@ export function renderOverview(
const gatewayNote = excludedGatewayNote(durable?.excludedGateway?.costUSD ?? 0)
if (gatewayNote) out.push(c.dim(` ${gatewayNote}`))

// #1545: say so when Cursor dollars in this period are local estimates
// rather than Cursor's bill, instead of letting the figure look exact.
if (cursorLocalEstimateUSD > 0) {
out.push(c.dim(
` includes ${formatCost(cursorLocalEstimateUSD)} of Cursor priced from local files, not Cursor's bill — ` +
'codeburn import cursor <file.csv> replaces it with the dashboard\'s own totals',
))
}

return out.join('\n') + '\n'
}
61 changes: 60 additions & 1 deletion tests/overview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function makeProject(opts: {
provider: string
tokens: { input: number; output: number; cacheR: number; cacheW: number }
estimated?: boolean
dedupKey?: string
}): ProjectSummary {
const usage = {
inputTokens: opts.tokens.input,
Expand Down Expand Up @@ -48,7 +49,7 @@ function makeProject(opts: {
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [{ provider: opts.provider, model: opts.model, costUSD: opts.cost, usage }],
assistantCalls: [{ provider: opts.provider, model: opts.model, costUSD: opts.cost, usage, ...(opts.dedupKey ? { deduplicationKey: opts.dedupKey } : {}) }],
}],
}],
} as unknown as ProjectSummary
Expand Down Expand Up @@ -224,6 +225,64 @@ describe('renderOverview', () => {
})
})

describe('renderOverview Cursor local-estimate disclosure (#1545)', () => {
it('marks Cursor dollars priced from local files as not the bill', () => {
const out = renderOverview([makeProject({
project: 'proj',
projectPath: '/Users/test/proj',
cost: 3.14,
calls: 2,
model: 'claude-sonnet-4-5',
provider: 'cursor',
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
dedupKey: 'cursor:state-vscdb:1',
})], { label: 'June 2026', color: false })

expect(out).toContain("includes $3.14 of Cursor priced from local files, not Cursor's bill")
expect(out).toContain('codeburn import cursor')
})

it('stays silent when the Cursor rows are imported dashboard events', () => {
const out = renderOverview([makeProject({
project: 'Cursor (imported)',
projectPath: '/Users/test/proj',
cost: 41.2,
calls: 70,
model: 'claude-opus-5-5-medium',
provider: 'cursor',
tokens: { input: 10000, output: 2000, cacheR: 0, cacheW: 0 },
dedupKey: 'cursor-import:abc123',
})], { label: 'June 2026', color: false })

expect(out).not.toContain("not Cursor's bill")
})

it('stays silent for non-Cursor providers and names the Cursor-agent variant too', () => {
const claude = renderOverview([makeProject({
project: 'proj',
projectPath: '/Users/test/proj',
cost: 5,
calls: 1,
model: 'claude-sonnet-4-5',
provider: 'claude',
tokens: { input: 100, output: 20, cacheR: 0, cacheW: 0 },
})], { label: 'June 2026', color: false })
expect(claude).not.toContain('codeburn import cursor')

const agent = renderOverview([makeProject({
project: 'proj',
projectPath: '/Users/test/proj',
cost: 2,
calls: 1,
model: 'gpt-5.6',
provider: 'cursor-agent',
tokens: { input: 100, output: 20, cacheR: 0, cacheW: 0 },
dedupKey: 'cursor-agent:session:1',
})], { label: 'June 2026', color: false })
expect(agent).toContain("includes $2.00 of Cursor priced from local files, not Cursor's bill")
})
})

describe('renderOverview unpriced models', () => {
it('warns when a model with usage has no pricing data', () => {
const out = renderOverview([makeProject({
Expand Down
Loading