Merge PR #117: cursor-agent provider
Some checks are pending
CI / semgrep (push) Waiting to run

Adds a cursor-agent provider that reads sessions from
~/.cursor/projects/*/agent-transcripts/*.txt and joins against the
ai-code-tracking.db for model attribution, closing issue #55. Token
counts are estimated from transcript character length since neither the
transcript nor the attribution DB carries real counts, and every row
carries an (est.) label to surface that.

Contributed by @mvanhorn.
This commit is contained in:
iamtoruk 2026-04-20 19:26:32 -07:00
commit 28c825ff15
4 changed files with 692 additions and 2 deletions

View file

@ -17,7 +17,7 @@
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
</p>
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

View file

@ -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 = '<user_query>'
const USER_QUERY_CLOSE = '</user_query>'
const CONVERSATION_SUMMARY_QUERY = `
SELECT conversationId, model, title, updatedAt
FROM conversation_summaries
WHERE conversationId = ?
`
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.)',
}
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<string, boolean> = 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<string>,
dbPath: string,
summariesByConversationId: Record<string, ConversationSummary | undefined>,
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
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<string, ConversationSummary | undefined> = Object.create(null)
return {
name: 'cursor-agent',
displayName: 'Cursor Agent',
modelDisplayName(model: string): string {
if (model === 'default') return modelDisplayNames.default
const label = modelDisplayNames[model] ?? model
return `${label} (est.)`
},
toolDisplayName(rawTool: string): string {
return rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
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<string>): SessionParser {
return createParser(source, seenKeys, dbPath, summariesByConversationId)
},
}
}
export const cursor_agent = createCursorAgentProvider()

View file

@ -22,6 +22,9 @@ async function loadCursor(): Promise<Provider | null> {
let opencodeProvider: Provider | null = null
let opencodeLoadAttempted = false
let cursorAgentProvider: Provider | null = null
let cursorAgentLoadAttempted = false
async function loadOpenCode(): Promise<Provider | null> {
if (opencodeLoadAttempted) return opencodeProvider
opencodeLoadAttempted = true
@ -34,13 +37,26 @@ async function loadOpenCode(): Promise<Provider | null> {
}
}
async function loadCursorAgent(): Promise<Provider | null> {
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<Provider[]> {
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<Provider | undefined> {
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)
}

View file

@ -0,0 +1,246 @@
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<string> {
const dir = await mkdtemp(join(tmpdir(), 'cursor-agent-test-'))
tempRoots.push(dir)
return dir
}
async function collectCalls(provider: Provider, source: SessionSource): Promise<ParsedProviderCall[]> {
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('falls through to raw model name for unknown models with single est. suffix', () => {
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
expect(provider.modelDisplayName('claude-5-future-model')).toBe('claude-5-future-model (est.)')
expect(provider.modelDisplayName('gpt-9')).toBe('gpt-9 (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:\n<user_query>hello</user_query>\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:\n<user_query>a</user_query>\nA:\na\n')
await writeFile(join(transcriptB, `${FIXED_UUID}.txt`), 'user:\n<user_query>b</user_query>\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<user_query>${userText}</user_query>\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:\n<user_query>hello world</user_query>\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:\n<user_query>test</user_query>\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:\n<user_query>estimate cost</user_query>\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')
})
})