Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 87 additions & 51 deletions src/providers/cursor-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,83 @@ function costModel(model: string): string {
return model === 'cursor-agent-auto' ? CURSOR_AGENT_COST_MODEL : model
}

function transcriptStem(transcriptPath: string): string {
const name = basename(transcriptPath)
if (name.endsWith('.jsonl')) return name.slice(0, -'.jsonl'.length)
if (name.endsWith('.txt')) return name.slice(0, -'.txt'.length)
return name
}

function toConversationId(transcriptPath: string): string {
const filename = basename(transcriptPath, '.txt')
const filename = transcriptStem(transcriptPath)
if (filename.length === 36 && UUID_LIKE.test(filename)) return filename
return createHash('sha1').update(transcriptPath).digest('hex').slice(0, 16)
}

async function appendTranscriptSources(
scanDir: string,
projectId: string,
sources: SessionSource[],
): Promise<void> {
const transcriptEntries = await readdir(scanDir, { withFileTypes: true })
for (const transcript of transcriptEntries) {
// Legacy format: .txt files directly in the scan dir
if (transcript.isFile() && transcript.name.endsWith('.txt')) {
sources.push({
path: join(scanDir, transcript.name),
project: projectId,
provider: 'cursor-agent',
})
continue
}

// Composer 2 format: UUID subdirectories with .jsonl files
if (transcript.isDirectory() && UUID_LIKE.test(transcript.name)) {
const subdir = join(scanDir, transcript.name)
const subEntries = await readdir(subdir, { withFileTypes: true }).catch(() => [])
const transcriptFilesByStem = new Map<string, { jsonl?: string; txt?: string }>()

for (const sub of subEntries) {
if (sub.isFile() && (sub.name.endsWith('.jsonl') || sub.name.endsWith('.txt'))) {
const stem = transcriptStem(sub.name)
const existing = transcriptFilesByStem.get(stem) ?? {}
if (sub.name.endsWith('.jsonl')) {
transcriptFilesByStem.set(stem, { ...existing, jsonl: sub.name })
} else {
transcriptFilesByStem.set(stem, { ...existing, txt: sub.name })
}
continue
}

// Subagent transcripts inside a subagents/ directory
if (sub.isDirectory() && sub.name === 'subagents') {
const subagentEntries = await readdir(join(subdir, sub.name), { withFileTypes: true }).catch(() => [])
for (const sa of subagentEntries) {
if (!sa.isFile()) continue
if (!sa.name.endsWith('.jsonl') && !sa.name.endsWith('.txt')) continue
sources.push({
path: join(subdir, sub.name, sa.name),
project: projectId,
provider: 'cursor-agent',
})
}
}
}

for (const files of transcriptFilesByStem.values()) {
const selectedName = files.jsonl ?? files.txt
if (selectedName) {
sources.push({
path: join(subdir, selectedName),
project: projectId,
provider: 'cursor-agent',
})
}
}
}
}
}

