diff --git a/src/providers/cursor-agent.ts b/src/providers/cursor-agent.ts
index 3a20d743..cb66f9b0 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 00000000..84e2bfac
--- /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)
+ }
+ })
+})