diff --git a/README.md b/README.md
index 7aac798..ec633cd 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
-By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. Native macOS menubar app in `mac/`. CSV/JSON export.
+By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **cursor-agent**, **OpenCode**, **Pi**, and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. Native macOS menubar app in `mac/`. CSV/JSON export.
Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported).
@@ -108,6 +108,7 @@ codeburn report # all providers combined (default)
codeburn report --provider claude # Claude Code only
codeburn report --provider codex # Codex only
codeburn report --provider cursor # Cursor only
+codeburn report --provider cursor-agent # cursor-agent CLI only
codeburn report --provider opencode # OpenCode only
codeburn report --provider pi # Pi only
codeburn report --provider copilot # GitHub Copilot only
diff --git a/src/providers/cursor-agent.ts b/src/providers/cursor-agent.ts
new file mode 100644
index 0000000..c8a29d0
--- /dev/null
+++ b/src/providers/cursor-agent.ts
@@ -0,0 +1,423 @@
+import { createHash } from 'crypto'
+import { existsSync } from 'fs'
+import { readdir, readFile, stat } from 'fs/promises'
+import { join, basename } from 'path'
+import { homedir } from 'os'
+
+import { calculateCost } from '../models.js'
+import { openDatabase, type SqliteDatabase } from '../sqlite.js'
+import type {
+ Provider,
+ SessionSource,
+ SessionParser,
+ ParsedProviderCall,
+} from './types.js'
+
+type ConversationSummary = {
+ conversationId: string
+ model: string | null
+ title: string | null
+ updatedAt: string | null
+}
+
+type AssistantTurn = {
+ body: string
+ reasoning: string
+ tools: string[]
+}
+
+type ParsedTurn = {
+ userMessage: string
+ assistant: AssistantTurn
+}
+
+const CURSOR_AGENT_DEFAULT_MODEL = 'claude-sonnet-4-5'
+const CHARS_PER_TOKEN = 4
+const MAX_USER_TEXT_LENGTH = 500
+const DIGITS_ONLY = /^\d+$/
+const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+const USER_MARKER = /^\s*user:\s*/i
+const ASSISTANT_MARKER = /^\s*A:\s*/
+const THINKING_MARKER = /^\s*\[Thinking\]\s*/
+const TOOL_CALL_MARKER = /^\s*\[Tool call\]\s*(.+?)\s*$/i
+const TOOL_RESULT_MARKER = /^\s*\[Tool result\]\b/i
+const USER_QUERY_OPEN = ''
+const USER_QUERY_CLOSE = ''
+const CONVERSATION_SUMMARY_QUERY = `
+ SELECT conversationId, model, title, updatedAt
+ FROM conversation_summaries
+ WHERE conversationId = ?
+`
+
+const modelDisplayNames: Record = {
+ '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.)',
+}
+
+function getCursorAgentBaseDir(baseDirOverride?: string): string {
+ if (baseDirOverride) return baseDirOverride
+ // Windows paths unverified; tracked as Open Question 3 in issue #55.
+ return join(homedir(), '.cursor')
+}
+
+function getProjectsDir(baseDir: string): string {
+ return join(baseDir, 'projects')
+}
+
+function getAttributionDbPath(baseDir: string): string {
+ return join(baseDir, 'ai-tracking', 'ai-code-tracking.db')
+}
+
+function estimateTokens(charCount: number): number {
+ if (charCount <= 0) return 0
+ return Math.ceil(charCount / CHARS_PER_TOKEN)
+}
+
+function parseToolName(raw: string): string {
+ const clean = raw.trim()
+ if (clean.length === 0) return 'unknown'
+ return clean.toLowerCase().replace(/\s+/g, '-')
+}
+
+function normalizeTimestamp(raw: string | number | null | undefined): string | null {
+ if (raw === null || raw === undefined) return null
+ if (typeof raw === 'string') {
+ const trimmed = raw.trim()
+ if (trimmed.length === 0) return null
+ if (DIGITS_ONLY.test(trimmed)) {
+ const num = Number(trimmed)
+ if (!Number.isNaN(num)) {
+ const ms = num < 1e12 ? num * 1000 : num
+ return new Date(ms).toISOString()
+ }
+ }
+ const parsed = new Date(trimmed)
+ if (!Number.isNaN(parsed.getTime())) return parsed.toISOString()
+ return null
+ }
+
+ const ms = raw < 1e12 ? raw * 1000 : raw
+ return new Date(ms).toISOString()
+}
+
+function prettifyProjectId(raw: string): string {
+ if (!raw) return raw
+
+ if (DIGITS_ONLY.test(raw)) {
+ const num = Number(raw)
+ if (!Number.isNaN(num) && raw.length >= 13) {
+ const iso = new Date(num).toISOString()
+ return `cursor-agent:${iso}`
+ }
+ }
+
+ const withoutPrefix = raw.replace(/^-Users-/, '')
+ const parts = withoutPrefix.split('-').filter(Boolean)
+ if (parts.length > 0) return parts[parts.length - 1]!
+
+ return raw
+}
+
+function resolveModel(raw: string | null | undefined): string {
+ if (!raw || raw === 'default') return CURSOR_AGENT_DEFAULT_MODEL
+ return raw
+}
+
+function toConversationId(transcriptPath: string): string {
+ const filename = basename(transcriptPath, '.txt')
+ if (filename.length === 36 && UUID_LIKE.test(filename)) return filename
+ return createHash('sha1').update(transcriptPath).digest('hex').slice(0, 16)
+}
+
+function extractUserQuery(userBlock: string): string {
+ const chunks: string[] = []
+ let cursor = 0
+
+ while (cursor < userBlock.length) {
+ const openIndex = userBlock.indexOf(USER_QUERY_OPEN, cursor)
+ if (openIndex === -1) break
+ const start = openIndex + USER_QUERY_OPEN.length
+ const closeIndex = userBlock.indexOf(USER_QUERY_CLOSE, start)
+ if (closeIndex === -1) {
+ chunks.push(userBlock.slice(start).trim())
+ break
+ }
+ chunks.push(userBlock.slice(start, closeIndex).trim())
+ cursor = closeIndex + USER_QUERY_CLOSE.length
+ }
+
+ const combined = chunks.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
+ return combined.slice(0, MAX_USER_TEXT_LENGTH)
+}
+
+function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolean } {
+ const lines = raw.split(/\r?\n/)
+ let recognized = false
+
+ const pendingUsers: string[] = []
+ const turns: ParsedTurn[] = []
+
+ let active: 'none' | 'user' | 'assistant' = 'none'
+ let userLines: string[] = []
+ let assistantLines: string[] = []
+
+ const flushUser = () => {
+ if (userLines.length === 0) return
+ const userQuery = extractUserQuery(userLines.join('\n'))
+ if (userQuery.length > 0) pendingUsers.push(userQuery)
+ userLines = []
+ }
+
+ const flushAssistant = () => {
+ if (assistantLines.length === 0) return
+
+ let output = ''
+ let reasoning = ''
+ const toolsByTurn: Record = Object.create(null)
+
+ for (const line of assistantLines) {
+ if (TOOL_RESULT_MARKER.test(line)) continue
+
+ const thinkingMatch = line.match(THINKING_MARKER)
+ if (thinkingMatch) {
+ const body = line.replace(THINKING_MARKER, '').trim()
+ if (body.length > 0) reasoning += `${body}\n`
+ continue
+ }
+
+ const toolMatch = line.match(TOOL_CALL_MARKER)
+ if (toolMatch) {
+ const parsedTool = parseToolName(toolMatch[1] ?? '')
+ const toolKey = `cursor:${parsedTool}`
+ toolsByTurn[toolKey] = true
+ continue
+ }
+
+ output += `${line}\n`
+ }
+
+ if (pendingUsers.length > 0) {
+ const userMessage = pendingUsers.shift()!
+ const tools = Object.keys(toolsByTurn)
+ turns.push({
+ userMessage,
+ assistant: {
+ body: output.trim(),
+ reasoning: reasoning.trim(),
+ tools,
+ },
+ })
+ }
+
+ assistantLines = []
+ }
+
+ for (const line of lines) {
+ if (USER_MARKER.test(line)) {
+ recognized = true
+ if (active === 'user') flushUser()
+ if (active === 'assistant') flushAssistant()
+ active = 'user'
+ userLines = [line.replace(USER_MARKER, '')]
+ continue
+ }
+
+ if (ASSISTANT_MARKER.test(line)) {
+ recognized = true
+ if (active === 'user') flushUser()
+ if (active === 'assistant') flushAssistant()
+ active = 'assistant'
+ assistantLines = [line.replace(ASSISTANT_MARKER, '')]
+ continue
+ }
+
+ if (active === 'user') {
+ userLines.push(line)
+ continue
+ }
+
+ if (active === 'assistant') {
+ assistantLines.push(line)
+ }
+ }
+
+ if (active === 'user') flushUser()
+ if (active === 'assistant') flushAssistant()
+
+ return { turns, recognized }
+}
+
+function createParser(
+ source: SessionSource,
+ seenKeys: Set,
+ dbPath: string,
+ summariesByConversationId: Record,
+): SessionParser {
+ return {
+ async *parse(): AsyncGenerator {
+ const conversationId = toConversationId(source.path)
+
+ let summary = summariesByConversationId[conversationId]
+ let db: SqliteDatabase | null = null
+
+ try {
+ if (!summary) {
+ if (existsSync(dbPath)) {
+ try {
+ db = openDatabase(dbPath)
+ const rows = db.query<{
+ conversationId: string
+ model: string | null
+ title: string | null
+ updatedAt: string | number | null
+ }>(CONVERSATION_SUMMARY_QUERY, [conversationId])
+
+ if (rows.length > 0) {
+ const row = rows[0]!
+ summary = {
+ conversationId: row.conversationId,
+ model: row.model,
+ title: row.title,
+ updatedAt: normalizeTimestamp(row.updatedAt),
+ }
+ summariesByConversationId[conversationId] = summary
+ }
+ } catch {
+ summary = undefined
+ }
+ }
+ }
+
+ const transcript = await readFile(source.path, 'utf-8')
+ const parsed = parseTranscript(transcript)
+
+ if (!parsed.recognized) {
+ process.stderr.write(`codeburn: skipped ${basename(source.path)}: unrecognized cursor-agent transcript format\n`)
+ return
+ }
+
+ let timestamp = summary?.updatedAt ?? null
+ if (!timestamp) {
+ const fileStat = await stat(source.path)
+ timestamp = fileStat.mtime.toISOString()
+ }
+
+ const model = resolveModel(summary?.model ?? null)
+
+ for (let turnIndex = 0; turnIndex < parsed.turns.length; turnIndex++) {
+ const turn = parsed.turns[turnIndex]!
+ const inputTokens = estimateTokens(turn.userMessage.length)
+ const outputTokens = estimateTokens(turn.assistant.body.length)
+ const reasoningTokens = estimateTokens(turn.assistant.reasoning.length)
+ const deduplicationKey = `cursor-agent:${conversationId}:${turnIndex}`
+
+ if (seenKeys.has(deduplicationKey)) continue
+ seenKeys.add(deduplicationKey)
+
+ const costUSD = calculateCost(
+ model,
+ inputTokens,
+ outputTokens + reasoningTokens,
+ 0,
+ 0,
+ 0,
+ )
+
+ yield {
+ provider: 'cursor-agent',
+ model,
+ inputTokens,
+ outputTokens,
+ cacheCreationInputTokens: 0,
+ cacheReadInputTokens: 0,
+ cachedInputTokens: 0,
+ reasoningTokens,
+ webSearchRequests: 0,
+ costUSD,
+ tools: turn.assistant.tools,
+ bashCommands: [],
+ timestamp,
+ speed: 'standard',
+ deduplicationKey,
+ userMessage: turn.userMessage,
+ sessionId: conversationId,
+ }
+ }
+ } finally {
+ db?.close()
+ }
+ },
+ }
+}
+
+export function createCursorAgentProvider(baseDirOverride?: string): Provider {
+ const baseDir = getCursorAgentBaseDir(baseDirOverride)
+ const projectsDir = getProjectsDir(baseDir)
+ const dbPath = getAttributionDbPath(baseDir)
+ const summariesByConversationId: Record = Object.create(null)
+
+ return {
+ name: 'cursor-agent',
+ displayName: 'Cursor Agent',
+
+ modelDisplayName(model: string): string {
+ const label = modelDisplayNames[model] ?? modelDisplayNames.default
+ if (model === 'default') return label
+ return label.endsWith('(est.)') ? label : `${label} (est.)`
+ },
+
+ toolDisplayName(rawTool: string): string {
+ return rawTool
+ },
+
+ async discoverSessions(): Promise {
+ if (!existsSync(projectsDir)) return []
+
+ const projectEntries = await readdir(projectsDir, { withFileTypes: true })
+ const sources: SessionSource[] = []
+
+ for (const entry of projectEntries) {
+ if (!entry.isDirectory()) continue
+
+ const projectId = prettifyProjectId(entry.name)
+ const transcriptDir = join(projectsDir, entry.name, 'agent-transcripts')
+ if (!existsSync(transcriptDir)) continue
+
+ const transcriptEntries = await readdir(transcriptDir, { withFileTypes: true })
+ for (const transcript of transcriptEntries) {
+ if (!transcript.isFile()) continue
+ if (!transcript.name.endsWith('.txt')) continue
+
+ const transcriptPath = join(transcriptDir, transcript.name)
+ sources.push({
+ path: transcriptPath,
+ project: projectId,
+ provider: 'cursor-agent',
+ fingerprintPath: transcriptPath,
+ cacheStrategy: 'full-reparse',
+ progressLabel: `cursor-agent:${basename(transcript.name)}`,
+ parserVersion: 'cursor-agent:v1',
+ })
+ }
+ }
+
+ return sources
+ },
+
+ createSessionParser(source: SessionSource, seenKeys: Set): SessionParser {
+ return createParser(source, seenKeys, dbPath, summariesByConversationId)
+ },
+ }
+}
+
+export const cursor_agent = createCursorAgentProvider()
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 208a4fa..8419fda 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -22,6 +22,9 @@ async function loadCursor(): Promise {
let opencodeProvider: Provider | null = null
let opencodeLoadAttempted = false
+let cursorAgentProvider: Provider | null = null
+let cursorAgentLoadAttempted = false
+
async function loadOpenCode(): Promise {
if (opencodeLoadAttempted) return opencodeProvider
opencodeLoadAttempted = true
@@ -34,13 +37,26 @@ async function loadOpenCode(): Promise {
}
}
+async function loadCursorAgent(): Promise {
+ if (cursorAgentLoadAttempted) return cursorAgentProvider
+ cursorAgentLoadAttempted = true
+ try {
+ const { cursor_agent } = await import('./cursor-agent.js')
+ cursorAgentProvider = cursor_agent
+ return cursor_agent
+ } catch {
+ return null
+ }
+}
+
const coreProviders: Provider[] = [claude, codex, copilot, pi]
export async function getAllProviders(): Promise {
- const [cursor, opencode] = await Promise.all([loadCursor(), loadOpenCode()])
+ const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()])
const all = [...coreProviders]
if (cursor) all.push(cursor)
if (opencode) all.push(opencode)
+ if (cursorAgent) all.push(cursorAgent)
return all
}
@@ -68,5 +84,9 @@ export async function getProvider(name: string): Promise {
const oc = await loadOpenCode()
return oc ?? undefined
}
+ if (name === 'cursor-agent') {
+ const ca = await loadCursorAgent()
+ return ca ?? undefined
+ }
return coreProviders.find(p => p.name === name)
}
diff --git a/tests/providers/cursor-agent.test.ts b/tests/providers/cursor-agent.test.ts
new file mode 100644
index 0000000..0a93d16
--- /dev/null
+++ b/tests/providers/cursor-agent.test.ts
@@ -0,0 +1,239 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
+import { existsSync } from 'fs'
+import { join } from 'path'
+import { tmpdir } from 'os'
+
+import { getAllProviders } from '../../src/providers/index.js'
+import { createCursorAgentProvider } from '../../src/providers/cursor-agent.js'
+import type { ParsedProviderCall, Provider, SessionSource } from '../../src/providers/types.js'
+import { isSqliteAvailable } from '../../src/sqlite.js'
+
+const CHARS_PER_TOKEN = 4
+const CURSOR_AGENT_DEFAULT_MODEL = 'claude-sonnet-4-5'
+const FIXED_UUID = '123e4567-e89b-12d3-a456-426614174000'
+
+const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
+
+type TestDb = {
+ exec(sql: string): void
+ prepare(sql: string): { run(...params: unknown[]): void }
+ close(): void
+}
+
+let tempRoots: string[] = []
+
+beforeEach(() => {
+ tempRoots = []
+})
+
+afterEach(async () => {
+ await Promise.all(tempRoots.filter(existsSync).map((dir) => rm(dir, { recursive: true, force: true })))
+})
+
+async function makeBaseDir(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), 'cursor-agent-test-'))
+ tempRoots.push(dir)
+ return dir
+}
+
+async function collectCalls(provider: Provider, source: SessionSource): Promise {
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) {
+ calls.push(call)
+ }
+ return calls
+}
+
+function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
+ const { DatabaseSync: Database } = require('node:sqlite')
+ const db = new Database(dbPath)
+ fn(db)
+ db.close()
+}
+
+describe('cursor-agent provider', () => {
+ it('is registered', async () => {
+ const all = await getAllProviders()
+ const provider = all.find((p) => p.name === 'cursor-agent')
+
+ expect(provider).toBeDefined()
+ expect(provider?.displayName).toBe('Cursor Agent')
+ })
+
+ it('maps default model to auto with estimation label', () => {
+ const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
+ expect(provider.modelDisplayName('default')).toBe('Auto (Sonnet est.)')
+ })
+
+ it('maps known models and appends estimation label', () => {
+ const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
+
+ expect(provider.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking) (est.)')
+ expect(provider.modelDisplayName('claude-4.6-sonnet')).toBe('Sonnet 4.6 (est.)')
+ expect(provider.modelDisplayName('composer-1')).toBe('Composer 1 (est.)')
+ })
+
+ it('returns identity for tool display name', () => {
+ const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
+ expect(provider.toolDisplayName('cursor:edit')).toBe('cursor:edit')
+ })
+
+ it('returns empty discovery when projects dir is missing', async () => {
+ const baseDir = await makeBaseDir()
+ const provider = createCursorAgentProvider(baseDir)
+ const sources = await provider.discoverSessions()
+
+ expect(sources).toEqual([])
+ })
+
+ it('discovers a single transcript', async () => {
+ const baseDir = await makeBaseDir()
+ const transcriptDir = join(baseDir, 'projects', 'test-proj', 'agent-transcripts')
+ await mkdir(transcriptDir, { recursive: true })
+ const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
+ await writeFile(transcriptPath, 'user:\nhello\nA:\nworld\n')
+
+ const provider = createCursorAgentProvider(baseDir)
+ const sources = await provider.discoverSessions()
+
+ expect(sources).toHaveLength(1)
+ expect(sources[0]!.provider).toBe('cursor-agent')
+ expect(sources[0]!.path).toBe(transcriptPath)
+ expect(sources[0]!.fingerprintPath).toBe(transcriptPath)
+ expect(sources[0]!.cacheStrategy).toBe('full-reparse')
+ expect(sources[0]!.parserVersion).toBe('cursor-agent:v1')
+ })
+
+ it('discovers transcripts across multiple projects', async () => {
+ const baseDir = await makeBaseDir()
+ const transcriptA = join(baseDir, 'projects', 'proj-one', 'agent-transcripts')
+ const transcriptB = join(baseDir, 'projects', 'proj-two', 'agent-transcripts')
+ await mkdir(transcriptA, { recursive: true })
+ await mkdir(transcriptB, { recursive: true })
+ await writeFile(join(transcriptA, `${FIXED_UUID}.txt`), 'user:\na\nA:\na\n')
+ await writeFile(join(transcriptB, `${FIXED_UUID}.txt`), 'user:\nb\nA:\nb\n')
+
+ const provider = createCursorAgentProvider(baseDir)
+ const sources = await provider.discoverSessions()
+
+ expect(sources).toHaveLength(2)
+ expect(sources.every((s) => s.provider === 'cursor-agent')).toBe(true)
+ })
+
+ it('parses one user/assistant pair with estimated token counts', async () => {
+ const baseDir = await makeBaseDir()
+ const transcriptDir = join(baseDir, 'projects', 'my-proj', 'agent-transcripts')
+ await mkdir(transcriptDir, { recursive: true })
+
+ const userText = 'explain parser output'
+ const assistantText = 'first line\nsecond line'
+ const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
+
+ await writeFile(
+ transcriptPath,
+ `user:\n${userText}\nA:\n${assistantText}\n`
+ )
+
+ const provider = createCursorAgentProvider(baseDir)
+ const source = (await provider.discoverSessions())[0]!
+ const calls = await collectCalls(provider, source)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.provider).toBe('cursor-agent')
+ expect(calls[0]!.model).toBe(CURSOR_AGENT_DEFAULT_MODEL)
+ expect(calls[0]!.inputTokens).toBe(Math.ceil(userText.length / CHARS_PER_TOKEN))
+ expect(calls[0]!.outputTokens).toBe(Math.ceil(assistantText.length / CHARS_PER_TOKEN))
+ expect(calls[0]!.reasoningTokens).toBe(0)
+ expect(calls[0]!.deduplicationKey).toBe(`cursor-agent:${FIXED_UUID}:0`)
+ })
+
+ it('parses without sqlite db and defaults model', async () => {
+ const baseDir = await makeBaseDir()
+ const transcriptDir = join(baseDir, 'projects', 'fallback-proj', 'agent-transcripts')
+ await mkdir(transcriptDir, { recursive: true })
+ const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
+
+ await writeFile(transcriptPath, 'user:\nhello world\nA:\n[Thinking]private\nvisible\n')
+
+ const provider = createCursorAgentProvider(baseDir)
+ const source = (await provider.discoverSessions())[0]!
+ const calls = await collectCalls(provider, source)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.model).toBe(CURSOR_AGENT_DEFAULT_MODEL)
+ expect(calls[0]!.reasoningTokens).toBe(2)
+ expect(calls[0]!.outputTokens).toBe(2)
+ })
+
+ it('skips unrecognized transcript format and writes stderr message', async () => {
+ const baseDir = await makeBaseDir()
+ const transcriptDir = join(baseDir, 'projects', 'bad-proj', 'agent-transcripts')
+ await mkdir(transcriptDir, { recursive: true })
+ const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
+ await writeFile(transcriptPath, 'no markers in this transcript')
+
+ const provider = createCursorAgentProvider(baseDir)
+ const source = (await provider.discoverSessions())[0]!
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
+
+ const calls = await collectCalls(provider, source)
+
+ expect(calls).toHaveLength(0)
+ expect(stderrSpy).toHaveBeenCalled()
+ expect(String(stderrSpy.mock.calls[0]?.[0] ?? '')).toContain('unrecognized cursor-agent transcript format')
+
+ stderrSpy.mockRestore()
+ })
+
+ it('falls back to stable sha1 conversation id for non-uuid filenames', async () => {
+ const baseDir = await makeBaseDir()
+ const transcriptDir = join(baseDir, 'projects', 'sha-proj', 'agent-transcripts')
+ await mkdir(transcriptDir, { recursive: true })
+ const transcriptPath = join(transcriptDir, 'not-a-uuid.txt')
+ await writeFile(transcriptPath, 'user:\ntest\nA:\nresult\n')
+
+ const provider = createCursorAgentProvider(baseDir)
+ const source = (await provider.discoverSessions())[0]!
+
+ const callsFirst = await collectCalls(provider, source)
+ const callsSecond = await collectCalls(provider, source)
+
+ expect(callsFirst).toHaveLength(1)
+ expect(callsSecond).toHaveLength(1)
+ expect(callsFirst[0]!.sessionId).toHaveLength(16)
+ expect(callsFirst[0]!.deduplicationKey.startsWith('cursor-agent:')).toBe(true)
+ expect(callsFirst[0]!.sessionId).toBe(callsSecond[0]!.sessionId)
+ expect(callsFirst[0]!.deduplicationKey).toBe(callsSecond[0]!.deduplicationKey)
+ })
+})
+
+skipUnlessSqlite('cursor-agent sqlite metadata', () => {
+ it('uses model metadata from ai-code-tracking db when present', async () => {
+ const baseDir = await makeBaseDir()
+ const transcriptDir = join(baseDir, 'projects', 'proj-with-db', 'agent-transcripts')
+ const aiTrackingDir = join(baseDir, 'ai-tracking')
+ await mkdir(transcriptDir, { recursive: true })
+ await mkdir(aiTrackingDir, { recursive: true })
+
+ await writeFile(
+ join(transcriptDir, `${FIXED_UUID}.txt`),
+ 'user:\nestimate cost\nA:\nanswer\n'
+ )
+
+ const dbPath = join(aiTrackingDir, 'ai-code-tracking.db')
+ withTestDb(dbPath, (db) => {
+ db.exec('CREATE TABLE conversation_summaries (conversationId TEXT, title TEXT, tldr TEXT, model TEXT, mode TEXT, updatedAt INTEGER)')
+ db.prepare('INSERT INTO conversation_summaries (conversationId, title, tldr, model, mode, updatedAt) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(FIXED_UUID, 'Demo title', '', 'claude-4.6-sonnet', 'agent', 1735689600000)
+ })
+
+ const provider = createCursorAgentProvider(baseDir)
+ const source = (await provider.discoverSessions())[0]!
+ const calls = await collectCalls(provider, source)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.model).toBe('claude-4.6-sonnet')
+ expect(calls[0]!.timestamp).toBe('2025-01-01T00:00:00.000Z')
+ })
+})