diff --git a/src/models.ts b/src/models.ts index 890a026..e501fbf 100644 --- a/src/models.ts +++ b/src/models.ts @@ -221,9 +221,12 @@ export function getShortModelName(model: string): string { 'gpt-4.1-nano': 'GPT-4.1 Nano', 'gpt-4.1-mini': 'GPT-4.1 Mini', 'gpt-4.1': 'GPT-4.1', + 'codex-auto-review': 'Codex Auto Review', 'gpt-5.4-mini': 'GPT-5.4 Mini', 'gpt-5.4': 'GPT-5.4', 'gpt-5.3-codex': 'GPT-5.3 Codex', + 'gpt-5.2-low': 'GPT-5.2 Low', + 'gpt-5.2': 'GPT-5.2', 'gpt-5-mini': 'GPT-5 Mini', 'gpt-5': 'GPT-5', 'gemini-2.5-pro': 'Gemini 2.5 Pro', diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 01d48b7..cd49c1a 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -7,9 +7,12 @@ import { calculateCost } from '../models.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' const modelDisplayNames: Record = { - 'gpt-5.3-codex': 'GPT-5.3 Codex', + 'codex-auto-review': 'Codex Auto Review', 'gpt-5.4-mini': 'GPT-5.4 Mini', 'gpt-5.4': 'GPT-5.4', + 'gpt-5.3-codex': 'GPT-5.3 Codex', + 'gpt-5.2-low': 'GPT-5.2 Low', + 'gpt-5.2': 'GPT-5.2', 'gpt-5': 'GPT-5', 'gpt-4o-mini': 'GPT-4o Mini', 'gpt-4o': 'GPT-4o', @@ -132,7 +135,8 @@ async function discoverSessionsInDir(codexDir: string): Promise } function resolveModel(info: CodexEntry['payload'], sessionModel?: string): string { - return info?.info?.model + return info?.model + ?? info?.info?.model ?? info?.info?.model_name ?? sessionModel ?? 'gpt-5' @@ -164,7 +168,12 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars if (entry.type === 'session_meta') { sessionId = entry.payload?.session_id ?? basename(source.path, '.jsonl') - sessionModel = entry.payload?.model + sessionModel = entry.payload?.model ?? sessionModel + continue + } + + if (entry.type === 'turn_context' && entry.payload?.model) { + sessionModel = entry.payload.model continue } diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index 839538a..0c4c61e 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -18,6 +18,8 @@ const modelDisplayNames: Record = { 'composer-1': 'Composer 1', 'grok-code-fast-1': 'Grok Code Fast', 'gemini-3-pro': 'Gemini 3 Pro', + 'gpt-5.2-low': 'GPT-5.2 Low', + 'gpt-5.2': 'GPT-5.2', 'gpt-5.1-codex-high': 'GPT-5.1 Codex', 'gpt-5': 'GPT-5', 'gpt-4.1': 'GPT-4.1', @@ -34,6 +36,27 @@ type BubbleRow = { code_blocks: string | null } +type AgentKvRow = { + key: string + role: string | null + content: string | null + request_id: string | null + content_length: number +} + +type AgentKvContent = { + type?: string + text?: string + providerOptions?: { + cursor?: { + modelName?: string + requestId?: string + } + } +} + +const CHARS_PER_TOKEN = 4 + function getCursorDbPath(): string { if (process.platform === 'darwin') { return join(homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb') @@ -87,6 +110,19 @@ const BUBBLE_QUERY_BASE = ` AND json_extract(value, '$.tokenCount.inputTokens') > 0 ` +const AGENTKV_QUERY = ` + SELECT + key, + json_extract(value, '$.role') as role, + json_extract(value, '$.content') as content, + json_extract(value, '$.providerOptions.cursor.requestId') as request_id, + length(value) as content_length + FROM cursorDiskKV + WHERE key LIKE 'agentKv:blob:%' + AND hex(substr(value, 1, 1)) = '7B' + ORDER BY ROWID ASC +` + const USER_MESSAGES_QUERY = ` SELECT json_extract(value, '$.conversationId') as conversation_id, @@ -207,6 +243,116 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: Parse return { calls: results } } +function extractModelFromContent(content: AgentKvContent[]): string | null { + for (const c of content) { + if (c.providerOptions?.cursor?.modelName) { + return c.providerOptions.cursor.modelName + } + } + return null +} + +function extractTextLength(content: AgentKvContent[]): number { + let total = 0 + for (const c of content) { + if (c.text) total += c.text.length + } + return total +} + +function parseAgentKv(db: SqliteDatabase, seenKeys: Set): { calls: ParsedProviderCall[] } { + const results: ParsedProviderCall[] = [] + + let rows: AgentKvRow[] + try { + rows = db.query(AGENTKV_QUERY) + } catch { + return { calls: results } + } + + const sessions: Map = new Map() + let currentRequestId = 'unknown' + let turnIndex = 0 + + for (const row of rows) { + if (!row.role || !row.content) continue + + let content: AgentKvContent[] + try { + content = JSON.parse(row.content) + if (!Array.isArray(content)) continue + } catch { + continue + } + + const requestId = row.request_id ?? currentRequestId + if (requestId !== currentRequestId) { + currentRequestId = requestId + turnIndex = 0 + } + + const textLength = extractTextLength(content) + const model = extractModelFromContent(content) + + if (row.role === 'user') { + const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' } + existing.inputChars += textLength + if (!existing.userText && content[0]?.text) { + const text = content[0].text + const queryMatch = text.match(/([\s\S]*?)<\/user_query>/) + existing.userText = queryMatch ? queryMatch[1].trim().slice(0, 500) : text.slice(0, 500) + } + sessions.set(requestId, existing) + } else if (row.role === 'assistant') { + const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' } + existing.outputChars += textLength + if (model) existing.model = model + sessions.set(requestId, existing) + } else if (row.role === 'tool' || row.role === 'system') { + const existing = sessions.get(requestId) ?? { inputChars: 0, outputChars: 0, model: null, userText: '' } + existing.inputChars += textLength + sessions.set(requestId, existing) + } + } + + for (const [requestId, session] of sessions) { + if (session.inputChars === 0 && session.outputChars === 0) continue + + const inputTokens = Math.ceil(session.inputChars / CHARS_PER_TOKEN) + const outputTokens = Math.ceil(session.outputChars / CHARS_PER_TOKEN) + const dedupKey = `cursor:agentKv:${requestId}` + + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const pricingModel = resolveModel(session.model) + const displayModel = modelForDisplay(session.model) + const costUSD = calculateCost(pricingModel, inputTokens, outputTokens, 0, 0, 0) + + results.push({ + provider: 'cursor', + model: displayModel, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [], + bashCommands: [], + timestamp: new Date().toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: session.userText, + sessionId: requestId, + }) + } + + return { calls: results } +} + function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { @@ -239,7 +385,12 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars return } - const { calls } = parseBubbles(db, seenKeys) + let { calls } = parseBubbles(db, seenKeys) + + if (calls.length === 0) { + const agentKvResult = parseAgentKv(db, seenKeys) + calls = agentKvResult.calls + } await writeCachedResults(source.path, calls)