diff --git a/README.md b/README.md index 64eda6d0..66bdfab4 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Don't need a browser? `code-insights stats` gives you the full picture from the | Tool | Data Location | |------|---------------| | Claude Code | `~/.claude/projects/**/*.jsonl` | -| Cursor | Workspace storage SQLite (macOS, Linux, Windows) | +| Cursor | Agent transcript JSONL (`~/.cursor/projects/*/agent-transcripts/`) + legacy workspace SQLite | | Codex CLI | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | | Copilot CLI | `~/.copilot/session-state/{id}/events.jsonl` | | VS Code Copilot Chat | Platform-specific Copilot Chat storage | diff --git a/cli/README.md b/cli/README.md index 140e93c6..530d4a30 100644 --- a/cli/README.md +++ b/cli/README.md @@ -58,7 +58,7 @@ code-insights doctor # diagnose your installation (start here | Tool | Data Location | |------|---------------| | **Claude Code** | `~/.claude/projects/**/*.jsonl` | -| **Cursor** | Workspace storage SQLite (macOS, Linux, Windows) | +| **Cursor** | Agent transcript JSONL (`~/.cursor/projects/*/agent-transcripts/`) + legacy workspace SQLite | | **Codex CLI** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | | **Copilot CLI** | `~/.copilot/session-state/{id}/events.jsonl` | | **VS Code Copilot Chat** | Platform-specific Copilot Chat storage | diff --git a/cli/src/providers/__tests__/cursor.test.ts b/cli/src/providers/__tests__/cursor.test.ts index 6fb73ac0..7ae2ea52 100644 --- a/cli/src/providers/__tests__/cursor.test.ts +++ b/cli/src/providers/__tests__/cursor.test.ts @@ -14,6 +14,7 @@ import { CursorProvider } from '../cursor.js'; // --------------------------------------------------------------------------- const COMPOSER_ID = 'test-composer-abc123'; +const originalCursorAgentHome = process.env.CURSOR_AGENT_HOME; function makeCursorDb(dir: string, composerData: Record): string { const dbPath = path.join(dir, 'state.vscdb'); @@ -31,6 +32,25 @@ function virtualPath(dbPath: string): string { return `${dbPath}#${COMPOSER_ID}`; } +function makeAgentTranscript( + dir: string, + sessionId: string, + records: Array>, + projectSlug = 'Users-test-example-project', +): string { + const transcriptDir = path.join( + dir, + 'projects', + projectSlug, + 'agent-transcripts', + sessionId, + ); + fs.mkdirSync(transcriptDir, { recursive: true }); + const transcriptPath = path.join(transcriptDir, `${sessionId}.jsonl`); + fs.writeFileSync(transcriptPath, records.map(record => JSON.stringify(record)).join('\n')); + return transcriptPath; +} + function userBubble(overrides: Record = {}): Record { return { bubbleId: 'bubble-user-1', type: 1, text: 'How do I fix the login bug?', ...overrides }; } @@ -49,6 +69,11 @@ describe('CursorProvider — parsing accuracy fixes', () => { afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalCursorAgentHome === undefined) { + delete process.env.CURSOR_AGENT_HOME; + } else { + process.env.CURSOR_AGENT_HOME = originalCursorAgentHome; + } }); // ── Timestamps ──────────────────────────────────────────────────────────── @@ -273,3 +298,92 @@ describe('CursorProvider — parsing accuracy fixes', () => { expect(session!.messageCount).toBe(4); }); }); + +describe('CursorProvider — modern Agent transcripts', () => { + let tempDir: string; + const provider = new CursorProvider(); + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-agent-test-')); + process.env.CURSOR_AGENT_HOME = tempDir; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalCursorAgentHome === undefined) { + delete process.env.CURSOR_AGENT_HOME; + } else { + process.env.CURSOR_AGENT_HOME = originalCursorAgentHome; + } + }); + + it('discovers and parses modern JSONL transcripts', async () => { + const transcriptPath = makeAgentTranscript(tempDir, 'session-modern-1', [ + { + role: 'user', + message: { content: [{ type: 'text', text: 'Fix the login flow' }] }, + }, + { + role: 'assistant', + message: { content: [{ type: 'text', text: 'I will inspect the authentication code.' }] }, + }, + { + role: 'assistant', + message: { + content: [ + { type: 'thinking', thinking: 'The session cookie may be stale.' }, + { type: 'tool_use', id: 'tool-1', name: 'Read', input: { path: 'src/auth.ts' } }, + ], + }, + }, + { type: 'turn_ended', status: 'success' }, + ]); + + await expect(provider.discover()).resolves.toContain(transcriptPath); + + const session = await provider.parse(transcriptPath); + expect(session).not.toBeNull(); + expect(session!.id).toBe('cursor:session-modern-1'); + expect(session!.sourceTool).toBe('cursor'); + expect(session!.projectPath).toBe('/Users/test/example/project'); + expect(session!.messages).toHaveLength(3); + expect(session!.messages[0].content).toBe('Fix the login flow'); + expect(session!.messages[2].thinking).toBe('The session cookie may be stale.'); + expect(session!.messages[2].toolCalls).toEqual([ + { id: 'tool-1', name: 'Read', input: { path: 'src/auth.ts' } }, + ]); + expect(session!.toolCallCount).toBe(1); + }); + + it('accepts string content and skips malformed or lifecycle records', async () => { + const transcriptPath = makeAgentTranscript(tempDir, 'session-modern-2', [ + { role: 'user', message: { content: 'First question' } }, + { role: 'assistant', message: { content: 'First answer' } }, + { type: 'turn_ended', status: 'success' }, + ]); + fs.appendFileSync(transcriptPath, '\nnot-json'); + + const session = await provider.parse(transcriptPath); + expect(session).not.toBeNull(); + expect(session!.messageCount).toBe(2); + expect(session!.userMessageCount).toBe(1); + expect(session!.assistantMessageCount).toBe(1); + }); + + it('applies the project filter to modern transcript directories', async () => { + const included = makeAgentTranscript( + tempDir, + 'included-session', + [{ role: 'user', message: { content: 'Included' } }], + 'Users-test-alpha-project', + ); + makeAgentTranscript( + tempDir, + 'excluded-session', + [{ role: 'user', message: { content: 'Excluded' } }], + 'Users-test-beta-project', + ); + + await expect(provider.discover({ projectFilter: 'alpha' })).resolves.toEqual([included]); + }); +}); diff --git a/cli/src/providers/cursor.ts b/cli/src/providers/cursor.ts index 7e0427f3..d200327e 100644 --- a/cli/src/providers/cursor.ts +++ b/cli/src/providers/cursor.ts @@ -9,7 +9,8 @@ import { isVerbose } from './context.js'; /** * Cursor IDE session provider. - * Discovers and parses sessions from Cursor's SQLite databases. + * Discovers and parses sessions from Cursor's SQLite databases and modern + * Agent transcript JSONL files. * * Cursor stores composer conversations in state.vscdb files (SQLite). * One DB can contain multiple sessions (composers), so discover() returns @@ -27,15 +28,13 @@ export class CursorProvider implements SessionProvider { */ async discover(options?: { projectFilter?: string }): Promise { const cursorDataDir = getCursorDataDir(); - if (!cursorDataDir) { - return []; - } - const dbPaths: string[] = []; // 1. Check workspace storage databases - const workspaceStorageDir = path.join(cursorDataDir, 'workspaceStorage'); - if (fs.existsSync(workspaceStorageDir)) { + const workspaceStorageDir = cursorDataDir + ? path.join(cursorDataDir, 'workspaceStorage') + : null; + if (workspaceStorageDir && fs.existsSync(workspaceStorageDir)) { const entries = fs.readdirSync(workspaceStorageDir); for (const entry of entries) { const wsDir = path.join(workspaceStorageDir, entry); @@ -57,8 +56,10 @@ export class CursorProvider implements SessionProvider { } // 2. Check global storage database - const globalDbPath = path.join(cursorDataDir, 'globalStorage', 'state.vscdb'); - if (fs.existsSync(globalDbPath)) { + const globalDbPath = cursorDataDir + ? path.join(cursorDataDir, 'globalStorage', 'state.vscdb') + : null; + if (globalDbPath && fs.existsSync(globalDbPath)) { dbPaths.push(globalDbPath); } @@ -72,6 +73,21 @@ export class CursorProvider implements SessionProvider { } } + // 3. Check modern Agent transcript JSONL files. Cursor writes these under + // ~/.cursor/projects//agent-transcripts//.jsonl. + // Prefer the richer SQLite representation when the same session ID exists + // in both stores. + const legacyComposerIds = new Set( + virtualPaths.map(virtualPath => virtualPath.slice(virtualPath.lastIndexOf('#') + 1)), + ); + const transcriptPaths = discoverAgentTranscripts(options?.projectFilter); + for (const transcriptPath of transcriptPaths) { + const sessionId = path.basename(transcriptPath, '.jsonl'); + if (!legacyComposerIds.has(sessionId)) { + virtualPaths.push(transcriptPath); + } + } + return virtualPaths; } @@ -80,6 +96,10 @@ export class CursorProvider implements SessionProvider { * Virtual path format: `#` */ async parse(virtualPath: string): Promise { + if (virtualPath.endsWith('.jsonl')) { + return parseAgentTranscript(virtualPath); + } + const hashIndex = virtualPath.lastIndexOf('#'); if (hashIndex === -1) return null; @@ -95,6 +115,248 @@ export class CursorProvider implements SessionProvider { // Helper functions // --------------------------------------------------------------------------- +interface AgentTranscriptBlock { + type?: unknown; + text?: unknown; + thinking?: unknown; + id?: unknown; + name?: unknown; + input?: unknown; +} + +interface AgentTranscriptRecord { + role?: unknown; + type?: unknown; + timestamp?: unknown; + createdAt?: unknown; + message?: { + content?: unknown; + }; +} + +function getCursorProjectsDir(): string { + const cursorHome = process.env.CURSOR_AGENT_HOME || path.join(os.homedir(), '.cursor'); + return path.join(cursorHome, 'projects'); +} + +function discoverAgentTranscripts(projectFilter?: string): string[] { + const projectsDir = getCursorProjectsDir(); + if (!fs.existsSync(projectsDir)) return []; + + const transcripts: string[] = []; + for (const projectEntry of fs.readdirSync(projectsDir, { withFileTypes: true })) { + if (!projectEntry.isDirectory()) continue; + + const projectDir = path.join(projectsDir, projectEntry.name); + const projectPath = resolveAgentProjectPath(projectDir); + if (projectFilter) { + const filter = projectFilter.toLowerCase(); + if (!projectPath.toLowerCase().includes(filter) && !projectEntry.name.toLowerCase().includes(filter)) { + continue; + } + } + + const transcriptRoot = path.join(projectDir, 'agent-transcripts'); + if (!fs.existsSync(transcriptRoot)) continue; + + for (const sessionEntry of fs.readdirSync(transcriptRoot, { withFileTypes: true })) { + if (!sessionEntry.isDirectory()) continue; + const transcriptPath = path.join(transcriptRoot, sessionEntry.name, `${sessionEntry.name}.jsonl`); + if (fs.existsSync(transcriptPath)) transcripts.push(transcriptPath); + } + } + + return transcripts; +} + +/** Best-effort decode of Cursor's slash-and-underscore-to-dash project slug. */ +function resolveAgentProjectPath(projectDir: string): string { + const workerLog = path.join(projectDir, 'worker.log'); + if (fs.existsSync(workerLog)) { + try { + const matches = [...fs.readFileSync(workerLog, 'utf-8').matchAll(/workspacePath=(\S+)/g)]; + const latest = matches.at(-1)?.[1]; + if (latest) return latest; + } catch { + // Fall through to slug decoding. + } + } + + const slug = path.basename(projectDir); + const parts = slug.split('-').filter(Boolean); + if (parts.length === 0) return `cursor://project-${slug}`; + + // Resolve greedily against real directories so names containing '-' or '_' + // are preserved whenever the original checkout still exists. + if (parts[0] === 'Users' || parts[0] === 'home') { + let current = path.join(path.parse(os.homedir()).root, parts[0]); + let index = 1; + while (index < parts.length && fs.existsSync(current)) { + let matched: string | null = null; + let nextIndex = index; + for (let end = parts.length; end > index && !matched; end--) { + for (const candidate of [parts.slice(index, end).join('_'), parts.slice(index, end).join('-')]) { + const candidatePath = path.join(current, candidate); + if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()) { + matched = candidatePath; + nextIndex = end; + break; + } + } + } + if (!matched) break; + current = matched; + index = nextIndex; + } + if (index === parts.length) return current; + } + + const decoded = parts.join(path.sep); + return parts[0] === 'Users' || parts[0] === 'home' + ? `${path.parse(os.homedir()).root}${decoded}` + : decoded; +} + +function extractAgentText(content: unknown): { + content: string; + thinking: string | null; + toolCalls: ToolCall[]; +} { + if (typeof content === 'string') { + return { content, thinking: null, toolCalls: [] }; + } + + if (!Array.isArray(content)) { + return { content: '', thinking: null, toolCalls: [] }; + } + + const textParts: string[] = []; + const thinkingParts: string[] = []; + const toolCalls: ToolCall[] = []; + for (const rawBlock of content) { + if (!rawBlock || typeof rawBlock !== 'object') continue; + const block = rawBlock as AgentTranscriptBlock; + if (block.type === 'text' && typeof block.text === 'string') { + textParts.push(block.text); + } else if (block.type === 'thinking') { + const thinking = typeof block.thinking === 'string' ? block.thinking : block.text; + if (typeof thinking === 'string') thinkingParts.push(thinking); + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: typeof block.id === 'string' ? block.id : `cursor-tool-${toolCalls.length + 1}`, + name: typeof block.name === 'string' ? block.name : 'unknown', + input: block.input && typeof block.input === 'object' && !Array.isArray(block.input) + ? block.input as Record + : {}, + }); + } + } + + return { + content: textParts.join('\n'), + thinking: thinkingParts.length > 0 ? thinkingParts.join('\n') : null, + toolCalls, + }; +} + +function cleanAgentUserText(text: string): string { + const userQuery = text.match(/\s*([\s\S]*?)\s*<\/user_query>/i); + return userQuery ? userQuery[1].trim() : text.trim(); +} + +function parseAgentTimestamp(value: unknown): Date | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function parseAgentTranscript(transcriptPath: string): ParsedSession | null { + try { + const sessionId = path.basename(transcriptPath, '.jsonl'); + if (!sessionId || !fs.existsSync(transcriptPath)) return null; + + const messages: ParsedMessage[] = []; + const timestamps: Date[] = []; + const lines = fs.readFileSync(transcriptPath, 'utf-8').split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + + let record: AgentTranscriptRecord; + try { + record = JSON.parse(line) as AgentTranscriptRecord; + } catch { + if (isVerbose()) console.warn(`[cursor] skipping malformed JSONL record in ${transcriptPath}`); + continue; + } + + if (record.role !== 'user' && record.role !== 'assistant') continue; + const extracted = extractAgentText(record.message?.content); + const content = record.role === 'user' ? cleanAgentUserText(extracted.content) : extracted.content; + if (!content && !extracted.thinking && extracted.toolCalls.length === 0) continue; + + const timestamp = parseAgentTimestamp(record.timestamp ?? record.createdAt) ?? new Date(0); + if (timestamp.getTime() > 0) timestamps.push(timestamp); + messages.push({ + id: `cursor:${sessionId}:message:${messages.length + 1}`, + sessionId: `cursor:${sessionId}`, + type: record.role, + content, + thinking: extracted.thinking, + toolCalls: extracted.toolCalls, + toolResults: [], + usage: null, + timestamp, + parentId: null, + }); + } + + if (messages.length === 0) return null; + + const stats = fs.statSync(transcriptPath); + const fallbackStartedAt = stats.birthtimeMs > 0 ? stats.birthtime : stats.mtime; + const startedAt = timestamps.length > 0 ? timestamps[0] : fallbackStartedAt; + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1] : stats.mtime; + const projectDir = path.dirname(path.dirname(path.dirname(transcriptPath))); + const projectPath = resolveAgentProjectPath(projectDir); + const projectName = projectPath.startsWith('cursor://') + ? projectPath.replace('cursor://', '') + : path.basename(projectPath); + const userMessages = messages.filter(message => message.type === 'user'); + const assistantMessages = messages.filter(message => message.type === 'assistant'); + + const session: ParsedSession = { + id: `cursor:${sessionId}`, + projectPath, + projectName, + summary: null, + generatedTitle: null, + titleSource: null, + sessionCharacter: null, + startedAt, + endedAt, + messageCount: messages.length, + userMessageCount: userMessages.length, + assistantMessageCount: assistantMessages.length, + toolCallCount: messages.reduce((sum, message) => sum + message.toolCalls.length, 0), + compactCount: 0, + autoCompactCount: 0, + slashCommands: [], + gitBranch: null, + claudeVersion: null, + sourceTool: 'cursor', + messages, + }; + + const titleResult = generateTitle(session); + session.generatedTitle = titleResult.title; + session.titleSource = titleResult.source; + session.sessionCharacter = titleResult.character || detectSessionCharacter(session); + return session; + } catch { + return null; + } +} + /** * Find Cursor's data directory based on the current platform. */ diff --git a/docs/source-tool-format-analysis.md b/docs/source-tool-format-analysis.md index b7829022..156a1e93 100644 --- a/docs/source-tool-format-analysis.md +++ b/docs/source-tool-format-analysis.md @@ -139,7 +139,15 @@ Parser is production-quality. Handles all current format features. ## 3. Cursor -### Format +### Formats + +Modern Cursor Agent sessions are exported as JSONL: + +- **Location:** `~/.cursor/projects//agent-transcripts//.jsonl` +- **Records:** `{role, message: {content}}` user/assistant entries plus lifecycle records such as `turn_ended` +- **Content:** text, thinking, and tool-use input blocks; tool outputs may be absent + +The provider also retains support for the legacy Cursor IDE SQLite storage: - **File type:** SQLite (VS Code `state.vscdb`) - **Location:** `~/Library/Application Support/Cursor/User/workspaceStorage//state.vscdb`