mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-13 14:33:00 +00:00
Cursor stores createdAt as ISO strings ('2026-02-23T06:00:51.123Z'),
not numeric timestamps. Was comparing string against number, so all
rows were filtered out. Now uses ISO string comparison throughout.
227 lines
6.8 KiB
TypeScript
227 lines
6.8 KiB
TypeScript
import { existsSync, statSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { homedir } from 'os'
|
|
|
|
import { calculateCost } from '../models.js'
|
|
import { readCursorCache, writeCursorCache } from '../cursor-cache.js'
|
|
import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
|
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
|
|
|
const CURSOR_DEFAULT_MODEL = 'claude-sonnet-4-5'
|
|
|
|
const modelDisplayNames: Record<string, string> = {
|
|
'claude-4.5-opus-high-thinking': 'Opus 4.5 (Thinking)',
|
|
'claude-4-opus': 'Opus 4',
|
|
'claude-4-sonnet-thinking': 'Sonnet 4 (Thinking)',
|
|
'claude-4.5-sonnet-thinking': 'Sonnet 4.5 (Thinking)',
|
|
'claude-4.6-sonnet': 'Sonnet 4.6',
|
|
'composer-1': 'Composer 1',
|
|
'grok-code-fast-1': 'Grok Code Fast',
|
|
'gemini-3-pro': 'Gemini 3 Pro',
|
|
'gpt-5.1-codex-high': 'GPT-5.1 Codex',
|
|
'gpt-5': 'GPT-5',
|
|
'gpt-4.1': 'GPT-4.1',
|
|
'default': 'Auto (Sonnet est.)',
|
|
}
|
|
|
|
type BubbleRow = {
|
|
input_tokens: number | null
|
|
output_tokens: number | null
|
|
model: string | null
|
|
created_at: string | null
|
|
conversation_id: string | null
|
|
}
|
|
|
|
function getCursorDbPath(): string {
|
|
if (process.platform === 'darwin') {
|
|
return join(homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb')
|
|
}
|
|
if (process.platform === 'win32') {
|
|
return join(homedir(), 'AppData', 'Roaming', 'Cursor', 'User', 'globalStorage', 'state.vscdb')
|
|
}
|
|
return join(homedir(), '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb')
|
|
}
|
|
|
|
function resolveModel(raw: string | null): string {
|
|
if (!raw || raw === 'default') return CURSOR_DEFAULT_MODEL
|
|
return raw
|
|
}
|
|
|
|
function modelForDisplay(raw: string | null): string {
|
|
if (!raw || raw === 'default') return 'default'
|
|
return raw
|
|
}
|
|
|
|
const BUBBLE_QUERY_BASE = `
|
|
SELECT
|
|
json_extract(value, '$.tokenCount.inputTokens') as input_tokens,
|
|
json_extract(value, '$.tokenCount.outputTokens') as output_tokens,
|
|
json_extract(value, '$.modelInfo.modelName') as model,
|
|
json_extract(value, '$.createdAt') as created_at,
|
|
json_extract(value, '$.conversationId') as conversation_id
|
|
FROM cursorDiskKV
|
|
WHERE key LIKE 'bubbleId:%'
|
|
AND json_extract(value, '$.tokenCount.inputTokens') > 0
|
|
`
|
|
|
|
const BUBBLE_QUERY_SINCE = BUBBLE_QUERY_BASE + `
|
|
AND json_extract(value, '$.createdAt') > ?
|
|
ORDER BY json_extract(value, '$.createdAt') ASC
|
|
`
|
|
|
|
function validateSchema(db: SqliteDatabase): boolean {
|
|
try {
|
|
const rows = db.query<{ cnt: number }>(
|
|
"SELECT COUNT(*) as cnt FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' LIMIT 1"
|
|
)
|
|
return rows.length > 0
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function parseBubbles(db: SqliteDatabase, seenKeys: Set<string>, afterTimestamp?: string): { calls: ParsedProviderCall[]; maxCreatedAt: string } {
|
|
const results: ParsedProviderCall[] = []
|
|
let skipped = 0
|
|
let maxCreatedAt = afterTimestamp ?? ''
|
|
|
|
const DEFAULT_LOOKBACK_DAYS = 120
|
|
const timeFloor = afterTimestamp
|
|
?? new Date(Date.now() - DEFAULT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString()
|
|
|
|
let rows: BubbleRow[]
|
|
try {
|
|
rows = db.query<BubbleRow>(BUBBLE_QUERY_SINCE, [timeFloor])
|
|
} catch {
|
|
return { calls: results, maxCreatedAt }
|
|
}
|
|
|
|
for (const row of rows) {
|
|
try {
|
|
const inputTokens = row.input_tokens ?? 0
|
|
const outputTokens = row.output_tokens ?? 0
|
|
if (inputTokens === 0 && outputTokens === 0) continue
|
|
|
|
const createdAt = (row.created_at as string) ?? ''
|
|
if (createdAt > maxCreatedAt) maxCreatedAt = createdAt
|
|
const conversationId = row.conversation_id ?? 'unknown'
|
|
const dedupKey = `cursor:${conversationId}:${createdAt}:${inputTokens}:${outputTokens}`
|
|
|
|
if (seenKeys.has(dedupKey)) continue
|
|
seenKeys.add(dedupKey)
|
|
|
|
const pricingModel = resolveModel(row.model)
|
|
const displayModel = modelForDisplay(row.model)
|
|
|
|
const costUSD = calculateCost(pricingModel, inputTokens, outputTokens, 0, 0, 0)
|
|
|
|
const timestamp = createdAt || ''
|
|
|
|
results.push({
|
|
provider: 'cursor',
|
|
model: displayModel,
|
|
inputTokens,
|
|
outputTokens,
|
|
cacheCreationInputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
reasoningTokens: 0,
|
|
webSearchRequests: 0,
|
|
costUSD,
|
|
tools: [],
|
|
timestamp,
|
|
speed: 'standard',
|
|
deduplicationKey: dedupKey,
|
|
userMessage: '',
|
|
sessionId: conversationId,
|
|
})
|
|
} catch {
|
|
skipped++
|
|
}
|
|
}
|
|
|
|
if (skipped > 0) {
|
|
process.stderr.write(`codeburn: skipped ${skipped} unreadable Cursor entries\n`)
|
|
}
|
|
|
|
return { calls: results, maxCreatedAt }
|
|
}
|
|
|
|
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
|
return {
|
|
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
|
if (!isSqliteAvailable()) {
|
|
process.stderr.write(getSqliteLoadError() + '\n')
|
|
return
|
|
}
|
|
|
|
let db: SqliteDatabase
|
|
try {
|
|
db = openDatabase(source.path)
|
|
} catch (err) {
|
|
process.stderr.write(`codeburn: cannot open Cursor database: ${err instanceof Error ? err.message : err}\n`)
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (!validateSchema(db)) {
|
|
process.stderr.write('codeburn: Cursor storage format not recognized. You may need to update CodeBurn.\n')
|
|
return
|
|
}
|
|
|
|
const cache = await readCursorCache()
|
|
let dbSize = 0
|
|
try { dbSize = statSync(source.path).size } catch {}
|
|
const cacheValid = cache
|
|
&& cache.lastCreatedAt.length > 0
|
|
&& cache.dbSizeBytes > 0
|
|
&& dbSize >= cache.dbSizeBytes
|
|
const afterTimestamp = cacheValid ? cache.lastCreatedAt : undefined
|
|
|
|
await new Promise(r => setTimeout(r, 0))
|
|
|
|
const { calls, maxCreatedAt } = parseBubbles(db, seenKeys, afterTimestamp)
|
|
|
|
if (maxCreatedAt.length > 0) {
|
|
await writeCursorCache(maxCreatedAt, dbSize).catch(() => {})
|
|
}
|
|
|
|
for (const call of calls) {
|
|
yield call
|
|
}
|
|
} finally {
|
|
db.close()
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
export function createCursorProvider(dbPathOverride?: string): Provider {
|
|
return {
|
|
name: 'cursor',
|
|
displayName: 'Cursor',
|
|
|
|
modelDisplayName(model: string): string {
|
|
return modelDisplayNames[model] ?? model
|
|
},
|
|
|
|
toolDisplayName(rawTool: string): string {
|
|
return rawTool
|
|
},
|
|
|
|
async discoverSessions(): Promise<SessionSource[]> {
|
|
if (!isSqliteAvailable()) return []
|
|
|
|
const dbPath = dbPathOverride ?? getCursorDbPath()
|
|
if (!existsSync(dbPath)) return []
|
|
|
|
return [{ path: dbPath, project: 'cursor', provider: 'cursor' }]
|
|
},
|
|
|
|
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
|
return createParser(source, seenKeys)
|
|
},
|
|
}
|
|
}
|
|
|
|
export const cursor = createCursorProvider()
|