function extractUserQuery(userBlock: string): string {
const chunks: string[] = []
let cursor = 0
Expand Down Expand Up @@ -241,7 +312,7 @@ function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolea

let output = ''
let reasoning = ''
const toolsByTurn: Record<string, boolean> = Object.create(null)
const toolsByTurn = new Map<string, true>()

for (const line of assistantLines) {
if (TOOL_RESULT_MARKER.test(line)) continue
Expand All @@ -257,7 +328,7 @@ function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolea
if (toolMatch) {
const parsedTool = parseToolName(toolMatch[1] ?? '')
const toolKey = `cursor:${parsedTool}`
toolsByTurn[toolKey] = true
toolsByTurn.set(toolKey, true)
continue
}

Expand All @@ -266,7 +337,7 @@ function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolea

if (pendingUsers.length > 0) {
const userMessage = pendingUsers.shift()!
const tools = Object.keys(toolsByTurn)
const tools = Array.from(toolsByTurn.keys())
turns.push({
userMessage,
assistant: {
Expand Down Expand Up @@ -319,13 +390,13 @@ function createParser(
source: SessionSource,
seenKeys: Set<string>,
dbPath: string,
summariesByConversationId: Record<string, ConversationSummary | undefined>,
summariesByConversationId: Map<string, ConversationSummary>,
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const conversationId = toConversationId(source.path)

let summary = summariesByConversationId[conversationId]
let summary = summariesByConversationId.get(conversationId)
let db: SqliteDatabase | null = null

try {
Expand All @@ -348,7 +419,7 @@ function createParser(
title: row.title,
updatedAt: normalizeTimestamp(row.updatedAt),
}
summariesByConversationId[conversationId] = summary
summariesByConversationId.set(conversationId, summary)
}
} catch {
summary = undefined
Expand Down Expand Up @@ -426,7 +497,7 @@ 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)
const summariesByConversationId = new Map<string, ConversationSummary>()

return {
name: 'cursor-agent',
Expand All @@ -452,50 +523,15 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider {
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) {
// Legacy format: .txt files directly in agent-transcripts/
if (transcript.isFile() && transcript.name.endsWith('.txt')) {
const transcriptPath = join(transcriptDir, transcript.name)
sources.push({
path: transcriptPath,
project: projectId,
provider: 'cursor-agent',
})
continue
}

// Composer 2 format: UUID subdirectories with .jsonl files
if (transcript.isDirectory() && UUID_LIKE.test(transcript.name)) {
const subdir = join(transcriptDir, transcript.name)
const subEntries = await readdir(subdir, { withFileTypes: true }).catch(() => [])
for (const sub of subEntries) {
if (sub.isFile() && (sub.name.endsWith('.jsonl') || sub.name.endsWith('.txt'))) {
sources.push({
path: join(subdir, sub.name),
project: projectId,
provider: 'cursor-agent',
})
}
// Subagent transcripts inside a subagents/ directory
if (sub.isDirectory() && sub.name === 'subagents') {
const subagentEntries = await readdir(join(subdir, sub.name), { withFileTypes: true }).catch(() => [])
for (const sa of subagentEntries) {
if (!sa.isFile()) continue
if (!sa.name.endsWith('.jsonl') && !sa.name.endsWith('.txt')) continue
sources.push({
path: join(subdir, sub.name, sa.name),
project: projectId,
provider: 'cursor-agent',
})
}
}
}
}
const projectDir = join(projectsDir, entry.name)
if (entry.name === 'agent-transcripts') {
await appendTranscriptSources(projectDir, projectId, sources)
continue
}

const transcriptDir = join(projectDir, 'agent-transcripts')
if (!existsSync(transcriptDir)) continue
await appendTranscriptSources(transcriptDir, projectId, sources)
}

return sources
Expand Down
1 change: 1 addition & 0 deletions src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet<string> = new Set(['copilot'])
const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
claude: 'cowork-space-grouping-v1',
cline: 'worktree-project-grouping-v1',
'cursor-agent': 'workspaceless-transcript-v1',
copilot: 'otel-durable-v1',
'ibm-bob': 'worktree-project-grouping-v1',
'kilo-code': 'worktree-project-grouping-v1',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"role":"user","message":{"content":[{"type":"text","text":"<user_query>\nRun a quick smoke test\n</user_query>"}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"Smoke test passed."}]}}
66 changes: 65 additions & 1 deletion tests/providers/cursor-agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
Expand Down Expand Up @@ -125,6 +125,40 @@ describe('cursor-agent provider', () => {
expect(sources.every((s) => s.provider === 'cursor-agent')).toBe(true)
})

it('does not scan a workspace root when agent-transcripts is missing', async () => {
const baseDir = await makeBaseDir()
const workspaceRoot = join(baseDir, 'projects', 'workspace-without-transcripts')
await mkdir(workspaceRoot, { recursive: true })
await writeFile(
join(workspaceRoot, 'extension-state.txt'),
'user:\n<user_query>not a transcript</user_query>\nA:\nnot a cursor-agent answer\n',
)

const provider = createCursorAgentProvider(baseDir)
const sources = await provider.discoverSessions()

expect(sources).toEqual([])
})

it('prefers jsonl over same-session txt inside UUID transcript dirs', async () => {
const baseDir = await makeBaseDir()
const sessionDir = join(baseDir, 'projects', 'proj-with-duplicates', 'agent-transcripts', FIXED_UUID)
const jsonlPath = join(sessionDir, `${FIXED_UUID}.jsonl`)
const txtPath = join(sessionDir, `${FIXED_UUID}.txt`)
await mkdir(sessionDir, { recursive: true })
await writeFile(
jsonlPath,
'{"role":"user","message":{"content":[{"type":"text","text":"<user_query>jsonl wins</user_query>"}]}}\n{"role":"assistant","message":{"content":[{"type":"text","text":"jsonl answer"}]}}\n',
)
await writeFile(txtPath, 'user:\n<user_query>txt duplicate</user_query>\nA:\ntxt answer\n')

const provider = createCursorAgentProvider(baseDir)
const sources = await provider.discoverSessions()

expect(sources).toHaveLength(1)
expect(sources[0]!.path).toBe(jsonlPath)
})

it('parses one user/assistant pair with estimated token counts', async () => {
const baseDir = await makeBaseDir()
const transcriptDir = join(baseDir, 'projects', 'my-proj', 'agent-transcripts')
Expand Down Expand Up @@ -212,6 +246,36 @@ describe('cursor-agent provider', () => {
stderrSpy.mockRestore()
})

it('discovers jsonl transcripts stored directly under project dir (workspace-less layout)', async () => {
const baseDir = await makeBaseDir()
const fixtureRoot = join(import.meta.dirname, '../fixtures/cursor-agent/workspace-less')
const sessionDir = join(baseDir, 'projects', 'agent-transcripts', '1031d227-0c67-4e17-8954-0b6e2b3322f0')
await mkdir(sessionDir, { recursive: true })
await writeFile(
join(sessionDir, '1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl'),
await readFile(
join(
fixtureRoot,
'projects/agent-transcripts/1031d227-0c67-4e17-8954-0b6e2b3322f0/1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl',
),
'utf-8',
),
)

const provider = createCursorAgentProvider(baseDir)
const sources = await provider.discoverSessions()

expect(sources).toHaveLength(1)
expect(sources[0]!.project).toBe('transcripts')
expect(sources[0]!.path.endsWith('.jsonl')).toBe(true)

const calls = await collectCalls(provider, sources[0]!)
expect(calls).toHaveLength(1)
expect(calls[0]!.sessionId).toBe('1031d227-0c67-4e17-8954-0b6e2b3322f0')
expect(calls[0]!.userMessage).toBe('Run a quick smoke test')
expect(calls[0]!.costUSD).toBeGreaterThan(0)
})

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')
Expand Down
Loading