From 6afc5880c135f8c51a372391e6cb09ef614981e0 Mon Sep 17 00:00:00 2001 From: vicnaum Date: Fri, 12 Jun 2026 16:20:35 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20add=20Cursor=20support=20=E2=80=94?= =?UTF-8?q?=20live=20agent=20transcripts=20+=20legacy=20state.vscdb=20impo?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect and parse Cursor agent transcripts from ~/.cursor/projects (role/message JSONL): tool_use extraction, unwrapping, sessionId from filename, cwd recovered from tool-call inputs with path-slug fallback - Add import-cursor-history command: exports pre-agent-transcript conversations from Cursor's state.vscdb (read-only) as parser-compatible JSONL with embedded per-message timestamps, deduped against live transcripts, project resolved to the enclosing git root - Scan ~/.cursor/projects and the legacy export dir as conversation sources (CURSOR_HOME override supported) - Preserve source mtime in sync's copyIfNewer so harnesses without per-message timestamps index with real conversation times --- README.md | 21 ++- cli/episodic-memory.js | 19 ++- dist/cursor-import-cli.d.ts | 1 + dist/cursor-import-cli.js | 91 ++++++++++ dist/cursor-legacy.d.ts | 28 +++ dist/cursor-legacy.js | 228 +++++++++++++++++++++++++ dist/mcp-server.js | 2 +- dist/parser.d.ts | 9 + dist/parser.js | 233 +++++++++++++++++++++++++ dist/paths.d.ts | 14 +- dist/paths.js | 21 ++- dist/sync.js | 7 + dist/types.d.ts | 2 +- dist/version.d.ts | 2 +- dist/version.js | 2 +- src/cursor-import-cli.ts | 104 +++++++++++ src/cursor-legacy.ts | 294 ++++++++++++++++++++++++++++++++ src/parser.ts | 273 +++++++++++++++++++++++++++++ src/paths.ts | 23 ++- src/sync.ts | 8 + src/types.ts | 2 +- test/codex-transcripts.test.ts | 19 +++ test/cursor-legacy.test.ts | 195 +++++++++++++++++++++ test/cursor-transcripts.test.ts | 218 +++++++++++++++++++++++ 24 files changed, 1799 insertions(+), 17 deletions(-) create mode 100644 dist/cursor-import-cli.d.ts create mode 100644 dist/cursor-import-cli.js create mode 100644 dist/cursor-legacy.d.ts create mode 100644 dist/cursor-legacy.js create mode 100644 src/cursor-import-cli.ts create mode 100644 src/cursor-legacy.ts create mode 100644 test/cursor-legacy.test.ts create mode 100644 test/cursor-transcripts.test.ts diff --git a/README.md b/README.md index 23893be..4f27688 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Episodic Memory -Semantic search for Claude Code and Codex conversations. Remember past discussions, decisions, and patterns. +Semantic search for Claude Code, Codex, and Cursor conversations. Remember past discussions, decisions, and patterns. ## Testimonial @@ -102,7 +102,7 @@ npm install -g github:obra/episodic-memory ### Quick Start ```bash -# Sync conversations from Claude Code and Codex and index them +# Sync conversations from Claude Code, Codex, and Cursor and index them episodic-memory sync # Search your conversation history @@ -118,6 +118,23 @@ episodic-memory doctor codex episodic-memory show path/to/conversation.jsonl ``` +### Cursor support + +Sync automatically indexes Cursor agent transcripts from `~/.cursor/projects` +(written by Cursor since early 2026). Conversations older than that exist only +inside Cursor's global SQLite store; backfill them once with: + +```bash +# Export legacy Cursor conversations from state.vscdb (read-only), then index +episodic-memory import-cursor-history +episodic-memory sync +``` + +The importer skips conversations that already have a live agent transcript, +recovers each conversation's project from tool-call working directories, and +embeds original message timestamps. Re-running it only exports new +conversations; use `--force` to re-export everything. + ### Command Line ```bash diff --git a/cli/episodic-memory.js b/cli/episodic-memory.js index 60e5ad4..08ebccc 100755 --- a/cli/episodic-memory.js +++ b/cli/episodic-memory.js @@ -31,18 +31,19 @@ function runScript(scriptPath, args) { } function showHelp() { - console.log(`episodic-memory - Manage and search Claude Code and Codex conversations + console.log(`episodic-memory - Manage and search Claude Code, Codex, and Cursor conversations USAGE: episodic-memory [options] COMMANDS: - sync Sync conversations from Claude Code and Codex and index them - index Index conversations for search - search Search indexed conversations - show Display a conversation in readable format - stats Show index statistics - doctor Diagnose Claude Code or Codex integration issues + sync Sync conversations from Claude Code, Codex, and Cursor and index them + index Index conversations for search + search Search indexed conversations + show Display a conversation in readable format + stats Show index statistics + doctor Diagnose Claude Code or Codex integration issues + import-cursor-history Export legacy Cursor conversations from state.vscdb for indexing Run 'episodic-memory --help' for command-specific help. @@ -89,6 +90,10 @@ async function main() { await runScript(join(distDir, 'sync-cli.js'), args); break; + case 'import-cursor-history': + await runScript(join(distDir, 'cursor-import-cli.js'), args); + break; + case '--help': case '-h': case undefined: diff --git a/dist/cursor-import-cli.d.ts b/dist/cursor-import-cli.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/cursor-import-cli.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/cursor-import-cli.js b/dist/cursor-import-cli.js new file mode 100644 index 0000000..9244b35 --- /dev/null +++ b/dist/cursor-import-cli.js @@ -0,0 +1,91 @@ +import path from 'path'; +import { getDefaultCursorVscdbPath, collectLiveTranscriptIds, importCursorLegacy, } from './cursor-legacy.js'; +import { getCursorDir, getCursorLegacyExportDir } from './paths.js'; +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log(` +Usage: episodic-memory import-cursor-history [options] + +Export legacy Cursor conversations from Cursor's global SQLite store +(state.vscdb) as JSONL transcripts, so sync can archive and index them. + +Cursor only began writing per-session agent transcripts to ~/.cursor/projects +in early 2026; conversations older than that exist only in state.vscdb. This +command backfills them. Conversations that already have a live agent +transcript are skipped automatically. + +The export is written to: + ${getCursorLegacyExportDir()} +which sync scans as a conversation source. Run 'episodic-memory sync' after +importing to archive and index the exported conversations. + +The database is opened read-only; Cursor's data is never modified. + +OPTIONS: + --db Path to state.vscdb (default: auto-detected per platform) + --out Export directory (default: shown above) + --force Re-export conversations whose output file already exists + --dry-run Report what would be exported without writing files + +EXAMPLES: + # Export all legacy conversations, then index them + episodic-memory import-cursor-history + episodic-memory sync + + # Preview without writing + episodic-memory import-cursor-history --dry-run +`); + process.exit(0); +} +function argValue(flag) { + const index = args.indexOf(flag); + if (index === -1) + return undefined; + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + console.error(`Error: ${flag} requires a value`); + process.exit(1); + } + return value; +} +const dbPath = argValue('--db') ?? getDefaultCursorVscdbPath(); +if (!dbPath) { + console.error('Error: could not find Cursor state.vscdb; pass --db '); + process.exit(1); +} +const exportDir = argValue('--out') ?? getCursorLegacyExportDir(); +const dryRun = args.includes('--dry-run'); +const force = args.includes('--force'); +const cursorProjectsDir = path.join(getCursorDir(), 'projects'); +const liveTranscriptIds = collectLiveTranscriptIds(cursorProjectsDir); +console.log(`Importing legacy Cursor conversations${dryRun ? ' (dry run)' : ''}...`); +console.log(` Database: ${dbPath}`); +console.log(` Export dir: ${exportDir}`); +console.log(` Live transcripts found: ${liveTranscriptIds.size}\n`); +try { + const result = importCursorLegacy({ + dbPath, + exportDir, + liveTranscriptIds, + force, + dryRun, + }); + console.log(`✅ Import ${dryRun ? 'preview' : 'complete'}!`); + console.log(` Exported: ${result.exported}`); + console.log(` Skipped (live transcript exists): ${result.skippedLive}`); + console.log(` Skipped (already exported): ${result.skippedExisting}`); + console.log(` Skipped (empty): ${result.skippedEmpty}`); + if (result.errors.length > 0) { + console.log(`\n⚠️ Errors: ${result.errors.length}`); + for (const err of result.errors.slice(0, 10)) { + console.log(` ${err.composerId}: ${err.error}`); + } + } + if (!dryRun && result.exported > 0) { + console.log(`\nRun 'episodic-memory sync' to archive and index the exported conversations.`); + } +} +catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); +} diff --git a/dist/cursor-legacy.d.ts b/dist/cursor-legacy.d.ts new file mode 100644 index 0000000..a035ba0 --- /dev/null +++ b/dist/cursor-legacy.d.ts @@ -0,0 +1,28 @@ +export declare function getDefaultCursorVscdbPath(): string | undefined; +/** + * Collect composer IDs that already have live agent transcripts under + * /projects//agent-transcripts//, so the importer + * doesn't export conversations sync already picks up from there. + */ +export declare function collectLiveTranscriptIds(cursorProjectsDir: string): Set; +export interface CursorLegacyImportOptions { + dbPath: string; + exportDir: string; + /** Composer IDs covered by live agent transcripts (skipped). */ + liveTranscriptIds?: Set; + /** Re-export conversations whose output file already exists. */ + force?: boolean; + /** Report what would be exported without writing files. */ + dryRun?: boolean; +} +export interface CursorLegacyImportResult { + exported: number; + skippedLive: number; + skippedExisting: number; + skippedEmpty: number; + errors: Array<{ + composerId: string; + error: string; + }>; +} +export declare function importCursorLegacy(options: CursorLegacyImportOptions): CursorLegacyImportResult; diff --git a/dist/cursor-legacy.js b/dist/cursor-legacy.js new file mode 100644 index 0000000..811e563 --- /dev/null +++ b/dist/cursor-legacy.js @@ -0,0 +1,228 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { detectCursorCwd } from './parser.js'; +/** + * Import legacy Cursor conversations from Cursor's global SQLite store + * (state.vscdb) into JSONL files compatible with the Cursor transcript parser. + * + * Cursor only began writing per-session agent transcripts to + * ~/.cursor/projects in early 2026; older conversations exist solely inside + * state.vscdb (cursorDiskKV table): + * + * composerData: -> conversation metadata (createdAt, + * fullConversationHeadersOnly: ordered + * bubble refs) + * bubbleId:: -> one message (type 1=user 2=assistant, + * text, toolFormerData, createdAt) + * + * Exported files embed per-message timestamps, sessionId, and cwd so the + * parser doesn't need mtime or path heuristics for them. + */ +const VSCDB_CANDIDATES = [ + // macOS + 'Library/Application Support/Cursor/User/globalStorage/state.vscdb', + // Linux + '.config/Cursor/User/globalStorage/state.vscdb', + // Windows + 'AppData/Roaming/Cursor/User/globalStorage/state.vscdb', +]; +export function getDefaultCursorVscdbPath() { + for (const candidate of VSCDB_CANDIDATES) { + const full = path.join(os.homedir(), candidate); + if (fs.existsSync(full)) { + return full; + } + } + return undefined; +} +/** + * Collect composer IDs that already have live agent transcripts under + * /projects//agent-transcripts//, so the importer + * doesn't export conversations sync already picks up from there. + */ +export function collectLiveTranscriptIds(cursorProjectsDir) { + const ids = new Set(); + if (!fs.existsSync(cursorProjectsDir)) { + return ids; + } + for (const slug of fs.readdirSync(cursorProjectsDir)) { + const transcriptsDir = path.join(cursorProjectsDir, slug, 'agent-transcripts'); + let entries; + try { + entries = fs.readdirSync(transcriptsDir); + } + catch { + continue; + } + for (const entry of entries) { + ids.add(entry); + } + } + return ids; +} +function toIsoTimestamp(value) { + if (typeof value === 'string' && value) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString(); + } + if (typeof value === 'number' && value > 0) { + return new Date(value).toISOString(); + } + return undefined; +} +function parseToolInput(toolFormerData) { + const raw = toolFormerData?.params ?? toolFormerData?.rawArgs; + if (typeof raw === 'string') { + try { + return JSON.parse(raw); + } + catch { + return raw; + } + } + return raw; +} +/** + * Walk up from a detected working directory to the enclosing git repository + * root when one still exists on disk. The cwd heuristic can land inside a + * subdirectory (every touched file under /src yields /src); the + * repo root is the meaningful project boundary. + */ +function resolveProjectRoot(dir) { + let current = dir; + while (true) { + if (fs.existsSync(path.join(current, '.git'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return dir; + } + current = parent; + } +} +export function importCursorLegacy(options) { + const result = { + exported: 0, + skippedLive: 0, + skippedExisting: 0, + skippedEmpty: 0, + errors: [], + }; + const liveIds = options.liveTranscriptIds ?? new Set(); + const db = new Database(options.dbPath, { readonly: true, fileMustExist: true }); + try { + const composerRows = db + .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'") + .all(); + const bubbleStmt = db.prepare('SELECT value FROM cursorDiskKV WHERE key = ?'); + for (const row of composerRows) { + const composerId = row.key.split(':', 2)[1]; + try { + const composer = JSON.parse(String(row.value)); + // Some composerData rows are literal JSON null (abandoned drafts) + if (!composer) { + result.skippedEmpty++; + continue; + } + const headers = composer.fullConversationHeadersOnly ?? []; + if (headers.length === 0) { + result.skippedEmpty++; + continue; + } + if (liveIds.has(composerId)) { + result.skippedLive++; + continue; + } + const fallbackTimestamp = toIsoTimestamp(composer.createdAt) ?? new Date(0).toISOString(); + const lines = []; + const toolInputs = []; + let lastTimestamp = fallbackTimestamp; + for (const header of headers) { + if (!header?.bubbleId) + continue; + const bubbleRow = bubbleStmt.get(`bubbleId:${composerId}:${header.bubbleId}`); + if (!bubbleRow) + continue; + let bubble; + try { + bubble = JSON.parse(String(bubbleRow.value)); + } + catch { + continue; + } + const role = bubble.type === 1 ? 'user' : bubble.type === 2 ? 'assistant' : undefined; + if (!role) + continue; + const timestamp = toIsoTimestamp(bubble.createdAt) ?? lastTimestamp; + lastTimestamp = timestamp; + const content = []; + if (bubble.text && bubble.text.trim()) { + content.push({ type: 'text', text: bubble.text }); + } + if (role === 'assistant' && bubble.toolFormerData) { + const input = parseToolInput(bubble.toolFormerData); + if (input !== undefined && input !== null) { + toolInputs.push(input); + } + content.push({ + type: 'tool_use', + name: bubble.toolFormerData.name ?? + String(bubble.toolFormerData.tool ?? 'unknown'), + input, + }); + } + if (content.length === 0) + continue; + lines.push(JSON.stringify({ + role, + message: { content }, + timestamp, + sessionId: composerId, + })); + } + if (lines.length === 0) { + result.skippedEmpty++; + continue; + } + let cwd = detectCursorCwd(toolInputs, true); + if (cwd && fs.existsSync(cwd)) { + cwd = resolveProjectRoot(cwd); + } + const project = cwd ? path.basename(cwd) : 'cursor-unknown-project'; + const outFile = path.join(options.exportDir, project, `${composerId}.jsonl`); + if (!options.force && fs.existsSync(outFile)) { + result.skippedExisting++; + continue; + } + if (!options.dryRun) { + // Re-serialize with cwd now that it's known (it's derived from the + // whole conversation's tool calls). + const finalLines = cwd + ? lines.map(line => JSON.stringify({ ...JSON.parse(line), cwd })) + : lines; + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, finalLines.join('\n') + '\n', 'utf-8'); + // Stamp the conversation's end time so mtime-based fallbacks and + // copyIfNewer comparisons reflect when it actually happened. + const mtime = toIsoTimestamp(composer.lastUpdatedAt) ?? lastTimestamp; + const mtimeDate = new Date(mtime); + fs.utimesSync(outFile, mtimeDate, mtimeDate); + } + result.exported++; + } + catch (error) { + result.errors.push({ + composerId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + finally { + db.close(); + } + return result; +} diff --git a/dist/mcp-server.js b/dist/mcp-server.js index b46fabb..57e7885 100644 --- a/dist/mcp-server.js +++ b/dist/mcp-server.js @@ -26865,7 +26865,7 @@ ${result} } // src/version.ts -var VERSION = "1.4.1"; +var VERSION = "1.4.2"; // src/mcp-server.ts import fs4 from "fs"; diff --git a/dist/parser.d.ts b/dist/parser.d.ts index d48c511..ae91f24 100644 --- a/dist/parser.d.ts +++ b/dist/parser.d.ts @@ -1,5 +1,14 @@ import { ConversationExchange } from './types.js'; export declare function parseConversation(filePath: string, projectName: string, archivePath: string): Promise; +/** + * Cursor transcripts carry no workspace field; recover the working directory + * from tool-call inputs: explicit cwd/working_directory values when present, + * otherwise (with `useFilePathFallback`) the longest common directory prefix + * of absolute paths the tools touched. The fallback is for the legacy vscdb + * importer, which has no other signal; live transcripts have the project slug + * in their path, which beats prefix guessing when no explicit cwd exists. + */ +export declare function detectCursorCwd(toolInputs: unknown[], useFilePathFallback?: boolean): string | undefined; /** * Convenience function to parse a conversation file * Extracts project name from the file path and returns exchanges with metadata diff --git a/dist/parser.js b/dist/parser.js index 018637b..572743c 100644 --- a/dist/parser.js +++ b/dist/parser.js @@ -21,6 +21,17 @@ async function detectConversationHarness(filePath) { parsed.type === 'compacted')) { return 'codex'; } + // Cursor agent transcripts (~/.cursor/projects//agent-transcripts/) + // carry role+message with no top-level type field. + const maybeCursor = parsed; + if (parsed.type === undefined && maybeCursor.role && maybeCursor.message) { + return 'cursor'; + } + // Cursor transcripts also contain status/error noise lines; skip them + // rather than misdetecting the file as a Claude conversation. + if (parsed.type === 'status' || parsed.type === 'error') { + continue; + } return 'claude'; } catch { @@ -34,6 +45,9 @@ export async function parseConversation(filePath, projectName, archivePath) { if (harness === 'codex') { return parseCodexConversation(filePath, projectName, archivePath); } + if (harness === 'cursor') { + return parseCursorConversation(filePath, projectName, archivePath); + } return parseClaudeConversation(filePath, projectName, archivePath); } async function parseClaudeConversation(filePath, projectName, archivePath) { @@ -423,6 +437,225 @@ async function parseCodexConversation(filePath, projectName, archivePath) { finalizeExchange(); return exchanges; } +function stripFileScheme(value) { + return value.startsWith('file://') ? decodeURI(value.slice('file://'.length)) : value; +} +/** + * Cursor transcripts carry no workspace field; recover the working directory + * from tool-call inputs: explicit cwd/working_directory values when present, + * otherwise (with `useFilePathFallback`) the longest common directory prefix + * of absolute paths the tools touched. The fallback is for the legacy vscdb + * importer, which has no other signal; live transcripts have the project slug + * in their path, which beats prefix guessing when no explicit cwd exists. + */ +export function detectCursorCwd(toolInputs, useFilePathFallback = false) { + const cwdCounts = new Map(); + const filePaths = []; + for (const input of toolInputs) { + if (!input || typeof input !== 'object') + continue; + const params = input; + for (const key of ['cwd', 'working_directory']) { + const value = params[key]; + if (typeof value === 'string' && path.isAbsolute(value)) { + cwdCounts.set(value, (cwdCounts.get(value) ?? 0) + 1); + } + } + for (const key of ['targetFile', 'effectiveUri', 'path', 'target_directory']) { + const value = params[key]; + if (typeof value === 'string') { + const candidate = stripFileScheme(value); + if (path.isAbsolute(candidate)) { + filePaths.push(candidate); + } + } + } + } + if (cwdCounts.size > 0) { + return [...cwdCounts.entries()].sort((a, b) => b[1] - a[1])[0][0]; + } + if (!useFilePathFallback || filePaths.length === 0) { + return undefined; + } + if (filePaths.length === 1) { + return path.dirname(filePaths[0]); + } + let prefix = filePaths[0]; + for (const filePath of filePaths.slice(1)) { + while (!filePath.startsWith(prefix)) { + prefix = prefix.slice(0, -1); + if (!prefix) + return undefined; + } + } + // Trim a partially matched final segment ("/repos/proj" matching + // "/repos/project-a" and "/repos/project-b" must become "/repos"). + const lastSep = prefix.lastIndexOf(path.sep); + if (lastSep <= 0) + return undefined; + const dir = prefix.slice(0, prefix.endsWith(path.sep) ? prefix.length - 1 : lastSep); + // A one-segment prefix like "/Users" identifies no project. + return dir.split(path.sep).filter(Boolean).length >= 2 ? dir : undefined; +} +function cursorProjectFromPath(filePath) { + // Live transcripts live at <...>//agent-transcripts//.jsonl + const parts = filePath.split(path.sep); + const idx = parts.indexOf('agent-transcripts'); + if (idx > 0) { + return parts[idx - 1]; + } + return undefined; +} +const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; +async function parseCursorConversation(filePath, projectName, archivePath) { + const exchanges = []; + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + // Live Cursor transcripts carry no per-message timestamps; fall back to the + // file mtime (preserved from the source by sync's copyIfNewer). Legacy + // exports from import-cursor-history embed real per-message timestamps. + let fallbackTimestamp; + try { + fallbackTimestamp = fs.statSync(filePath).mtime.toISOString(); + } + catch { + fallbackTimestamp = new Date().toISOString(); + } + let sessionId = path.basename(filePath, '.jsonl').match(UUID_PATTERN)?.[0]; + let cwd; // only set by legacy-export lines + const toolInputs = []; + const slugProject = cursorProjectFromPath(archivePath) ?? cursorProjectFromPath(filePath); + let lineNumber = 0; + let currentExchange = null; + const finalizeExchange = () => { + if (currentExchange && currentExchange.assistantMessages.length > 0) { + const exchangeId = crypto + .createHash('md5') + .update(`${archivePath}:${currentExchange.userLine}-${currentExchange.lastAssistantLine}`) + .digest('hex'); + const toolCalls = currentExchange.toolCalls.map(tc => ({ + ...tc, + exchangeId + })); + exchanges.push({ + id: exchangeId, + project: currentExchange.project, + timestamp: currentExchange.timestamp, + userMessage: currentExchange.userMessage, + assistantMessage: currentExchange.assistantMessages.join('\n\n'), + archivePath, + lineStart: currentExchange.userLine, + lineEnd: currentExchange.lastAssistantLine, + harness: 'cursor', + sessionId: currentExchange.sessionId, + cwd: currentExchange.cwd, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined + }); + } + currentExchange = null; + }; + for await (const line of rl) { + lineNumber++; + if (!line.trim()) { + continue; + } + try { + const parsed = JSON.parse(line); + // Skip status/error noise lines and anything that isn't a message + if (!parsed.role || !parsed.message) { + continue; + } + if (parsed.sessionId) + sessionId = parsed.sessionId; + if (parsed.cwd) + cwd = parsed.cwd; + const timestamp = parsed.timestamp || fallbackTimestamp; + let text = ''; + const toolCalls = []; + const content = parsed.message.content; + if (typeof content === 'string') { + text = content; + } + else if (Array.isArray(content)) { + text = content + .filter(block => block && block.type === 'text' && typeof block.text === 'string') + .map(block => block.text) + .join('\n'); + if (parsed.role === 'assistant') { + for (const block of content) { + if (block && block.type === 'tool_use') { + if (block.input !== undefined && block.input !== null) { + toolInputs.push(block.input); + } + toolCalls.push({ + id: crypto.randomUUID(), + exchangeId: '', + toolName: block.name || 'unknown', + toolInput: block.input, + isError: false, + timestamp + }); + } + } + } + } + if (parsed.role === 'user') { + // Cursor wraps the typed prompt in tags; strip the + // wrapper so embeddings see only the actual prompt text. + text = text.replace(/<\/?user_query>/g, '').trim(); + } + if (!text.trim() && toolCalls.length === 0) { + continue; + } + if (parsed.role === 'user') { + finalizeExchange(); + currentExchange = { + project: projectName, + userMessage: text || '(tool results only)', + userLine: lineNumber, + assistantMessages: [], + lastAssistantLine: lineNumber, + timestamp, + harness: 'cursor', + sessionId, + cwd, + toolCalls: [] + }; + } + else if (parsed.role === 'assistant' && currentExchange) { + if (text.trim()) { + currentExchange.assistantMessages.push(text); + } + currentExchange.lastAssistantLine = lineNumber; + if (toolCalls.length > 0) { + currentExchange.toolCalls.push(...toolCalls); + } + if (parsed.timestamp) { + currentExchange.timestamp = parsed.timestamp; + } + } + } + catch { + // Skip malformed JSON lines + continue; + } + } + finalizeExchange(); + // Live transcripts carry no cwd field; recover it from tool-call inputs and + // apply the final values uniformly since a transcript is one session in one + // project. + cwd = cwd ?? detectCursorCwd(toolInputs); + const project = projectFromCwd(cwd) || slugProject || projectName; + for (const exchange of exchanges) { + exchange.project = project; + exchange.sessionId = exchange.sessionId ?? sessionId; + exchange.cwd = exchange.cwd ?? cwd; + } + return exchanges; +} /** * Convenience function to parse a conversation file * Extracts project name from the file path and returns exchanges with metadata diff --git a/dist/paths.d.ts b/dist/paths.d.ts index b63f2e2..d70fd16 100644 --- a/dist/paths.d.ts +++ b/dist/paths.d.ts @@ -10,10 +10,22 @@ export declare function getClaudeDir(): string; * Falls back to ~/.codex when not set. */ export declare function getCodexDir(): string; +/** + * Get the Cursor configuration directory. + * Supports CURSOR_HOME for alternate profiles. + * Falls back to ~/.cursor when not set. + */ +export declare function getCursorDir(): string; +/** + * Get the staging directory where `import-cursor-history` exports legacy + * Cursor conversations (extracted from state.vscdb) as JSONL. Scanned as a + * conversation source so sync picks the exports up like any other harness. + */ +export declare function getCursorLegacyExportDir(): string; /** * Get all directories where supported harnesses store conversation files. * Checks Claude Code legacy (projects/) and current (transcripts/) locations, - * plus Codex sessions. + * Codex sessions, and Cursor agent transcripts (live and legacy exports). * Returns only directories that exist. */ export declare function getConversationSourceDirs(): string[]; diff --git a/dist/paths.js b/dist/paths.js index 6305610..7bb0c1c 100644 --- a/dist/paths.js +++ b/dist/paths.js @@ -26,10 +26,26 @@ export function getClaudeDir() { export function getCodexDir() { return process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); } +/** + * Get the Cursor configuration directory. + * Supports CURSOR_HOME for alternate profiles. + * Falls back to ~/.cursor when not set. + */ +export function getCursorDir() { + return process.env.CURSOR_HOME || path.join(os.homedir(), '.cursor'); +} +/** + * Get the staging directory where `import-cursor-history` exports legacy + * Cursor conversations (extracted from state.vscdb) as JSONL. Scanned as a + * conversation source so sync picks the exports up like any other harness. + */ +export function getCursorLegacyExportDir() { + return path.join(getSuperpowersDir(), 'cursor-legacy-export'); +} /** * Get all directories where supported harnesses store conversation files. * Checks Claude Code legacy (projects/) and current (transcripts/) locations, - * plus Codex sessions. + * Codex sessions, and Cursor agent transcripts (live and legacy exports). * Returns only directories that exist. */ export function getConversationSourceDirs() { @@ -38,10 +54,13 @@ export function getConversationSourceDirs() { return [testDir]; const claudeDir = getClaudeDir(); const codexDir = getCodexDir(); + const cursorDir = getCursorDir(); return [ path.join(claudeDir, 'projects'), path.join(claudeDir, 'transcripts'), path.join(codexDir, 'sessions'), + path.join(cursorDir, 'projects'), + getCursorLegacyExportDir(), ].filter(d => fs.existsSync(d)); } /** diff --git a/dist/sync.js b/dist/sync.js index a098046..9975606 100644 --- a/dist/sync.js +++ b/dist/sync.js @@ -36,6 +36,13 @@ function copyIfNewer(src, dest) { const tempDest = dest + '.tmp.' + process.pid; fs.copyFileSync(src, tempDest); fs.renameSync(tempDest, dest); // Atomic on same filesystem + // Preserve source mtime: harnesses without per-message timestamps (Cursor + // agent transcripts) fall back to file mtime. Round up to the next whole + // millisecond — utimes can't always represent the source's sub-millisecond + // precision, and a dest mtime even fractionally older would defeat the + // skip-if-current check above on every subsequent sync. + const srcStat = fs.statSync(src); + fs.utimesSync(dest, srcStat.atimeMs / 1000, Math.ceil(srcStat.mtimeMs) / 1000); return true; } export function extractSessionIdFromPath(filePath) { diff --git a/dist/types.d.ts b/dist/types.d.ts index 2b0ad64..7ba13df 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -7,7 +7,7 @@ export interface ToolCall { isError: boolean; timestamp: string; } -export type ConversationHarness = 'claude' | 'codex'; +export type ConversationHarness = 'claude' | 'codex' | 'cursor'; export interface ConversationExchange { id: string; project: string; diff --git a/dist/version.d.ts b/dist/version.d.ts index bef3a7a..690464d 100644 --- a/dist/version.d.ts +++ b/dist/version.d.ts @@ -1 +1 @@ -export declare const VERSION = "1.4.1"; +export declare const VERSION = "1.4.2"; diff --git a/dist/version.js b/dist/version.js index 317a397..5fccce4 100644 --- a/dist/version.js +++ b/dist/version.js @@ -1,3 +1,3 @@ // This file is generated by scripts/generate-version.js. // Do not edit by hand — change package.json and rebuild. -export const VERSION = "1.4.1"; +export const VERSION = "1.4.2"; diff --git a/src/cursor-import-cli.ts b/src/cursor-import-cli.ts new file mode 100644 index 0000000..f7783ab --- /dev/null +++ b/src/cursor-import-cli.ts @@ -0,0 +1,104 @@ +import path from 'path'; +import { + getDefaultCursorVscdbPath, + collectLiveTranscriptIds, + importCursorLegacy, +} from './cursor-legacy.js'; +import { getCursorDir, getCursorLegacyExportDir } from './paths.js'; + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(` +Usage: episodic-memory import-cursor-history [options] + +Export legacy Cursor conversations from Cursor's global SQLite store +(state.vscdb) as JSONL transcripts, so sync can archive and index them. + +Cursor only began writing per-session agent transcripts to ~/.cursor/projects +in early 2026; conversations older than that exist only in state.vscdb. This +command backfills them. Conversations that already have a live agent +transcript are skipped automatically. + +The export is written to: + ${getCursorLegacyExportDir()} +which sync scans as a conversation source. Run 'episodic-memory sync' after +importing to archive and index the exported conversations. + +The database is opened read-only; Cursor's data is never modified. + +OPTIONS: + --db Path to state.vscdb (default: auto-detected per platform) + --out Export directory (default: shown above) + --force Re-export conversations whose output file already exists + --dry-run Report what would be exported without writing files + +EXAMPLES: + # Export all legacy conversations, then index them + episodic-memory import-cursor-history + episodic-memory sync + + # Preview without writing + episodic-memory import-cursor-history --dry-run +`); + process.exit(0); +} + +function argValue(flag: string): string | undefined { + const index = args.indexOf(flag); + if (index === -1) return undefined; + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + console.error(`Error: ${flag} requires a value`); + process.exit(1); + } + return value; +} + +const dbPath = argValue('--db') ?? getDefaultCursorVscdbPath(); +if (!dbPath) { + console.error('Error: could not find Cursor state.vscdb; pass --db '); + process.exit(1); +} + +const exportDir = argValue('--out') ?? getCursorLegacyExportDir(); +const dryRun = args.includes('--dry-run'); +const force = args.includes('--force'); + +const cursorProjectsDir = path.join(getCursorDir(), 'projects'); +const liveTranscriptIds = collectLiveTranscriptIds(cursorProjectsDir); + +console.log(`Importing legacy Cursor conversations${dryRun ? ' (dry run)' : ''}...`); +console.log(` Database: ${dbPath}`); +console.log(` Export dir: ${exportDir}`); +console.log(` Live transcripts found: ${liveTranscriptIds.size}\n`); + +try { + const result = importCursorLegacy({ + dbPath, + exportDir, + liveTranscriptIds, + force, + dryRun, + }); + + console.log(`✅ Import ${dryRun ? 'preview' : 'complete'}!`); + console.log(` Exported: ${result.exported}`); + console.log(` Skipped (live transcript exists): ${result.skippedLive}`); + console.log(` Skipped (already exported): ${result.skippedExisting}`); + console.log(` Skipped (empty): ${result.skippedEmpty}`); + + if (result.errors.length > 0) { + console.log(`\n⚠️ Errors: ${result.errors.length}`); + for (const err of result.errors.slice(0, 10)) { + console.log(` ${err.composerId}: ${err.error}`); + } + } + + if (!dryRun && result.exported > 0) { + console.log(`\nRun 'episodic-memory sync' to archive and index the exported conversations.`); + } +} catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); +} diff --git a/src/cursor-legacy.ts b/src/cursor-legacy.ts new file mode 100644 index 0000000..a62f4b5 --- /dev/null +++ b/src/cursor-legacy.ts @@ -0,0 +1,294 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { detectCursorCwd } from './parser.js'; + +/** + * Import legacy Cursor conversations from Cursor's global SQLite store + * (state.vscdb) into JSONL files compatible with the Cursor transcript parser. + * + * Cursor only began writing per-session agent transcripts to + * ~/.cursor/projects in early 2026; older conversations exist solely inside + * state.vscdb (cursorDiskKV table): + * + * composerData: -> conversation metadata (createdAt, + * fullConversationHeadersOnly: ordered + * bubble refs) + * bubbleId:: -> one message (type 1=user 2=assistant, + * text, toolFormerData, createdAt) + * + * Exported files embed per-message timestamps, sessionId, and cwd so the + * parser doesn't need mtime or path heuristics for them. + */ + +const VSCDB_CANDIDATES = [ + // macOS + 'Library/Application Support/Cursor/User/globalStorage/state.vscdb', + // Linux + '.config/Cursor/User/globalStorage/state.vscdb', + // Windows + 'AppData/Roaming/Cursor/User/globalStorage/state.vscdb', +]; + +export function getDefaultCursorVscdbPath(): string | undefined { + for (const candidate of VSCDB_CANDIDATES) { + const full = path.join(os.homedir(), candidate); + if (fs.existsSync(full)) { + return full; + } + } + return undefined; +} + +/** + * Collect composer IDs that already have live agent transcripts under + * /projects//agent-transcripts//, so the importer + * doesn't export conversations sync already picks up from there. + */ +export function collectLiveTranscriptIds(cursorProjectsDir: string): Set { + const ids = new Set(); + if (!fs.existsSync(cursorProjectsDir)) { + return ids; + } + for (const slug of fs.readdirSync(cursorProjectsDir)) { + const transcriptsDir = path.join(cursorProjectsDir, slug, 'agent-transcripts'); + let entries: string[]; + try { + entries = fs.readdirSync(transcriptsDir); + } catch { + continue; + } + for (const entry of entries) { + ids.add(entry); + } + } + return ids; +} + +interface ComposerHeader { + bubbleId?: string; +} + +interface ComposerData { + composerId?: string; + createdAt?: number; + lastUpdatedAt?: number; + fullConversationHeadersOnly?: ComposerHeader[]; +} + +interface BubbleData { + type?: number; // 1=user, 2=assistant + text?: string; + createdAt?: string | number; + toolFormerData?: { + name?: string; + tool?: string | number; + params?: unknown; + rawArgs?: unknown; + }; +} + +function toIsoTimestamp(value: string | number | undefined): string | undefined { + if (typeof value === 'string' && value) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString(); + } + if (typeof value === 'number' && value > 0) { + return new Date(value).toISOString(); + } + return undefined; +} + +function parseToolInput(toolFormerData: BubbleData['toolFormerData']): unknown { + const raw = toolFormerData?.params ?? toolFormerData?.rawArgs; + if (typeof raw === 'string') { + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + return raw; +} + +/** + * Walk up from a detected working directory to the enclosing git repository + * root when one still exists on disk. The cwd heuristic can land inside a + * subdirectory (every touched file under /src yields /src); the + * repo root is the meaningful project boundary. + */ +function resolveProjectRoot(dir: string): string { + let current = dir; + while (true) { + if (fs.existsSync(path.join(current, '.git'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return dir; + } + current = parent; + } +} + +export interface CursorLegacyImportOptions { + dbPath: string; + exportDir: string; + /** Composer IDs covered by live agent transcripts (skipped). */ + liveTranscriptIds?: Set; + /** Re-export conversations whose output file already exists. */ + force?: boolean; + /** Report what would be exported without writing files. */ + dryRun?: boolean; +} + +export interface CursorLegacyImportResult { + exported: number; + skippedLive: number; + skippedExisting: number; + skippedEmpty: number; + errors: Array<{ composerId: string; error: string }>; +} + +export function importCursorLegacy(options: CursorLegacyImportOptions): CursorLegacyImportResult { + const result: CursorLegacyImportResult = { + exported: 0, + skippedLive: 0, + skippedExisting: 0, + skippedEmpty: 0, + errors: [], + }; + + const liveIds = options.liveTranscriptIds ?? new Set(); + const db = new Database(options.dbPath, { readonly: true, fileMustExist: true }); + + try { + const composerRows = db + .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'") + .all() as Array<{ key: string; value: string | Buffer }>; + const bubbleStmt = db.prepare('SELECT value FROM cursorDiskKV WHERE key = ?'); + + for (const row of composerRows) { + const composerId = row.key.split(':', 2)[1]; + try { + const composer = JSON.parse(String(row.value)) as ComposerData | null; + // Some composerData rows are literal JSON null (abandoned drafts) + if (!composer) { + result.skippedEmpty++; + continue; + } + const headers = composer.fullConversationHeadersOnly ?? []; + if (headers.length === 0) { + result.skippedEmpty++; + continue; + } + if (liveIds.has(composerId)) { + result.skippedLive++; + continue; + } + + const fallbackTimestamp = + toIsoTimestamp(composer.createdAt) ?? new Date(0).toISOString(); + + const lines: string[] = []; + const toolInputs: unknown[] = []; + let lastTimestamp = fallbackTimestamp; + + for (const header of headers) { + if (!header?.bubbleId) continue; + const bubbleRow = bubbleStmt.get(`bubbleId:${composerId}:${header.bubbleId}`) as + | { value: string | Buffer } + | undefined; + if (!bubbleRow) continue; + + let bubble: BubbleData; + try { + bubble = JSON.parse(String(bubbleRow.value)) as BubbleData; + } catch { + continue; + } + + const role = bubble.type === 1 ? 'user' : bubble.type === 2 ? 'assistant' : undefined; + if (!role) continue; + + const timestamp = toIsoTimestamp(bubble.createdAt) ?? lastTimestamp; + lastTimestamp = timestamp; + + const content: unknown[] = []; + if (bubble.text && bubble.text.trim()) { + content.push({ type: 'text', text: bubble.text }); + } + if (role === 'assistant' && bubble.toolFormerData) { + const input = parseToolInput(bubble.toolFormerData); + if (input !== undefined && input !== null) { + toolInputs.push(input); + } + content.push({ + type: 'tool_use', + name: + bubble.toolFormerData.name ?? + String(bubble.toolFormerData.tool ?? 'unknown'), + input, + }); + } + if (content.length === 0) continue; + + lines.push( + JSON.stringify({ + role, + message: { content }, + timestamp, + sessionId: composerId, + }) + ); + } + + if (lines.length === 0) { + result.skippedEmpty++; + continue; + } + + let cwd = detectCursorCwd(toolInputs, true); + if (cwd && fs.existsSync(cwd)) { + cwd = resolveProjectRoot(cwd); + } + const project = cwd ? path.basename(cwd) : 'cursor-unknown-project'; + const outFile = path.join(options.exportDir, project, `${composerId}.jsonl`); + + if (!options.force && fs.existsSync(outFile)) { + result.skippedExisting++; + continue; + } + + if (!options.dryRun) { + // Re-serialize with cwd now that it's known (it's derived from the + // whole conversation's tool calls). + const finalLines = cwd + ? lines.map(line => JSON.stringify({ ...JSON.parse(line), cwd })) + : lines; + + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, finalLines.join('\n') + '\n', 'utf-8'); + + // Stamp the conversation's end time so mtime-based fallbacks and + // copyIfNewer comparisons reflect when it actually happened. + const mtime = + toIsoTimestamp(composer.lastUpdatedAt) ?? lastTimestamp; + const mtimeDate = new Date(mtime); + fs.utimesSync(outFile, mtimeDate, mtimeDate); + } + result.exported++; + } catch (error) { + result.errors.push({ + composerId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } finally { + db.close(); + } + + return result; +} diff --git a/src/parser.ts b/src/parser.ts index 5d566a1..55015b5 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -32,6 +32,19 @@ interface CodexRolloutLine { payload?: any; } +interface CursorTranscriptLine { + role?: 'user' | 'assistant'; + message?: { + content?: string | Array; + }; + type?: string; // present on status/error noise lines, absent on messages + // Embedded by `import-cursor-history` legacy exports; absent in live + // ~/.cursor/projects agent transcripts: + timestamp?: string; + sessionId?: string; + cwd?: string; +} + interface ExchangeBuilder { project: string; userMessage: string; @@ -76,6 +89,17 @@ async function detectConversationHarness(filePath: string): Promise/agent-transcripts/) + // carry role+message with no top-level type field. + const maybeCursor = parsed as CursorTranscriptLine; + if (parsed.type === undefined && maybeCursor.role && maybeCursor.message) { + return 'cursor'; + } + // Cursor transcripts also contain status/error noise lines; skip them + // rather than misdetecting the file as a Claude conversation. + if (parsed.type === 'status' || parsed.type === 'error') { + continue; + } return 'claude'; } catch { continue; @@ -94,6 +118,9 @@ export async function parseConversation( if (harness === 'codex') { return parseCodexConversation(filePath, projectName, archivePath); } + if (harness === 'cursor') { + return parseCursorConversation(filePath, projectName, archivePath); + } return parseClaudeConversation(filePath, projectName, archivePath); } @@ -528,6 +555,252 @@ async function parseCodexConversation( return exchanges; } +function stripFileScheme(value: string): string { + return value.startsWith('file://') ? decodeURI(value.slice('file://'.length)) : value; +} + +/** + * Cursor transcripts carry no workspace field; recover the working directory + * from tool-call inputs: explicit cwd/working_directory values when present, + * otherwise (with `useFilePathFallback`) the longest common directory prefix + * of absolute paths the tools touched. The fallback is for the legacy vscdb + * importer, which has no other signal; live transcripts have the project slug + * in their path, which beats prefix guessing when no explicit cwd exists. + */ +export function detectCursorCwd( + toolInputs: unknown[], + useFilePathFallback = false +): string | undefined { + const cwdCounts = new Map(); + const filePaths: string[] = []; + + for (const input of toolInputs) { + if (!input || typeof input !== 'object') continue; + const params = input as Record; + + for (const key of ['cwd', 'working_directory']) { + const value = params[key]; + if (typeof value === 'string' && path.isAbsolute(value)) { + cwdCounts.set(value, (cwdCounts.get(value) ?? 0) + 1); + } + } + for (const key of ['targetFile', 'effectiveUri', 'path', 'target_directory']) { + const value = params[key]; + if (typeof value === 'string') { + const candidate = stripFileScheme(value); + if (path.isAbsolute(candidate)) { + filePaths.push(candidate); + } + } + } + } + + if (cwdCounts.size > 0) { + return [...cwdCounts.entries()].sort((a, b) => b[1] - a[1])[0][0]; + } + + if (!useFilePathFallback || filePaths.length === 0) { + return undefined; + } + if (filePaths.length === 1) { + return path.dirname(filePaths[0]); + } + + let prefix = filePaths[0]; + for (const filePath of filePaths.slice(1)) { + while (!filePath.startsWith(prefix)) { + prefix = prefix.slice(0, -1); + if (!prefix) return undefined; + } + } + // Trim a partially matched final segment ("/repos/proj" matching + // "/repos/project-a" and "/repos/project-b" must become "/repos"). + const lastSep = prefix.lastIndexOf(path.sep); + if (lastSep <= 0) return undefined; + const dir = prefix.slice(0, prefix.endsWith(path.sep) ? prefix.length - 1 : lastSep); + // A one-segment prefix like "/Users" identifies no project. + return dir.split(path.sep).filter(Boolean).length >= 2 ? dir : undefined; +} + +function cursorProjectFromPath(filePath: string): string | undefined { + // Live transcripts live at <...>//agent-transcripts//.jsonl + const parts = filePath.split(path.sep); + const idx = parts.indexOf('agent-transcripts'); + if (idx > 0) { + return parts[idx - 1]; + } + return undefined; +} + +const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +async function parseCursorConversation( + filePath: string, + projectName: string, + archivePath: string +): Promise { + const exchanges: ConversationExchange[] = []; + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + + // Live Cursor transcripts carry no per-message timestamps; fall back to the + // file mtime (preserved from the source by sync's copyIfNewer). Legacy + // exports from import-cursor-history embed real per-message timestamps. + let fallbackTimestamp: string; + try { + fallbackTimestamp = fs.statSync(filePath).mtime.toISOString(); + } catch { + fallbackTimestamp = new Date().toISOString(); + } + + let sessionId = path.basename(filePath, '.jsonl').match(UUID_PATTERN)?.[0]; + let cwd: string | undefined; // only set by legacy-export lines + const toolInputs: unknown[] = []; + const slugProject = cursorProjectFromPath(archivePath) ?? cursorProjectFromPath(filePath); + + let lineNumber = 0; + let currentExchange: ExchangeBuilder | null = null; + + const finalizeExchange = () => { + if (currentExchange && currentExchange.assistantMessages.length > 0) { + const exchangeId = crypto + .createHash('md5') + .update(`${archivePath}:${currentExchange.userLine}-${currentExchange.lastAssistantLine}`) + .digest('hex'); + + const toolCalls = currentExchange.toolCalls.map(tc => ({ + ...tc, + exchangeId + })); + + exchanges.push({ + id: exchangeId, + project: currentExchange.project, + timestamp: currentExchange.timestamp, + userMessage: currentExchange.userMessage, + assistantMessage: currentExchange.assistantMessages.join('\n\n'), + archivePath, + lineStart: currentExchange.userLine, + lineEnd: currentExchange.lastAssistantLine, + harness: 'cursor', + sessionId: currentExchange.sessionId, + cwd: currentExchange.cwd, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined + }); + } + currentExchange = null; + }; + + for await (const line of rl) { + lineNumber++; + if (!line.trim()) { + continue; + } + + try { + const parsed = JSON.parse(line) as CursorTranscriptLine; + + // Skip status/error noise lines and anything that isn't a message + if (!parsed.role || !parsed.message) { + continue; + } + + if (parsed.sessionId) sessionId = parsed.sessionId; + if (parsed.cwd) cwd = parsed.cwd; + const timestamp = parsed.timestamp || fallbackTimestamp; + + let text = ''; + const toolCalls: ToolCall[] = []; + const content = parsed.message.content; + + if (typeof content === 'string') { + text = content; + } else if (Array.isArray(content)) { + text = content + .filter(block => block && block.type === 'text' && typeof block.text === 'string') + .map(block => block.text) + .join('\n'); + + if (parsed.role === 'assistant') { + for (const block of content) { + if (block && block.type === 'tool_use') { + if (block.input !== undefined && block.input !== null) { + toolInputs.push(block.input); + } + toolCalls.push({ + id: crypto.randomUUID(), + exchangeId: '', + toolName: block.name || 'unknown', + toolInput: block.input, + isError: false, + timestamp + }); + } + } + } + } + + if (parsed.role === 'user') { + // Cursor wraps the typed prompt in tags; strip the + // wrapper so embeddings see only the actual prompt text. + text = text.replace(/<\/?user_query>/g, '').trim(); + } + + if (!text.trim() && toolCalls.length === 0) { + continue; + } + + if (parsed.role === 'user') { + finalizeExchange(); + currentExchange = { + project: projectName, + userMessage: text || '(tool results only)', + userLine: lineNumber, + assistantMessages: [], + lastAssistantLine: lineNumber, + timestamp, + harness: 'cursor', + sessionId, + cwd, + toolCalls: [] + }; + } else if (parsed.role === 'assistant' && currentExchange) { + if (text.trim()) { + currentExchange.assistantMessages.push(text); + } + currentExchange.lastAssistantLine = lineNumber; + if (toolCalls.length > 0) { + currentExchange.toolCalls.push(...toolCalls); + } + if (parsed.timestamp) { + currentExchange.timestamp = parsed.timestamp; + } + } + } catch { + // Skip malformed JSON lines + continue; + } + } + + finalizeExchange(); + + // Live transcripts carry no cwd field; recover it from tool-call inputs and + // apply the final values uniformly since a transcript is one session in one + // project. + cwd = cwd ?? detectCursorCwd(toolInputs); + const project = projectFromCwd(cwd) || slugProject || projectName; + for (const exchange of exchanges) { + exchange.project = project; + exchange.sessionId = exchange.sessionId ?? sessionId; + exchange.cwd = exchange.cwd ?? cwd; + } + + return exchanges; +} + /** * Convenience function to parse a conversation file * Extracts project name from the file path and returns exchanges with metadata diff --git a/src/paths.ts b/src/paths.ts index 2a7e2f6..187bb1d 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -30,10 +30,28 @@ export function getCodexDir(): string { return process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); } +/** + * Get the Cursor configuration directory. + * Supports CURSOR_HOME for alternate profiles. + * Falls back to ~/.cursor when not set. + */ +export function getCursorDir(): string { + return process.env.CURSOR_HOME || path.join(os.homedir(), '.cursor'); +} + +/** + * Get the staging directory where `import-cursor-history` exports legacy + * Cursor conversations (extracted from state.vscdb) as JSONL. Scanned as a + * conversation source so sync picks the exports up like any other harness. + */ +export function getCursorLegacyExportDir(): string { + return path.join(getSuperpowersDir(), 'cursor-legacy-export'); +} + /** * Get all directories where supported harnesses store conversation files. * Checks Claude Code legacy (projects/) and current (transcripts/) locations, - * plus Codex sessions. + * Codex sessions, and Cursor agent transcripts (live and legacy exports). * Returns only directories that exist. */ export function getConversationSourceDirs(): string[] { @@ -42,10 +60,13 @@ export function getConversationSourceDirs(): string[] { const claudeDir = getClaudeDir(); const codexDir = getCodexDir(); + const cursorDir = getCursorDir(); return [ path.join(claudeDir, 'projects'), path.join(claudeDir, 'transcripts'), path.join(codexDir, 'sessions'), + path.join(cursorDir, 'projects'), + getCursorLegacyExportDir(), ].filter(d => fs.existsSync(d)); } diff --git a/src/sync.ts b/src/sync.ts index d9ddcbe..96729be 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -54,6 +54,14 @@ function copyIfNewer(src: string, dest: string): boolean { const tempDest = dest + '.tmp.' + process.pid; fs.copyFileSync(src, tempDest); fs.renameSync(tempDest, dest); // Atomic on same filesystem + + // Preserve source mtime: harnesses without per-message timestamps (Cursor + // agent transcripts) fall back to file mtime. Round up to the next whole + // millisecond — utimes can't always represent the source's sub-millisecond + // precision, and a dest mtime even fractionally older would defeat the + // skip-if-current check above on every subsequent sync. + const srcStat = fs.statSync(src); + fs.utimesSync(dest, srcStat.atimeMs / 1000, Math.ceil(srcStat.mtimeMs) / 1000); return true; } diff --git a/src/types.ts b/src/types.ts index a05369d..9afdf0a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,7 @@ export interface ToolCall { timestamp: string; } -export type ConversationHarness = 'claude' | 'codex'; +export type ConversationHarness = 'claude' | 'codex' | 'cursor'; export interface ConversationExchange { id: string; diff --git a/test/codex-transcripts.test.ts b/test/codex-transcripts.test.ts index d2d788a..9b39c58 100644 --- a/test/codex-transcripts.test.ts +++ b/test/codex-transcripts.test.ts @@ -151,6 +151,8 @@ describe('Codex transcript support', () => { let testDir: string; let originalClaudeConfigDir: string | undefined; let originalCodexHome: string | undefined; + let originalCursorHome: string | undefined; + let originalConfigDir: string | undefined; let originalTestProjectsDir: string | undefined; let originalTestDbPath: string | undefined; @@ -158,6 +160,8 @@ describe('Codex transcript support', () => { testDir = mkdtempSync(join(tmpdir(), 'episodic-memory-codex-test-')); originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; originalCodexHome = process.env.CODEX_HOME; + originalCursorHome = process.env.CURSOR_HOME; + originalConfigDir = process.env.EPISODIC_MEMORY_CONFIG_DIR; originalTestProjectsDir = process.env.TEST_PROJECTS_DIR; originalTestDbPath = process.env.TEST_DB_PATH; }); @@ -167,6 +171,10 @@ describe('Codex transcript support', () => { else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; if (originalCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = originalCodexHome; + if (originalCursorHome === undefined) delete process.env.CURSOR_HOME; + else process.env.CURSOR_HOME = originalCursorHome; + if (originalConfigDir === undefined) delete process.env.EPISODIC_MEMORY_CONFIG_DIR; + else process.env.EPISODIC_MEMORY_CONFIG_DIR = originalConfigDir; if (originalTestProjectsDir === undefined) delete process.env.TEST_PROJECTS_DIR; else process.env.TEST_PROJECTS_DIR = originalTestProjectsDir; if (originalTestDbPath === undefined) delete process.env.TEST_DB_PATH; @@ -178,17 +186,28 @@ describe('Codex transcript support', () => { it('discovers Codex sessions alongside Claude transcript directories', () => { const claudeDir = join(testDir, 'claude'); const codexHome = join(testDir, 'codex'); + const cursorHome = join(testDir, 'cursor'); mkdirSync(join(claudeDir, 'projects'), { recursive: true }); mkdirSync(join(codexHome, 'sessions'), { recursive: true }); delete process.env.TEST_PROJECTS_DIR; process.env.CLAUDE_CONFIG_DIR = claudeDir; process.env.CODEX_HOME = codexHome; + process.env.CURSOR_HOME = cursorHome; + process.env.EPISODIC_MEMORY_CONFIG_DIR = join(testDir, 'superpowers'); expect(getConversationSourceDirs()).toEqual([ join(claudeDir, 'projects'), join(codexHome, 'sessions') ]); + + // Cursor directories join the scan once they exist + mkdirSync(join(cursorHome, 'projects'), { recursive: true }); + expect(getConversationSourceDirs()).toEqual([ + join(claudeDir, 'projects'), + join(codexHome, 'sessions'), + join(cursorHome, 'projects') + ]); }); it('parses Codex rollout JSONL into an exchange with harness metadata', async () => { diff --git a/test/cursor-legacy.test.ts b/test/cursor-legacy.test.ts new file mode 100644 index 0000000..03ff1ce --- /dev/null +++ b/test/cursor-legacy.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, statSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import Database from 'better-sqlite3'; +import { + importCursorLegacy, + collectLiveTranscriptIds, +} from '../src/cursor-legacy.js'; +import { parseConversation } from '../src/parser.js'; + +const COMPOSER_A = '1f512764-8211-4582-88f7-251df5e43bc9'; +const COMPOSER_B = '4e9e9864-6b33-42ff-83bb-5144f8819088'; +const COMPOSER_EMPTY = 'aaaaaaaa-0000-0000-0000-000000000000'; + +function createFixtureDb(dbPath: string): void { + const db = new Database(dbPath); + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)'); + const insert = db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)'); + + // Composer A: two bubbles with a terminal tool call carrying cwd + insert.run( + `composerData:${COMPOSER_A}`, + JSON.stringify({ + composerId: COMPOSER_A, + createdAt: Date.parse('2025-10-24T08:14:00.000Z'), + lastUpdatedAt: Date.parse('2025-10-24T08:20:00.000Z'), + fullConversationHeadersOnly: [ + { bubbleId: 'b1' }, + { bubbleId: 'b2' }, + { bubbleId: 'missing-bubble' } + ] + }) + ); + insert.run( + `bubbleId:${COMPOSER_A}:b1`, + JSON.stringify({ + type: 1, + text: 'Search git history for OwnershipTransferred', + createdAt: '2025-10-24T08:14:39.904Z' + }) + ); + insert.run( + `bubbleId:${COMPOSER_A}:b2`, + JSON.stringify({ + type: 2, + text: 'Searching the commit history now.', + createdAt: '2025-10-24T08:15:02.000Z', + toolFormerData: { + name: 'run_terminal_cmd', + params: JSON.stringify({ + command: 'git log --oneline', + cwd: '/Users/jesse/Documents/GitHub/example-org/legacy-project' + }) + } + }) + ); + + // Composer B: covered by a live agent transcript (must be skipped) + insert.run( + `composerData:${COMPOSER_B}`, + JSON.stringify({ + composerId: COMPOSER_B, + createdAt: Date.parse('2026-03-01T10:00:00.000Z'), + fullConversationHeadersOnly: [{ bubbleId: 'b1' }] + }) + ); + insert.run( + `bubbleId:${COMPOSER_B}:b1`, + JSON.stringify({ type: 1, text: 'hello', createdAt: '2026-03-01T10:00:01.000Z' }) + ); + + // Empty composer: no messages + insert.run( + `composerData:${COMPOSER_EMPTY}`, + JSON.stringify({ + composerId: COMPOSER_EMPTY, + createdAt: Date.parse('2025-06-01T00:00:00.000Z'), + fullConversationHeadersOnly: [] + }) + ); + + db.close(); +} + +describe('cursor legacy import', () => { + let tempDir: string; + let dbPath: string; + let exportDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'cursor-legacy-')); + dbPath = join(tempDir, 'state.vscdb'); + exportDir = join(tempDir, 'export'); + createFixtureDb(dbPath); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('exports legacy conversations as parseable cursor JSONL', async () => { + const result = importCursorLegacy({ dbPath, exportDir }); + + expect(result.exported).toBe(2); + expect(result.skippedEmpty).toBe(1); + expect(result.errors).toHaveLength(0); + + const outFile = join(exportDir, 'legacy-project', `${COMPOSER_A}.jsonl`); + expect(existsSync(outFile)).toBe(true); + + const exchanges = await parseConversation(outFile, 'fallback', outFile); + expect(exchanges).toHaveLength(1); + expect(exchanges[0].harness).toBe('cursor'); + expect(exchanges[0].userMessage).toBe('Search git history for OwnershipTransferred'); + expect(exchanges[0].assistantMessage).toBe('Searching the commit history now.'); + expect(exchanges[0].timestamp).toBe('2025-10-24T08:15:02.000Z'); + expect(exchanges[0].sessionId).toBe(COMPOSER_A); + expect(exchanges[0].cwd).toBe('/Users/jesse/Documents/GitHub/example-org/legacy-project'); + expect(exchanges[0].project).toBe('legacy-project'); + expect(exchanges[0].toolCalls).toHaveLength(1); + }); + + it('derives project from tool cwd and stamps file mtime with conversation end time', () => { + importCursorLegacy({ dbPath, exportDir }); + + const outFile = join(exportDir, 'legacy-project', `${COMPOSER_A}.jsonl`); + const stat = statSync(outFile); + expect(stat.mtime.toISOString()).toBe('2025-10-24T08:20:00.000Z'); + }); + + it('skips composers that have live agent transcripts', () => { + const result = importCursorLegacy({ + dbPath, + exportDir, + liveTranscriptIds: new Set([COMPOSER_B]) + }); + + expect(result.exported).toBe(1); + expect(result.skippedLive).toBe(1); + expect(existsSync(join(exportDir, 'cursor-unknown-project', `${COMPOSER_B}.jsonl`))).toBe(false); + }); + + it('is idempotent: re-running skips already-exported files', () => { + const first = importCursorLegacy({ dbPath, exportDir }); + expect(first.exported).toBe(2); + + const second = importCursorLegacy({ dbPath, exportDir }); + expect(second.exported).toBe(0); + expect(second.skippedExisting).toBe(2); + }); + + it('dry run reports counts without writing files', () => { + const result = importCursorLegacy({ dbPath, exportDir, dryRun: true }); + + expect(result.exported).toBe(2); + expect(existsSync(exportDir)).toBe(false); + }); + + it('opens the database read-only', () => { + const before = readFileSync(dbPath); + importCursorLegacy({ dbPath, exportDir }); + const after = readFileSync(dbPath); + expect(after.equals(before)).toBe(true); + }); +}); + +describe('collectLiveTranscriptIds', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'cursor-live-ids-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('collects session UUIDs from agent-transcripts directories', () => { + const sessionDir = join(tempDir, 'some-slug', 'agent-transcripts', COMPOSER_B); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, `${COMPOSER_B}.jsonl`), '{}\n'); + // Slug without agent-transcripts must not break the scan + mkdirSync(join(tempDir, 'slug-without-transcripts', 'canvases'), { recursive: true }); + + const ids = collectLiveTranscriptIds(tempDir); + expect(ids.has(COMPOSER_B)).toBe(true); + expect(ids.size).toBe(1); + }); + + it('returns an empty set for a missing directory', () => { + const ids = collectLiveTranscriptIds(join(tempDir, 'does-not-exist')); + expect(ids.size).toBe(0); + }); +}); diff --git a/test/cursor-transcripts.test.ts b/test/cursor-transcripts.test.ts new file mode 100644 index 0000000..b6574ac --- /dev/null +++ b/test/cursor-transcripts.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { parseConversation } from '../src/parser.js'; + +function writeJsonl(path: string, lines: unknown[]): void { + writeFileSync(path, lines.map(line => JSON.stringify(line)).join('\n') + '\n', 'utf-8'); +} + +function liveCursorLines() { + return [ + { + role: 'user', + message: { + content: [ + { + type: 'text', + text: '\nWhy does the vault APY calculation drift?\n' + } + ] + } + }, + { + role: 'assistant', + message: { + content: [ + { type: 'text', text: 'Let me look at the APY math first.' }, + { + type: 'tool_use', + name: 'Shell', + input: { + command: 'grep -rn "apy" src/', + working_directory: '/Users/jesse/Documents/GitHub/example-org/example-project' + } + } + ] + } + }, + // Noise lines that appear in real transcripts + { type: 'status', status: 'completed' }, + { type: 'error', error: 'transient stream error' }, + { + role: 'assistant', + message: { + content: [ + { type: 'text', text: 'The drift comes from compounding per-block instead of per-second.' } + ] + } + }, + { + role: 'user', + message: { + content: [{ type: 'text', text: 'Can you fix it?' }] + } + }, + { + role: 'assistant', + message: { + content: [{ type: 'text', text: 'Done - switched to per-second compounding.' }] + } + } + ]; +} + +describe('cursor agent transcripts', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'cursor-transcripts-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function writeLiveTranscript(lines: unknown[]): string { + // Mirror the real layout: /agent-transcripts//.jsonl + const sessionDir = join( + tempDir, + 'Users-jesse-example-project', + 'agent-transcripts', + '0a979b03-8327-4ca2-a7f5-f1ee2ec7212a' + ); + mkdirSync(sessionDir, { recursive: true }); + const filePath = join(sessionDir, '0a979b03-8327-4ca2-a7f5-f1ee2ec7212a.jsonl'); + writeJsonl(filePath, lines); + return filePath; + } + + it('parses live transcripts into exchanges with cursor harness', async () => { + const filePath = writeLiveTranscript(liveCursorLines()); + const exchanges = await parseConversation(filePath, 'fallback-project', filePath); + + expect(exchanges).toHaveLength(2); + expect(exchanges[0].harness).toBe('cursor'); + expect(exchanges[0].userMessage).toBe('Why does the vault APY calculation drift?'); + expect(exchanges[0].assistantMessage).toContain('per-block instead of per-second'); + expect(exchanges[1].userMessage).toBe('Can you fix it?'); + }); + + it('strips the wrapper from user messages', async () => { + const filePath = writeLiveTranscript(liveCursorLines()); + const exchanges = await parseConversation(filePath, 'fallback-project', filePath); + + expect(exchanges[0].userMessage).not.toContain(''); + }); + + it('extracts tool calls and derives cwd from Shell working_directory', async () => { + const filePath = writeLiveTranscript(liveCursorLines()); + const exchanges = await parseConversation(filePath, 'fallback-project', filePath); + + expect(exchanges[0].toolCalls).toHaveLength(1); + expect(exchanges[0].toolCalls![0].toolName).toBe('Shell'); + expect(exchanges[0].cwd).toBe('/Users/jesse/Documents/GitHub/example-org/example-project'); + // Project comes from cwd basename, not the path slug or fallback + expect(exchanges[0].project).toBe('example-project'); + }); + + it('takes sessionId from the filename UUID', async () => { + const filePath = writeLiveTranscript(liveCursorLines()); + const exchanges = await parseConversation(filePath, 'fallback-project', filePath); + + expect(exchanges[0].sessionId).toBe('0a979b03-8327-4ca2-a7f5-f1ee2ec7212a'); + }); + + it('falls back to the path slug for project when no tool calls reveal cwd', async () => { + const lines = [ + { role: 'user', message: { content: [{ type: 'text', text: 'hello' }] } }, + { role: 'assistant', message: { content: [{ type: 'text', text: 'hi there' }] } } + ]; + const filePath = writeLiveTranscript(lines); + const exchanges = await parseConversation(filePath, 'fallback-project', filePath); + + expect(exchanges).toHaveLength(1); + expect(exchanges[0].project).toBe('Users-jesse-example-project'); + }); + + it('uses file mtime as the exchange timestamp', async () => { + const filePath = writeLiveTranscript(liveCursorLines()); + const mtime = new Date('2026-03-15T12:00:00.000Z'); + utimesSync(filePath, mtime, mtime); + + const exchanges = await parseConversation(filePath, 'fallback-project', filePath); + expect(exchanges[0].timestamp).toBe('2026-03-15T12:00:00.000Z'); + }); + + it('does not misdetect cursor files that start with a noise line', async () => { + const lines = [ + { type: 'status', status: 'started' }, + ...liveCursorLines() + ]; + const filePath = writeLiveTranscript(lines); + const exchanges = await parseConversation(filePath, 'fallback-project', filePath); + + expect(exchanges).toHaveLength(2); + expect(exchanges[0].harness).toBe('cursor'); + }); + + it('parses legacy exports with embedded timestamps, sessionId, and cwd', async () => { + const projectDir = join(tempDir, 'legacy-project'); + mkdirSync(projectDir, { recursive: true }); + const filePath = join(projectDir, '1f512764-8211-4582-88f7-251df5e43bc9.jsonl'); + writeJsonl(filePath, [ + { + role: 'user', + message: { content: [{ type: 'text', text: 'Search git history for OwnershipTransferred' }] }, + timestamp: '2025-10-24T08:14:39.904Z', + sessionId: '1f512764-8211-4582-88f7-251df5e43bc9', + cwd: '/Users/jesse/Documents/GitHub/example-org/legacy-project' + }, + { + role: 'assistant', + message: { + content: [ + { type: 'text', text: 'Searching the commit history now.' }, + { type: 'tool_use', name: 'run_terminal_cmd', input: { command: 'git log --oneline' } } + ] + }, + timestamp: '2025-10-24T08:15:02.000Z', + sessionId: '1f512764-8211-4582-88f7-251df5e43bc9', + cwd: '/Users/jesse/Documents/GitHub/example-org/legacy-project' + } + ]); + + const exchanges = await parseConversation(filePath, 'fallback-project', filePath); + + expect(exchanges).toHaveLength(1); + expect(exchanges[0].harness).toBe('cursor'); + expect(exchanges[0].timestamp).toBe('2025-10-24T08:15:02.000Z'); + expect(exchanges[0].sessionId).toBe('1f512764-8211-4582-88f7-251df5e43bc9'); + expect(exchanges[0].cwd).toBe('/Users/jesse/Documents/GitHub/example-org/legacy-project'); + expect(exchanges[0].project).toBe('legacy-project'); + expect(exchanges[0].toolCalls).toHaveLength(1); + expect(exchanges[0].toolCalls![0].toolName).toBe('run_terminal_cmd'); + }); + + it('still detects claude conversations correctly', async () => { + const filePath = join(tempDir, 'claude-session.jsonl'); + writeJsonl(filePath, [ + { + type: 'user', + message: { role: 'user', content: 'Fix the build' }, + timestamp: '2026-01-01T00:00:00.000Z', + sessionId: 'abc' + }, + { + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Fixed.' }] }, + timestamp: '2026-01-01T00:00:10.000Z' + } + ]); + + const exchanges = await parseConversation(filePath, 'claude-project', filePath); + expect(exchanges).toHaveLength(1); + expect(exchanges[0].harness).toBe('claude'); + }); +}); From 37132b8455924626ccc76d8800e98dafcd1b0e4c Mon Sep 17 00:00:00 2001 From: vicnaum Date: Fri, 12 Jun 2026 16:50:08 +0200 Subject: [PATCH 2/5] fix: skip session resume for non-Claude conversations in summarizer --- dist/summarizer.js | 7 ++++++- src/summarizer.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/dist/summarizer.js b/dist/summarizer.js index fab8110..8eec4e1 100644 --- a/dist/summarizer.js +++ b/dist/summarizer.js @@ -417,7 +417,12 @@ export async function summarizeConversation(exchanges, sessionId) { } // For short conversations (≤15 exchanges), summarize directly if (exchanges.length <= 15) { - const claudeSessionId = codexSessionId ? undefined : sessionId; + // Only Claude Code sessions can be resumed by `claude --resume`; Cursor + // sessions carry composer UUIDs Claude Code doesn't know, so resuming + // would fail on every one before the no-resume retry kicks in. Treat + // missing harness as Claude for backward compatibility with old archives. + const isClaudeSession = exchanges.some(e => e.harness === 'claude' || e.harness === undefined); + const claudeSessionId = !codexSessionId && isClaudeSession ? sessionId : undefined; const cwd = claudeSessionId ? exchanges.find(e => e.cwd)?.cwd : undefined; const conversationText = claudeSessionId ? '' // When resuming, no need to include conversation text - it's already in context diff --git a/src/summarizer.ts b/src/summarizer.ts index 82484c5..9280933 100644 --- a/src/summarizer.ts +++ b/src/summarizer.ts @@ -486,7 +486,14 @@ export async function summarizeConversation(exchanges: ConversationExchange[], s // For short conversations (≤15 exchanges), summarize directly if (exchanges.length <= 15) { - const claudeSessionId = codexSessionId ? undefined : sessionId; + // Only Claude Code sessions can be resumed by `claude --resume`; Cursor + // sessions carry composer UUIDs Claude Code doesn't know, so resuming + // would fail on every one before the no-resume retry kicks in. Treat + // missing harness as Claude for backward compatibility with old archives. + const isClaudeSession = exchanges.some( + e => e.harness === 'claude' || e.harness === undefined + ); + const claudeSessionId = !codexSessionId && isClaudeSession ? sessionId : undefined; const cwd = claudeSessionId ? exchanges.find(e => e.cwd)?.cwd : undefined; const conversationText = claudeSessionId ? '' // When resuming, no need to include conversation text - it's already in context From 78691122bc2e32dbf9b699e74f730799919db2a7 Mon Sep 17 00:00:00 2001 From: vicnaum Date: Fri, 12 Jun 2026 23:20:52 +0200 Subject: [PATCH 3/5] fix: skip message-less transcripts in sync (summarizer-spawned stubs) --- dist/sync.js | 47 +++++++++++++++++++++++++++++++++++++++++ src/sync.ts | 46 ++++++++++++++++++++++++++++++++++++++++ test/sync.test.ts | 53 +++++++++++++++++++++++++++++++++++------------ 3 files changed, 133 insertions(+), 13 deletions(-) diff --git a/dist/sync.js b/dist/sync.js index 9975606..caac0c0 100644 --- a/dist/sync.js +++ b/dist/sync.js @@ -18,6 +18,47 @@ function shouldSkipConversation(filePath) { return false; } } +/** + * True when a transcript contains at least one message line in any supported + * harness format. Summarizer-spawned Agent SDK sessions materialize as + * message-less stub files (a single {"type":"ai-title"} line) that defeat the + * marker-based exclusion above and would otherwise re-enter the sync queue on + * every run — one new stub per summary generated. A transcript that has no + * messages *yet* (a session that just started) is skipped this run and picked + * up on a later sync once it has content, since its mtime keeps advancing. + */ +function hasConversationContent(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + for (const line of content.split('\n')) { + if (!line.trim()) + continue; + try { + const parsed = JSON.parse(line); + // Claude: {type: "user"|"assistant", message: {...}} + if ((parsed.type === 'user' || parsed.type === 'assistant') && parsed.message) { + return true; + } + // Codex: {type: "response_item"|..., payload: {...}} + if (parsed.payload) { + return true; + } + // Cursor: {role: "user"|"assistant", message: {...}} + if (parsed.role && parsed.message) { + return true; + } + } + catch { + continue; + } + } + return false; + } + catch { + // If we can't read the file, let the normal pipeline handle it + return true; + } +} function copyIfNewer(src, dest) { // Ensure destination directory exists const destDir = path.dirname(dest); @@ -88,6 +129,12 @@ export async function syncConversations(sourceDir, destDir, options = {}) { const srcFile = path.join(projectPath, file); const destFile = path.join(destDir, project, file); try { + // Skip message-less transcripts (summarizer-spawned stubs, sessions + // that haven't produced content yet) before they enter the archive. + if (!hasConversationContent(srcFile)) { + result.skipped++; + continue; + } const wasCopied = copyIfNewer(srcFile, destFile); if (wasCopied) { result.copied++; diff --git a/src/sync.ts b/src/sync.ts index 96729be..600bc10 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -20,6 +20,45 @@ function shouldSkipConversation(filePath: string): boolean { } } +/** + * True when a transcript contains at least one message line in any supported + * harness format. Summarizer-spawned Agent SDK sessions materialize as + * message-less stub files (a single {"type":"ai-title"} line) that defeat the + * marker-based exclusion above and would otherwise re-enter the sync queue on + * every run — one new stub per summary generated. A transcript that has no + * messages *yet* (a session that just started) is skipped this run and picked + * up on a later sync once it has content, since its mtime keeps advancing. + */ +function hasConversationContent(filePath: string): boolean { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + // Claude: {type: "user"|"assistant", message: {...}} + if ((parsed.type === 'user' || parsed.type === 'assistant') && parsed.message) { + return true; + } + // Codex: {type: "response_item"|..., payload: {...}} + if (parsed.payload) { + return true; + } + // Cursor: {role: "user"|"assistant", message: {...}} + if (parsed.role && parsed.message) { + return true; + } + } catch { + continue; + } + } + return false; + } catch { + // If we can't read the file, let the normal pipeline handle it + return true; + } +} + export interface SyncResult { copied: number; skipped: number; @@ -121,6 +160,13 @@ export async function syncConversations( const destFile = path.join(destDir, project, file); try { + // Skip message-less transcripts (summarizer-spawned stubs, sessions + // that haven't produced content yet) before they enter the archive. + if (!hasConversationContent(srcFile)) { + result.skipped++; + continue; + } + const wasCopied = copyIfNewer(srcFile, destFile); if (wasCopied) { result.copied++; diff --git a/test/sync.test.ts b/test/sync.test.ts index b391364..9f4cc03 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -6,6 +6,12 @@ import { syncConversations } from '../src/sync.js'; import Database from 'better-sqlite3'; import * as sqliteVec from 'sqlite-vec'; +// Minimal valid Claude transcript line — sync skips message-less files +// (summarizer-stub filtering), so fixtures need at least one message. +function transcriptContent(text = 'hello'): string { + return JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n'; +} + describe('sync command', () => { let testDir: string; let sourceDir: string; @@ -37,7 +43,7 @@ describe('sync command', () => { it('should copy new files from source to destination', async () => { mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); const testFile = join(sourceDir, 'project-a', 'test.jsonl'); - writeFileSync(testFile, 'test content', 'utf-8'); + writeFileSync(testFile, transcriptContent(), 'utf-8'); const result = await syncConversations(sourceDir, destDir, { skipIndex: true }); @@ -52,7 +58,7 @@ describe('sync command', () => { it('should skip files that have not been modified', async () => { mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); const testFile = join(sourceDir, 'project-a', 'test.jsonl'); - writeFileSync(testFile, 'test content', 'utf-8'); + writeFileSync(testFile, transcriptContent(), 'utf-8'); // First sync - should copy await syncConversations(sourceDir, destDir, { skipIndex: true }); @@ -67,7 +73,7 @@ describe('sync command', () => { it('should copy files that were modified after previous sync', async () => { mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); const testFile = join(sourceDir, 'project-a', 'test.jsonl'); - writeFileSync(testFile, 'version 1', 'utf-8'); + writeFileSync(testFile, transcriptContent('version 1'), 'utf-8'); // First sync await syncConversations(sourceDir, destDir, { skipIndex: true }); @@ -75,7 +81,7 @@ describe('sync command', () => { // Modify source file (update mtime) const now = new Date(); const future = new Date(now.getTime() + 5000); - writeFileSync(testFile, 'version 2', 'utf-8'); + writeFileSync(testFile, transcriptContent('version 2'), 'utf-8'); utimesSync(testFile, future, future); // Second sync - should copy updated file @@ -89,9 +95,9 @@ describe('sync command', () => { mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); mkdirSync(join(sourceDir, 'project-b'), { recursive: true }); mkdirSync(join(sourceDir, 'project-c'), { recursive: true }); - writeFileSync(join(sourceDir, 'project-a', 'test1.jsonl'), 'content 1', 'utf-8'); - writeFileSync(join(sourceDir, 'project-b', 'test2.jsonl'), 'content 2', 'utf-8'); - writeFileSync(join(sourceDir, 'project-c', 'test3.jsonl'), 'content 3', 'utf-8'); + writeFileSync(join(sourceDir, 'project-a', 'test1.jsonl'), transcriptContent('content 1'), 'utf-8'); + writeFileSync(join(sourceDir, 'project-b', 'test2.jsonl'), transcriptContent('content 2'), 'utf-8'); + writeFileSync(join(sourceDir, 'project-c', 'test3.jsonl'), transcriptContent('content 3'), 'utf-8'); const result = await syncConversations(sourceDir, destDir, { skipIndex: true }); @@ -101,7 +107,7 @@ describe('sync command', () => { it('should only sync jsonl files', async () => { mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); - writeFileSync(join(sourceDir, 'project-a', 'test.jsonl'), 'good', 'utf-8'); + writeFileSync(join(sourceDir, 'project-a', 'test.jsonl'), transcriptContent('good'), 'utf-8'); writeFileSync(join(sourceDir, 'project-a', 'test.txt'), 'bad', 'utf-8'); writeFileSync(join(sourceDir, 'project-a', 'test.json'), 'bad', 'utf-8'); @@ -113,8 +119,8 @@ describe('sync command', () => { it('should skip excluded projects', async () => { mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); mkdirSync(join(sourceDir, 'project-b'), { recursive: true }); - writeFileSync(join(sourceDir, 'project-a', 'test1.jsonl'), 'content', 'utf-8'); - writeFileSync(join(sourceDir, 'project-b', 'test2.jsonl'), 'content', 'utf-8'); + writeFileSync(join(sourceDir, 'project-a', 'test1.jsonl'), transcriptContent(), 'utf-8'); + writeFileSync(join(sourceDir, 'project-b', 'test2.jsonl'), transcriptContent(), 'utf-8'); process.env.CONVERSATION_SEARCH_EXCLUDE_PROJECTS = 'project-a'; const result = await syncConversations(sourceDir, destDir, { skipIndex: true }); @@ -125,6 +131,23 @@ describe('sync command', () => { expect(existsSync(join(destDir, 'project-b', 'test2.jsonl'))).toBe(true); }); + it('skips message-less transcripts (summarizer-spawned ai-title stubs)', async () => { + mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); + writeFileSync( + join(sourceDir, 'project-a', 'stub.jsonl'), + JSON.stringify({ type: 'ai-title', aiTitle: 'Summarize conversation X', sessionId: 'abc' }) + '\n', + 'utf-8' + ); + writeFileSync(join(sourceDir, 'project-a', 'real.jsonl'), transcriptContent(), 'utf-8'); + + const result = await syncConversations(sourceDir, destDir, { skipIndex: true }); + + expect(result.copied).toBe(1); + expect(result.skipped).toBe(1); + expect(existsSync(join(destDir, 'project-a', 'stub.jsonl'))).toBe(false); + expect(existsSync(join(destDir, 'project-a', 'real.jsonl'))).toBe(true); + }); + it('should skip indexing conversations with DO NOT INDEX marker', async () => { mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); @@ -212,15 +235,19 @@ describe('sync command', () => { it('writes an empty summary sentinel for zero-exchange files so they do not re-queue forever', async () => { mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); - // Stage more than summaryLimit (default 10) zero-exchange files — file-history-snapshot is a metadata record the parser drops. + // Stage more than summaryLimit (default 10) zero-exchange files — a lone + // user message with no assistant reply parses to zero exchanges (it has + // message content, so it passes the message-less stub filter and reaches + // the sentinel path). const zeroExchangeFileCount = 12; for (let i = 0; i < zeroExchangeFileCount; i++) { const id = `1111aaaa-1111-1111-1111-${String(i).padStart(12, '0')}`; const content = JSON.stringify({ - type: 'file-history-snapshot', + type: 'user', sessionId: id, uuid: `meta-${i}`, - timestamp: '2025-10-01T12:00:00Z' + timestamp: '2025-10-01T12:00:00Z', + message: { role: 'user', content: 'unanswered question' } }); writeFileSync(join(sourceDir, 'project-a', `${id}.jsonl`), content, 'utf-8'); } From 645f5cae69351680773fb3bc03cbe6fb32fa3fc2 Mon Sep 17 00:00:00 2001 From: vicnaum Date: Sat, 13 Jun 2026 10:56:34 +0200 Subject: [PATCH 4/5] fix: summarize transcripts whose filename lacks a UUID (subagent transcripts) --- dist/sync.js | 10 ++++++---- src/sync.ts | 12 +++++++----- test/sync.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/dist/sync.js b/dist/sync.js index caac0c0..31c9d9c 100644 --- a/dist/sync.js +++ b/dist/sync.js @@ -149,10 +149,12 @@ export async function syncConversations(sourceDir, destDir, options = {}) { if (!options.skipSummaries) { const summaryPath = destFile.replace('.jsonl', '-summary.txt'); if (shouldQueueForSummary(summaryPath) && !shouldSkipConversation(destFile)) { - const sessionId = extractSessionIdFromPath(destFile); - if (sessionId) { - filesToSummarize.push({ path: destFile, sessionId }); - } + // sessionId enables Claude session-resume summarization; when the + // filename has no UUID to extract (e.g. subagent transcripts named + // agent-.jsonl), queue anyway — summarizeConversation falls + // back to summarizing from the transcript text. + const sessionId = extractSessionIdFromPath(destFile) ?? undefined; + filesToSummarize.push({ path: destFile, sessionId }); } } } diff --git a/src/sync.ts b/src/sync.ts index 600bc10..14b27b2 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -135,7 +135,7 @@ export async function syncConversations( // Collect files to index and summarize const filesToIndex: string[] = []; - const filesToSummarize: Array<{ path: string; sessionId: string }> = []; + const filesToSummarize: Array<{ path: string; sessionId: string | undefined }> = []; // Walk source directory const projects = fs.readdirSync(sourceDir); @@ -181,10 +181,12 @@ export async function syncConversations( if (!options.skipSummaries) { const summaryPath = destFile.replace('.jsonl', '-summary.txt'); if (shouldQueueForSummary(summaryPath) && !shouldSkipConversation(destFile)) { - const sessionId = extractSessionIdFromPath(destFile); - if (sessionId) { - filesToSummarize.push({ path: destFile, sessionId }); - } + // sessionId enables Claude session-resume summarization; when the + // filename has no UUID to extract (e.g. subagent transcripts named + // agent-.jsonl), queue anyway — summarizeConversation falls + // back to summarizing from the transcript text. + const sessionId = extractSessionIdFromPath(destFile) ?? undefined; + filesToSummarize.push({ path: destFile, sessionId }); } } } catch (error) { diff --git a/test/sync.test.ts b/test/sync.test.ts index 9f4cc03..f35f9a0 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -148,6 +148,28 @@ describe('sync command', () => { expect(existsSync(join(destDir, 'project-a', 'real.jsonl'))).toBe(true); }); + it('queues summaries for transcripts whose filename has no UUID (subagent transcripts)', async () => { + // Subagent transcripts are named agent-.jsonl — no UUID to extract. + // Before the fix, the missing-sessionId gate dropped them from the summary + // queue entirely. Use a zero-exchange transcript (a lone user message): if + // it reaches the summary pipeline it gets an empty sentinel written — which + // is observable without any network/CLI call. No sentinel ⇒ it was dropped. + mkdirSync(join(sourceDir, 'project-a', 'session-1', 'subagents'), { recursive: true }); + const subagentFile = join(sourceDir, 'project-a', 'session-1', 'subagents', 'agent-a81c86e504e1e7c14.jsonl'); + writeFileSync( + subagentFile, + JSON.stringify({ type: 'user', message: { role: 'user', content: 'unanswered subagent prompt' } }), + 'utf-8' + ); + + const result = await syncConversations(sourceDir, destDir, { skipIndex: true }); + + expect(result.copied).toBe(1); + const sentinel = join(destDir, 'project-a', 'session-1', 'subagents', 'agent-a81c86e504e1e7c14-summary.txt'); + expect(existsSync(sentinel)).toBe(true); + expect(statSync(sentinel).size).toBe(0); + }); + it('should skip indexing conversations with DO NOT INDEX marker', async () => { mkdirSync(join(sourceDir, 'project-a'), { recursive: true }); From 296e7bac3b4876b09bc23d137872e1189c8d3d0a Mon Sep 17 00:00:00 2001 From: vicnaum Date: Wed, 17 Jun 2026 00:04:43 +0200 Subject: [PATCH 5/5] fix: render Cursor transcripts in show command --- dist/mcp-server.js | 75 ++++++++++++++++++++++++++++++++++++++++ dist/show.js | 85 ++++++++++++++++++++++++++++++++++++++++++++++ src/show.ts | 85 ++++++++++++++++++++++++++++++++++++++++++++++ test/show.test.ts | 50 +++++++++++++++++++++++++++ 4 files changed, 295 insertions(+) diff --git a/dist/mcp-server.js b/dist/mcp-server.js index 57e7885..22d0f86 100644 --- a/dist/mcp-server.js +++ b/dist/mcp-server.js @@ -26485,6 +26485,9 @@ function formatConversationAsMarkdown(jsonl, startLine, endLine) { if (isCodexRollout(lines)) { return formatCodexConversationAsMarkdown(lines); } + if (isCursorTranscript(lines)) { + return formatCursorConversationAsMarkdown(lines); + } const allMessages = lines.map((line) => JSON.parse(line)); const messages = allMessages.filter((msg) => { if (msg.type !== "user" && msg.type !== "assistant") return false; @@ -26691,6 +26694,19 @@ function isCodexRollout(lines) { } return false; } +function isCursorTranscript(lines) { + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.type === "status" || parsed.type === "error") continue; + if (parsed.type === void 0 && parsed.role && parsed.message) return true; + return false; + } catch { + continue; + } + } + return false; +} function extractCodexText(content) { if (typeof content === "string") { return content; @@ -26863,6 +26879,65 @@ ${result} } return output; } +function formatCursorConversationAsMarkdown(lines) { + const entries = lines.map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }).filter((e) => e && e.role && e.message); + const first = entries[0] || {}; + let output = "# Conversation\n\n"; + output += "## Metadata\n\n"; + output += "**Harness:** Cursor\n\n"; + if (first.sessionId) output += `**Session ID:** ${first.sessionId} + +`; + if (first.cwd) output += `**Working Directory:** ${first.cwd} + +`; + output += "---\n\n"; + output += "## Messages\n\n"; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const timestamp = entry.timestamp ? new Date(entry.timestamp).toLocaleString() : ""; + const anchor = `msg-${i}`; + const content = entry.message.content; + let text = ""; + const toolUses = []; + if (typeof content === "string") { + text = content; + } else if (Array.isArray(content)) { + text = content.filter((b2) => b2 && b2.type === "text" && typeof b2.text === "string").map((b2) => b2.text).join("\n"); + for (const b2 of content) { + if (b2 && b2.type === "tool_use") { + toolUses.push({ name: b2.name || "unknown", input: b2.input }); + } + } + } + if (entry.role === "user") { + text = text.replace(/<\/?user_query>/g, "").trim(); + } + if (!text.trim() && toolUses.length === 0) continue; + const roleLabel = entry.role === "user" ? "User" : "Agent"; + output += `### **${roleLabel}** (${timestamp}) {#${anchor}} + +`; + if (text.trim()) { + output += `${text} + +`; + } + for (const tool of toolUses) { + output += `**Tool Use:** \`${tool.name}\` + +`; + output += formatCodexToolInputMarkdown(tool.input); + } + } + return output; +} // src/version.ts var VERSION = "1.4.2"; diff --git a/dist/show.js b/dist/show.js index e258023..befacf4 100644 --- a/dist/show.js +++ b/dist/show.js @@ -8,6 +8,9 @@ export function formatConversationAsMarkdown(jsonl, startLine, endLine) { if (isCodexRollout(lines)) { return formatCodexConversationAsMarkdown(lines); } + if (isCursorTranscript(lines)) { + return formatCursorConversationAsMarkdown(lines); + } const allMessages = lines.map(line => JSON.parse(line)); // Filter out system messages and messages with no content const messages = allMessages.filter(msg => { @@ -197,6 +200,9 @@ export function formatConversationAsHTML(jsonl) { if (isCodexRollout(lines)) { return formatMarkdownDocumentAsHTML(formatCodexConversationAsMarkdown(lines)); } + if (isCursorTranscript(lines)) { + return formatMarkdownDocumentAsHTML(formatCursorConversationAsMarkdown(lines)); + } const allMessages = lines.map(line => JSON.parse(line)); // Filter out system messages and messages with no content const messages = allMessages.filter(msg => { @@ -738,6 +744,25 @@ function isCodexRollout(lines) { } return false; } +// Cursor agent transcripts carry role+message with no top-level `type`. +// Mirrors detectConversationHarness in parser.ts; skips status/error noise +// lines so a transcript that opens with one isn't misdetected. +function isCursorTranscript(lines) { + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.type === 'status' || parsed.type === 'error') + continue; + if (parsed.type === undefined && parsed.role && parsed.message) + return true; + return false; + } + catch { + continue; + } + } + return false; +} function extractCodexText(content) { if (typeof content === 'string') { return content; @@ -888,6 +913,66 @@ function formatCodexConversationAsMarkdown(lines) { } return output; } +function formatCursorConversationAsMarkdown(lines) { + const entries = lines + .map(line => { try { + return JSON.parse(line); + } + catch { + return null; + } }) + .filter(e => e && e.role && e.message); + // sessionId/cwd are present only on legacy state.vscdb exports, not live + // transcripts; show whatever the first message carries. + const first = entries[0] || {}; + let output = '# Conversation\n\n'; + output += '## Metadata\n\n'; + output += '**Harness:** Cursor\n\n'; + if (first.sessionId) + output += `**Session ID:** ${first.sessionId}\n\n`; + if (first.cwd) + output += `**Working Directory:** ${first.cwd}\n\n`; + output += '---\n\n'; + output += '## Messages\n\n'; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const timestamp = entry.timestamp ? new Date(entry.timestamp).toLocaleString() : ''; + const anchor = `msg-${i}`; + const content = entry.message.content; + let text = ''; + const toolUses = []; + if (typeof content === 'string') { + text = content; + } + else if (Array.isArray(content)) { + text = content + .filter(b => b && b.type === 'text' && typeof b.text === 'string') + .map(b => b.text) + .join('\n'); + for (const b of content) { + if (b && b.type === 'tool_use') { + toolUses.push({ name: b.name || 'unknown', input: b.input }); + } + } + } + if (entry.role === 'user') { + // Strip Cursor's wrapper, consistent with the parser. + text = text.replace(/<\/?user_query>/g, '').trim(); + } + if (!text.trim() && toolUses.length === 0) + continue; + const roleLabel = entry.role === 'user' ? 'User' : 'Agent'; + output += `### **${roleLabel}** (${timestamp}) {#${anchor}}\n\n`; + if (text.trim()) { + output += `${text}\n\n`; + } + for (const tool of toolUses) { + output += `**Tool Use:** \`${tool.name}\`\n\n`; + output += formatCodexToolInputMarkdown(tool.input); + } + } + return output; +} function formatMarkdownDocumentAsHTML(markdown) { return ` diff --git a/src/show.ts b/src/show.ts index affa113..b2e7cb6 100644 --- a/src/show.ts +++ b/src/show.ts @@ -38,6 +38,10 @@ export function formatConversationAsMarkdown(jsonl: string, startLine?: number, return formatCodexConversationAsMarkdown(lines); } + if (isCursorTranscript(lines)) { + return formatCursorConversationAsMarkdown(lines); + } + const allMessages: ConversationMessage[] = lines.map(line => JSON.parse(line)); // Filter out system messages and messages with no content @@ -230,6 +234,10 @@ export function formatConversationAsHTML(jsonl: string): string { return formatMarkdownDocumentAsHTML(formatCodexConversationAsMarkdown(lines)); } + if (isCursorTranscript(lines)) { + return formatMarkdownDocumentAsHTML(formatCursorConversationAsMarkdown(lines)); + } + const allMessages: ConversationMessage[] = lines.map(line => JSON.parse(line)); // Filter out system messages and messages with no content @@ -780,6 +788,23 @@ function isCodexRollout(lines: string[]): boolean { return false; } +// Cursor agent transcripts carry role+message with no top-level `type`. +// Mirrors detectConversationHarness in parser.ts; skips status/error noise +// lines so a transcript that opens with one isn't misdetected. +function isCursorTranscript(lines: string[]): boolean { + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.type === 'status' || parsed.type === 'error') continue; + if (parsed.type === undefined && parsed.role && parsed.message) return true; + return false; + } catch { + continue; + } + } + return false; +} + function extractCodexText(content: unknown): string { if (typeof content === 'string') { return content; @@ -942,6 +967,66 @@ function formatCodexConversationAsMarkdown(lines: string[]): string { return output; } +function formatCursorConversationAsMarkdown(lines: string[]): string { + const entries = lines + .map(line => { try { return JSON.parse(line); } catch { return null; } }) + .filter(e => e && e.role && e.message); + + // sessionId/cwd are present only on legacy state.vscdb exports, not live + // transcripts; show whatever the first message carries. + const first = entries[0] || {}; + let output = '# Conversation\n\n'; + output += '## Metadata\n\n'; + output += '**Harness:** Cursor\n\n'; + if (first.sessionId) output += `**Session ID:** ${first.sessionId}\n\n`; + if (first.cwd) output += `**Working Directory:** ${first.cwd}\n\n`; + + output += '---\n\n'; + output += '## Messages\n\n'; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const timestamp = entry.timestamp ? new Date(entry.timestamp).toLocaleString() : ''; + const anchor = `msg-${i}`; + const content = entry.message.content; + + let text = ''; + const toolUses: Array<{ name: string; input: unknown }> = []; + if (typeof content === 'string') { + text = content; + } else if (Array.isArray(content)) { + text = content + .filter(b => b && b.type === 'text' && typeof b.text === 'string') + .map(b => b.text) + .join('\n'); + for (const b of content) { + if (b && b.type === 'tool_use') { + toolUses.push({ name: b.name || 'unknown', input: b.input }); + } + } + } + + if (entry.role === 'user') { + // Strip Cursor's wrapper, consistent with the parser. + text = text.replace(/<\/?user_query>/g, '').trim(); + } + + if (!text.trim() && toolUses.length === 0) continue; + + const roleLabel = entry.role === 'user' ? 'User' : 'Agent'; + output += `### **${roleLabel}** (${timestamp}) {#${anchor}}\n\n`; + if (text.trim()) { + output += `${text}\n\n`; + } + for (const tool of toolUses) { + output += `**Tool Use:** \`${tool.name}\`\n\n`; + output += formatCodexToolInputMarkdown(tool.input); + } + } + + return output; +} + function formatMarkdownDocumentAsHTML(markdown: string): string { return ` diff --git a/test/show.test.ts b/test/show.test.ts index 0597777..ff906dc 100644 --- a/test/show.test.ts +++ b/test/show.test.ts @@ -269,6 +269,56 @@ describe('show command - markdown formatting', () => { expect(markdown).toContain('Exit code: 0'); expect(markdown).toContain('local shell'); }); + + it('should format a live Cursor transcript (role/message, no timestamps)', () => { + const jsonl = [ + JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: '\nMake all addresses clickable\n' }] } }), + JSON.stringify({ role: 'assistant', message: { content: [ + { type: 'text', text: "I'll update the address cells." }, + { type: 'tool_use', name: 'Shell', input: { command: 'grep -rn address src/', working_directory: '/repo' } }, + ] } }), + // status/error noise lines must be ignored, not crash the renderer + JSON.stringify({ type: 'status', status: 'completed' }), + JSON.stringify({ role: 'assistant', message: { content: [{ type: 'text', text: 'Done — addresses are clickable now.' }] } }), + ].join('\n'); + + const markdown = formatConversationAsMarkdown(jsonl); + + expect(markdown).toContain('**Harness:** Cursor'); + expect(markdown).toContain('Make all addresses clickable'); + expect(markdown).not.toContain(''); + expect(markdown).toContain("I'll update the address cells."); + expect(markdown).toContain('**Tool Use:** `Shell`'); + expect(markdown).toContain('Done — addresses are clickable now.'); + }); + + it('should format a legacy Cursor export (embedded timestamp/sessionId/cwd)', () => { + const jsonl = [ + JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: 'Search git history' }] }, timestamp: '2025-10-24T08:14:39.904Z', sessionId: '1f512764-8211-4582-88f7-251df5e43bc9', cwd: '/repo' }), + JSON.stringify({ role: 'assistant', message: { content: [{ type: 'text', text: 'Searching now.' }] }, timestamp: '2025-10-24T08:15:02.000Z' }), + ].join('\n'); + + const markdown = formatConversationAsMarkdown(jsonl); + + expect(markdown).toContain('**Harness:** Cursor'); + expect(markdown).toContain('**Session ID:** 1f512764-8211-4582-88f7-251df5e43bc9'); + expect(markdown).toContain('**Working Directory:** /repo'); + expect(markdown).toContain('Search git history'); + expect(markdown).toContain('Searching now.'); + }); + + it('should not misroute a Cursor transcript that opens with a noise line', () => { + const jsonl = [ + JSON.stringify({ type: 'status', status: 'started' }), + JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: 'hello cursor' }] } }), + JSON.stringify({ role: 'assistant', message: { content: [{ type: 'text', text: 'hi there' }] } }), + ].join('\n'); + + const markdown = formatConversationAsMarkdown(jsonl); + + expect(markdown).toContain('**Harness:** Cursor'); + expect(markdown).toContain('hello cursor'); + }); }); describe('show command - HTML formatting', () => {