From 554036d2a775693bedfc21b545fe31aab46b69f3 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:49:45 -0700 Subject: [PATCH] feat(cursor-agent): add provider for cursor-agent CLI sessions Discovers transcripts at ~/.cursor/projects/*/agent-transcripts/*.txt and joins against ~/.cursor/ai-tracking/ai-code-tracking.db for model attribution. Token counts are estimated from transcript character length since the attribution DB does not carry them; the model label surfaces the estimation with an (est.) suffix on every row. Deduplication keys prefix cursor-agent: to stay disjoint from the existing cursor: prefix so the two providers do not cross-dedupe on shared conversationId namespaces. Tests cover: empty ~/.cursor/projects/, single transcript, multiple projects, missing ai-code-tracking.db, unrecognized transcript format skip, non-UUID filename fallback, and sqlite metadata join. Closes #55 --- README.md | 3 +- src/providers/cursor-agent.ts | 423 +++++++++++++++++++++++++++ src/providers/index.ts | 22 +- tests/providers/cursor-agent.test.ts | 239 +++++++++++++++ 4 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 src/providers/cursor-agent.ts create mode 100644 tests/providers/cursor-agent.test.ts diff --git a/README.md b/README.md index 7aac798..ec633cd 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ CodeBurn TUI dashboard

-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') + }) +})