import type { Database } from "bun:sqlite"; import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { type ChatMessage, type ChatRole, type ChatSession, clampText, compactJson, isInScanRoots, stableMessageId, toDateOrUndefined, } from "./chat-types"; interface CursorMetaState { agentId?: string; latestRootBlobId?: string; name?: string; mode?: string; createdAt?: number; lastUsedModel?: string; } interface CursorBlobMessage { id?: string; role?: string; content?: unknown; } interface CursorBlobRow { rowid?: number | string; id?: string; data?: Uint8Array | string | null; } interface CursorWorkspaceFile { folder?: string; folders?: Array<{ uri?: string; path?: string }>; } async function existsDir(path: string): Promise { try { const details = await stat(path); return details.isDirectory(); } catch { return false; } } function isLikelyWorkspaceHash(value: string | undefined): value is string { return typeof value === "string" && /^[a-f0-9]{32}$/i.test(value); } function parseMetaValue(raw: unknown): CursorMetaState { if (typeof raw !== "string" || !raw) { return {}; } let decoded = raw; if (/^[a-f0-9]+$/i.test(raw) && raw.length % 2 === 0) { try { decoded = Buffer.from(raw, "hex").toString("utf8"); } catch { // Keep original if hex decoding fails. } } try { const parsed = JSON.parse(decoded) as CursorMetaState; return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } function extractJsonObjects(input: string): unknown[] { const parsed: unknown[] = []; let depth = 0; let start = -1; let inString = false; let escaped = false; for (let i = 0; i < input.length; i++) { const char = input[i]; if (inString) { if (escaped) { escaped = false; } else if (char === "\\") { escaped = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; continue; } if (char === "{") { if (depth === 0) start = i; depth += 1; continue; } if (char === "}" && depth > 0) { depth -= 1; if (depth === 0 && start >= 0) { const candidate = input.slice(start, i + 1); try { parsed.push(JSON.parse(candidate)); } catch { // Ignore malformed fragments. } start = -1; } } } return parsed; } function asUtf8(value: Uint8Array | string | null | undefined): string { if (value == null) return ""; if (typeof value === "string") return value; return Buffer.from(value).toString("utf8"); } function mapCursorRole(role: string | undefined): ChatRole { switch (role) { case "user": return "user"; case "assistant": return "assistant"; case "tool": return "tool"; default: return "system"; } } function extractPartText(part: unknown): string { if (typeof part === "string") { return part.trim(); } if (!part || typeof part !== "object") { return ""; } const typedPart = part as { type?: unknown; text?: unknown; toolName?: unknown; name?: unknown; args?: unknown; input?: unknown; output?: unknown; result?: unknown; content?: unknown; }; const type = typeof typedPart.type === "string" ? typedPart.type.toLowerCase() : ""; if ( (type === "text" || type === "reasoning") && typeof typedPart.text === "string" ) { return typedPart.text.trim(); } if (type === "tool-call") { const toolName = typeof typedPart.toolName === "string" ? typedPart.toolName : typeof typedPart.name === "string" ? typedPart.name : "tool-call"; const args = compactJson( typedPart.args ?? typedPart.input ?? typedPart.content, 12000, ); return args ? `Tool call: ${toolName}\n${args}` : `Tool call: ${toolName}`; } if (type === "tool-result") { const payload = typedPart.output ?? typedPart.result ?? typedPart.content; const result = compactJson(payload, 12000); return result ? `Tool result\n${result}` : "Tool result"; } if (typeof typedPart.text === "string" && typedPart.text.trim()) { return typedPart.text.trim(); } return ""; } function extractCursorText(content: unknown): string { if (typeof content === "string") { return clampText(content.trim(), 32000); } if (!Array.isArray(content)) { return ""; } const parts: string[] = []; for (const part of content) { const text = extractPartText(part); if (text) parts.push(text); } return clampText(parts.join("\n\n"), 32000); } function isCursorBlobMessage(value: unknown): value is CursorBlobMessage { if (!value || typeof value !== "object") { return false; } const record = value as Record; return typeof record.role === "string" && "content" in record; } function buildSyntheticTimestamp( index: number, total: number, startedAt: Date, endedAt: Date, ): Date { if (total <= 1) return startedAt; const span = Math.max(endedAt.getTime() - startedAt.getTime(), 0); const step = span === 0 ? 1000 : Math.max(Math.floor(span / (total - 1)), 1); const candidate = startedAt.getTime() + index * step; return new Date(Math.min(candidate, endedAt.getTime())); } async function collectChatDirectories(roots: string[]): Promise { const chatDirs = new Set(); async function scanCandidate(root: string) { if (!(await existsDir(root))) return; if (existsSync(join(root, "store.db"))) { chatDirs.add(root); return; } let levelOne: string[] = []; try { levelOne = (await readdir(root, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map((entry) => join(root, entry.name)); } catch { return; } for (const firstLevelDir of levelOne) { if (existsSync(join(firstLevelDir, "store.db"))) { chatDirs.add(firstLevelDir); continue; } let levelTwo: string[] = []; try { levelTwo = (await readdir(firstLevelDir, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map((entry) => join(firstLevelDir, entry.name)); } catch { continue; } for (const secondLevelDir of levelTwo) { if (existsSync(join(secondLevelDir, "store.db"))) { chatDirs.add(secondLevelDir); } } } } for (const root of roots) { const candidate = (await existsDir(join(root, "chats"))) ? join(root, "chats") : root; await scanCandidate(candidate); } return Array.from(chatDirs); } export function getCursorChatsDir(): string { return join(homedir(), ".cursor", "chats"); } export function getCursorWorkspaceStorageDir(): string { return join( homedir(), "Library", "Application Support", "Cursor", "User", "workspaceStorage", ); } function decodeWorkspaceUri(uri: string): string | undefined { if (!uri) return undefined; if (uri.startsWith("file://")) { try { const parsed = new URL(uri); if (parsed.protocol !== "file:") return undefined; return decodeURIComponent(parsed.pathname); } catch { return undefined; } } if (uri.startsWith("/")) { return uri; } return undefined; } function extractWorkspacePath(data: CursorWorkspaceFile): string | undefined { if (typeof data.folder === "string") { return decodeWorkspaceUri(data.folder); } if (Array.isArray(data.folders)) { for (const folder of data.folders) { if (typeof folder.uri === "string") { const fromUri = decodeWorkspaceUri(folder.uri); if (fromUri) return fromUri; } if (typeof folder.path === "string" && folder.path.startsWith("/")) { return folder.path; } } } return undefined; } async function loadCursorWorkspacePathMap( workspaceStorageRoots?: string[], ): Promise> { const roots = workspaceStorageRoots && workspaceStorageRoots.length > 0 ? workspaceStorageRoots : [getCursorWorkspaceStorageDir()]; const mapping = new Map(); for (const root of roots) { if (!(await existsDir(root))) continue; let entries: Array<{ name: string; isDirectory(): boolean }> = []; try { entries = await readdir(root, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory()) continue; if (!isLikelyWorkspaceHash(entry.name)) continue; if (mapping.has(entry.name)) continue; const workspaceJsonPath = join(root, entry.name, "workspace.json"); let parsed: CursorWorkspaceFile | null = null; try { const content = await readFile(workspaceJsonPath, "utf-8"); parsed = JSON.parse(content) as CursorWorkspaceFile; } catch { parsed = null; } if (!parsed) continue; const workspacePath = extractWorkspacePath(parsed); if (!workspacePath) continue; mapping.set(entry.name, workspacePath); } } return mapping; } export async function resolveCursorWorkspacePath( workspaceHash: string, workspaceStorageRoots?: string[], ): Promise { if (!isLikelyWorkspaceHash(workspaceHash)) return undefined; const mapping = await loadCursorWorkspacePathMap(workspaceStorageRoots); return mapping.get(workspaceHash); } async function openReadonlySqlite(dbPath: string): Promise { const { Database } = await import("bun:sqlite"); return new Database(dbPath, { readonly: true }); } export async function parseCursorSessionDb( storeDbPath: string, workspaceHash?: string, modifiedAt?: Date, ): Promise { const db = await openReadonlySqlite(storeDbPath); try { const metaRows = db .query("SELECT value FROM meta WHERE key='0' LIMIT 1") .all() as Array<{ value?: unknown }>; const meta = parseMetaValue(metaRows[0]?.value); const blobRows = { rows: db .query("SELECT rowid, id, data FROM blobs ORDER BY rowid ASC") .all() as CursorBlobRow[], }; const parsedMessages: Array<{ role: ChatRole; idHint: string; text: string; order: number; }> = []; const seenSignatures = new Set(); for (let rowIndex = 0; rowIndex < blobRows.rows.length; rowIndex++) { const row = blobRows.rows[rowIndex] as CursorBlobRow; const text = asUtf8(row.data); if (!text) continue; const objects = extractJsonObjects(text); for (let objIndex = 0; objIndex < objects.length; objIndex++) { const obj = objects[objIndex]; if (!isCursorBlobMessage(obj)) continue; const signature = JSON.stringify([obj.role, obj.content]); if (seenSignatures.has(signature)) continue; seenSignatures.add(signature); const extracted = extractCursorText(obj.content); if (!extracted) continue; const rowOrder = Number(row.rowid) || rowIndex * 1000 + objIndex + 1; const baseHint = typeof obj.id === "string" ? obj.id : `m-${objIndex}`; parsedMessages.push({ role: mapCursorRole(obj.role), idHint: `${baseHint}-${rowOrder}`, text: extracted, order: rowOrder, }); } } if (parsedMessages.length === 0) { return null; } parsedMessages.sort((a, b) => a.order - b.order); const externalThreadId = (typeof meta.agentId === "string" && meta.agentId) || basename(dirname(storeDbPath)); const startedAt = toDateOrUndefined(meta.createdAt) || modifiedAt || new Date(); const endedAt = modifiedAt || startedAt; const messages: ChatMessage[] = parsedMessages.map((message, index) => ({ externalMessageId: stableMessageId( externalThreadId, index, message.idHint, ), role: message.role, timestamp: buildSyntheticTimestamp( index, parsedMessages.length, startedAt, endedAt, ), text: message.text, })); return { providerId: "cursor", externalThreadId, projectHash: workspaceHash, title: typeof meta.name === "string" ? meta.name : undefined, conversationFile: storeDbPath, startedAt: messages[0]?.timestamp, endedAt: messages[messages.length - 1]?.timestamp || endedAt, metadata: { mode: meta.mode, lastUsedModel: meta.lastUsedModel, latestRootBlobId: meta.latestRootBlobId, workspaceHash, syntheticTimestamps: true, }, messages, }; } catch { return null; } finally { db.close(); } } export async function* getCursorSessionsStreaming( scanRoots: string[], since?: Date, options?: { workspaceStorageRoots?: string[]; dataRoots?: string[]; }, ): AsyncGenerator { const chatDirs = await collectChatDirectories( options?.dataRoots ?? [getCursorChatsDir()], ); const workspacePathMap = await loadCursorWorkspacePathMap( options?.workspaceStorageRoots, ); for (const chatDir of chatDirs) { const storeDbPath = join(chatDir, "store.db"); let fileStats: Awaited>; try { fileStats = await stat(storeDbPath); } catch { continue; } if (since && fileStats.mtime < since) { continue; } // Yield event loop so the HTTP server can flush buffered progress chunks. // parseCursorSessionDb uses synchronous bun:sqlite which blocks the thread. await new Promise((r) => setTimeout(r, 0)); const workspaceHashCandidate = basename(dirname(chatDir)); const workspaceHash = isLikelyWorkspaceHash(workspaceHashCandidate) ? workspaceHashCandidate : undefined; const workspacePath = workspaceHash ? workspacePathMap.get(workspaceHash) : undefined; const session = await parseCursorSessionDb( storeDbPath, workspaceHash, fileStats.mtime, ); if (!session) continue; if (workspacePath) { session.projectPath = workspacePath; } session.metadata = { ...(session.metadata || {}), workspacePath, workspaceResolved: workspaceHash ? Boolean(workspacePath) : undefined, }; if (!isInScanRoots(session.projectPath, scanRoots)) continue; if (since) { const filtered = session.messages.filter((m) => m.timestamp >= since); if (filtered.length === 0) continue; session.messages = filtered; } yield session; } }