diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts new file mode 100644 index 0000000..4fe7824 --- /dev/null +++ b/src/adapters/cursor.ts @@ -0,0 +1,207 @@ +import { createReadStream, existsSync } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import { createInterface } from "node:readline"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Adapter, AdapterOptions, Message } from "./index"; + +/** + * Cursor stores agent transcripts as JSONL files at: + * ~/.cursor/projects//agent-transcripts//.jsonl + * + * Legacy flat layout: + * ~/.cursor/projects//agent-transcripts/.jsonl + * + * Subagent transcripts: + * ~/.cursor/projects//agent-transcripts//subagents/.jsonl + * + * Each JSONL line is one of: + * Metadata: { "type": "metadata", "metadata": { "overview": "..." } } + * Error: { "type": "error", "error": "..." } + * Message: { "role": "user"|"assistant", "message": { "content": [{ "type": "text", "text": "..." }] } } + * + * User messages embed the actual query inside tags within system context. + */ + +const CURSOR_PROJECTS_DIR = join(homedir(), ".cursor", "projects"); + +export function cursorAdapter(): Adapter { + return { + name: "cursor", + async *messages(options?: AdapterOptions): AsyncGenerator { + if (!existsSync(CURSOR_PROJECTS_DIR)) return; + + let projectDirs: string[]; + try { + projectDirs = await readdir(CURSOR_PROJECTS_DIR); + } catch { + return; + } + + for (const projectDir of projectDirs) { + const transcriptsDir = join( + CURSOR_PROJECTS_DIR, + projectDir, + "agent-transcripts", + ); + if (!existsSync(transcriptsDir)) continue; + + yield* walkTranscripts(transcriptsDir, { + project: projectDir, + since: options?.since, + }); + } + }, + }; +} + +async function* walkTranscripts( + dir: string, + context: { project: string; since?: Date }, +): AsyncGenerator { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = join(dir, entry); + const entryStat = await stat(fullPath).catch(() => null); + if (!entryStat) continue; + + if (entryStat.isDirectory()) { + if (context.since && entryStat.mtime < context.since) continue; + + const subEntries = await readdir(fullPath).catch(() => [] as string[]); + for (const sub of subEntries) { + if (sub.endsWith(".jsonl")) { + yield* parseCursorJsonl(join(fullPath, sub), { + session: sub.replace(".jsonl", ""), + project: context.project, + since: context.since, + }); + } else if (sub === "subagents") { + const subagentsDir = join(fullPath, "subagents"); + const subFiles = await readdir(subagentsDir).catch( + () => [] as string[], + ); + for (const sf of subFiles) { + if (!sf.endsWith(".jsonl")) continue; + yield* parseCursorJsonl(join(subagentsDir, sf), { + session: `${entry}/${sf.replace(".jsonl", "")}`, + project: context.project, + since: context.since, + }); + } + } + } + } else if (entry.endsWith(".jsonl")) { + if (context.since && entryStat.mtime < context.since) continue; + yield* parseCursorJsonl(fullPath, { + session: entry.replace(".jsonl", ""), + project: context.project, + since: context.since, + }); + } + } +} + +async function* parseCursorJsonl( + filePath: string, + context: { session: string; project: string; since?: Date }, +): AsyncGenerator { + const rl = createInterface({ + input: createReadStream(filePath, { encoding: "utf-8" }), + crlfDelay: Infinity, + }); + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const entry = JSON.parse(line) as Record; + + // Skip metadata and error sidecar lines + if (entry["type"] === "metadata" || entry["type"] === "error") continue; + + if (entry["role"] !== "user") continue; + + const message = entry["message"] as + | { content?: unknown } + | undefined; + if (!message?.content) continue; + + const rawText = extractText(message.content); + if (!rawText) continue; + + const text = stripSystemContext(rawText); + if (!text.trim()) continue; + + const timestamp = extractTimestamp(rawText) ?? undefined; + if (context.since && timestamp) { + const ts = new Date(timestamp); + if (ts < context.since) continue; + } + + yield { + text, + timestamp, + session: context.session, + project: context.project, + }; + } catch { + // Skip malformed lines + } + } +} + +function extractText(content: unknown): string | null { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = content + .filter( + (p): p is { type: string; text: string } => + typeof p === "object" && + p !== null && + p.type === "text" && + typeof p.text === "string", + ) + .map((p) => p.text); + return parts.length > 0 ? parts.join(" ") : null; + } + return null; +} + +/** + * Extract the user's actual message from Cursor's system context wrapper. + * User messages are wrapped in tags with surrounding system + * context like , , , , etc. + */ +function stripSystemContext(text: string): string { + // Try to extract just the content + const queryMatch = text.match( + /\n?([\s\S]*?)\n?<\/user_query>/, + ); + if (queryMatch?.[1]) { + return queryMatch[1].trim(); + } + + // If no user_query tags, strip known system context tags + return text + .replace(/[\s\S]*?<\/timestamp>/g, "") + .replace(/[\s\S]*?<\/user_info>/g, "") + .replace(/[\s\S]*?<\/system_reminder>/g, "") + .replace(/[\s\S]*?<\/rules>/g, "") + .replace(/[\s\S]*?<\/attached_files>/g, "") + .replace(/[\s\S]*?<\/agent_transcripts>/g, "") + .replace(/[\s\S]*?<\/agent_skills>/g, "") + .replace(/[\s\S]*?<\/mcp_file_system>/g, "") + .trim(); +} + +function extractTimestamp(text: string): string | null { + const match = text.match(/([\s\S]*?)<\/timestamp>/); + return match?.[1] ? match[1].trim() : null; +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index bccf573..50ff174 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -2,6 +2,7 @@ import { ampAdapter } from "./amp"; import { claudeAdapter } from "./claude"; import { clineAdapter } from "./cline"; import { codexAdapter } from "./codex"; +import { cursorAdapter } from "./cursor"; import { opencodeAdapter } from "./opencode"; import { zedAdapter } from "./zed"; @@ -25,6 +26,7 @@ export interface AdapterOptions { const ADAPTERS: Record Adapter> = { claude: claudeAdapter, codex: codexAdapter, + cursor: cursorAdapter, opencode: opencodeAdapter, amp: ampAdapter, cline: clineAdapter, diff --git a/src/commands/scan.ts b/src/commands/scan.ts index fe36563..dba5975 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -84,7 +84,7 @@ function parseArgs(args: string[]): ScanOptions { console.log(`devrage scan — scan sessions for profanity Options: - --agent, -a Scan only a specific agent (claude, codex, opencode, amp, cline, zed) + --agent, -a Scan only a specific agent (claude, codex, cursor, opencode, amp, cline, zed) --since, -s Only scan messages after this date (ISO 8601) --help, -h Show this help`); process.exit(0);