Skip to content
Open
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
78 changes: 78 additions & 0 deletions src/core/parser-cursor.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
242 changes: 242 additions & 0 deletions src/core/parser-cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
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";

// ---------------------------------------------------------------------------
// 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
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 ?? "";
if (wslUser) {
const wslPath = `/mnt/c/Users/${wslUser}/AppData/Roaming/Cursor/User/globalStorage/state.vscdb`;
if (fs.existsSync(wslPath)) return wslPath;
}
return linuxPath;
}
}

// ---------------------------------------------------------------------------
// SQLite helpers
// ---------------------------------------------------------------------------

function runSqlite(dbPath: string, sql: string): string {
try {
return execSync(`sqlite3 "${dbPath}" ${JSON.stringify(sql)}`, {
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
timeout: 30_000,
}).trim();
} catch {
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 LexicalNode {
text?: string;
children?: LexicalNode[];
}

// ---------------------------------------------------------------------------
// Parsers
// ---------------------------------------------------------------------------

function parseComposerMeta(value: string): ComposerMeta | null {
try {
const d = JSON.parse(value);
if (!d.composerId) return null;
return {
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;
}
}

/** Extract plain text from a Lexical editor JSON tree */
function extractLexicalText(nodeJson: string | undefined): string {
if (!nodeJson) return "";
try {
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;
};
return collect((root as { root?: LexicalNode }).root ?? root).trim();
} catch {
return "";
}
}

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;
}
}

// ---------------------------------------------------------------------------
// Main parser
// ---------------------------------------------------------------------------

export async function parseCursorSessions(): Promise<Session[]> {
const dbPath = getCursorGlobalDb();
if (!dbPath || !fs.existsSync(dbPath)) return [];

assertTrustedPath(dbPath);

// 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 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);

const meta = parseComposerMeta(value);
if (!meta) continue;

// 2. Load all bubbles for this composer
const bubbleRows = queryRows(
dbPath,
`SELECT value FROM cursorDiskKV WHERE key LIKE 'bubbleId:${meta.composerId}:%'`
);

if (bubbleRows.length === 0) continue;

const bubbles: ParsedBubble[] = [];
for (const bRow of bubbleRows) {
const b = parseBubble(bRow);
if (b) bubbles.push(b);
}

// 3. Pair user→assistant turns
const messages: Message[] = [];
let linesOfCode = 0;
let i = 0;
while (i < bubbles.length) {
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: cur.text,
response: next?.text ?? "",
model: meta.model,
});
linesOfCode += next?.linesAdded ?? 0;
i += next ? 2 : 1;
} else {
i++;
}
}

if (messages.length === 0) continue;

// 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.subtitle || meta.composerId,
source: "cursor",
model: meta.model ?? "unknown",
startedAt: meta.createdAt ? new Date(meta.createdAt) : undefined,
endedAt: meta.lastUpdatedAt ? new Date(meta.lastUpdatedAt) : undefined,
messages,
linesOfCode: totalLoc,
filePath: dbPath,
});
}

return sessions;
}
32 changes: 20 additions & 12 deletions src/core/parser-harnesses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Workspace>;

Expand Down Expand Up @@ -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 {
Expand All @@ -78,15 +93,10 @@ export interface ExternalHarnessProgressHandlers {
yieldToLoop?: () => Promise<void>;
}

/** 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;
}
Expand All @@ -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<string>([
'Claude',
'Codex',
'OpenCode',
'Cursor',
]);

export async function collectExternalHarnessesAsync(
Expand Down Expand Up @@ -133,4 +141,4 @@ export async function collectExternalHarnessesAsync(

if (handlers.yieldToLoop) await handlers.yieldToLoop();
}
}
}
Loading