diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ca40ecf..b72bd7933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, 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 ` 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. diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index 6c889bae8..664de7957 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -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 diff --git a/src/cursor-import.ts b/src/cursor-import.ts index fa03a6429..712174668 100644 --- a/src/cursor-import.ts +++ b/src/cursor-import.ts @@ -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)' diff --git a/src/overview.ts b/src/overview.ts index b055e339c..ea7db26e0 100644 --- a/src/overview.ts +++ b/src/overview.ts @@ -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' @@ -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 @@ -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() const byModel = new Map() const byCat = new Map() @@ -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() } dd.cost += call.costUSD @@ -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 replaces it with the dashboard\'s own totals', + )) + } + return out.join('\n') + '\n' } diff --git a/tests/overview.test.ts b/tests/overview.test.ts index 48f054314..331f398d9 100644 --- a/tests/overview.test.ts +++ b/tests/overview.test.ts @@ -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, @@ -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 @@ -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({