From babcd2660c58f277ea48d78f37f9984ce4a516b2 Mon Sep 17 00:00:00 2001 From: maheshsingh20 Date: Wed, 2 Sep 2026 11:40:24 +0530 Subject: [PATCH] feat(cursor-agent): discover and reconstruct sessions from ~/.cursor/chats/**/store.db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #986. ## What changed - **Discovery**: discoverSessions() now scans ~/.cursor/chats///store.db in addition to the existing ~/.cursor/projects/**/agent-transcripts/ transcript walk. probeRoots() gains a chats entry so codeburn doctor can report it. - **Schema validation**: each store.db is checked for the required meta and lobs tables before any reads are attempted. A missing or incompatible schema emits a warning and falls through to the transcript fallback. - **Metadata decoding**: meta['0'] is hex-decoded from UTF-8 JSON to extract gentId, latestRootBlobId, ame, createdAt, and lastUsedModel. lobEncryptionKey is explicitly excluded from the decoded object and must never appear in logs, caches, exports, snapshots, or committed fixtures. - **Blob-graph reconstruction**: starting at latestRootBlobId, the parser walks extBlobId chains and childBlobIds arrays in BFS order, classifying blobs by ole (user / assistant / model / request). Unknown fields are silently ignored for forward compatibility. - **Token provenance**: explicit per-request inputTokens/outputTokens fields are used when present and plausible (≤ 2 000 000 — values above that are treated as context-window gauges and discarded). Missing components are estimated from character counts. costIsEstimated is set on calls where any count was estimated. - **Timestamps**: internal request/blob timestamps take precedence; the session createdAt is used as a session-level fallback. File mtime is never reported as an exact request timestamp. - **WAL-aware cache**: the in-memory per-session cache is keyed on a fingerprint combining the store.db mtime and the -wal sidecar size/mtime, so active sessions (written via WAL) always trigger a re-parse. - **Source precedence + dedup**: store.db sources are emitted first. A successfully decoded store records a sentinel in seenKeys; transcript parsers for the same session UUID check the sentinel and skip themselves. A store that fails schema/metadata validation does not set the sentinel, so the transcript fallback runs normally. ## Tests (44 new, 0 regressions) Covers all items from the issue test matrix: empty chats dir, valid minimal store, multi-turn blob graph, malformed hex metadata, missing/invalid root blob, unknown fields, seconds vs milliseconds timestamps, exact/partial/gauge/absent token data, store+transcript dedup, transcript fallback on corrupt store, WAL-aware cache invalidation, blobEncryptionKey redaction. --- src/providers/cursor-agent.ts | 821 +++++++++++++- tests/providers/cursor-agent-store.test.ts | 1198 ++++++++++++++++++++ 2 files changed, 1984 insertions(+), 35 deletions(-) create mode 100644 tests/providers/cursor-agent-store.test.ts diff --git a/src/providers/cursor-agent.ts b/src/providers/cursor-agent.ts index 3a20d7438..cb66f9b01 100644 --- a/src/providers/cursor-agent.ts +++ b/src/providers/cursor-agent.ts @@ -1,11 +1,17 @@ import { createHash } from 'crypto' -import { existsSync } from 'fs' +import { existsSync, statSync } from 'fs' import { readdir, readFile, stat } from 'fs/promises' import { join, basename } from 'path' import { homedir } from 'os' import { calculateCost, getShortModelName } from '../models.js' -import { openDatabase, type SqliteDatabase } from '../sqlite.js' +import { + openDatabase, + blobToText, + isSqliteAvailable, + isSqliteBusyError, + type SqliteDatabase, +} from '../sqlite.js' import { normalizeContentBlocks } from '../content-utils.js' import { estimateTokensFromChars } from '../token-estimate.js' import type { @@ -16,28 +22,15 @@ import type { ProbeRoot, } from './types.js' -type ConversationSummary = { - conversationId: string - model: string | null - title: string | null - updatedAt: string | null -} - -type AssistantTurn = { - body: string - reasoning: string - tools: string[] -} - -type ParsedTurn = { - userMessage: string - assistant: AssistantTurn -} +// --------------------------------------------------------------------------- +// Shared constants +// --------------------------------------------------------------------------- const CURSOR_AGENT_COST_MODEL = 'claude-sonnet-4-5' const MAX_USER_TEXT_LENGTH = 500 const DIGITS_ONLY = /^\d+$/ const UUID_LIKE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const HEX64 = /^[0-9a-f]{64}$/i const USER_MARKER = /^\s*user:\s*/i const ASSISTANT_MARKER = /^\s*A:\s*/ const THINKING_MARKER = /^\s*\[Thinking\]\s*/ @@ -45,7 +38,19 @@ const TOOL_CALL_MARKER = /^\s*\[Tool call\]\s*(.+?)\s*$/i const TOOL_RESULT_MARKER = /^\s*\[Tool result\]\b/i const USER_QUERY_OPEN = '' const USER_QUERY_CLOSE = '' + +// Marker on SessionSource.path so the parser knows this is a store.db source +// rather than a transcript. The marker is a private implementation detail; +// it never appears in output. +const STORE_SOURCE_PREFIX = 'cursor-agent-store:' + +// Sentinel written into seenKeys to record that a store.db was decoded +// successfully for a given session UUID. Transcript parsers check for this +// and skip themselves to prevent double-counting. +const STORE_DECODED_KEY_PREFIX = 'cursor-agent-store-decoded:' + const warnedUnrecognizedTranscripts = new Set() + const CONVERSATION_SUMMARY_QUERY = ` SELECT conversationId, model, title, updatedAt FROM conversation_summaries @@ -67,6 +72,10 @@ const modelDisplayNames: Record = { default: 'Auto (Sonnet est.)', } +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + function getCursorAgentBaseDir(baseDirOverride?: string): string { if (baseDirOverride) return baseDirOverride // Windows paths unverified; tracked as Open Question 3 in issue #55. @@ -77,10 +86,73 @@ function getProjectsDir(baseDir: string): string { return join(baseDir, 'projects') } +function getChatsDir(baseDir: string): string { + return join(baseDir, 'chats') +} + function getAttributionDbPath(baseDir: string): string { return join(baseDir, 'ai-tracking', 'ai-code-tracking.db') } +// --------------------------------------------------------------------------- +// Type definitions +// --------------------------------------------------------------------------- + +type ConversationSummary = { + conversationId: string + model: string | null + title: string | null + updatedAt: string | null +} + +type AssistantTurn = { + body: string + reasoning: string + tools: string[] +} + +type ParsedTurn = { + userMessage: string + assistant: AssistantTurn +} + +/** Decoded root metadata from meta table key '0'. */ +type StoreRootMeta = { + agentId: string + latestRootBlobId: string + name: string | null + mode: string | null + createdAt: number | null + lastUsedModel: string | null + // blobEncryptionKey is intentionally excluded — it must never be stored, + // logged, cached, exported, or appear in any emitted value. +} + +/** Provenance tag for token counts. */ +type TokenProvenance = 'exact' | 'estimated' + +/** One reconstructed request/turn from the blob graph. */ +type BlobTurn = { + userMessage: string + outputText: string + reasoningText: string + tools: string[] + inputTokens: number + outputTokens: number + reasoningTokens: number + cacheCreationTokens: number + cacheReadTokens: number + inputProvenance: TokenProvenance + outputProvenance: TokenProvenance + timestamp: string | null + requestId: string | null + model: string | null +} + +// --------------------------------------------------------------------------- +// Utility functions +// --------------------------------------------------------------------------- + function estimateTokens(charCount: number): number { if (charCount <= 0) return 0 return estimateTokensFromChars(charCount) @@ -92,6 +164,11 @@ function parseToolName(raw: string): string { return clean.toLowerCase().replace(/\s+/g, '-') } +/** + * Normalizes a raw timestamp value to ISO string. + * Accepts: ISO strings, numeric epoch-ms, numeric epoch-s (< 1e12). + * Returns null for missing or invalid inputs. + */ function normalizeTimestamp(raw: string | number | null | undefined): string | null { if (raw === null || raw === undefined) return null if (typeof raw === 'string') { @@ -153,6 +230,648 @@ function toConversationId(transcriptPath: string): string { return createHash('sha1').update(transcriptPath).digest('hex').slice(0, 16) } +// --------------------------------------------------------------------------- +// store.db: schema validation +// --------------------------------------------------------------------------- + +/** + * Validates that the given database has the expected meta and blobs tables. + * Throws SQLITE_BUSY errors so they propagate to the busy-handling path upstream. + */ +function validateStoreSchema(db: SqliteDatabase): boolean { + for (const table of ['meta', 'blobs']) { + try { + db.query<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table} LIMIT 1`) + } catch (err) { + if (isSqliteBusyError(err)) throw err + return false + } + } + return true +} + +// --------------------------------------------------------------------------- +// store.db: root metadata decoding +// --------------------------------------------------------------------------- + +/** + * Reads meta['0'] from the store and decodes it from hex-encoded UTF-8 JSON. + * blobEncryptionKey is explicitly stripped from the result before returning. + * Returns null when the row is absent, the hex decode fails, or required + * fields are missing/invalid. + */ +function decodeStoreMeta(db: SqliteDatabase): StoreRootMeta | null { + let rows: Array<{ value: Uint8Array | string | null }> + try { + rows = db.query<{ value: Uint8Array | string | null }>( + "SELECT CAST(value AS BLOB) AS value FROM meta WHERE key = '0' LIMIT 1", + ) + } catch (err) { + if (isSqliteBusyError(err)) throw err + return null + } + if (rows.length === 0) return null + + const raw = blobToText(rows[0]!.value) + if (!raw) return null + + // The value is hex-encoded UTF-8 JSON. Each pair of hex digits is one byte. + let jsonStr: string + try { + if (!/^[0-9a-f]+$/i.test(raw.trim())) { + // Not hex — try treating as plain JSON (defensive for format variations) + jsonStr = raw + } else { + const hex = raw.trim() + if (hex.length % 2 !== 0) return null + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16) + } + jsonStr = new TextDecoder('utf-8', { fatal: false }).decode(bytes) + } + } catch { + return null + } + + let parsed: Record + try { + parsed = JSON.parse(jsonStr) as Record + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null + } catch { + return null + } + + const agentId = typeof parsed['agentId'] === 'string' ? parsed['agentId'] : null + const latestRootBlobId = typeof parsed['latestRootBlobId'] === 'string' ? parsed['latestRootBlobId'] : null + + // Both are required for graph reconstruction. + if (!agentId || !latestRootBlobId) return null + if (!HEX64.test(latestRootBlobId)) return null + + const name = typeof parsed['name'] === 'string' ? parsed['name'] : null + const mode = typeof parsed['mode'] === 'string' ? parsed['mode'] : null + const lastUsedModel = typeof parsed['lastUsedModel'] === 'string' ? parsed['lastUsedModel'] : null + const createdAt = + typeof parsed['createdAt'] === 'number' + ? parsed['createdAt'] + : typeof parsed['createdAt'] === 'string' + ? Number(parsed['createdAt']) || null + : null + + // Explicitly do NOT include blobEncryptionKey in the returned object. + // It must never appear in any log, cache, export, or emitted value. + return { agentId, latestRootBlobId, name, mode, createdAt, lastUsedModel } +} + +// --------------------------------------------------------------------------- +// store.db: blob graph reconstruction +// --------------------------------------------------------------------------- + +/** + * Fetches a blob's raw data by ID. Returns null when the blob is absent or + * its data cannot be parsed as JSON. Unknown fields are silently ignored. + */ +function fetchBlob(db: SqliteDatabase, blobId: string): Record | null { + let rows: Array<{ data: Uint8Array | string | null }> + try { + rows = db.query<{ data: Uint8Array | string | null }>( + 'SELECT CAST(data AS BLOB) AS data FROM blobs WHERE id = ? LIMIT 1', + [blobId], + ) + } catch (err) { + if (isSqliteBusyError(err)) throw err + return null + } + if (rows.length === 0) return null + const text = blobToText(rows[0]!.data) + if (!text) return null + try { + const parsed: unknown = JSON.parse(text) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null + return parsed as Record + } catch { + return null + } +} + +/** Safely reads a string field from a parsed blob object. */ +function blobStr(obj: Record, key: string): string | null { + const v = obj[key] + return typeof v === 'string' && v.length > 0 ? v : null +} + +/** Safely reads a number field. */ +function blobNum(obj: Record, key: string): number | null { + const v = obj[key] + return typeof v === 'number' && Number.isFinite(v) ? v : null +} + +/** + * Extracts text content from a blob's 'content' or 'text' field. + * Handles both plain strings and arrays of content blocks + * (e.g. [{type:'text', text:'...'}, ...]). + * Unknown block types are silently skipped (forward-compatible). + */ +function extractBlobText(obj: Record): string { + const direct = obj['text'] ?? obj['content'] + if (typeof direct === 'string') return direct + + if (Array.isArray(direct)) { + return direct + .filter((b): b is Record => typeof b === 'object' && b !== null && !Array.isArray(b)) + .filter(b => b['type'] === 'text' || b['type'] === undefined) + .map(b => (typeof b['text'] === 'string' ? b['text'] : '')) + .join('') + } + + return '' +} + +/** + * Extracts tool calls from a blob. Returns an array of tool name strings. + * Handles both a direct 'toolCalls' array and 'content' blocks with + * type 'tool_use'. Unknown formats are silently ignored. + */ +function extractBlobTools(obj: Record): string[] { + const tools: string[] = [] + + // Format A: toolCalls: [{name: string}] + const toolCalls = obj['toolCalls'] + if (Array.isArray(toolCalls)) { + for (const tc of toolCalls) { + if (typeof tc === 'object' && tc !== null && !Array.isArray(tc)) { + const name = (tc as Record)['name'] + if (typeof name === 'string' && name.length > 0) { + tools.push(`cursor:${parseToolName(name)}`) + } + } + } + } + + // Format B: content blocks with type 'tool_use' + const content = obj['content'] + if (Array.isArray(content)) { + for (const block of content) { + if (typeof block === 'object' && block !== null && !Array.isArray(block)) { + const b = block as Record + if (b['type'] === 'tool_use') { + const name = b['name'] + if (typeof name === 'string' && name.length > 0) { + tools.push(`cursor:${parseToolName(name)}`) + } + } + } + } + } + + return tools +} + +/** + * Determines whether a numeric token count is a plausible exact billed + * count vs. a running context-window gauge. + * + * A gauge is a monotonically increasing context-window snapshot that is NOT + * a per-request delta. We reject values that are impossibly large for a + * single request (> 2_000_000 tokens) as gauges. Values ≤ 0 are also + * rejected. This is a heuristic; the issue spec calls for trusting + * explicitly labelled fields over this. + */ +function isPlausibleExactCount(n: number | null): boolean { + if (n === null || !Number.isFinite(n) || n < 0) return false + // 0 is valid (e.g. a turn with no output). Counts above 2M are extremely + // unlikely to be per-request deltas and more likely context-window gauges. + return n <= 2_000_000 +} + +/** + * Walks the blob graph starting at rootBlobId, reconstructing a sequence of + * conversation turns. Returns an ordered array of BlobTurn objects. + * + * The graph is a linked-list-like structure: each blob may carry a + * 'nextBlobId' / 'parentBlobId' / 'childBlobIds' reference. We follow + * 'nextBlobId' linearly and fall back to children arrays. + * + * Unknown protobuf-style fields in blobs are silently ignored for forward + * compatibility (issue requirement 5). + */ +function reconstructBlobGraph( + db: SqliteDatabase, + rootBlobId: string, + sessionTimestampFallback: string | null, +): BlobTurn[] { + const turns: BlobTurn[] = [] + const visited = new Set() + + // Collect blobs in traversal order. We walk 'nextBlobId' chains from root. + // Each blob can be: a message blob (role='user'|'assistant'), a request + // wrapper blob, or a container blob with childBlobIds. We do a BFS and + // classify each blob by its fields. + const queue: string[] = [rootBlobId] + const orderedBlobs: Array> = [] + + while (queue.length > 0) { + const id = queue.shift()! + if (visited.has(id)) continue + visited.add(id) + + const blob = fetchBlob(db, id) + if (!blob) continue + orderedBlobs.push(blob) + + // Follow the linear chain first (most common layout) + const next = blobStr(blob, 'nextBlobId') + if (next && HEX64.test(next) && !visited.has(next)) { + queue.unshift(next) // maintain ordering: next before children + } + + // Follow child blobs (branching structure) + const children = blob['childBlobIds'] + if (Array.isArray(children)) { + for (const child of children) { + if (typeof child === 'string' && HEX64.test(child) && !visited.has(child)) { + queue.push(child) + } + } + } + } + + // Now group into user/assistant pairs. We scan for 'role' fields. + // A request blob typically has: role='user' for the prompt, and + // role='assistant' (or role='model') for the response, plus token fields. + let pendingUser = '' + let pendingUserTs: string | null = null + + for (const blob of orderedBlobs) { + const role = blobStr(blob, 'role') + + if (role === 'user') { + const text = extractBlobText(blob).slice(0, MAX_USER_TEXT_LENGTH) + if (text) { + pendingUser = text + pendingUserTs = normalizeTimestamp(blobNum(blob, 'timestamp') ?? blobNum(blob, 'createdAt') ?? blobStr(blob, 'timestamp')) + } + continue + } + + if (role === 'assistant' || role === 'model' || role === 'request') { + const outputText = role === 'request' ? '' : extractBlobText(blob) + + // --- Reasoning text --- + const reasoningText = (() => { + const r = blob['reasoning'] ?? blob['thinkingContent'] + if (typeof r === 'string') return r + if (Array.isArray(r)) { + return r + .filter((b): b is Record => typeof b === 'object' && b !== null) + .map(b => (typeof b['text'] === 'string' ? b['text'] : '')) + .join('') + } + return '' + })() + + // --- Tools --- + const tools = extractBlobTools(blob) + + // --- Timestamp: prefer request-level; fall back to session-level --- + const ts = normalizeTimestamp( + blobNum(blob, 'timestamp') ?? + blobNum(blob, 'createdAt') ?? + blobStr(blob, 'timestamp') ?? + blobStr(blob, 'createdAt') ?? + pendingUserTs ?? + blobNum(blob, 'requestTimestamp'), + ) ?? sessionTimestampFallback + + // --- Token counts with provenance --- + // Prefer per-request input/output fields. Reject implausibly large + // context-window gauges (issue requirement 8-9). + const rawInput = + blobNum(blob, 'inputTokenCount') ?? + blobNum(blob, 'inputTokens') ?? + blobNum(blob, 'promptTokens') ?? + blobNum(blob, 'tokensInput') + + const rawOutput = + blobNum(blob, 'outputTokenCount') ?? + blobNum(blob, 'outputTokens') ?? + blobNum(blob, 'completionTokens') ?? + blobNum(blob, 'tokensOutput') + + const rawCacheCreate = + blobNum(blob, 'cacheCreationInputTokens') ?? + blobNum(blob, 'cacheWriteTokens') ?? 0 + + const rawCacheRead = + blobNum(blob, 'cacheReadInputTokens') ?? + blobNum(blob, 'cacheReadTokens') ?? 0 + + const hasExactInput = isPlausibleExactCount(rawInput) + const hasExactOutput = isPlausibleExactCount(rawOutput) + + const inputTokens = hasExactInput + ? rawInput! + : estimateTokens(pendingUser.length) + const outputTokens = hasExactOutput + ? rawOutput! + : estimateTokens(outputText.length) + const reasoningTokens = estimateTokens(reasoningText.length) + + const cacheCreationTokens = isPlausibleExactCount(rawCacheCreate ?? null) ? (rawCacheCreate ?? 0) : 0 + const cacheReadTokens = isPlausibleExactCount(rawCacheRead ?? null) ? (rawCacheRead ?? 0) : 0 + + // --- Request ID --- + const requestId = blobStr(blob, 'requestId') ?? blobStr(blob, 'id') + + // --- Model --- + const model = blobStr(blob, 'model') ?? blobStr(blob, 'modelId') + + if (outputText || inputTokens > 0 || outputTokens > 0) { + turns.push({ + userMessage: pendingUser, + outputText, + reasoningText, + tools, + inputTokens, + outputTokens, + reasoningTokens, + cacheCreationTokens, + cacheReadTokens, + inputProvenance: hasExactInput ? 'exact' : 'estimated', + outputProvenance: hasExactOutput ? 'exact' : 'estimated', + timestamp: ts, + requestId, + model, + }) + pendingUser = '' + pendingUserTs = null + } + continue + } + + // Container blobs with no role (purely structural). Their children + // are already queued above; nothing to emit here. + } + + return turns +} + +// --------------------------------------------------------------------------- +// store.db: WAL-aware fingerprinting for cache invalidation +// --------------------------------------------------------------------------- + +type StoreFingerprint = { + mtimeMs: number + sizeBytes: number +} + +/** + * Returns the combined fingerprint of the store.db and its WAL sidecar. + * Cursor writes via WAL, so the main file's mtime does not change on writes; + * we must include the WAL in the fingerprint to detect active sessions. + */ +function fingerprintStore(storePath: string): StoreFingerprint | null { + try { + const main = statSync(storePath) + let walMtime = main.mtimeMs + let walSize = 0 + try { + const wal = statSync(storePath + '-wal') + walMtime = Math.max(walMtime, wal.mtimeMs) + walSize = wal.size + } catch { + // No WAL — quiescent database, fine. + } + return { + mtimeMs: walMtime, + sizeBytes: main.size + walSize, + } + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// store.db: session-level result cache +// --------------------------------------------------------------------------- + +// Keyed by session UUID. Entries are invalidated when the store.db fingerprint +// changes (WAL-aware, so active sessions always refresh). +type StoreCacheEntry = { + fp: StoreFingerprint + turns: BlobTurn[] +} + +// --------------------------------------------------------------------------- +// store.db: parser creation +// --------------------------------------------------------------------------- + +/** + * Creates a SessionParser for a single store.db source. + * + * Source path format: "cursor-agent-store::" + * + * Precedence (issue requirement 11): + * store.db (this parser) > JSONL transcript > TXT transcript + * + * If the store decodes successfully, we record a sentinel in seenKeys so the + * transcript parsers for the same session UUID skip themselves. + */ +function createStoreParser( + source: SessionSource, + seenKeys: Set, + storeCache: Map, +): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) return + + // Decode the source path + const rest = source.path.slice(STORE_SOURCE_PREFIX.length) + const lastColon = rest.lastIndexOf(':') + if (lastColon < 0) return + const dbPath = rest.slice(0, lastColon) + const sessionUUID = rest.slice(lastColon + 1) + if (!sessionUUID || !dbPath) return + + const fp = fingerprintStore(dbPath) + if (!fp) return + + // Check cache: hit when fingerprint matches + const cached = storeCache.get(sessionUUID) + let turns: BlobTurn[] + let storeDecodedOk = false + + if (cached && cached.fp.mtimeMs === fp.mtimeMs && cached.fp.sizeBytes === fp.sizeBytes) { + turns = cached.turns + storeDecodedOk = true + } else { + // Parse the store + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + if (isSqliteBusyError(err)) throw err + process.stderr.write( + `codeburn: cannot open Cursor store database ${dbPath}: ${err instanceof Error ? err.message : err}\n`, + ) + return + } + + try { + if (!validateStoreSchema(db)) { + process.stderr.write( + `codeburn: Cursor store.db ${dbPath} is missing expected tables (meta, blobs). Skipping.\n`, + ) + return + } + + const meta = decodeStoreMeta(db) + if (!meta) { + process.stderr.write( + `codeburn: Cursor store.db ${dbPath}: could not decode root metadata. Skipping.\n`, + ) + return + } + + // Verify agentId matches the session UUID from the directory path. + // A mismatch indicates a copy or corrupt layout; skip to avoid + // misattributed sessions. + if (meta.agentId.toLowerCase() !== sessionUUID.toLowerCase()) { + process.stderr.write( + `codeburn: Cursor store.db agentId mismatch (${meta.agentId} vs ${sessionUUID}). Skipping.\n`, + ) + return + } + + const sessionTs = meta.createdAt ? normalizeTimestamp(meta.createdAt) : null + + turns = reconstructBlobGraph(db, meta.latestRootBlobId, sessionTs) + storeDecodedOk = true + + // Populate cache + storeCache.set(sessionUUID, { fp, turns }) + } finally { + db.close() + } + } + + if (!storeDecodedOk || turns.length === 0) return + + // Mark the store as decoded for this session. Transcript parsers for the + // same session UUID will check this and skip themselves. + const storeDecodedSentinel = `${STORE_DECODED_KEY_PREFIX}${sessionUUID.toLowerCase()}` + seenKeys.add(storeDecodedSentinel) + + const sessionModel = resolveModel( + turns.find(t => t.model)?.model ?? source.project ?? null, + ) + + for (let i = 0; i < turns.length; i++) { + const turn = turns[i]! + // Stable dedup key: store-path hash + turn index + const dbHash = createHash('sha1').update(dbPath).digest('hex').slice(0, 12) + const dedupKey = `cursor-agent-store:${sessionUUID}:${dbHash}:${i}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const model = resolveModel(turn.model ?? sessionModel) + const costIsEstimated = turn.inputProvenance === 'estimated' || turn.outputProvenance === 'estimated' + + const costUSD = calculateCost( + costModel(model), + turn.inputTokens, + turn.outputTokens + turn.reasoningTokens, + turn.cacheCreationTokens, + turn.cacheReadTokens, + 0, + ) + + // Timestamp: prefer turn-level; fall back to session createdAt. + // Never use file mtime as an exact request timestamp (issue req 7). + const timestamp = turn.timestamp ?? normalizeTimestamp(source.project) ?? new Date().toISOString() + + yield { + provider: 'cursor-agent', + model, + inputTokens: turn.inputTokens, + outputTokens: turn.outputTokens, + cacheCreationInputTokens: turn.cacheCreationTokens, + cacheReadInputTokens: turn.cacheReadTokens, + cachedInputTokens: turn.cacheReadTokens, + reasoningTokens: turn.reasoningTokens, + webSearchRequests: 0, + costUSD, + costIsEstimated: costIsEstimated ? true : undefined, + tools: turn.tools, + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: turn.userMessage, + sessionId: sessionUUID, + } + } + }, + } +} + +// --------------------------------------------------------------------------- +// store.db: discovery helper +// --------------------------------------------------------------------------- + +/** + * Scans ~/.cursor/chats///store.db for each session. + * Does not follow symlinks beyond the first two directory levels. + * Returns SessionSource entries with path = "cursor-agent-store::". + */ +async function appendStoreSources( + chatsDir: string, + sources: SessionSource[], +): Promise { + let hashDirs: Awaited> + try { + hashDirs = await readdir(chatsDir, { withFileTypes: true }) + } catch { + return + } + + for (const hashEntry of hashDirs) { + // Only process real directories at this level; skip symlinks. + if (!hashEntry.isDirectory()) continue + + const hashDir = join(chatsDir, hashEntry.name) + let sessionDirs: Awaited> + try { + sessionDirs = await readdir(hashDir, { withFileTypes: true }) + } catch { + continue + } + + for (const sessionEntry of sessionDirs) { + // Session directories are named by UUID. + if (!sessionEntry.isDirectory()) continue + if (!UUID_LIKE.test(sessionEntry.name)) continue + + const sessionUUID = sessionEntry.name + const storePath = join(hashDir, sessionEntry.name, 'store.db') + if (!existsSync(storePath)) continue + + sources.push({ + path: `${STORE_SOURCE_PREFIX}${storePath}:${sessionUUID}`, + project: sessionUUID, // refined by metadata at parse time + provider: 'cursor-agent', + sourceKind: undefined, // no special sourceKind needed + }) + } + } +} + +// --------------------------------------------------------------------------- +// Transcript: discovery helper +// --------------------------------------------------------------------------- + async function appendTranscriptSources( scanDir: string, projectId: string, @@ -217,6 +936,10 @@ async function appendTranscriptSources( } } +// --------------------------------------------------------------------------- +// Transcript text extraction helpers +// --------------------------------------------------------------------------- + function extractUserQuery(userBlock: string): string { const chunks: string[] = [] let cursor = 0 @@ -387,7 +1110,11 @@ function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolea return { turns, recognized } } -function createParser( +// --------------------------------------------------------------------------- +// Transcript: parser creation +// --------------------------------------------------------------------------- + +function createTranscriptParser( source: SessionSource, seenKeys: Set, dbPath: string, @@ -397,6 +1124,11 @@ function createParser( async *parse(): AsyncGenerator { const conversationId = toConversationId(source.path) + // Precedence: if a store.db for this session UUID was decoded + // successfully, skip the transcript entirely to avoid double-counting. + const storeDecodedSentinel = `${STORE_DECODED_KEY_PREFIX}${conversationId.toLowerCase()}` + if (seenKeys.has(storeDecodedSentinel)) return + let summary = summariesByConversationId.get(conversationId) let db: SqliteDatabase | null = null @@ -494,12 +1226,21 @@ function createParser( } } +// --------------------------------------------------------------------------- +// Provider factory +// --------------------------------------------------------------------------- + export function createCursorAgentProvider(baseDirOverride?: string): Provider { const baseDir = getCursorAgentBaseDir(baseDirOverride) const projectsDir = getProjectsDir(baseDir) + const chatsDir = getChatsDir(baseDir) const dbPath = getAttributionDbPath(baseDir) const summariesByConversationId = new Map() + // Per-session blob-graph cache: keyed by session UUID. + // Entries are invalidated when the store.db WAL-aware fingerprint changes. + const storeCache = new Map() + return { name: 'cursor-agent', displayName: 'Cursor Agent', @@ -517,36 +1258,46 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider { async probeRoots(): Promise { return [ { path: projectsDir, label: 'projects' }, + { path: chatsDir, label: 'chats' }, { path: dbPath, label: 'db' }, ] }, async discoverSessions(): Promise { - if (!existsSync(projectsDir)) return [] - - const projectEntries = await readdir(projectsDir, { withFileTypes: true }) const sources: SessionSource[] = [] - for (const entry of projectEntries) { - if (!entry.isDirectory()) continue + // 1. store.db sources (highest precedence). Discovered first so their + // sentinel keys are set before transcript parsers run. + await appendStoreSources(chatsDir, sources) - const projectId = prettifyProjectId(entry.name) - const projectDir = join(projectsDir, entry.name) - if (entry.name === 'agent-transcripts') { - await appendTranscriptSources(projectDir, projectId, sources) - continue - } + // 2. Transcript sources (JSONL and TXT). + if (existsSync(projectsDir)) { + const projectEntries = await readdir(projectsDir, { withFileTypes: true }) + + for (const entry of projectEntries) { + if (!entry.isDirectory()) continue + + const projectId = prettifyProjectId(entry.name) + const projectDir = join(projectsDir, entry.name) + if (entry.name === 'agent-transcripts') { + await appendTranscriptSources(projectDir, projectId, sources) + continue + } - const transcriptDir = join(projectDir, 'agent-transcripts') - if (!existsSync(transcriptDir)) continue - await appendTranscriptSources(transcriptDir, projectId, sources) + const transcriptDir = join(projectDir, 'agent-transcripts') + if (!existsSync(transcriptDir)) continue + await appendTranscriptSources(transcriptDir, projectId, sources) + } } return sources }, createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys, dbPath, summariesByConversationId) + if (source.path.startsWith(STORE_SOURCE_PREFIX)) { + return createStoreParser(source, seenKeys, storeCache) + } + return createTranscriptParser(source, seenKeys, dbPath, summariesByConversationId) }, } } diff --git a/tests/providers/cursor-agent-store.test.ts b/tests/providers/cursor-agent-store.test.ts new file mode 100644 index 000000000..84e2bfac5 --- /dev/null +++ b/tests/providers/cursor-agent-store.test.ts @@ -0,0 +1,1198 @@ +/** + * Tests for cursor-agent store.db discovery and reconstruction (issue #986). + * + * Covers all acceptance-criteria items and the test matrix from the issue: + * - Empty chats directory + * - Valid minimal store (single-turn) + * - Multi-turn blob graph + * - Malformed hex metadata + * - Missing / invalid root-blob references + * - Unknown protobuf fields and message block types + * - Seconds vs milliseconds timestamp normalization + * - Exact, partial, gauge-only, and absent token data + * - Store + transcript deduplication (one set of calls per session) + * - Transcript fallback after store decoding fails + * - WAL-aware cache invalidation + * - blobEncryptionKey redaction (must never surface) + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { existsSync, writeFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +import { createCursorAgentProvider } from '../../src/providers/cursor-agent.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall, Provider, SessionSource } from '../../src/providers/types.js' +import { estimateTokensFromChars } from '../../src/token-estimate.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +/** A real 64-hex-char blob ID used throughout the tests. */ +const ROOT_BLOB_ID = 'a'.repeat(64) +const SECOND_BLOB_ID = 'b'.repeat(64) +const THIRD_BLOB_ID = 'c'.repeat(64) +const FOURTH_BLOB_ID = 'd'.repeat(64) + +/** A valid-looking UUID used as the session ID. */ +const SESSION_UUID = '11111111-2222-3333-4444-555555555555' +/** A second session UUID for multi-session tests. */ +const SESSION_UUID_2 = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +function openWritableDb(dbPath: string): TestDb { + const { DatabaseSync } = require('node:sqlite') as { DatabaseSync: new (p: string) => TestDb } + return new DatabaseSync(dbPath) +} + +/** Hex-encodes a UTF-8 string to produce the wire format used in meta['0']. */ +function hexEncodeJson(obj: unknown): string { + const json = JSON.stringify(obj) + return Buffer.from(json, 'utf-8').toString('hex') +} + +/** Creates a minimal valid store.db at the given path. */ +function createMinimalStore( + dbPath: string, + opts: { + agentId?: string + latestRootBlobId?: string + createdAt?: number + lastUsedModel?: string + blobs?: Array<{ id: string; data: unknown }> + blobEncryptionKey?: string + } = {}, +): void { + const db = openWritableDb(dbPath) + // Use DROP TABLE + CREATE to support re-writing the same path in tests. + db.exec('DROP TABLE IF EXISTS meta') + db.exec('DROP TABLE IF EXISTS blobs') + db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)') + db.exec('CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)') + + const metaValue = hexEncodeJson({ + agentId: opts.agentId ?? SESSION_UUID, + latestRootBlobId: opts.latestRootBlobId ?? ROOT_BLOB_ID, + name: 'Test session', + mode: 'agent', + createdAt: opts.createdAt ?? 1_700_000_000_000, + lastUsedModel: opts.lastUsedModel ?? 'claude-4.6-sonnet', + // Include blobEncryptionKey in the raw DB data to test that it never + // leaks out of the provider. + ...(opts.blobEncryptionKey !== undefined ? { blobEncryptionKey: opts.blobEncryptionKey } : { blobEncryptionKey: 'super-secret-key-do-not-leak' }), + }) + db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('0', metaValue) + + for (const blob of opts.blobs ?? []) { + db.prepare('INSERT INTO blobs (id, data) VALUES (?, ?)').run(blob.id, JSON.stringify(blob.data)) + } + + db.close() +} + +/** Creates a single-turn store with one user blob + one assistant blob. */ +function createSingleTurnStore( + dbPath: string, + opts: { + agentId?: string + userText?: string + assistantText?: string + model?: string + inputTokens?: number + outputTokens?: number + timestamp?: number + } = {}, +): void { + const userText = opts.userText ?? 'Hello from user' + const assistantText = opts.assistantText ?? 'Hello from assistant' + const model = opts.model ?? 'claude-4.6-sonnet' + + createMinimalStore(dbPath, { + agentId: opts.agentId, + latestRootBlobId: ROOT_BLOB_ID, + blobs: [ + { + id: ROOT_BLOB_ID, + data: { + role: 'user', + text: userText, + timestamp: opts.timestamp ?? 1_700_000_000_000, + nextBlobId: SECOND_BLOB_ID, + }, + }, + { + id: SECOND_BLOB_ID, + data: { + role: 'assistant', + text: assistantText, + model, + ...(opts.inputTokens !== undefined ? { inputTokens: opts.inputTokens } : {}), + ...(opts.outputTokens !== undefined ? { outputTokens: opts.outputTokens } : {}), + timestamp: opts.timestamp ?? 1_700_000_000_001, + }, + }, + ], + }) +} + +let tempRoots: string[] = [] + +beforeEach(() => { + tempRoots = [] +}) + +afterEach(async () => { + // On Windows, SQLite may hold a brief lock on closed DB files. Retry the + // cleanup a few times before giving up rather than failing the test suite. + for (const dir of tempRoots) { + if (!existsSync(dir)) continue + for (let attempt = 0; attempt < 3; attempt++) { + try { + await rm(dir, { recursive: true, force: true }) + break + } catch { + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + } +}) + +async function makeBaseDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'cursor-agent-store-test-')) + tempRoots.push(dir) + return dir +} + +async function collectCalls( + provider: Provider, + source: SessionSource, + seenKeys: Set = new Set(), +): Promise { + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + calls.push(call) + } + return calls +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +skipUnlessSqlite('cursor-agent store.db: discovery', () => { + it('returns no store sources when chats dir is absent', async () => { + const baseDir = await makeBaseDir() + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + const storeSources = sources.filter(s => s.path.startsWith('cursor-agent-store:')) + expect(storeSources).toHaveLength(0) + }) + + it('returns no store sources when chats dir is empty', async () => { + const baseDir = await makeBaseDir() + await mkdir(join(baseDir, 'chats'), { recursive: true }) + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + const storeSources = sources.filter(s => s.path.startsWith('cursor-agent-store:')) + expect(storeSources).toHaveLength(0) + }) + + it('discovers a store.db under chats///store.db', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'abc123', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + createSingleTurnStore(join(storeDir, 'store.db')) + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + const storeSources = sources.filter(s => s.path.startsWith('cursor-agent-store:')) + + expect(storeSources).toHaveLength(1) + expect(storeSources[0]!.provider).toBe('cursor-agent') + expect(storeSources[0]!.path).toContain(SESSION_UUID) + }) + + it('discovers multiple sessions across different hash dirs', async () => { + const baseDir = await makeBaseDir() + + for (const [hash, uuid] of [['hash1', SESSION_UUID], ['hash2', SESSION_UUID_2]]) { + const storeDir = join(baseDir, 'chats', hash, uuid) + await mkdir(storeDir, { recursive: true }) + createSingleTurnStore(join(storeDir, 'store.db'), { agentId: uuid }) + } + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + const storeSources = sources.filter(s => s.path.startsWith('cursor-agent-store:')) + + expect(storeSources).toHaveLength(2) + }) + + it('skips directories whose name is not a UUID', async () => { + const baseDir = await makeBaseDir() + const hashDir = join(baseDir, 'chats', 'hashXYZ') + await mkdir(join(hashDir, 'not-a-uuid'), { recursive: true }) + writeFileSync(join(hashDir, 'not-a-uuid', 'store.db'), '') + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + const storeSources = sources.filter(s => s.path.startsWith('cursor-agent-store:')) + expect(storeSources).toHaveLength(0) + }) + + it('skips session dirs with no store.db file', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'hashABC', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + // No store.db written + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + const storeSources = sources.filter(s => s.path.startsWith('cursor-agent-store:')) + expect(storeSources).toHaveLength(0) + }) + + it('probeRoots includes the chats directory', async () => { + const baseDir = await makeBaseDir() + const provider = createCursorAgentProvider(baseDir) + const roots = await provider.probeRoots!() + const chatsRoot = roots.find(r => r.label === 'chats') + expect(chatsRoot).toBeDefined() + expect(chatsRoot!.path).toContain('chats') + }) +}) + +skipUnlessSqlite('cursor-agent store.db: valid minimal store', () => { + it('parses a single-turn store and yields one call', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + createSingleTurnStore(join(storeDir, 'store.db')) + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + const storeSources = sources.filter(s => s.path.startsWith('cursor-agent-store:')) + expect(storeSources).toHaveLength(1) + + const calls = await collectCalls(provider, storeSources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.provider).toBe('cursor-agent') + expect(calls[0]!.sessionId).toBe(SESSION_UUID) + expect(calls[0]!.userMessage).toBe('Hello from user') + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) + + it('uses lastUsedModel from metadata when blob has no model', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + lastUsedModel: 'claude-4.6-sonnet', + blobs: [ + { id: ROOT_BLOB_ID, data: { role: 'user', text: 'hi', nextBlobId: SECOND_BLOB_ID } }, + { id: SECOND_BLOB_ID, data: { role: 'assistant', text: 'hello' } }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + // Model in blob takes precedence; if absent the session model is used + expect(calls[0]!.model).not.toBe('') + }) + + it('emits costUSD > 0 for a turn with tokens', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + createSingleTurnStore(join(storeDir, 'store.db'), { inputTokens: 100, outputTokens: 200 }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) +}) + +skipUnlessSqlite('cursor-agent store.db: multi-turn blob graph', () => { + it('reconstructs three turns in order', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + latestRootBlobId: ROOT_BLOB_ID, + blobs: [ + { + id: ROOT_BLOB_ID, + data: { role: 'user', text: 'Turn 1 user', nextBlobId: SECOND_BLOB_ID }, + }, + { + id: SECOND_BLOB_ID, + data: { role: 'assistant', text: 'Turn 1 assistant', nextBlobId: THIRD_BLOB_ID }, + }, + { + id: THIRD_BLOB_ID, + data: { role: 'user', text: 'Turn 2 user', nextBlobId: FOURTH_BLOB_ID }, + }, + { + id: FOURTH_BLOB_ID, + data: { role: 'assistant', text: 'Turn 2 assistant' }, + }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('Turn 1 user') + expect(calls[1]!.userMessage).toBe('Turn 2 user') + }) + + it('follows childBlobIds for branching graphs', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + // Root is a container blob with childBlobIds instead of nextBlobId + createMinimalStore(join(storeDir, 'store.db'), { + latestRootBlobId: ROOT_BLOB_ID, + blobs: [ + { + id: ROOT_BLOB_ID, + data: { childBlobIds: [SECOND_BLOB_ID, THIRD_BLOB_ID] }, + }, + { + id: SECOND_BLOB_ID, + data: { role: 'user', text: 'child user' }, + }, + { + id: THIRD_BLOB_ID, + data: { role: 'assistant', text: 'child assistant' }, + }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + // Should reconstruct at least one turn from the children + expect(calls.length).toBeGreaterThanOrEqual(1) + }) + + it('deduplication keys are stable across re-parses', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + createSingleTurnStore(join(storeDir, 'store.db')) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + + const calls1 = await collectCalls(provider, sources[0]!) + const calls2 = await collectCalls(provider, sources[0]!, new Set()) + expect(calls1[0]!.deduplicationKey).toBe(calls2[0]!.deduplicationKey) + }) + + it('respects seenKeys and does not re-yield already-seen dedup keys', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + createSingleTurnStore(join(storeDir, 'store.db')) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + + // First parse builds the seenKeys set + const seenKeys = new Set() + const calls1 = await collectCalls(provider, sources[0]!, seenKeys) + expect(calls1).toHaveLength(1) + + // Second parse reuses the same seenKeys — already consumed + const calls2 = await collectCalls(provider, sources[0]!, seenKeys) + expect(calls2).toHaveLength(0) + }) +}) + +skipUnlessSqlite('cursor-agent store.db: malformed metadata', () => { + it('skips a store where meta["0"] is not hex', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const db = openWritableDb(join(storeDir, 'store.db')) + db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)') + db.exec('CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)') + db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('0', '!!not-hex!!') + db.close() + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const calls = await collectCalls(provider, sources[0]!) + stderrSpy.mockRestore() + expect(calls).toHaveLength(0) + }) + + it('skips a store where hex decodes to invalid JSON', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const db = openWritableDb(join(storeDir, 'store.db')) + db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)') + db.exec('CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)') + // Hex encodes '{not json' (invalid JSON) + const badHex = Buffer.from('{not json', 'utf-8').toString('hex') + db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('0', badHex) + db.close() + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const calls = await collectCalls(provider, sources[0]!) + stderrSpy.mockRestore() + expect(calls).toHaveLength(0) + }) + + it('skips a store where agentId is missing from metadata', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const db = openWritableDb(join(storeDir, 'store.db')) + db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)') + db.exec('CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)') + // Valid hex JSON but missing agentId + db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run( + '0', + hexEncodeJson({ latestRootBlobId: ROOT_BLOB_ID, name: 'no-agent-id' }), + ) + db.close() + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const calls = await collectCalls(provider, sources[0]!) + stderrSpy.mockRestore() + expect(calls).toHaveLength(0) + }) + + it('skips a store where latestRootBlobId is not 64 hex chars', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + latestRootBlobId: 'short-invalid-id', + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const calls = await collectCalls(provider, sources[0]!) + stderrSpy.mockRestore() + expect(calls).toHaveLength(0) + }) + + it('skips a store where agentId mismatches the directory UUID', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + // Write a store with a different agentId than the directory UUID + createSingleTurnStore(join(storeDir, 'store.db'), { agentId: SESSION_UUID_2 }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const calls = await collectCalls(provider, sources[0]!) + stderrSpy.mockRestore() + expect(calls).toHaveLength(0) + }) + + it('skips a store missing the meta table', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const db = openWritableDb(join(storeDir, 'store.db')) + db.exec('CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)') + // No meta table + db.close() + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const calls = await collectCalls(provider, sources[0]!) + stderrSpy.mockRestore() + expect(calls).toHaveLength(0) + }) + + it('skips a store missing the blobs table', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const db = openWritableDb(join(storeDir, 'store.db')) + db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)') + // No blobs table + db.close() + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const calls = await collectCalls(provider, sources[0]!) + stderrSpy.mockRestore() + expect(calls).toHaveLength(0) + }) +}) + +skipUnlessSqlite('cursor-agent store.db: missing or invalid blobs', () => { + it('yields no calls when root blob is absent from blobs table', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + // meta points to ROOT_BLOB_ID but blobs table is empty + createMinimalStore(join(storeDir, 'store.db'), { blobs: [] }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(0) + }) + + it('tolerates a broken nextBlobId reference and still yields turns from reachable blobs', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const MISSING_BLOB = 'f'.repeat(64) + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { + id: ROOT_BLOB_ID, + data: { role: 'user', text: 'first turn', nextBlobId: SECOND_BLOB_ID }, + }, + { + id: SECOND_BLOB_ID, + data: { + role: 'assistant', + text: 'first reply', + // Points to a blob that does not exist + nextBlobId: MISSING_BLOB, + }, + }, + // MISSING_BLOB is intentionally absent + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + // Should still yield the one turn we could reconstruct + expect(calls).toHaveLength(1) + }) + + it('silently skips blobs with invalid JSON data', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const db = openWritableDb(join(storeDir, 'store.db')) + db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)') + db.exec('CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)') + db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run( + '0', + hexEncodeJson({ + agentId: SESSION_UUID, + latestRootBlobId: ROOT_BLOB_ID, + name: 'test', + createdAt: 1_700_000_000_000, + }), + ) + // First blob has corrupt JSON + db.prepare('INSERT INTO blobs (id, data) VALUES (?, ?)').run(ROOT_BLOB_ID, '{corrupt json') + db.close() + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(0) + }) +}) + +skipUnlessSqlite('cursor-agent store.db: unknown fields and future-proofing', () => { + it('ignores unknown fields in blob objects without crashing', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { + id: ROOT_BLOB_ID, + data: { + role: 'user', + text: 'query', + unknownProtoField: 42, + futureFeature: { nested: true }, + nextBlobId: SECOND_BLOB_ID, + }, + }, + { + id: SECOND_BLOB_ID, + data: { + role: 'assistant', + text: 'response', + unknownV2Field: 'something', + anotherFutureField: [1, 2, 3], + }, + }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe('query') + }) + + it('handles content-block array format for text extraction', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { + id: ROOT_BLOB_ID, + data: { + role: 'user', + content: [{ type: 'text', text: 'block-format user msg' }], + nextBlobId: SECOND_BLOB_ID, + }, + }, + { + id: SECOND_BLOB_ID, + data: { + role: 'assistant', + content: [ + { type: 'text', text: 'block-format assistant reply' }, + { type: 'tool_use', name: 'read_file' }, + ], + }, + }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.userMessage).toBe('block-format user msg') + expect(calls[0]!.tools).toContain('cursor:read_file') + }) + + it('handles toolCalls array format for tool extraction', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { + id: ROOT_BLOB_ID, + data: { role: 'user', text: 'do something', nextBlobId: SECOND_BLOB_ID }, + }, + { + id: SECOND_BLOB_ID, + data: { + role: 'assistant', + text: 'done', + toolCalls: [{ name: 'Edit File' }, { name: 'Run Terminal' }], + }, + }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toContain('cursor:edit-file') + expect(calls[0]!.tools).toContain('cursor:run-terminal') + }) +}) + +skipUnlessSqlite('cursor-agent store.db: timestamp normalization', () => { + it('uses epoch-milliseconds timestamp (> 1e12)', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const MS_TIMESTAMP = 1_700_000_000_000 // 2023-11-14 in ms + createSingleTurnStore(join(storeDir, 'store.db'), { timestamp: MS_TIMESTAMP }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe(new Date(MS_TIMESTAMP).toISOString()) + }) + + it('converts epoch-seconds timestamp (< 1e12) to ISO', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const SEC_TIMESTAMP = 1_700_000_000 // seconds + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { + id: ROOT_BLOB_ID, + data: { role: 'user', text: 'hi', nextBlobId: SECOND_BLOB_ID }, + }, + { + id: SECOND_BLOB_ID, + data: { + role: 'assistant', + text: 'hello', + timestamp: SEC_TIMESTAMP, // seconds — should be promoted to ms + }, + }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe(new Date(SEC_TIMESTAMP * 1000).toISOString()) + }) + + it('falls back to session createdAt when no turn-level timestamp exists', async () => { + const SESSION_CREATED_MS = 1_690_000_000_000 + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + createdAt: SESSION_CREATED_MS, + blobs: [ + { id: ROOT_BLOB_ID, data: { role: 'user', text: 'no ts', nextBlobId: SECOND_BLOB_ID } }, + { id: SECOND_BLOB_ID, data: { role: 'assistant', text: 'no ts either' } }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe(new Date(SESSION_CREATED_MS).toISOString()) + }) +}) + +skipUnlessSqlite('cursor-agent store.db: token count provenance', () => { + it('uses exact token counts when both are present and plausible', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createSingleTurnStore(join(storeDir, 'store.db'), { + inputTokens: 150, + outputTokens: 300, + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(150) + expect(calls[0]!.outputTokens).toBe(300) + // costIsEstimated should be absent or falsy when counts are exact + expect(calls[0]!.costIsEstimated).toBeFalsy() + }) + + it('estimates input tokens when inputTokens is absent', async () => { + const userText = 'A'.repeat(400) // 400 chars → 100 estimated tokens + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { id: ROOT_BLOB_ID, data: { role: 'user', text: userText, nextBlobId: SECOND_BLOB_ID } }, + { id: SECOND_BLOB_ID, data: { role: 'assistant', text: 'ok', outputTokens: 5 } }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + // Estimated input from 400 chars + expect(calls[0]!.inputTokens).toBe(estimateTokensFromChars(400)) + // Output is exact (5) + expect(calls[0]!.outputTokens).toBe(5) + // costIsEstimated should be set because input was estimated + expect(calls[0]!.costIsEstimated).toBe(true) + }) + + it('estimates output tokens when outputTokens is absent', async () => { + const assistantText = 'B'.repeat(800) + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { id: ROOT_BLOB_ID, data: { role: 'user', text: 'hi', nextBlobId: SECOND_BLOB_ID } }, + { + id: SECOND_BLOB_ID, + data: { + role: 'assistant', + text: assistantText, + inputTokens: 10, + // no outputTokens + }, + }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(10) + expect(calls[0]!.outputTokens).toBe(estimateTokensFromChars(assistantText.length)) + expect(calls[0]!.costIsEstimated).toBe(true) + }) + + it('treats implausibly large token count as gauge and estimates instead', async () => { + const userText = 'C'.repeat(200) + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { + id: ROOT_BLOB_ID, + data: { role: 'user', text: userText, nextBlobId: SECOND_BLOB_ID }, + }, + { + id: SECOND_BLOB_ID, + data: { + role: 'assistant', + text: 'response', + // 3 million tokens is implausible for a single request → treated as gauge + inputTokens: 3_000_000, + outputTokens: 100, + }, + }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + // Input is estimated (gauge rejected), output exact + expect(calls[0]!.inputTokens).toBe(estimateTokensFromChars(userText.length)) + expect(calls[0]!.outputTokens).toBe(100) + expect(calls[0]!.costIsEstimated).toBe(true) + }) + + it('records cache-read and cache-creation token counts', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { id: ROOT_BLOB_ID, data: { role: 'user', text: 'cache test', nextBlobId: SECOND_BLOB_ID } }, + { + id: SECOND_BLOB_ID, + data: { + role: 'assistant', + text: 'cached response', + inputTokens: 50, + outputTokens: 80, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, + }, + }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.cacheReadInputTokens).toBe(200) + expect(calls[0]!.cacheCreationInputTokens).toBe(100) + }) + + it('all-absent token data still yields a turn with estimated counts', async () => { + const userText = 'D'.repeat(120) + const assistantText = 'E'.repeat(240) + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + createMinimalStore(join(storeDir, 'store.db'), { + blobs: [ + { id: ROOT_BLOB_ID, data: { role: 'user', text: userText, nextBlobId: SECOND_BLOB_ID } }, + { id: SECOND_BLOB_ID, data: { role: 'assistant', text: assistantText } }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(estimateTokensFromChars(userText.length)) + expect(calls[0]!.outputTokens).toBe(estimateTokensFromChars(assistantText.length)) + expect(calls[0]!.costIsEstimated).toBe(true) + }) +}) + +skipUnlessSqlite('cursor-agent store.db: store + transcript deduplication', () => { + it('store and matching transcript produce one set of calls', async () => { + const baseDir = await makeBaseDir() + + // Set up store.db + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + createSingleTurnStore(join(storeDir, 'store.db')) + + // Set up matching transcript (same session UUID) + const transcriptDir = join(baseDir, 'projects', 'my-proj', 'agent-transcripts', SESSION_UUID) + await mkdir(transcriptDir, { recursive: true }) + await writeFile( + join(transcriptDir, `${SESSION_UUID}.jsonl`), + JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: 'transcript query' }] } }) + '\n' + + JSON.stringify({ role: 'assistant', message: { content: [{ type: 'text', text: 'transcript answer' }] } }) + '\n', + ) + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + // Parse ALL sources with a single shared seenKeys set (as the real parser does) + const seenKeys = new Set() + const allCalls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + allCalls.push(call) + } + } + + // Store sources yield calls, transcript is suppressed by the sentinel + const storeCalls = allCalls.filter(c => c.deduplicationKey.startsWith('cursor-agent-store:')) + const transcriptCalls = allCalls.filter(c => c.deduplicationKey.startsWith('cursor-agent:')) + expect(storeCalls.length).toBeGreaterThan(0) + expect(transcriptCalls).toHaveLength(0) + }) + + it('transcript is used when store decoding fails (schema missing)', async () => { + const baseDir = await makeBaseDir() + + // Create a broken store (no meta table) + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + const db = openWritableDb(join(storeDir, 'store.db')) + db.exec('CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)') + // No meta table — schema invalid + db.close() + + // Set up matching transcript + const transcriptDir = join(baseDir, 'projects', 'fallback-proj', 'agent-transcripts', SESSION_UUID) + await mkdir(transcriptDir, { recursive: true }) + await writeFile( + join(transcriptDir, `${SESSION_UUID}.jsonl`), + JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: 'fallback query' }] } }) + '\n' + + JSON.stringify({ role: 'assistant', message: { content: [{ type: 'text', text: 'fallback answer' }] } }) + '\n', + ) + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + const seenKeys = new Set() + const allCalls: ParsedProviderCall[] = [] + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + allCalls.push(call) + } + } + stderrSpy.mockRestore() + + // Store failed → transcript should produce calls + const transcriptCalls = allCalls.filter(c => c.deduplicationKey.startsWith('cursor-agent:')) + expect(transcriptCalls).toHaveLength(1) + expect(transcriptCalls[0]!.userMessage).toBe('fallback query') + }) + + it('store-only session (no matching transcript) appears in results', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + createSingleTurnStore(join(storeDir, 'store.db')) + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + const seenKeys = new Set() + const allCalls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + allCalls.push(call) + } + } + + expect(allCalls.length).toBeGreaterThan(0) + expect(allCalls[0]!.sessionId).toBe(SESSION_UUID) + }) +}) + +skipUnlessSqlite('cursor-agent store.db: WAL-aware cache invalidation', () => { + it('re-parses the store when the main file changes', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + const storePath = join(storeDir, 'store.db') + createSingleTurnStore(storePath, { userText: 'first version' }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + + const calls1 = await collectCalls(provider, sources[0]!, new Set()) + expect(calls1[0]!.userMessage).toBe('first version') + + // Overwrite with a new store (different size ensures fingerprint changes). + // createMinimalStore uses DROP TABLE IF EXISTS so writing to the same path works. + createSingleTurnStore(storePath, { + userText: 'second version — updated content with extra padding to guarantee a different file size', + }) + + const calls2 = await collectCalls(provider, sources[0]!, new Set()) + expect(calls2[0]!.userMessage).toBe( + 'second version — updated content with extra padding to guarantee a different file size', + ) + }) + + it('reuses the in-memory cache when fingerprint is unchanged', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + const storePath = join(storeDir, 'store.db') + createSingleTurnStore(storePath) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + + // Spy on openDatabase to count how many times the store is opened + const sqliteModule = await import('../../src/sqlite.js') + const openSpy = vi.spyOn(sqliteModule, 'openDatabase') + + await collectCalls(provider, sources[0]!, new Set()) + const openCount1 = openSpy.mock.calls.length + + // Second parse on the same provider instance should hit the cache + await collectCalls(provider, sources[0]!, new Set()) + const openCount2 = openSpy.mock.calls.length + + // The DB should not be re-opened on the second call + expect(openCount2).toBe(openCount1) + openSpy.mockRestore() + }) +}) + +skipUnlessSqlite('cursor-agent store.db: blobEncryptionKey redaction', () => { + it('does not include blobEncryptionKey in any emitted call field', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const SECRET_KEY = 'super-secret-encryption-key-12345' + // Write directly with blobEncryptionKey present (no double-write needed) + createMinimalStore(join(storeDir, 'store.db'), { + blobEncryptionKey: SECRET_KEY, + blobs: [ + { id: ROOT_BLOB_ID, data: { role: 'user', text: 'redact test', nextBlobId: SECOND_BLOB_ID } }, + { id: SECOND_BLOB_ID, data: { role: 'assistant', text: 'answer', inputTokens: 10, outputTokens: 20 } }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + const calls = await collectCalls(provider, sources[0]!) + + // The secret must not appear in any field of any emitted call + for (const call of calls) { + const serialized = JSON.stringify(call) + expect(serialized).not.toContain(SECRET_KEY) + } + }) + + it('does not write blobEncryptionKey to stderr', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const SECRET_KEY = 'leak-test-key-xyz-98765' + createMinimalStore(join(storeDir, 'store.db'), { + blobEncryptionKey: SECRET_KEY, + blobs: [ + { id: ROOT_BLOB_ID, data: { role: 'user', text: 'hi', nextBlobId: SECOND_BLOB_ID } }, + { id: SECOND_BLOB_ID, data: { role: 'assistant', text: 'ok' } }, + ], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = (await provider.discoverSessions()).filter(s => s.path.startsWith('cursor-agent-store:')) + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + await collectCalls(provider, sources[0]!) + const stderrOutput = stderrSpy.mock.calls.map(c => String(c[0] ?? '')).join('') + stderrSpy.mockRestore() + + expect(stderrOutput).not.toContain(SECRET_KEY) + }) + + it('does not include blobEncryptionKey in the source path', async () => { + const baseDir = await makeBaseDir() + const storeDir = join(baseDir, 'chats', 'h1', SESSION_UUID) + await mkdir(storeDir, { recursive: true }) + + const SECRET_KEY = 'path-leak-test-key-99999' + createMinimalStore(join(storeDir, 'store.db'), { + blobEncryptionKey: SECRET_KEY, + blobs: [], + }) + + const provider = createCursorAgentProvider(baseDir) + const sources = await provider.discoverSessions() + + for (const source of sources) { + expect(source.path).not.toContain(SECRET_KEY) + } + }) +})