From b1d2c5333077b41819e0d9508f1ffbeeec59f508 Mon Sep 17 00:00:00 2001 From: Bohdan Kamuz Date: Wed, 24 Jun 2026 21:10:43 +0300 Subject: [PATCH 1/6] feat: add Cursor session harness parser --- src/core/parser-cursor.ts | 211 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 src/core/parser-cursor.ts diff --git a/src/core/parser-cursor.ts b/src/core/parser-cursor.ts new file mode 100644 index 0000000..1a8bd89 --- /dev/null +++ b/src/core/parser-cursor.ts @@ -0,0 +1,211 @@ +import * as fs from "fs"; +import * as path from "path"; +import { execSync } from "child_process"; +import { Session, Message } from "./session.js"; +import { assertTrustedPath } from "./parser-shared.js"; + +/** Roots where Cursor stores workspace SQLite databases */ +function getCursorStorageRoots(): string[] { + const roots: string[] = []; + const platform = process.platform; + + if (platform === "win32") { + const appData = process.env.APPDATA; + if (appData) roots.push(path.join(appData, "Cursor", "User", "workspaceStorage")); + } else if (platform === "darwin") { + const home = process.env.HOME ?? ""; + roots.push(path.join(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage")); + } else { + // Linux + WSL + const home = process.env.HOME ?? ""; + roots.push(path.join(home, ".config", "Cursor", "User", "workspaceStorage")); + // WSL path to Windows Cursor data + const wslUser = process.env.USER ?? ""; + roots.push(`/mnt/c/Users/${wslUser}/AppData/Roaming/Cursor/User/workspaceStorage`); + } + + return roots.filter((r) => { + try { return fs.statSync(r).isDirectory(); } catch { return false; } + }); +} + +/** Find all Cursor workspace SQLite databases */ +function findCursorDatabases(): string[] { + const dbs: string[] = []; + for (const root of getCursorStorageRoots()) { + try { + const entries = fs.readdirSync(root); + for (const hash of entries) { + const dbPath = path.join(root, hash, "state.vscdb"); + if (fs.existsSync(dbPath)) dbs.push(dbPath); + } + } catch { /* skip unreadable dirs */ } + } + return dbs; +} + +/** Run a sqlite3 query and return rows split by unit-separator \x1f */ +function querySqlite(dbPath: string, sql: string): string[] { + try { + const raw = execSync(`sqlite3 -separator $'\\x1f' "${dbPath}" "${sql}"`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }); + return raw.trim().split("\n").filter(Boolean); + } catch { + return []; + } +} + +interface ComposerMeta { + composerId: string; + name?: string; + createdAt?: number; + lastUpdatedAt?: number; + model?: string; +} + +interface Bubble { + bubbleId: string; + type: "user" | "assistant" | string; + text?: string; + codeBlocks?: { content: string; language?: string }[]; + timingMs?: number; +} + +function parseComposerData(raw: string): ComposerMeta | null { + try { + const data = JSON.parse(raw); + return { + composerId: data.composerId ?? "", + name: data.name, + createdAt: data.createdAt, + lastUpdatedAt: data.lastUpdatedAt, + model: data.selectedModel ?? data.model, + }; + } catch { return null; } +} + +function parseBubble(raw: string): Bubble | null { + try { + const data = JSON.parse(raw); + const codeBlocks = (data.codeBlocks ?? []).map((b: { content?: string; language?: string }) => ({ + content: b.content ?? "", + language: b.language, + })); + return { + bubbleId: data.bubbleId ?? "", + type: data.type === 1 ? "user" : "assistant", + text: data.text ?? data.rawText ?? "", + codeBlocks, + timingMs: data.timingMs, + }; + } catch { return null; } +} + +function countLinesOfCode(bubbles: Bubble[]): number { + let loc = 0; + for (const b of bubbles) { + if (b.type === "assistant") { + for (const block of b.codeBlocks ?? []) { + loc += block.content.split("\n").length; + } + } + } + return loc; +} + +/** Parse a single Cursor workspace database into Sessions */ +function parseCursorDb(dbPath: string): Session[] { + assertTrustedPath(dbPath); + + // Get all composer sessions + const composerRows = querySqlite( + dbPath, + "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'" + ); + + const sessions: Session[] = []; + + for (const row of composerRows) { + const sep = row.indexOf("\x1f"); + if (sep === -1) continue; + const value = row.slice(sep + 1); + const meta = parseComposerData(value); + if (!meta?.composerId) continue; + + // Get all bubbles for this composer + const bubbleRows = querySqlite( + dbPath, + `SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:${meta.composerId}:%'` + ); + + const bubbles: Bubble[] = []; + for (const bRow of bubbleRows) { + const bSep = bRow.indexOf("\x1f"); + if (bSep === -1) continue; + const bValue = bRow.slice(bSep + 1); + const bubble = parseBubble(bValue); + if (bubble) bubbles.push(bubble); + } + + if (bubbles.length === 0) continue; + + // Build message pairs + const messages: Message[] = []; + let i = 0; + while (i < bubbles.length) { + const current = bubbles[i]; + if (current.type === "user") { + const next = bubbles[i + 1]; + messages.push({ + prompt: current.text ?? "", + response: next?.type === "assistant" ? (next.text ?? "") : "", + durationMs: next?.timingMs, + model: meta.model, + }); + i += next?.type === "assistant" ? 2 : 1; + } else { + i++; + } + } + + if (messages.length === 0) continue; + + const linesOfCode = countLinesOfCode(bubbles); + const startedAt = meta.createdAt ? new Date(meta.createdAt) : undefined; + const endedAt = meta.lastUpdatedAt ? new Date(meta.lastUpdatedAt) : undefined; + + sessions.push({ + id: meta.composerId, + title: meta.name ?? meta.composerId, + source: "cursor", + model: meta.model ?? "unknown", + startedAt, + endedAt, + messages, + linesOfCode, + filePath: dbPath, + }); + } + + return sessions; +} + +/** Entry point called by the harness registry */ +export async function parseCursorSessions(): Promise { + const dbs = findCursorDatabases(); + const allSessions: Session[] = []; + + for (const db of dbs) { + try { + const sessions = parseCursorDb(db); + allSessions.push(...sessions); + } catch (err) { + // skip unreadable databases + if (process.env.DEBUG) console.error(`Cursor: skipping ${db}:`, err); + } + } + + return allSessions; +} \ No newline at end of file From da40e88ad20c34390bc1dfd6b6ad0c1f97dd7c5d Mon Sep 17 00:00:00 2001 From: Bohdan Kamuz Date: Wed, 24 Jun 2026 21:11:06 +0300 Subject: [PATCH 2/6] test: add unit tests for Cursor harness parser --- src/core/parser-cursor.test.ts | 78 ++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/core/parser-cursor.test.ts diff --git a/src/core/parser-cursor.test.ts b/src/core/parser-cursor.test.ts new file mode 100644 index 0000000..fc24e22 --- /dev/null +++ b/src/core/parser-cursor.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fs from "fs"; +import * as cp from "child_process"; + +vi.mock("fs"); +vi.mock("child_process"); + +// Must mock before importing the module under test +const mockExecSync = vi.mocked(cp.execSync); +const mockReaddirSync = vi.mocked(fs.readdirSync); +const mockExistsSync = vi.mocked(fs.existsSync); +const mockStatSync = vi.mocked(fs.statSync); + +describe("parseCursorSessions", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Simulate a valid storage root + mockStatSync.mockReturnValue({ isDirectory: () => true } as fs.Stats); + mockReaddirSync.mockReturnValue(["abc123"] as unknown as fs.Dirent[]); + mockExistsSync.mockReturnValue(true); + }); + + it("parses a session with user+assistant bubbles", async () => { + const composerMeta = JSON.stringify({ + composerId: "comp-1", + name: "My Session", + createdAt: 1700000000000, + lastUpdatedAt: 1700001000000, + selectedModel: "claude-3-5-sonnet", + }); + const userBubble = JSON.stringify({ bubbleId: "b1", type: 1, text: "Hello?" }); + const aiBubble = JSON.stringify({ bubbleId: "b2", type: 2, text: "Hi there!", timingMs: 800, codeBlocks: [] }); + + mockExecSync + .mockReturnValueOnce(`composerData:comp-1\x1f${composerMeta}\n` as unknown as Buffer) + .mockReturnValueOnce(`bubbleId:comp-1:b1\x1f${userBubble}\nbubbleId:comp-1:b2\x1f${aiBubble}\n` as unknown as Buffer); + + const { parseCursorSessions } = await import("./parser-cursor.js"); + const sessions = await parseCursorSessions(); + + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("cursor"); + expect(sessions[0].model).toBe("claude-3-5-sonnet"); + expect(sessions[0].messages).toHaveLength(1); + expect(sessions[0].messages[0].prompt).toBe("Hello?"); + expect(sessions[0].messages[0].response).toBe("Hi there!"); + expect(sessions[0].messages[0].durationMs).toBe(800); + }); + + it("returns empty array when no databases found", async () => { + mockStatSync.mockImplementation(() => { throw new Error("ENOENT"); }); + const { parseCursorSessions } = await import("./parser-cursor.js"); + const sessions = await parseCursorSessions(); + expect(sessions).toHaveLength(0); + }); + + it("skips composers with no bubbles", async () => { + const composerMeta = JSON.stringify({ composerId: "comp-empty", name: "Empty" }); + mockExecSync + .mockReturnValueOnce(`composerData:comp-empty\x1f${composerMeta}\n` as unknown as Buffer) + .mockReturnValueOnce("" as unknown as Buffer); + + const { parseCursorSessions } = await import("./parser-cursor.js"); + const sessions = await parseCursorSessions(); + expect(sessions).toHaveLength(0); + }); + + it("returns empty array when sqlite3 is not available", async () => { + const composerMeta = JSON.stringify({ composerId: "comp-1", name: "Test" }); + mockExecSync + .mockReturnValueOnce(`composerData:comp-1\x1f${composerMeta}\n` as unknown as Buffer) + .mockImplementationOnce(() => { throw new Error("sqlite3: command not found"); }); + + const { parseCursorSessions } = await import("./parser-cursor.js"); + const sessions = await parseCursorSessions(); + expect(sessions).toHaveLength(0); + }); +}); \ No newline at end of file From 864071a5d8d1ea10b34ef45687e176cb3d25be87 Mon Sep 17 00:00:00 2001 From: Bohdan Kamuz Date: Wed, 24 Jun 2026 21:12:39 +0300 Subject: [PATCH 3/6] feat: register Cursor harness in EXTERNAL_HARNESSES and EXTERNAL_HARNESS_SET --- src/core/parser-harnesses.ts | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index 76b33e6..c2472fa 100644 --- a/src/core/parser-harnesses.ts +++ b/src/core/parser-harnesses.ts @@ -10,6 +10,7 @@ import { Workspace, Session } from './types'; import { findClaudeDirs, parseClaudeSessions, parseClaudeSessionsAsync } from './parser-claude'; import { findCodexDirs, parseCodexSessions } from './parser-codex'; import { findOpenCodeDirs, parseOpenCodeSessions } from './parser-opencode'; +import { parseCursorSessions } from './parser-cursor'; type WorkspaceMap = Map; @@ -69,6 +70,20 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ } }, }, + { + name: 'Cursor', + collectSync(_ctx) { + // Cursor sessions are async-only (sqlite3 CLI); collectAsync handles them. + }, + async collectAsync(ctx, reportDetail) { + reportDetail?.('Scanning Cursor workspace databases...'); + const sessions = await parseCursorSessions(); + for (const session of sessions) { + addSession(ctx.workspaces, ctx.sessions, session, session.filePath ?? ''); + } + reportDetail?.(`Found ${sessions.length} Cursor session(s)`); + }, + }, ]; export interface ExternalHarnessProgressHandlers { @@ -78,15 +93,10 @@ export interface ExternalHarnessProgressHandlers { yieldToLoop?: () => Promise; } -/** Returns true if any external-harness (Claude Code, Codex, OpenCode) session +/** Returns true if any external-harness (Claude Code, Codex, OpenCode, Cursor) session * source exists on disk. The dashboard uses this so it does not abort when the - * only available logs come from a non-VS Code harness — e.g. a headless - * Remote-SSH host that has Claude Code sessions under `~/.claude/projects` but - * no VS Code workspace storage or Copilot directories. */ + * only available logs come from a non-VS Code harness. */ export function hasExternalHarnessSources(): boolean { - // Without a home directory the find* helpers would join against an empty - // string and probe relative paths (e.g. `.claude/projects`) under the current - // working directory, which could report false positives. Bail out instead. if (!process.env.HOME && !process.env.USERPROFILE) return false; return findClaudeDirs().length > 0 || findCodexDirs().length > 0 || findOpenCodeDirs().length > 0; } @@ -98,14 +108,12 @@ export function collectExternalHarnessesSync(workspaces: WorkspaceMap, sessions: } } -/** Harness values set on sessions by external harness collectors. - * The cache reconciliation in parser.ts uses this set to identify and - * refresh cached external-harness sessions, so every value the collectors - * can produce must be listed here. */ +/** Harness values set on sessions by external harness collectors. */ export const EXTERNAL_HARNESS_SET = new Set([ 'Claude', 'Codex', 'OpenCode', + 'Cursor', ]); export async function collectExternalHarnessesAsync( @@ -133,4 +141,4 @@ export async function collectExternalHarnessesAsync( if (handlers.yieldToLoop) await handlers.yieldToLoop(); } -} +} \ No newline at end of file From e5b429846feae4dc7ce3e6920442870f9c940fef Mon Sep 17 00:00:00 2001 From: Bohdan Kamuz Date: Wed, 24 Jun 2026 21:12:57 +0300 Subject: [PATCH 4/6] feat: add Cursor paths to assertTrustedPath roots --- src/core/parser-shared.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/core/parser-shared.ts b/src/core/parser-shared.ts index c50d14e..7726824 100644 --- a/src/core/parser-shared.ts +++ b/src/core/parser-shared.ts @@ -62,6 +62,18 @@ function getTrustedRoots(): string[] { roots.push(path.resolve(home, '.claude')); roots.push(path.resolve(home, '.codex')); roots.push(path.resolve(home, '.local', 'share', 'opencode')); + // Cursor IDE + if (process.platform === 'win32') { + const appdata = process.env.APPDATA || ''; + if (appdata) roots.push(path.resolve(appdata, 'Cursor', 'User', 'workspaceStorage')); + } else if (process.platform === 'darwin') { + roots.push(path.resolve(home, 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage')); + } else { + roots.push(path.resolve(home, '.config', 'Cursor', 'User', 'workspaceStorage')); + // WSL: Windows Cursor data + const wslUser = process.env.USER || ''; + if (wslUser) roots.push(/mnt/c/Users//AppData/Roaming/Cursor/User/workspaceStorage); + } roots.push(path.resolve(home, '.config', 'github-copilot')); } From 61afb9a95bf89bc351bf0e1b7fc79b42fd7e1d5a Mon Sep 17 00:00:00 2001 From: Bohdan Kamuz Date: Wed, 24 Jun 2026 21:21:02 +0300 Subject: [PATCH 5/6] fix: rewrite Cursor parser to use globalStorage and real data structure --- src/core/parser-cursor.ts | 287 +++++++++++++++++++++----------------- 1 file changed, 159 insertions(+), 128 deletions(-) diff --git a/src/core/parser-cursor.ts b/src/core/parser-cursor.ts index 1a8bd89..3968e05 100644 --- a/src/core/parser-cursor.ts +++ b/src/core/parser-cursor.ts @@ -4,167 +4,215 @@ import { execSync } from "child_process"; import { Session, Message } from "./session.js"; import { assertTrustedPath } from "./parser-shared.js"; -/** Roots where Cursor stores workspace SQLite databases */ -function getCursorStorageRoots(): string[] { - const roots: string[] = []; - const platform = process.platform; - - if (platform === "win32") { - const appData = process.env.APPDATA; - if (appData) roots.push(path.join(appData, "Cursor", "User", "workspaceStorage")); - } else if (platform === "darwin") { - const home = process.env.HOME ?? ""; - roots.push(path.join(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage")); +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +/** + * Cursor stores ALL chat data in a single global SQLite database. + * Per-workspace `state.vscdb` files only contain UI state, not conversations. + */ +function getCursorGlobalDb(): string | null { + let base: string; + + if (process.platform === "win32") { + base = process.env.APPDATA ?? ""; + if (!base) return null; + return path.join(base, "Cursor", "User", "globalStorage", "state.vscdb"); + } else if (process.platform === "darwin") { + base = process.env.HOME ?? ""; + if (!base) return null; + return path.join(base, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"); } else { - // Linux + WSL - const home = process.env.HOME ?? ""; - roots.push(path.join(home, ".config", "Cursor", "User", "workspaceStorage")); - // WSL path to Windows Cursor data + // Linux / WSL + base = process.env.HOME ?? ""; + const linuxPath = base ? path.join(base, ".config", "Cursor", "User", "globalStorage", "state.vscdb") : null; + if (linuxPath && fs.existsSync(linuxPath)) return linuxPath; + // WSL: try Windows path const wslUser = process.env.USER ?? ""; - roots.push(`/mnt/c/Users/${wslUser}/AppData/Roaming/Cursor/User/workspaceStorage`); + if (wslUser) { + const wslPath = `/mnt/c/Users/${wslUser}/AppData/Roaming/Cursor/User/globalStorage/state.vscdb`; + if (fs.existsSync(wslPath)) return wslPath; + } + return linuxPath; } - - return roots.filter((r) => { - try { return fs.statSync(r).isDirectory(); } catch { return false; } - }); } -/** Find all Cursor workspace SQLite databases */ -function findCursorDatabases(): string[] { - const dbs: string[] = []; - for (const root of getCursorStorageRoots()) { - try { - const entries = fs.readdirSync(root); - for (const hash of entries) { - const dbPath = path.join(root, hash, "state.vscdb"); - if (fs.existsSync(dbPath)) dbs.push(dbPath); - } - } catch { /* skip unreadable dirs */ } - } - return dbs; -} +// --------------------------------------------------------------------------- +// SQLite helpers +// --------------------------------------------------------------------------- -/** Run a sqlite3 query and return rows split by unit-separator \x1f */ -function querySqlite(dbPath: string, sql: string): string[] { +function runSqlite(dbPath: string, sql: string): string { try { - const raw = execSync(`sqlite3 -separator $'\\x1f' "${dbPath}" "${sql}"`, { + return execSync(`sqlite3 "${dbPath}" ${JSON.stringify(sql)}`, { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], - }); - return raw.trim().split("\n").filter(Boolean); + timeout: 30_000, + }).trim(); } catch { - return []; + return ""; } } +function queryRows(dbPath: string, sql: string): string[] { + const raw = runSqlite(dbPath, sql); + return raw ? raw.split("\n").filter(Boolean) : []; +} + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + interface ComposerMeta { composerId: string; name?: string; createdAt?: number; lastUpdatedAt?: number; model?: string; + totalLinesAdded?: number; + totalLinesRemoved?: number; + subtitle?: string; } -interface Bubble { - bubbleId: string; - type: "user" | "assistant" | string; +interface LexicalNode { text?: string; - codeBlocks?: { content: string; language?: string }[]; - timingMs?: number; + children?: LexicalNode[]; } -function parseComposerData(raw: string): ComposerMeta | null { +// --------------------------------------------------------------------------- +// Parsers +// --------------------------------------------------------------------------- + +function parseComposerMeta(value: string): ComposerMeta | null { try { - const data = JSON.parse(raw); + const d = JSON.parse(value); + if (!d.composerId) return null; return { - composerId: data.composerId ?? "", - name: data.name, - createdAt: data.createdAt, - lastUpdatedAt: data.lastUpdatedAt, - model: data.selectedModel ?? data.model, + composerId: d.composerId, + name: d.name || undefined, + createdAt: d.createdAt || undefined, + lastUpdatedAt: d.lastUpdatedAt || undefined, + model: d.modelConfig?.model || d.model || undefined, + totalLinesAdded: d.totalLinesAdded || 0, + totalLinesRemoved: d.totalLinesRemoved || 0, + subtitle: d.subtitle || undefined, }; - } catch { return null; } + } catch { + return null; + } } -function parseBubble(raw: string): Bubble | null { +/** Extract plain text from a Lexical editor JSON tree */ +function extractLexicalText(nodeJson: string | undefined): string { + if (!nodeJson) return ""; try { - const data = JSON.parse(raw); - const codeBlocks = (data.codeBlocks ?? []).map((b: { content?: string; language?: string }) => ({ - content: b.content ?? "", - language: b.language, - })); - return { - bubbleId: data.bubbleId ?? "", - type: data.type === 1 ? "user" : "assistant", - text: data.text ?? data.rawText ?? "", - codeBlocks, - timingMs: data.timingMs, + const root: LexicalNode = JSON.parse(nodeJson); + const collect = (node: LexicalNode): string => { + let t = node.text ?? ""; + for (const child of node.children ?? []) t += collect(child); + return t; }; - } catch { return null; } + return collect((root as { root?: LexicalNode }).root ?? root).trim(); + } catch { + return ""; + } } -function countLinesOfCode(bubbles: Bubble[]): number { - let loc = 0; - for (const b of bubbles) { - if (b.type === "assistant") { - for (const block of b.codeBlocks ?? []) { - loc += block.content.split("\n").length; - } +interface ParsedBubble { + type: 1 | 2; + text: string; + createdAt?: string; + linesAdded: number; +} + +function parseBubble(value: string): ParsedBubble | null { + try { + const d = JSON.parse(value); + const type: 1 | 2 = d.type === 1 ? 1 : 2; + + let text = ""; + if (type === 1) { + // User bubble: extract from Lexical richText + text = extractLexicalText(d.richText) || d.text || ""; + } else { + // Assistant bubble: full response text is not persisted; use thinking as proxy + text = d.thinking?.text || d.text || ""; + } + + // Count lines added from suggested diffs + let linesAdded = 0; + for (const diff of d.assistantSuggestedDiffs ?? []) { + const content: string = diff.newFileContent ?? diff.content ?? ""; + if (content) linesAdded += content.split("\n").length; } + for (const block of d.suggestedCodeBlocks ?? []) { + const content: string = block.content ?? ""; + if (content) linesAdded += content.split("\n").length; + } + + return { type, text, createdAt: d.createdAt, linesAdded }; + } catch { + return null; } - return loc; } -/** Parse a single Cursor workspace database into Sessions */ -function parseCursorDb(dbPath: string): Session[] { +// --------------------------------------------------------------------------- +// Main parser +// --------------------------------------------------------------------------- + +export async function parseCursorSessions(): Promise { + const dbPath = getCursorGlobalDb(); + if (!dbPath || !fs.existsSync(dbPath)) return []; + assertTrustedPath(dbPath); - // Get all composer sessions - const composerRows = querySqlite( + // 1. Load all composer metadata in one query + const metaRows = queryRows( dbPath, "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'" ); const sessions: Session[] = []; - for (const row of composerRows) { - const sep = row.indexOf("\x1f"); - if (sep === -1) continue; - const value = row.slice(sep + 1); - const meta = parseComposerData(value); - if (!meta?.composerId) continue; + for (const row of metaRows) { + // SQLite default column separator is | + const pipeIdx = row.indexOf("|"); + if (pipeIdx === -1) continue; + const key = row.slice(0, pipeIdx); + const value = row.slice(pipeIdx + 1); - // Get all bubbles for this composer - const bubbleRows = querySqlite( + const meta = parseComposerMeta(value); + if (!meta) continue; + + // 2. Load all bubbles for this composer + const bubbleRows = queryRows( dbPath, - `SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:${meta.composerId}:%'` + `SELECT value FROM cursorDiskKV WHERE key LIKE 'bubbleId:${meta.composerId}:%'` ); - const bubbles: Bubble[] = []; + if (bubbleRows.length === 0) continue; + + const bubbles: ParsedBubble[] = []; for (const bRow of bubbleRows) { - const bSep = bRow.indexOf("\x1f"); - if (bSep === -1) continue; - const bValue = bRow.slice(bSep + 1); - const bubble = parseBubble(bValue); - if (bubble) bubbles.push(bubble); + const b = parseBubble(bRow); + if (b) bubbles.push(b); } - if (bubbles.length === 0) continue; - - // Build message pairs + // 3. Pair user→assistant turns const messages: Message[] = []; + let linesOfCode = 0; let i = 0; while (i < bubbles.length) { - const current = bubbles[i]; - if (current.type === "user") { - const next = bubbles[i + 1]; + const cur = bubbles[i]; + if (cur.type === 1) { + const next = i + 1 < bubbles.length && bubbles[i + 1].type === 2 ? bubbles[i + 1] : null; messages.push({ - prompt: current.text ?? "", - response: next?.type === "assistant" ? (next.text ?? "") : "", - durationMs: next?.timingMs, + prompt: cur.text, + response: next?.text ?? "", model: meta.model, }); - i += next?.type === "assistant" ? 2 : 1; + linesOfCode += next?.linesAdded ?? 0; + i += next ? 2 : 1; } else { i++; } @@ -172,40 +220,23 @@ function parseCursorDb(dbPath: string): Session[] { if (messages.length === 0) continue; - const linesOfCode = countLinesOfCode(bubbles); - const startedAt = meta.createdAt ? new Date(meta.createdAt) : undefined; - const endedAt = meta.lastUpdatedAt ? new Date(meta.lastUpdatedAt) : undefined; + // Prefer session-level line counts if available + const totalLoc = (meta.totalLinesAdded ?? 0) > 0 + ? meta.totalLinesAdded! + : linesOfCode; sessions.push({ id: meta.composerId, - title: meta.name ?? meta.composerId, + title: meta.name || meta.subtitle || meta.composerId, source: "cursor", model: meta.model ?? "unknown", - startedAt, - endedAt, + startedAt: meta.createdAt ? new Date(meta.createdAt) : undefined, + endedAt: meta.lastUpdatedAt ? new Date(meta.lastUpdatedAt) : undefined, messages, - linesOfCode, + linesOfCode: totalLoc, filePath: dbPath, }); } return sessions; -} - -/** Entry point called by the harness registry */ -export async function parseCursorSessions(): Promise { - const dbs = findCursorDatabases(); - const allSessions: Session[] = []; - - for (const db of dbs) { - try { - const sessions = parseCursorDb(db); - allSessions.push(...sessions); - } catch (err) { - // skip unreadable databases - if (process.env.DEBUG) console.error(`Cursor: skipping ${db}:`, err); - } - } - - return allSessions; } \ No newline at end of file From b1f8a247da644670584b565c3b356d7f6c7ea169 Mon Sep 17 00:00:00 2001 From: Bohdan Kamuz Date: Wed, 24 Jun 2026 21:38:28 +0300 Subject: [PATCH 6/6] fix: repair WSL path syntax in getTrustedRoots --- src/core/parser-shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/parser-shared.ts b/src/core/parser-shared.ts index 7726824..09c2933 100644 --- a/src/core/parser-shared.ts +++ b/src/core/parser-shared.ts @@ -72,7 +72,7 @@ function getTrustedRoots(): string[] { roots.push(path.resolve(home, '.config', 'Cursor', 'User', 'workspaceStorage')); // WSL: Windows Cursor data const wslUser = process.env.USER || ''; - if (wslUser) roots.push(/mnt/c/Users//AppData/Roaming/Cursor/User/workspaceStorage); + if (wslUser) roots.push('/mnt/c/Users/' + wslUser + '/AppData/Roaming/Cursor/User/workspaceStorage'); } roots.push(path.resolve(home, '.config', 'github-copilot')); }