mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
feat(cursor): import Cursor's usage CSV to replace local estimates (#1558)
Some checks are pending
CI / semgrep (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Some checks are pending
CI / semgrep (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
* feat(cursor): import Cursor's usage export to replace local estimates (#1545) * Price Cursor-hosted grok-4.6-high like grok-4.6 in the Cursor import * Skip no-op Cursor re-imports and price cursor-grok-4.6-high ids locally * test(cursor-import): write agent fixtures under os.homedir() so Windows finds them
This commit is contained in:
parent
d076d4935b
commit
34db32361d
10 changed files with 736 additions and 8 deletions
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- **`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.
|
||||
|
||||
### Removed
|
||||
- Roo Code support. Roo Code was discontinued in May 2026 (its repository is archived).
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,22 @@ The parser tries both and dedupes via `seenKeys`.
|
|||
- **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`).
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
codeburn import cursor ~/Downloads/usage-events-2026-09-25.csv --from 2026-08-27 --to 2026-09-25
|
||||
codeburn import cursor --remove
|
||||
```
|
||||
|
||||
- **Where it lives.** Events are stored in `~/.cache/codeburn/imports/cursor-usage.v1.json` (or `$CODEBURN_CACHE_DIR`), beside the daily cache, and never swept. Re-importing merges: each row is keyed by a hash of all its fields, so an overlapping export adds only rows not seen before. Nothing is uploaded.
|
||||
- **Coverage.** The export has no range of its own. Pass the range you exported with `--from`/`--to` (a date, an ISO time, or the epoch milliseconds in the export URL's `startDate`/`endDate`); a bare `--to` date runs to the end of that UTC day. Without them the import covers the UTC days of its first and last event. Either way coverage ends no later than the CSV file's modification time, so usage after the export keeps its local estimate. Events outside `--from`/`--to` are refused. Coverage from several imports is merged.
|
||||
- **Replacement.** Inside coverage, local `cursor` (IDE) and `cursor-agent` (CLI) calls are dropped at serve time and the imported events stand for them; outside it the local estimates stay. The export is the account's usage, which includes the Agent CLI. When the export holds Grok Bot events (`grok-bot-*`), the local `grokbot` mirror estimates are replaced the same way: Grok Bot bills the same account. The cached local calls are never touched, so `--remove` restores them.
|
||||
- **Rows.** IDE events show under provider Cursor, project `Cursor (imported)`; `grok-bot-*` events under provider Grok Bot, project `Grok Bot (imported)`. Tokens: `Input (w/o Cache Write)` is input, `Input (w/ Cache Write)` cache write, `Cache Read` cache read, `Output Tokens` output. Model `auto` is `cursor-auto` (Cursor (auto), priced as Sonnet 4.5 like the local parser), `cursor-grok-*` drops its prefix, and `grok-bot-*` prices at the grok-4.6 rate like the local Grok Bot provider.
|
||||
- **Cost.** A dollar amount in `Cost` is kept as billed (`costFromBilling`, billing `metered`). `Included` and `Free` rows are priced from their tokens at API rates, the same API-equivalent value every local Cursor call gets, and carry billing `subscription`.
|
||||
- **Daily cache.** An import or removal drops the Cursor, Cursor Agent and Grok Bot slices of the local days it covers and pulls the watermark back, so the next run re-derives those days.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
368
src/cursor-import.ts
Normal file
368
src/cursor-import.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import { createHash, randomBytes } from 'crypto'
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
import { getCodeburnCacheDir } from './cache-dir.js'
|
||||
import { calculateCost } from './models.js'
|
||||
import type { ParsedProviderCall, SessionParser, SessionSource } from './providers/types.js'
|
||||
|
||||
// Cursor's dashboard export (cursor.com/dashboard/usage, Export CSV): one row
|
||||
// per usage event, with the token split and cost Cursor itself recorded. The
|
||||
// import replaces CodeBurn's local Cursor estimates for the time it covers.
|
||||
|
||||
export const CURSOR_CSV_HEADER = [
|
||||
'Date', 'Cloud Agent ID', 'Automation ID', 'Kind', 'Model', 'Max Mode',
|
||||
'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:'
|
||||
const CURSOR_IMPORT_PROJECT = 'Cursor (imported)'
|
||||
const GROK_BOT_IMPORT_PROJECT = 'Grok Bot (imported)'
|
||||
|
||||
// Local providers whose usage the export also carries: the Cursor IDE and the
|
||||
// Cursor Agent CLI bill the same account. Grok Bot runs on Cursor's servers
|
||||
// and bills the account too, but its local mirror is only replaced when the
|
||||
// export actually holds Grok Bot events (see coverageFor).
|
||||
const REPLACED_PROVIDERS = ['cursor', 'cursor-agent']
|
||||
const GROK_BOT_PROVIDER = 'grokbot'
|
||||
|
||||
export type CursorUsageEvent = {
|
||||
hash: string
|
||||
date: string
|
||||
cloudAgentId: string
|
||||
automationId: string
|
||||
kind: string
|
||||
model: string
|
||||
maxMode: string
|
||||
inputCacheWrite: number
|
||||
input: number
|
||||
cacheRead: number
|
||||
output: number
|
||||
cost: string
|
||||
}
|
||||
|
||||
export type CoverageRange = { start: string; end: string }
|
||||
|
||||
export type CursorImportStore = {
|
||||
version: 1
|
||||
ranges: CoverageRange[]
|
||||
events: CursorUsageEvent[]
|
||||
}
|
||||
|
||||
// Beside the daily cache, CodeBurn's other never-swept record of history.
|
||||
export function cursorImportPath(): string {
|
||||
return join(getCodeburnCacheDir(), 'imports', 'cursor-usage.v1.json')
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = []
|
||||
let field = ''
|
||||
let quoted = false
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]!
|
||||
if (quoted) {
|
||||
if (ch === '"' && line[i + 1] === '"') { field += '"'; i++ }
|
||||
else if (ch === '"') quoted = false
|
||||
else field += ch
|
||||
} else if (ch === '"') quoted = true
|
||||
else if (ch === ',') { fields.push(field); field = '' }
|
||||
else field += ch
|
||||
}
|
||||
fields.push(field)
|
||||
return fields
|
||||
}
|
||||
|
||||
function tokenCount(value: string, line: number, column: string): number {
|
||||
if (value === '') return 0
|
||||
if (!/^\d+$/.test(value)) throw new Error(`line ${line}: ${column} is not a token count: "${value}"`)
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
function isGrokBotModel(model: string): boolean {
|
||||
return model.startsWith('grok-bot-')
|
||||
}
|
||||
|
||||
/// Dollar amount in the Cost column, or null for plan-covered rows
|
||||
/// ("Included", "Free") and anything else that names no amount.
|
||||
function billedCost(cost: string): number | null {
|
||||
const m = /^\$?(\d+(?:\.\d+)?)$/.exec(cost.trim())
|
||||
return m ? Number(m[1]) : null
|
||||
}
|
||||
|
||||
export function parseCursorUsageCsv(text: string): CursorUsageEvent[] {
|
||||
const lines = text.replace(/^/, '').split(/\r?\n/)
|
||||
const header = parseCsvLine(lines[0] ?? '')
|
||||
if (header.length !== CURSOR_CSV_HEADER.length || header.some((h, i) => h.trim() !== CURSOR_CSV_HEADER[i])) {
|
||||
throw new Error(
|
||||
'not a Cursor usage export. Expected the header from cursor.com/dashboard/usage (Export CSV):\n' +
|
||||
` ${CURSOR_CSV_HEADER.join(',')}\n` +
|
||||
`got:\n ${(lines[0] ?? '').slice(0, 300)}`,
|
||||
)
|
||||
}
|
||||
const events: CursorUsageEvent[] = []
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]!
|
||||
if (line.trim() === '') continue
|
||||
const f = parseCsvLine(line)
|
||||
if (f.length !== CURSOR_CSV_HEADER.length) throw new Error(`line ${i + 1}: expected ${CURSOR_CSV_HEADER.length} fields, got ${f.length}`)
|
||||
const [date, cloudAgentId, automationId, kind, model, maxMode, cw, inp, cr, out, total, cost] = f as [string, string, string, string, string, string, string, string, string, string, string, string]
|
||||
if (Number.isNaN(Date.parse(date)) || !date.endsWith('Z')) throw new Error(`line ${i + 1}: Date is not a UTC timestamp: "${date}"`)
|
||||
if (!model) throw new Error(`line ${i + 1}: Model is empty`)
|
||||
const event: CursorUsageEvent = {
|
||||
hash: createHash('sha256').update(f.join('\u001f')).digest('hex').slice(0, 32),
|
||||
date: new Date(date).toISOString(),
|
||||
cloudAgentId, automationId, kind, model, maxMode,
|
||||
inputCacheWrite: tokenCount(cw, i + 1, CURSOR_CSV_HEADER[6]),
|
||||
input: tokenCount(inp, i + 1, CURSOR_CSV_HEADER[7]),
|
||||
cacheRead: tokenCount(cr, i + 1, CURSOR_CSV_HEADER[8]),
|
||||
output: tokenCount(out, i + 1, CURSOR_CSV_HEADER[9]),
|
||||
cost,
|
||||
}
|
||||
if (total !== '' && tokenCount(total, i + 1, CURSOR_CSV_HEADER[10]) !== event.inputCacheWrite + event.input + event.cacheRead + event.output) {
|
||||
throw new Error(`line ${i + 1}: Total Tokens does not equal the sum of the input, cache and output columns`)
|
||||
}
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/// `2026-08-27`, an ISO instant, or epoch milliseconds (what the dashboard's
|
||||
/// export URL carries). A bare date is a whole UTC day: its start for `from`,
|
||||
/// its last millisecond for `to`.
|
||||
export function parseBoundary(value: string, edge: 'from' | 'to'): number {
|
||||
const v = value.trim()
|
||||
let ms: number
|
||||
if (/^\d{9,}$/.test(v)) ms = Number(v)
|
||||
else if (/^\d{4}-\d{2}-\d{2}$/.test(v)) ms = Date.parse(`${v}T00:00:00.000Z`) + (edge === 'to' ? 86_400_000 - 1 : 0)
|
||||
else ms = Date.parse(v)
|
||||
if (Number.isNaN(ms)) throw new Error(`--${edge} must be a date (2026-08-27), an ISO timestamp or epoch milliseconds, got "${value}"`)
|
||||
return ms
|
||||
}
|
||||
|
||||
function utcDayStart(ms: number): number {
|
||||
return Math.floor(ms / 86_400_000) * 86_400_000
|
||||
}
|
||||
|
||||
/// The span one export covers. The export records no range of its own, so it
|
||||
/// is the `--from`/`--to` the user exported with, else the UTC days of its
|
||||
/// first and last event. The end never passes the moment the file was saved:
|
||||
/// usage after the export is not in it and keeps its local estimate.
|
||||
function exportCoverage(
|
||||
events: CursorUsageEvent[],
|
||||
fileSavedMs: number,
|
||||
from?: number,
|
||||
to?: number,
|
||||
): { start: number; end: number; inferred: boolean } {
|
||||
const times = events.map(e => Date.parse(e.date))
|
||||
const first = Math.min(...times)
|
||||
const last = Math.max(...times)
|
||||
const start = from ?? utcDayStart(first)
|
||||
const declaredEnd = to ?? utcDayStart(last) + 86_400_000 - 1
|
||||
if (start > declaredEnd) throw new Error('--from is after --to')
|
||||
if (first < start || last > declaredEnd) {
|
||||
throw new Error(`the export holds events from ${new Date(first).toISOString()} to ${new Date(last).toISOString()}, outside ${new Date(start).toISOString()} .. ${new Date(declaredEnd).toISOString()}`)
|
||||
}
|
||||
return { start, end: Math.min(declaredEnd, Math.max(fileSavedMs, last)), inferred: from === undefined || to === undefined }
|
||||
}
|
||||
|
||||
function mergeRanges(ranges: CoverageRange[]): CoverageRange[] {
|
||||
const sorted = ranges.map(r => [Date.parse(r.start), Date.parse(r.end)] as [number, number]).sort((a, b) => a[0] - b[0])
|
||||
const out: Array<[number, number]> = []
|
||||
for (const r of sorted) {
|
||||
const last = out[out.length - 1]
|
||||
if (last && r[0] <= last[1] + 1) last[1] = Math.max(last[1], r[1])
|
||||
else out.push([...r])
|
||||
}
|
||||
return out.map(([s, e]) => ({ start: new Date(s).toISOString(), end: new Date(e).toISOString() }))
|
||||
}
|
||||
|
||||
let memo: { key: string; store: CursorImportStore | null } | null = null
|
||||
|
||||
export async function loadCursorImport(): Promise<CursorImportStore | null> {
|
||||
const path = cursorImportPath()
|
||||
let key: string
|
||||
try {
|
||||
const s = await stat(path)
|
||||
key = `${path}\0${s.ino}\0${s.mtimeMs}\0${s.size}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (memo?.key === key) return memo.store
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8')) as CursorImportStore
|
||||
if (parsed?.version !== 1 || !Array.isArray(parsed.events) || !Array.isArray(parsed.ranges)) {
|
||||
throw new Error(`unrecognized Cursor import file ${path}; remove it with \`codeburn import cursor --remove\``)
|
||||
}
|
||||
memo = { key, store: parsed }
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function saveCursorImport(store: CursorImportStore): Promise<void> {
|
||||
const path = cursorImportPath()
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
const tmp = `${path}.${randomBytes(8).toString('hex')}.tmp`
|
||||
await writeFile(tmp, JSON.stringify(store) + '\n', 'utf-8')
|
||||
await rename(tmp, path)
|
||||
}
|
||||
|
||||
export type CursorImportSummary = {
|
||||
changed: boolean
|
||||
added: number
|
||||
skipped: number
|
||||
total: number
|
||||
coverage: { start: string; end: string; inferred: boolean }
|
||||
firstEvent: string
|
||||
lastEvent: string
|
||||
tokens: number
|
||||
grokBotTokens: number
|
||||
grokBotEvents: number
|
||||
}
|
||||
|
||||
export async function importCursorCsv(csvPath: string, opts: { from?: number; to?: number } = {}): Promise<CursorImportSummary> {
|
||||
const [text, fileStat] = await Promise.all([readFile(csvPath, 'utf-8'), stat(csvPath)])
|
||||
const incoming = parseCursorUsageCsv(text)
|
||||
if (incoming.length === 0) throw new Error('the export holds no usage events')
|
||||
const coverage = exportCoverage(incoming, fileStat.mtimeMs, opts.from, opts.to)
|
||||
|
||||
const existing = await loadCursorImport()
|
||||
const known = new Set(existing?.events.map(e => e.hash))
|
||||
const events = [...(existing?.events ?? [])]
|
||||
let added = 0
|
||||
for (const e of incoming) {
|
||||
if (known.has(e.hash)) continue
|
||||
known.add(e.hash)
|
||||
events.push(e)
|
||||
added++
|
||||
}
|
||||
events.sort((a, b) => a.date.localeCompare(b.date))
|
||||
const range = { start: new Date(coverage.start).toISOString(), end: new Date(coverage.end).toISOString() }
|
||||
const ranges = mergeRanges([...(existing?.ranges ?? []), range])
|
||||
// An unchanged store keeps its mtime, so a repeat import re-parses nothing.
|
||||
const changed = added > 0 || JSON.stringify(ranges) !== JSON.stringify(existing?.ranges)
|
||||
if (changed) await saveCursorImport({ version: 1, ranges, events })
|
||||
|
||||
const tokensOf = (e: CursorUsageEvent) => e.inputCacheWrite + e.input + e.cacheRead + e.output
|
||||
const bot = incoming.filter(e => isGrokBotModel(e.model))
|
||||
const times = incoming.map(e => e.date).sort()
|
||||
return {
|
||||
changed,
|
||||
added,
|
||||
skipped: incoming.length - added,
|
||||
total: events.length,
|
||||
coverage: { ...range, inferred: coverage.inferred },
|
||||
firstEvent: times[0]!,
|
||||
lastEvent: times[times.length - 1]!,
|
||||
tokens: incoming.reduce((s, e) => s + tokensOf(e), 0),
|
||||
grokBotTokens: bot.reduce((s, e) => s + tokensOf(e), 0),
|
||||
grokBotEvents: bot.length,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the import and returns the coverage it held, so the caller can
|
||||
/// re-derive those days from the local estimates again.
|
||||
export async function removeCursorImport(): Promise<CoverageRange[] | null> {
|
||||
const store = await loadCursorImport().catch(() => null)
|
||||
try {
|
||||
await unlink(cursorImportPath())
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw err
|
||||
}
|
||||
memo = null
|
||||
return store?.ranges ?? []
|
||||
}
|
||||
|
||||
type ImportCoverage = { ranges: Array<[number, number]>; providers: ReadonlySet<string> }
|
||||
|
||||
export function coverageFor(store: CursorImportStore): ImportCoverage {
|
||||
const providers = new Set(REPLACED_PROVIDERS)
|
||||
if (store.events.some(e => isGrokBotModel(e.model))) providers.add(GROK_BOT_PROVIDER)
|
||||
return { ranges: store.ranges.map(r => [Date.parse(r.start), Date.parse(r.end)]), providers }
|
||||
}
|
||||
|
||||
export function replacedProviders(): string[] {
|
||||
return [...REPLACED_PROVIDERS, GROK_BOT_PROVIDER]
|
||||
}
|
||||
|
||||
/// Serve-time replacement: a local call of a replaced provider whose timestamp
|
||||
/// the import covers is dropped, since an imported event stands for it.
|
||||
export function dropImportCoveredCalls<T extends { timestamp: string; calls: Array<{ timestamp: string; deduplicationKey: string }> }>(
|
||||
providerName: string,
|
||||
turn: T,
|
||||
coverage: ImportCoverage | null,
|
||||
): T | null {
|
||||
if (!coverage?.providers.has(providerName)) return turn
|
||||
const covered = (ts: string) => {
|
||||
const ms = Date.parse(ts)
|
||||
return !Number.isNaN(ms) && coverage.ranges.some(([s, e]) => ms >= s && ms <= e)
|
||||
}
|
||||
const kept = turn.calls.filter(c => c.deduplicationKey.startsWith(CURSOR_IMPORT_KEY_PREFIX) || !covered(c.timestamp))
|
||||
if (kept.length === turn.calls.length) return turn
|
||||
if (kept.length === 0) return null
|
||||
return { ...turn, calls: kept, timestamp: kept[0]!.timestamp }
|
||||
}
|
||||
|
||||
/// Cursor's own ids, mapped to the catalog ids CodeBurn prices and labels:
|
||||
/// Auto is the same `cursor-auto` the local parser reports, and Cursor-hosted
|
||||
/// Grok drops its `cursor-` prefix.
|
||||
function catalogModel(model: string): string {
|
||||
if (model === 'auto' || model === 'default') return 'cursor-auto'
|
||||
if (model.startsWith('cursor-grok-')) return model.slice('cursor-'.length)
|
||||
return model
|
||||
}
|
||||
|
||||
function importedCalls(store: CursorImportStore, provider: 'cursor' | 'grokbot'): ParsedProviderCall[] {
|
||||
const bot = provider === GROK_BOT_PROVIDER
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const e of store.events) {
|
||||
if (isGrokBotModel(e.model) !== bot) continue
|
||||
const model = catalogModel(e.model)
|
||||
const billed = billedCost(e.cost)
|
||||
calls.push({
|
||||
provider,
|
||||
model,
|
||||
inputTokens: e.input,
|
||||
outputTokens: e.output,
|
||||
cacheCreationInputTokens: e.inputCacheWrite,
|
||||
cacheReadInputTokens: e.cacheRead,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: billed ?? calculateCost(model, e.input, e.output, e.inputCacheWrite, e.cacheRead, 0),
|
||||
...(billed !== null ? { costFromBilling: true } : {}),
|
||||
billing: billed !== null ? 'metered' : 'subscription',
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: e.date,
|
||||
speed: 'standard',
|
||||
deduplicationKey: `${CURSOR_IMPORT_KEY_PREFIX}${e.hash}`,
|
||||
userMessage: '',
|
||||
sessionId: e.cloudAgentId || e.automationId || `${CURSOR_IMPORT_KEY_PREFIX}${bot ? 'grok-bot:' : ''}${e.date.slice(0, 10)}`,
|
||||
project: bot ? GROK_BOT_IMPORT_PROJECT : CURSOR_IMPORT_PROJECT,
|
||||
})
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
/// The import file as one more source of the provider its events belong to,
|
||||
/// so it flows through the session cache and every report like local data.
|
||||
export function importSource(provider: 'cursor' | 'grokbot'): SessionSource[] {
|
||||
const path = cursorImportPath()
|
||||
if (!existsSync(path)) return []
|
||||
return [{ path, project: provider === GROK_BOT_PROVIDER ? GROK_BOT_IMPORT_PROJECT : CURSOR_IMPORT_PROJECT, provider }]
|
||||
}
|
||||
|
||||
export function importSourceParser(source: SessionSource, seenKeys: Set<string>, provider: 'cursor' | 'grokbot'): SessionParser | null {
|
||||
if (source.path !== cursorImportPath()) return null
|
||||
return {
|
||||
async *parse() {
|
||||
const store = await loadCursorImport()
|
||||
if (!store) return
|
||||
for (const call of importedCalls(store, provider)) {
|
||||
if (seenKeys.has(call.deduplicationKey)) continue
|
||||
seenKeys.add(call.deduplicationKey)
|
||||
yield call
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1308,6 +1308,28 @@ export function mergeDayEntries(
|
|||
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date))
|
||||
}
|
||||
|
||||
/// Forget the given providers' slices on the local days `start`..`end` and pull
|
||||
/// the watermark back before them, so the next hydration re-derives those days
|
||||
/// from sources. For a provider whose data changed on settled days by the
|
||||
/// user's own hand (a usage import added or removed), which the partial-
|
||||
/// survival guard would otherwise read as sources aging out and keep the old
|
||||
/// slices over.
|
||||
export async function invalidateProviderDays(providers: readonly string[], start: string, end: string): Promise<void> {
|
||||
await withDailyCacheLock(async () => {
|
||||
if (!existsSync(getCachePath())) return
|
||||
const c = await loadDailyCache()
|
||||
for (const day of c.days) {
|
||||
if (day.date < start || day.date > end) continue
|
||||
for (const provider of providers) {
|
||||
if (Object.hasOwn(day.providers, provider)) subtractSliceFromDay(day, provider, day.providers[provider]!)
|
||||
}
|
||||
}
|
||||
const before = toDateString(new Date(Number(start.slice(0, 4)), Number(start.slice(5, 7)) - 1, Number(start.slice(8, 10)) - 1))
|
||||
const lastComputedDate = c.lastComputedDate !== null && c.lastComputedDate > before ? before : c.lastComputedDate
|
||||
await saveDailyCache({ ...c, days: c.days.filter(hasPositiveDayContent), lastComputedDate })
|
||||
})
|
||||
}
|
||||
|
||||
export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] {
|
||||
return cache.days.filter(d => d.date >= start && d.date <= end)
|
||||
}
|
||||
|
|
|
|||
51
src/main.ts
51
src/main.ts
|
|
@ -1601,6 +1601,57 @@ program
|
|||
console.log(` Config saved to ${getConfigFilePath()}\n`)
|
||||
})
|
||||
|
||||
program
|
||||
.command('import <tool> [file]')
|
||||
.description('Replace local estimates with usage a tool exported itself. Supported: cursor (Export CSV at cursor.com/dashboard/usage)')
|
||||
.option('--from <date>', 'Start of the exported range (date, ISO time or epoch ms). Default: the first event\'s UTC day')
|
||||
.option('--to <date>', 'End of the exported range (date, ISO time or epoch ms). Default: the last event\'s UTC day')
|
||||
.option('--remove', 'Delete the imported usage and go back to local estimates')
|
||||
.action(async (tool: string, file: string | undefined, opts: { from?: string; to?: string; remove?: boolean }) => {
|
||||
if (tool !== 'cursor') {
|
||||
console.error(`\n Unknown import "${tool}". Supported: cursor\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const { cursorImportPath, importCursorCsv, parseBoundary, removeCursorImport, replacedProviders } = await import('./cursor-import.js')
|
||||
const { invalidateProviderDays } = await import('./daily-cache.js')
|
||||
const invalidate = async (ranges: Array<{ start: string; end: string }>) => {
|
||||
for (const r of ranges) {
|
||||
await invalidateProviderDays(replacedProviders(), toDateString(new Date(r.start)), toDateString(new Date(r.end)))
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (opts.remove) {
|
||||
const ranges = await removeCursorImport()
|
||||
if (!ranges) {
|
||||
console.log('\n No Cursor import to remove.\n')
|
||||
return
|
||||
}
|
||||
await invalidate(ranges)
|
||||
console.log('\n Removed the Cursor import. Local Cursor estimates are back for the days it covered.\n')
|
||||
return
|
||||
}
|
||||
if (!file) throw new Error('give the path of the CSV exported at cursor.com/dashboard/usage')
|
||||
const summary = await importCursorCsv(file, {
|
||||
...(opts.from ? { from: parseBoundary(opts.from, 'from') } : {}),
|
||||
...(opts.to ? { to: parseBoundary(opts.to, 'to') } : {}),
|
||||
})
|
||||
if (summary.changed) await invalidate([summary.coverage])
|
||||
const pct = summary.tokens > 0 ? ` (${(summary.grokBotTokens / summary.tokens * 100).toFixed(1)}%)` : ''
|
||||
console.log(`\n Imported Cursor usage from ${file}`)
|
||||
console.log(` Events: ${summary.added.toLocaleString()} added, ${summary.skipped.toLocaleString()} already imported (${summary.total.toLocaleString()} stored)`)
|
||||
console.log(` Covers: ${summary.coverage.start} to ${summary.coverage.end} UTC`)
|
||||
if (summary.coverage.inferred) console.log(' (range taken from the events; pass --from/--to from the export to set it)')
|
||||
console.log(` Events: ${summary.firstEvent} to ${summary.lastEvent}`)
|
||||
console.log(` Tokens: ${summary.tokens.toLocaleString()}, Grok Bot ${summary.grokBotTokens.toLocaleString()} in ${summary.grokBotEvents.toLocaleString()} events${pct}`)
|
||||
console.log(` Local Cursor, Cursor Agent${summary.grokBotEvents > 0 ? ' and Grok Bot' : ''} estimates in that window are replaced by these events.`)
|
||||
console.log(` Stored in ${cursorImportPath()}. Nothing was uploaded.\n`)
|
||||
} catch (err) {
|
||||
console.error(`\n codeburn import cursor: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('model-alias [from] [to]')
|
||||
.description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)')
|
||||
|
|
|
|||
|
|
@ -412,6 +412,16 @@ const BUILTIN_ALIASES: Record<string, string> = {
|
|||
// product, so it prices at xAI's published grok-4.6 rate ($2.00/M in,
|
||||
// $6.00/M out, $0.50/M cached). Every grokbot call is costIsEstimated.
|
||||
'grokbot-auto': 'grok-4.6',
|
||||
// The same Grok Bot work as Cursor's usage export names it, per bot kind.
|
||||
'grok-bot-automation': 'grok-4.6',
|
||||
'grok-bot-cua': 'grok-4.6',
|
||||
'grok-bot-default': 'grok-4.6',
|
||||
// Cursor-hosted Grok 4.6 as Cursor's usage export names it: the same model,
|
||||
// so it takes grok-4.6's 200k-prompt tier like the local Cursor rows do.
|
||||
'grok-4.6-high': 'grok-4.6',
|
||||
'grok-4.6-high-fast': 'grok-4.6',
|
||||
'cursor-grok-4.6-high': 'grok-4.6',
|
||||
'cursor-grok-4.6-high-fast': 'grok-4.6',
|
||||
'GPT-5.3 Codex (low reasoning)': 'gpt-5.3-codex',
|
||||
'GPT-5.3 Codex (medium reasoning)': 'gpt-5.3-codex',
|
||||
'GPT-5.3 Codex (high reasoning)': 'gpt-5.3-codex',
|
||||
|
|
@ -1288,6 +1298,11 @@ const autoModelNames: Record<string, string> = {
|
|||
'kiro-auto': 'Kiro (auto)',
|
||||
'quickdesk-auto': 'Quick Desktop (auto)',
|
||||
'grokbot-auto': 'Grok Bot (auto)',
|
||||
'grok-bot-automation': 'Grok Bot (automation)',
|
||||
'grok-bot-cua': 'Grok Bot (computer use)',
|
||||
'grok-bot-default': 'Grok Bot (default)',
|
||||
'grok-4.6-high': 'Grok 4.6 (high)',
|
||||
'grok-4.6-high-fast': 'Grok 4.6 (high, fast)',
|
||||
'cline-auto': 'Cline (auto)',
|
||||
'openclaw-auto': 'OpenClaw (auto)',
|
||||
'qwen-auto': 'Qwen (auto)',
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import type { CodexFullParse } from './providers/codex.js'
|
|||
import { dateKey } from './day-aggregator.js'
|
||||
import { behavioralCallWeight, isBehavioralTurn } from './behavioral-weight.js'
|
||||
import { gatewayIncludedInTotals } from './config.js'
|
||||
import { coverageFor, cursorImportPath, dropImportCoveredCalls, loadCursorImport, replacedProviders } from './cursor-import.js'
|
||||
import type { ParsedProviderCall, Provider, SessionSource } from './providers/types.js'
|
||||
import type {
|
||||
ApiUsageIteration,
|
||||
|
|
@ -4152,6 +4153,17 @@ export async function parseProviderSources(
|
|||
return { ...turn, calls: kept, ...(turnTsValid ? {} : { timestamp: kept[0]!.timestamp }) }
|
||||
}
|
||||
|
||||
// An imported Cursor usage export replaces this provider's local estimates
|
||||
// for the time it covers. Decided here, at serve time, so the cached local
|
||||
// calls stay intact and removing the import restores them.
|
||||
const importCoverage = replacedProviders().includes(providerName)
|
||||
? await loadCursorImport().then(store => store && coverageFor(store), () => null)
|
||||
: null
|
||||
const serveTurn = (rawTurn: CachedTurn): CachedTurn | null => {
|
||||
const turn = reconcileCopilotCalls(rawTurn)
|
||||
return turn && dropImportCoveredCalls(providerName, turn, importCoverage)
|
||||
}
|
||||
|
||||
// Query-time: derive SessionSummary from all cached turns.
|
||||
// Uses seenKeys (shared across providers) for cross-provider dedup.
|
||||
const sessionMap = new Map<string, { project: string; projectPath?: string; workingDirectory?: string; turns: ClassifiedTurn[]; prLinks?: Set<string>; title?: string; lineage?: SessionLineage; agentName?: string; agentStartedAt?: string }>()
|
||||
|
|
@ -4168,7 +4180,7 @@ export async function parseProviderSources(
|
|||
}
|
||||
|
||||
for (const rawTurn of cachedFile.turns) {
|
||||
const turn = reconcileCopilotCalls(rawTurn)
|
||||
const turn = serveTurn(rawTurn)
|
||||
if (!turn) continue
|
||||
const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey))
|
||||
if (hasDup) continue
|
||||
|
|
@ -4256,7 +4268,7 @@ export async function parseProviderSources(
|
|||
if (wslStatus === 'active' && !provider.durableSources) continue
|
||||
|
||||
for (const rawTurn of cachedFile.turns) {
|
||||
const turn = reconcileCopilotCalls(rawTurn)
|
||||
const turn = serveTurn(rawTurn)
|
||||
if (!turn) continue
|
||||
const hasDup = turn.calls.some(c => seenKeys.has(c.deduplicationKey))
|
||||
if (hasDup) continue
|
||||
|
|
@ -5600,6 +5612,9 @@ export async function computeCorpusFingerprint(providerFilter?: string): Promise
|
|||
}
|
||||
await record(source.path)
|
||||
}
|
||||
// Cursor Agent output depends on the Cursor import too (it replaces the
|
||||
// agent's estimates), so a snapshot scoped to it must move with the import.
|
||||
await record(cursorImportPath())
|
||||
entries.sort()
|
||||
const hash = createHash('sha256').update(entries.join('\n')).digest('hex')
|
||||
return { hash, newestMtimeMs, observedAtMs }
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { homedir } from 'os'
|
|||
import { calculateCost } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { readCachedResults, writeCachedResults } from '../cursor-cache.js'
|
||||
import { importSource, importSourceParser } from '../cursor-import.js'
|
||||
import {
|
||||
isSqliteAvailable,
|
||||
isSqliteBusyError,
|
||||
|
|
@ -1060,10 +1061,11 @@ export function createCursorProvider(dbPathOverride?: string): Provider {
|
|||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
if (!isSqliteAvailable()) return []
|
||||
const imported = importSource('cursor')
|
||||
if (!isSqliteAvailable()) return imported
|
||||
|
||||
const dbPath = dbPathOverride ?? getCursorDbPath()
|
||||
if (!existsSync(dbPath)) return []
|
||||
if (!existsSync(dbPath)) return imported
|
||||
|
||||
const wsMap = loadWorkspaceMap(getCursorWorkspaceStorageDir(dbPath))
|
||||
const sources: SessionSource[] = []
|
||||
|
|
@ -1085,11 +1087,11 @@ export function createCursorProvider(dbPathOverride?: string): Provider {
|
|||
project: ORPHAN_PROJECT,
|
||||
provider: 'cursor',
|
||||
})
|
||||
return sources
|
||||
return [...sources, ...imported]
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>, dateRange?: DateRange): SessionParser {
|
||||
return createParser(source, seenKeys, dateRange)
|
||||
return importSourceParser(source, seenKeys, 'cursor') ?? createParser(source, seenKeys, dateRange)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from 'path'
|
|||
import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { estimateTokensFromChars } from '../token-estimate.js'
|
||||
import { importSource, importSourceParser } from '../cursor-import.js'
|
||||
import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js'
|
||||
|
||||
// Grok Bot is xAI's Electron desktop agent app (bundle id com.anysphere.sand),
|
||||
|
|
@ -293,11 +294,11 @@ export function createGrokbotProvider(persistenceDir?: string): Provider {
|
|||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessions(resolveDir())
|
||||
return [...await discoverSessions(resolveDir()), ...importSource('grokbot')]
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
return importSourceParser(source, seenKeys, 'grokbot') ?? createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
235
tests/cursor-import.test.ts
Normal file
235
tests/cursor-import.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'fs/promises'
|
||||
import { existsSync, mkdtempSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir, tmpdir } from 'os'
|
||||
|
||||
import {
|
||||
CURSOR_CSV_HEADER,
|
||||
cursorImportPath,
|
||||
importCursorCsv,
|
||||
parseBoundary,
|
||||
parseCursorUsageCsv,
|
||||
removeCursorImport,
|
||||
replacedProviders,
|
||||
} from '../src/cursor-import.js'
|
||||
import { ensureCacheHydrated, invalidateProviderDays, loadDailyCache, saveDailyCache, emptyCache, toDateString, type DailyEntry } from '../src/daily-cache.js'
|
||||
import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
|
||||
import { calculateCost, loadPricing } from '../src/models.js'
|
||||
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
||||
import type { DateRange, ProjectSummary } from '../src/types.js'
|
||||
|
||||
const DAY = 86_400_000
|
||||
// Relative to now so every fixture day sits inside the daily cache's backfill
|
||||
// window and before yesterday, where days are sealed.
|
||||
const base = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()) - 20 * DAY
|
||||
const iso = (offsetDays: number, hour = 12) => new Date(base + offsetDays * DAY + hour * 3_600_000).toISOString()
|
||||
const dayOf = (offsetDays: number) => iso(offsetDays).slice(0, 10)
|
||||
|
||||
type Row = { date: string; model: string; cw?: number; input?: number; cr?: number; out?: number; cost?: string; kind?: string }
|
||||
|
||||
function csv(rows: Row[]): string {
|
||||
const q = (v: string | number) => `"${v}"`
|
||||
return [
|
||||
CURSOR_CSV_HEADER.join(','),
|
||||
...rows.map(r => {
|
||||
const t = [r.cw ?? 0, r.input ?? 0, r.cr ?? 0, r.out ?? 0]
|
||||
return [r.date, '', '', r.kind ?? 'Included', r.model, 'No', ...t, t.reduce((a, b) => a + b, 0), r.cost ?? 'Included'].map(q).join(',')
|
||||
}),
|
||||
].join('\n') + '\n'
|
||||
}
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ date: iso(0, 1), model: 'auto', cw: 10, input: 100, cr: 5000, out: 50 },
|
||||
{ date: iso(0, 13), model: 'claude-opus-5-thinking-high', input: 200, cr: 8000, out: 90, cost: '$1.25', kind: 'On-Demand' },
|
||||
{ date: iso(1, 9), model: 'cursor-grok-4.6-high', input: 300, cr: 1000, out: 20 },
|
||||
{ date: iso(1, 10), model: 'grok-bot-automation', input: 40, cr: 70000, out: 7 },
|
||||
{ date: iso(2, 22), model: 'composer-2.5-fast', cost: 'Free' },
|
||||
]
|
||||
|
||||
const HOME = mkdtempSync(join(tmpdir(), 'cursor-import-home-'))
|
||||
let root: string
|
||||
let csvPath: string
|
||||
|
||||
async function writeCsv(rows: Row[], name = 'usage.csv', savedAt = base + 30 * DAY): Promise<string> {
|
||||
const path = join(root, name)
|
||||
await writeFile(path, csv(rows))
|
||||
await utimes(path, savedAt / 1000, savedAt / 1000)
|
||||
return path
|
||||
}
|
||||
|
||||
// Cursor Agent transcripts carry no timestamps without their summary db, so
|
||||
// each one is stamped with its file mtime.
|
||||
async function writeAgentTranscript(id: string, mtimeMs: number): Promise<void> {
|
||||
const dir = join(homedir(), '.cursor', 'projects', 'proj', 'agent-transcripts')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const path = join(dir, `${id}.txt`)
|
||||
await writeFile(path, `user:\n<user_query>question ${id}</user_query>\nA:\nanswer ${'x'.repeat(400)}\n`)
|
||||
await utimes(path, mtimeMs / 1000, mtimeMs / 1000)
|
||||
}
|
||||
|
||||
type Totals = { calls: number; tokens: number; cost: number }
|
||||
|
||||
function byProvider(projects: ProjectSummary[]): Record<string, Totals> {
|
||||
const out: Record<string, Totals> = {}
|
||||
for (const p of projects) for (const s of p.sessions) for (const t of s.turns) for (const c of t.assistantCalls) {
|
||||
const acc = out[c.provider] ??= { calls: 0, tokens: 0, cost: 0 }
|
||||
acc.calls++
|
||||
acc.tokens += c.usage.inputTokens + c.usage.outputTokens + c.usage.cacheReadInputTokens + c.usage.cacheCreationInputTokens
|
||||
acc.cost += c.costUSD
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function parse(range: DateRange): Promise<Record<string, Totals>> {
|
||||
clearSessionCache()
|
||||
return byProvider(await parseAllSessions(range, 'all'))
|
||||
}
|
||||
|
||||
const whole: DateRange = { start: new Date(base - 10 * DAY), end: new Date(base + 10 * DAY) }
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'cursor-import-test-'))
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache')
|
||||
// The Cursor Agent provider resolves its home once, when it first loads,
|
||||
// so every test shares one home and starts from an empty one.
|
||||
// os.homedir() reads USERPROFILE on Windows and HOME elsewhere.
|
||||
process.env['HOME'] = HOME
|
||||
process.env['USERPROFILE'] = HOME
|
||||
await rm(HOME, { recursive: true, force: true })
|
||||
await loadPricing()
|
||||
csvPath = await writeCsv(ROWS)
|
||||
})
|
||||
|
||||
describe('parseCursorUsageCsv', () => {
|
||||
it('rejects a file that is not a Cursor usage export', () => {
|
||||
expect(() => parseCursorUsageCsv('Date,Model,Cost\n"2026-01-01T00:00:00Z","auto","1"\n')).toThrow(/not a Cursor usage export/)
|
||||
})
|
||||
|
||||
it('rejects a row whose Total Tokens disagrees with its parts', () => {
|
||||
const bad = csv(ROWS.slice(0, 1)).replace('"5160","Included"', '"5161","Included"')
|
||||
expect(() => parseCursorUsageCsv(bad)).toThrow(/Total Tokens/)
|
||||
})
|
||||
|
||||
it('reads empty token cells as zero', () => {
|
||||
const [free] = parseCursorUsageCsv(`${CURSOR_CSV_HEADER.join(',')}\n"${iso(0)}","","","Included","auto","No","","","","","","Free"\n`)
|
||||
expect(free).toMatchObject({ input: 0, output: 0, cacheRead: 0, inputCacheWrite: 0, cost: 'Free' })
|
||||
})
|
||||
|
||||
it('reads the export URL boundaries as epoch milliseconds', () => {
|
||||
expect(new Date(parseBoundary('1787788800000', 'from')).toISOString()).toBe('2026-08-27T00:00:00.000Z')
|
||||
expect(new Date(parseBoundary('2026-09-25', 'to')).toISOString()).toBe('2026-09-25T23:59:59.999Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('importCursorCsv', () => {
|
||||
it('re-importing an overlapping export never double counts', async () => {
|
||||
const first = await importCursorCsv(csvPath)
|
||||
expect(first).toMatchObject({ added: 5, skipped: 0, total: 5 })
|
||||
const savedAt = (await stat(cursorImportPath())).mtimeMs
|
||||
const again = await importCursorCsv(csvPath)
|
||||
expect(again).toMatchObject({ changed: false, added: 0, skipped: 5, total: 5 })
|
||||
expect((await stat(cursorImportPath())).mtimeMs).toBe(savedAt)
|
||||
const overlap = await writeCsv([...ROWS.slice(3), { date: iso(3, 8), model: 'auto', input: 7 }], 'later.csv')
|
||||
expect(await importCursorCsv(overlap)).toMatchObject({ added: 1, skipped: 2, total: 6 })
|
||||
const stored = JSON.parse(await readFile(cursorImportPath(), 'utf-8'))
|
||||
expect(stored.ranges).toEqual([{ start: `${dayOf(0)}T00:00:00.000Z`, end: `${dayOf(3)}T23:59:59.999Z` }])
|
||||
})
|
||||
|
||||
it('takes an explicit range and refuses events outside it', async () => {
|
||||
const s = await importCursorCsv(csvPath, { from: base - 3 * DAY, to: base + 5 * DAY - 1 })
|
||||
expect(s.coverage).toMatchObject({ start: new Date(base - 3 * DAY).toISOString(), inferred: false })
|
||||
await expect(importCursorCsv(csvPath, { from: base + DAY })).rejects.toThrow(/outside/)
|
||||
})
|
||||
|
||||
it('ends coverage when the file was saved, not at the end of its last day', async () => {
|
||||
const saved = base + 2 * DAY + 23 * 3_600_000
|
||||
const s = await importCursorCsv(await writeCsv(ROWS, 'fresh.csv', saved), { to: base + 3 * DAY - 1 })
|
||||
expect(s.coverage.end).toBe(new Date(saved).toISOString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cursor import through the report pipeline', () => {
|
||||
it('replaces local estimates inside coverage only, and removal restores them', async () => {
|
||||
await writeAgentTranscript('inside', base + DAY + 5 * 3_600_000)
|
||||
await writeAgentTranscript('outside', base - 5 * DAY)
|
||||
const before = await parse(whole)
|
||||
expect(before['cursor-agent']!.calls).toBe(2)
|
||||
|
||||
await importCursorCsv(csvPath)
|
||||
const after = await parse(whole)
|
||||
const outsideOnly = await parse({ start: whole.start, end: new Date(base - 1) })
|
||||
expect(after['cursor-agent']).toEqual(outsideOnly['cursor-agent'])
|
||||
expect(after['cursor-agent']!.calls).toBe(1)
|
||||
|
||||
// Every imported event is served exactly once: IDE work under Cursor,
|
||||
// Grok Bot cloud work under Grok Bot.
|
||||
expect(after['cursor']).toMatchObject({ calls: 4, tokens: 5160 + 8290 + 1320 + 0 })
|
||||
expect(after['grokbot']).toMatchObject({ calls: 1, tokens: 70047 })
|
||||
const opus = calculateCost('cursor-auto', 100, 50, 10, 5000, 0)
|
||||
+ 1.25
|
||||
+ calculateCost('grok-4.6-high', 300, 20, 0, 1000, 0)
|
||||
expect(after['cursor']!.cost).toBeCloseTo(opus, 10)
|
||||
|
||||
// Additive and monotonic across the coverage boundary.
|
||||
const inside = await parse({ start: new Date(base), end: whole.end })
|
||||
for (const p of ['cursor', 'cursor-agent', 'grokbot']) {
|
||||
const sum = (inside[p]?.tokens ?? 0) + (outsideOnly[p]?.tokens ?? 0)
|
||||
expect(after[p]?.tokens ?? 0).toBe(sum)
|
||||
}
|
||||
|
||||
await removeCursorImport()
|
||||
expect(existsSync(cursorImportPath())).toBe(false)
|
||||
expect(await parse(whole)).toEqual(before)
|
||||
})
|
||||
|
||||
it('the daily cache re-derives the covered days after an import and after removal', async () => {
|
||||
await writeAgentTranscript('inside', base + DAY + 5 * 3_600_000)
|
||||
await writeAgentTranscript('outside', base - 5 * DAY)
|
||||
const hydrate = () => {
|
||||
clearSessionCache()
|
||||
return ensureCacheHydrated((range) => parseAllSessions(range, 'all'), aggregateProjectsIntoDays)
|
||||
}
|
||||
const slice = (days: DailyEntry[], date: string, provider: string) => days.find(d => d.date === date)?.providers[provider]
|
||||
|
||||
const sealed = await hydrate()
|
||||
const coveredDay = toDateString(new Date(base + DAY + 5 * 3_600_000))
|
||||
expect(slice(sealed.days, coveredDay, 'cursor-agent')?.calls).toBe(1)
|
||||
|
||||
const s = await importCursorCsv(csvPath)
|
||||
await invalidateProviderDays(replacedProviders(), toDateString(new Date(s.coverage.start)), toDateString(new Date(s.coverage.end)))
|
||||
const imported = await hydrate()
|
||||
expect(slice(imported.days, coveredDay, 'cursor-agent')).toBeUndefined()
|
||||
expect(slice(imported.days, coveredDay, 'grokbot')?.calls).toBe(1)
|
||||
const cursorCalls = imported.days.reduce((n, d) => n + (d.providers['cursor']?.calls ?? 0), 0)
|
||||
expect(cursorCalls).toBe(4)
|
||||
expect(slice(imported.days, toDateString(new Date(base - 5 * DAY)), 'cursor-agent')?.calls).toBe(1)
|
||||
|
||||
const ranges = (await removeCursorImport())!
|
||||
for (const r of ranges) await invalidateProviderDays(replacedProviders(), toDateString(new Date(r.start)), toDateString(new Date(r.end)))
|
||||
const restored = await hydrate()
|
||||
expect(restored.days).toEqual(sealed.days)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateProviderDays', () => {
|
||||
it('drops only the named providers on the named days and pulls the watermark back', async () => {
|
||||
const day = (date: string): DailyEntry => ({
|
||||
date, cost: 3, savingsUSD: 0, calls: 3, sessions: 2, inputTokens: 30, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
editTurns: 0, oneShotTurns: 0, models: {}, categories: {},
|
||||
providers: {
|
||||
cursor: { calls: 1, cost: 1, savingsUSD: 0, sessions: 1, inputTokens: 10 },
|
||||
claude: { calls: 2, cost: 2, savingsUSD: 0, sessions: 1, inputTokens: 20 },
|
||||
},
|
||||
})
|
||||
await saveDailyCache({ ...emptyCache(), complete: true, lastComputedDate: dayOf(5), days: [day(dayOf(0)), day(dayOf(2)), day(dayOf(4))] })
|
||||
await invalidateProviderDays(['cursor'], dayOf(1), dayOf(3))
|
||||
const c = await loadDailyCache()
|
||||
expect(c.lastComputedDate).toBe(dayOf(0))
|
||||
expect(c.days.map(d => [d.date, Object.keys(d.providers), d.cost])).toEqual([
|
||||
[dayOf(0), ['cursor', 'claude'], 3],
|
||||
[dayOf(2), ['claude'], 2],
|
||||
[dayOf(4), ['cursor', 'claude'], 3],
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue