diff --git a/packages/cli/src/providers/cursor.ts b/packages/cli/src/providers/cursor.ts index 16fc90f2..769522e8 100644 --- a/packages/cli/src/providers/cursor.ts +++ b/packages/cli/src/providers/cursor.ts @@ -5,9 +5,17 @@ import { homedir } from 'os' import { extractBashCommands } from '../bash-utils.js' import { readCachedResults, writeCachedResults } from '../cursor-cache.js' import { isSqliteAvailable, isSqliteBusyError, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' -import { estimateTokensFromChars } from '../token-estimate.js' import type { DateRange } from '../types.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { DecodeContext } from '@codeburn/core' +import { decodeCursor } from '@codeburn/core/providers/cursor' +import type { + CursorBubbleRow, + CursorAgentKvRow, + CursorUserMessageRow, + CursorComposerMetaRow, + CursorDecodedCall, +} from '@codeburn/core/providers/cursor' /** Matches cli-date.ts "all" period cap (6 months). */ const CURSOR_MAX_LOOKBACK_MONTHS = 6 @@ -43,29 +51,6 @@ const modelDisplayNames: Record = { 'cursor-auto': 'Cursor (auto)', } -type BubbleRow = { - bubble_key: string - input_tokens: number | null - output_tokens: number | null - model: string | null - created_at: string | null - request_id: string | null - user_text: Uint8Array | string | null - text_length: number | null - bubble_type: number | null - code_blocks: Uint8Array | string | null - /// Only populated on the paged scan path (BUBBLE_QUERY_PAGE) used for very - /// large databases; undefined on the un-paged BUBBLE_QUERY_SINCE path. - rid?: number -} - -type AgentKvRow = { - role: string | null - content: Uint8Array | string | null - request_id: string | null - model: string | null -} - // SQLITE_BUSY must reach parser.ts, whose busy path skips the source without // caching; swallowing it here would stamp a silently degraded parse into the // results cache under an unchanged DB fingerprint (Cursor writes via WAL, so @@ -233,27 +218,6 @@ function loadWorkspaceMap(workspaceStorageDir: string): WorkspaceMapping { return result } -/// Pulls the composer id out of a `bubbleId::` key. -/// Returns null when the composer segment contains a CR/LF, which is the -/// signature Cursor uses for tool-call sub-composer rows in real data — -/// e.g. `bubbleId:task-call_xxxx\nfc_yyyy:` is one key with a -/// literal newline between the `task-call_` and `fc_` halves. Those rows -/// are not standalone composers and would otherwise inflate the orphan -/// project's session count. -function parseComposerIdFromKey(key: string | undefined): string | null { - if (!key) return null - const firstColon = key.indexOf(':') - if (firstColon < 0) return null - const secondColon = key.indexOf(':', firstColon + 1) - if (secondColon < 0) return null - const candidate = key.slice(firstColon + 1, secondColon) - if (!candidate) return null - // Reject any multi-line / control-char composer id. Real composer ids - // (UUIDs) and synthetic fixture ids are both single-line. - if (/[\r\n\x00]/.test(candidate)) return null - return candidate -} - // Encodes the active workspace into source.path so the parser knows which // composers to filter for. `#cursor-ws=` is a private separator: `state.vscdb` // does not contain `#` (we construct the path ourselves), and the literal @@ -277,25 +241,6 @@ function decodeSourcePath(sourcePath: string): { dbPath: string; workspaceTag: s } } -type CodeBlock = { languageId?: string } - -function extractLanguages(codeBlocksJson: string | null): string[] { - if (!codeBlocksJson) return [] - try { - const blocks = JSON.parse(codeBlocksJson) as CodeBlock[] - if (!Array.isArray(blocks)) return [] - const langs = new Set() - for (const block of blocks) { - if (block.languageId && block.languageId !== 'plaintext') { - langs.add(block.languageId) - } - } - return [...langs] - } catch { - return [] - } -} - function resolveModel(raw: string | null): string { if (!raw || raw === 'default') return CURSOR_COST_MODEL return raw @@ -381,6 +326,23 @@ const BUBBLE_QUERY_PAGE = ` LIMIT ? ` +// Cursor leaves the per-bubble tokenCount at {0,0} on current builds. The only +// real input figure on disk is the latest context-window snapshot, which Cursor +// records in composerData.promptTokenBreakdown.totalUsedTokens or +// contextTokensUsed (the in-app context meter). This is not cumulative per-turn, +// so local SQLite undercounts admin-console usage; parity requires the opt-in +// Cursor Admin API: POST api.cursor.com/teams/filtered-usage-events. +// The key-range predicate seeks the primary key instead of scanning the table. +const COMPOSER_META_QUERY = ` + SELECT + substr(key, length('composerData:') + 1) as composer_id, + json_extract(value, '$.promptTokenBreakdown.totalUsedTokens') as used, + json_extract(value, '$.contextTokensUsed') as ctx, + json_extract(value, '$.createdAt') as created_at + FROM cursorDiskKV + WHERE key >= 'composerData:' AND key < 'composerData;' +` + function validateSchema(db: SqliteDatabase): boolean { try { const rows = db.query<{ cnt: number }>( @@ -393,48 +355,6 @@ function validateSchema(db: SqliteDatabase): boolean { } } -type UserMsgRow = { bubble_key: string; created_at: string; text: Uint8Array | string } - -/// Per-conversation user-message buffer. We pop messages in arrival order via -/// the `pos` cursor — a previous implementation called Array.shift() which is -/// O(n) per call on large conversations and pinned multi-GB Cursor DBs at -/// minutes-of-parse for power users. The cursor walk is O(1). -type UserMessageQueue = { - messages: string[] - pos: number -} - -function buildUserMessageMap(db: SqliteDatabase, timeFloor: string): Map { - const map = new Map() - try { - const rows = db.query(USER_MESSAGES_QUERY, [timeFloor]) - for (const row of rows) { - // Extract the composerId from the bubble key, matching parseBubbles(). - // The JSON `conversationId` field is empty in current Cursor builds. - const composerId = parseComposerIdFromKey(row.bubble_key) - if (!composerId || !row.text) continue - const text = blobToText(row.text) - const existing = map.get(composerId) - if (existing) { - existing.messages.push(text) - } else { - map.set(composerId, { messages: [text], pos: 0 }) - } - } - } catch (err) { - rethrowBusy(err) - } - return map -} - -function takeUserMessage(queues: Map, conversationId: string): string { - const queue = queues.get(conversationId) - if (!queue || queue.pos >= queue.messages.length) return '' - const msg = queue.messages[queue.pos] - queue.pos += 1 - return msg -} - /// Scans bubbles for very large DBs by paging ROWID-descending (newest first), /// keeping only rows within the requested window (createdAt > timeFloor), and /// stopping once a full page lands below the floor. A `budget` caps the number @@ -445,16 +365,16 @@ function scanBubblesPaged( db: SqliteDatabase, timeFloor: string, budget: number, -): { rows: BubbleRow[]; truncated: boolean } { +): { rows: CursorBubbleRow[]; truncated: boolean } { const BATCH = 25_000 - const collected: BubbleRow[] = [] + const collected: CursorBubbleRow[] = [] let beforeRowId = Number.MAX_SAFE_INTEGER let truncated = false paging: while (true) { - let batch: BubbleRow[] + let batch: CursorBubbleRow[] try { - batch = db.query(BUBBLE_QUERY_PAGE, [beforeRowId, BATCH]) + batch = db.query(BUBBLE_QUERY_PAGE, [beforeRowId, BATCH]) } catch (err) { rethrowBusy(err) break @@ -480,461 +400,31 @@ function scanBubblesPaged( return { rows: collected, truncated } } -// Cursor leaves the per-bubble tokenCount at {0,0} on current builds. The only -// real input figure on disk is the latest context-window snapshot, which Cursor -// records in composerData.promptTokenBreakdown.totalUsedTokens or -// contextTokensUsed (the in-app context meter). This is not cumulative per-turn, -// so local SQLite undercounts admin-console usage; parity requires the opt-in -// Cursor Admin API: POST api.cursor.com/teams/filtered-usage-events. -// The key-range predicate seeks the primary key instead of scanning the table. -const COMPOSER_META_QUERY = ` - SELECT - substr(key, length('composerData:') + 1) as composer_id, - json_extract(value, '$.promptTokenBreakdown.totalUsedTokens') as used, - json_extract(value, '$.contextTokensUsed') as ctx, - json_extract(value, '$.createdAt') as created_at - FROM cursorDiskKV - WHERE key >= 'composerData:' AND key < 'composerData;' -` - -type ComposerMeta = { tokens: number; createdAt: number | null } - -function loadComposerMeta(db: SqliteDatabase): Map { - const map = new Map() - try { - const rows = db.query<{ composer_id: string; used: number | null; ctx: number | null; created_at: number | null }>(COMPOSER_META_QUERY) - for (const r of rows) { - // `||` rather than `??`: a recorded-but-zero breakdown must fall through - // to the context meter instead of shadowing it. - const tokens = (r.used || r.ctx) ?? 0 - if (r.composer_id && tokens > 0) map.set(r.composer_id, { tokens, createdAt: r.created_at ?? null }) - } - } catch (err) { - rethrowBusy(err) - /* best-effort: callers fall back to the per-bubble text estimate */ - } - return map -} - -type AgentStream = { - tools: string[] - bash: string[] - userChars: number - contextChars: number - assistantChars: number - model: string | null -} - -function newAgentStream(): AgentStream { - return { tools: [], bash: [], userChars: 0, contextChars: 0, assistantChars: 0, model: null } -} - -// agentKv rows store content as a plain string or a block array; count only -// the text inside blocks so the JSON envelope and non-text parts are not -// billed as prompt characters. -function contentTextLength(raw: string): number { - const trimmed = raw.trimStart() - if (trimmed.startsWith('[') || trimmed.startsWith('{')) { - try { - const parsed = JSON.parse(trimmed) as unknown - const blocks = Array.isArray(parsed) ? parsed : [parsed] - let len = 0 - for (const block of blocks) { - if (block == null || typeof block !== 'object') continue - const b = block as { text?: unknown; content?: unknown } - if (typeof b.text === 'string') len += b.text.length - else if (typeof b.content === 'string') len += b.content.length - } - return len - } catch { - return raw.length - } - } - return raw.length -} - -// Cursor logs the agent's stream (prompt, injected context, tool calls, reply -// deltas) in agentKv blobs keyed by requestId. Bubbles carry the same -// requestId, so the map built from the scanned bubbles joins each request to -// its conversation. Requests with no matching bubble are kept separately: -// they are real sessions (background runs, older builds) that would otherwise -// vanish from totals. -function loadAgentStreams( - db: SqliteDatabase, - requestToComposer: Map, -): { byComposer: Map; unjoined: Map } { - const byComposer = new Map() - const unjoined = new Map() - - let rows: AgentKvRow[] - try { - rows = db.query(AGENTKV_QUERY) - } catch (err) { - rethrowBusy(err) - return { byComposer, unjoined } - } - - const bucketFor = (requestId: string): AgentStream => { - const composer = requestToComposer.get(requestId) - const map = composer ? byComposer : unjoined - const key = composer ?? requestId - const existing = map.get(key) - if (existing) return existing - const fresh = newAgentStream() - map.set(key, fresh) - return fresh - } - - // Only the turn-opening (user) agentKv row carries the requestId; rows that - // follow inherit it. Rows written BEFORE their request's id appears (the - // system prompt and opening user prompt at a conversation start) buffer - // until the next id, and a system row closes the previous request so - // interleaved sessions cannot inherit across a conversation boundary. - let currentRequestId: string | null = null - let pendingUserChars = 0 - let pendingContextChars = 0 - for (const row of rows) { - if (row.request_id) { - currentRequestId = row.request_id - if (pendingUserChars > 0 || pendingContextChars > 0) { - const bucket = bucketFor(currentRequestId) - bucket.userChars += pendingUserChars - bucket.contextChars += pendingContextChars - pendingUserChars = 0 - pendingContextChars = 0 - } - } - if (row.model && currentRequestId) { - const bucket = bucketFor(currentRequestId) - if (!bucket.model) bucket.model = row.model - } - if (!row.content) continue - - if (row.role === 'system') { - pendingContextChars += contentTextLength(blobToText(row.content)) - currentRequestId = null - continue - } - if (row.role === 'user') { - const len = contentTextLength(blobToText(row.content)) - if (currentRequestId) bucketFor(currentRequestId).userChars += len - else pendingUserChars += len - continue - } - if (row.role === 'tool') { - if (currentRequestId) bucketFor(currentRequestId).contextChars += contentTextLength(blobToText(row.content)) - continue - } - if (row.role !== 'assistant' || !currentRequestId) continue - - let content: unknown - try { - content = JSON.parse(blobToText(row.content)) - } catch { - continue - } - if (!Array.isArray(content)) continue - const bucket = bucketFor(currentRequestId) - for (const block of content as Array<{ type?: string; text?: unknown; toolName?: unknown; args?: { command?: unknown } }>) { - if (block == null || typeof block !== 'object') continue - if (typeof block.text === 'string') bucket.assistantChars += block.text.length - if (block.type !== 'tool-call' || typeof block.toolName !== 'string' || !block.toolName) continue - // Cursor's terminal tool is 'Shell'; emit the canonical 'Bash' so the - // cross-provider tool and command breakdowns merge. - bucket.tools.push(block.toolName === 'Shell' ? 'Bash' : block.toolName) - if (block.toolName === 'Shell' && typeof block.args?.command === 'string') { - bucket.bash.push(...extractBashCommands(block.args.command)) - } - } - } - return { byComposer, unjoined } -} - -// What drives a conversation's input figure, decided once per conversation so -// the sources can never stack on each other: -// bubbleTokens - some bubble carries a real tokenCount (older builds), so -// per-turn counts are authoritative and nothing is estimated. -// meter - the composerData context meter exists; one conversation -// record carries it. -// stream - no meter, but the agent stream holds the prompt/context; one -// conversation record carries the estimate. -// text - only visible bubble text exists; estimated per bubble. -type InputSource = 'bubbleTokens' | 'meter' | 'stream' | 'text' - -type ComposerScan = { - hasRealTokens: boolean - firstBubbleTs: string | null - assistantTextChars: number - model: string | null -} - -function parseBubbles( - db: SqliteDatabase, - seenKeys: Set, - timeFloor: string, - agentKvTimestamp: string, -): { calls: ParsedProviderCall[] } { - const results: ParsedProviderCall[] = [] - let skipped = 0 - - const composerMeta = loadComposerMeta(db) - - // The bubble timestamp lives inside the JSON value (no index), so the date - // filter forces a full JSON decode per row. Multi-GB Cursor DBs (500k+ - // bubbles) were producing 30s+ parse stalls, so the scan is bounded. The old - // approach kept only the most-recent MAX_BUBBLES by ROWID, which dropped - // in-range older sessions and warned even when the requested window fit - // comfortably. Instead, for large DBs we page the requested window - // (ROWID-descending, stopping past the window floor) and only fall back to a - // hard budget — warning — when the in-range scan genuinely exceeds it. - // Override the budget in tests via CODEBURN_CURSOR_MAX_BUBBLES. - const MAX_BUBBLES = Number(process.env['CODEBURN_CURSOR_MAX_BUBBLES']) || 250_000 - - let total = 0 - try { - const countRows = db.query<{ cnt: number }>( - "SELECT COUNT(*) as cnt FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'" - ) - total = countRows[0]?.cnt ?? 0 - } catch (err) { - rethrowBusy(err) - } - - let rows: BubbleRow[] - try { - if (total > MAX_BUBBLES) { - const scan = scanBubblesPaged(db, timeFloor, MAX_BUBBLES) - rows = scan.rows - if (scan.truncated) { - process.stderr.write( - `codeburn: Cursor database has ${total.toLocaleString()} bubbles and the ` + - `requested range exceeds the ${MAX_BUBBLES.toLocaleString()}-bubble scan budget; ` + - `the oldest sessions in range may be missing from this report.\n` - ) - } - } else { - rows = db.query(BUBBLE_QUERY_SINCE, [timeFloor]) - } - } catch (err) { - rethrowBusy(err) - return { calls: results } - } - - // Pre-pass: per-conversation facts the crediting decisions need, plus the - // requestId join for the agent stream — all from the rows already fetched, - // so no extra unbudgeted table scans. - const scans = new Map() - const requestToComposer = new Map() - for (const row of rows) { - const cid = parseComposerIdFromKey(row.bubble_key) - if (!cid) continue - if (row.request_id) requestToComposer.set(row.request_id, cid) - let scan = scans.get(cid) - if (!scan) { - scan = { hasRealTokens: false, firstBubbleTs: null, assistantTextChars: 0, model: null } - scans.set(cid, scan) - } - if ((row.input_tokens ?? 0) > 0 || (row.output_tokens ?? 0) > 0) scan.hasRealTokens = true - if (!scan.firstBubbleTs && row.created_at) scan.firstBubbleTs = row.created_at - if (row.bubble_type !== 1) scan.assistantTextChars += row.text_length ?? 0 - if (!scan.model && row.model) scan.model = row.model - } - - const { byComposer: agentStreams, unjoined } = loadAgentStreams(db, requestToComposer) - const userMessages = buildUserMessageMap(db, timeFloor) - const lastUserMsg = new Map() - - const inputSource = (cid: string): InputSource => { - if (scans.get(cid)?.hasRealTokens) return 'bubbleTokens' - if (composerMeta.has(cid)) return 'meter' - const stream = agentStreams.get(cid) - if ((stream?.userChars ?? 0) + (stream?.contextChars ?? 0) > 0) return 'stream' - return 'text' - } - - const emit = (call: Omit): void => { - results.push({ - provider: 'cursor', - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - speed: 'standard', - // Output is a reply-text estimate and the input meter is the latest - // context snapshot, not a per-turn sum, so no cursor figure is exact. - costIsEstimated: true, - ...call, - }) - } - - const toolsAttached = new Set() - for (const row of rows) { - try { - // The real composerId lives in the row key `bubbleId::` - // (the JSON conversationId field is empty in current builds). - // parseComposerIdFromKey returns null for non-UUID composer segments - // (tool-call output rows and similar shapes), which are NOT sessions. - const conversationId = parseComposerIdFromKey(row.bubble_key) - if (!conversationId) { - skipped++ - continue - } - const createdAt = row.created_at - if (!createdAt) continue - - // Pair each user turn with its own prompt (even when the turn itself - // emits nothing) so the assistant reply that follows classifies against - // the right question. - if (row.bubble_type === 1) { - lastUserMsg.set(conversationId, takeUserMessage(userMessages, conversationId)) - } - - let inputTokens = row.input_tokens ?? 0 - let outputTokens = row.output_tokens ?? 0 - if (inputTokens === 0 && outputTokens === 0) { - const textLen = row.text_length ?? 0 - if (row.bubble_type === 1) { - // Conversation-level input (meter or stream) is emitted once after - // this loop; per-bubble text only counts when it is the - // conversation's best available signal. - if (inputSource(conversationId) === 'text' && textLen > 0) { - inputTokens = estimateTokensFromChars(textLen) - } - } else { - outputTokens = estimateTokensFromChars(textLen) - } - if (inputTokens === 0 && outputTokens === 0) continue - } - - // Use the SQLite row key (bubbleId:) as the dedup key. - // Cursor mutates token counts on the row in place when streaming - // completes — including tokens in the dedup key (the previous - // implementation) caused the same bubble to be counted twice once - // its tokens stabilized. - const dedupKey = `cursor:bubble:${row.bubble_key}` - if (seenKeys.has(dedupKey)) continue - seenKeys.add(dedupKey) - - // User bubbles (type=1) carry no modelInfo, so fall back to the - // conversation's model seen on its assistant bubbles or agent stream. - const effectiveModel = row.model ?? scans.get(conversationId)?.model ?? agentStreams.get(conversationId)?.model ?? null - const pricingModel = resolveModel(effectiveModel) - - const userQuestion = lastUserMsg.get(conversationId) ?? '' - const assistantText = blobToText(row.user_text) - const userText = (userQuestion + ' ' + assistantText).trim() - - const languages = extractLanguages(blobToText(row.code_blocks)) - const hasCode = languages.length > 0 - - // Meter/stream conversations carry their agent tools on the synthetic - // conversation record below; the rest attach them to their first - // emitted call so they are counted exactly once. - let agentTurn: AgentStream | undefined - const source = inputSource(conversationId) - if ((source === 'text' || source === 'bubbleTokens') && !toolsAttached.has(conversationId)) { - agentTurn = agentStreams.get(conversationId) - if (agentTurn) toolsAttached.add(conversationId) - } - - emit({ - model: modelForDisplay(effectiveModel), - inputTokens, - outputTokens, - costBasis: 'estimated', - pricingModel, - tools: [ - ...(hasCode ? ['cursor:edit', ...languages.map(l => `lang:${l}`)] : []), - ...(agentTurn?.tools ?? []), - ], - bashCommands: agentTurn?.bash ?? [], - timestamp: createdAt, - deduplicationKey: dedupKey, - userMessage: userText, - sessionId: conversationId, - }) - } catch { - skipped++ - } - } - - // One conversation-level input record per metered/stream conversation, - // anchored to the conversation's own start (composerData.createdAt) so the - // credited day never depends on the parse window or cache state, and keyed - // by composerId so re-parses and daily-cache gap fills dedupe instead of - // multiplying. The meter is the LATEST context size, not a per-turn sum; - // growth after the anchor day is finalized stays uncounted, which keeps the - // documented undercount-vs-admin-console tradeoff but never double counts. - for (const [cid, scan] of scans) { - const source = inputSource(cid) - if (source !== 'meter' && source !== 'stream') continue - const stream = agentStreams.get(cid) - const meta = composerMeta.get(cid) - const inputTokens = source === 'meter' - ? meta?.tokens ?? 0 - : estimateTokensFromChars((stream?.userChars ?? 0) + (stream?.contextChars ?? 0)) - // Reply text normally lives on assistant bubbles; count the stream's - // reply deltas only when the bubbles carried none. - const outputTokens = scan.assistantTextChars > 0 ? 0 : estimateTokensFromChars(stream?.assistantChars ?? 0) - if (inputTokens === 0 && outputTokens === 0) continue - - const dedupKey = `cursor:composer-input:${cid}` - if (seenKeys.has(dedupKey)) continue - seenKeys.add(dedupKey) - - const createdAtMs = meta?.createdAt - const timestamp = typeof createdAtMs === 'number' && createdAtMs > 0 ? new Date(createdAtMs).toISOString() : scan.firstBubbleTs - if (!timestamp) continue - - const effectiveModel = scan.model ?? stream?.model ?? null - emit({ - model: modelForDisplay(effectiveModel), - inputTokens, - outputTokens, - costBasis: 'estimated', - pricingModel: resolveModel(effectiveModel), - tools: stream?.tools ?? [], - bashCommands: stream?.bash ?? [], - timestamp, - deduplicationKey: dedupKey, - userMessage: '', - sessionId: cid, - }) - } - - // Sessions recorded only in the agent stream (no bubble carries their - // requestId). agentKv stores no timestamps, so these reuse the DB file's - // mtime as a bounded "last write" time, like the pre-composer parser did. - for (const [requestId, stream] of unjoined) { - const inputTokens = estimateTokensFromChars(stream.userChars + stream.contextChars) - const outputTokens = estimateTokensFromChars(stream.assistantChars) - if (inputTokens === 0 && outputTokens === 0) continue - - const dedupKey = `cursor:agentKv:${requestId}` - if (seenKeys.has(dedupKey)) continue - seenKeys.add(dedupKey) - - emit({ - model: modelForDisplay(stream.model), - inputTokens, - outputTokens, - costBasis: 'estimated', - pricingModel: resolveModel(stream.model), - tools: stream.tools, - bashCommands: stream.bash, - timestamp: agentKvTimestamp, - deduplicationKey: dedupKey, - userMessage: '', - sessionId: requestId, - }) - } - - if (skipped > 0) { - process.stderr.write(`codeburn: skipped ${skipped} unreadable Cursor entries\n`) +// Exported so the golden can assert the exact emitted shape directly. +export function toProviderCall(rich: CursorDecodedCall): ParsedProviderCall { + return { + provider: 'cursor', + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + speed: rich.speed, + // Output is a reply-text estimate and the input meter is the latest + // context snapshot, not a per-turn sum, so no cursor figure is exact. + costIsEstimated: true, + costBasis: 'estimated', + pricingModel: resolveModel(rich.rawModel), + tools: rich.tools, + bashCommands: rich.rawBashCommands.flatMap(c => extractBashCommands(c)), + timestamp: rich.timestamp, + deduplicationKey: rich.deduplicationKey, + userMessage: rich.userMessage, + sessionId: rich.sessionId, } - - return { calls: results } } function createParser( @@ -1011,8 +501,87 @@ function createParser( } catch { agentKvTimestamp = new Date().toISOString() } - const { calls: bubbleCalls } = parseBubbles(db, localSeen, timeFloor, agentKvTimestamp) - allCalls = bubbleCalls + + // Query order [1]..[5] is load-bearing for failure semantics. + // [1]/[4]/[5] degrade to empty; [2] degrades to total=0; [3] early- + // returns zero calls but still writes the cache. + + // [1] Composer metadata (S2). + let composerMetaRows: CursorComposerMetaRow[] = [] + try { + composerMetaRows = db.query(COMPOSER_META_QUERY) + } catch (err) { + rethrowBusy(err) + /* best-effort: callers fall back to the per-bubble text estimate */ + } + + // [2] Total bubble count for the large-DB paging decision. + let total = 0 + try { + const countRows = db.query<{ cnt: number }>( + "SELECT COUNT(*) as cnt FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'" + ) + total = countRows[0]?.cnt ?? 0 + } catch (err) { + rethrowBusy(err) + } + + // Override the budget in tests via CODEBURN_CURSOR_MAX_BUBBLES. + const MAX_BUBBLES = Number(process.env['CODEBURN_CURSOR_MAX_BUBBLES']) || 250_000 + + // [3] Bubble rows (S1). + let bubbles: CursorBubbleRow[] = [] + try { + if (total > MAX_BUBBLES) { + const scan = scanBubblesPaged(db, timeFloor, MAX_BUBBLES) + bubbles = scan.rows + if (scan.truncated) { + process.stderr.write( + `codeburn: Cursor database has ${total.toLocaleString()} bubbles and the ` + + `requested range exceeds the ${MAX_BUBBLES.toLocaleString()}-bubble scan budget; ` + + `the oldest sessions in range may be missing from this report.\n` + ) + } + } else { + bubbles = db.query(BUBBLE_QUERY_SINCE, [timeFloor]) + } + } catch (err) { + rethrowBusy(err) + await writeCachedResults(dbPath, [], timeFloor) + return + } + + // [4] Agent stream rows (S3). + let agentKvRows: CursorAgentKvRow[] = [] + try { + agentKvRows = db.query(AGENTKV_QUERY) + } catch (err) { + rethrowBusy(err) + } + + // [5] User-message queue rows (S4). + let userMessageRows: CursorUserMessageRow[] = [] + try { + userMessageRows = db.query(USER_MESSAGES_QUERY, [timeFloor]) + } catch (err) { + rethrowBusy(err) + } + + const { calls, skippedRecords } = decodeCursor({ + bubbles, + agentKvRows, + userMessageRows, + composerMetaRows, + agentKvTimestamp, + context: { privacyKey: '', providerId: 'cursor', sourceRef: dbPath }, + seenKeys: localSeen, + }) + + if (skippedRecords > 0) { + process.stderr.write(`codeburn: skipped ${skippedRecords} unreadable Cursor entries\n`) + } + + allCalls = calls.map(toProviderCall) await writeCachedResults(dbPath, allCalls, timeFloor) } finally { db.close() diff --git a/packages/cli/tests/providers/cursor-golden.test.ts b/packages/cli/tests/providers/cursor-golden.test.ts new file mode 100644 index 00000000..50fca576 --- /dev/null +++ b/packages/cli/tests/providers/cursor-golden.test.ts @@ -0,0 +1,750 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, rm, utimes } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' + +import { + createCursorProvider, + clearCursorWorkspaceMapCache, +} from '../../src/providers/cursor.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' +import type { DateRange } from '../../src/types.js' + +const requireForTest = createRequire(import.meta.url) + +const skipReason = isSqliteAvailable() + ? null + : 'node:sqlite not available — needs Node 22+; skipping' + +let tmpDir: string +let originalSuppressCacheWrites: string | undefined +let originalHome: string | undefined +let originalMaxBubbles: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cursor-golden-')) + clearCursorWorkspaceMapCache() + originalSuppressCacheWrites = process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] + process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] = '1' + originalMaxBubbles = process.env['CODEBURN_CURSOR_MAX_BUBBLES'] +}) + +afterEach(async () => { + clearCursorWorkspaceMapCache() + if (originalSuppressCacheWrites === undefined) { + delete process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] + } else { + process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] = originalSuppressCacheWrites + } + // G12 overrides the scan budget; vitest reuses a worker across files, so + // leaving it set would silently change cursor-large-db-cap.test.ts. + if (originalMaxBubbles === undefined) { + delete process.env['CODEBURN_CURSOR_MAX_BUBBLES'] + } else { + process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = originalMaxBubbles + } + if (originalHome !== undefined) { + process.env['HOME'] = originalHome + } else { + delete process.env['HOME'] + } + await rm(tmpDir, { recursive: true, force: true }) +}) + +function buildDb(fn: (db: { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +}) => void): string { + const dbPath = join(tmpDir, 'state.vscdb') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)') + db.exec('CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)') + fn(db) + db.close() + return dbPath +} + +function insertBubble(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + composerId: string + bubbleUuid: string + type: 1 | 2 + text: string + model?: string + inputTokens?: number + outputTokens?: number + createdAt?: string | null + requestId?: string + codeBlocks?: string +}): void { + const key = `bubbleId:${opts.composerId}:${opts.bubbleUuid}` + const valueObj: Record = { + type: opts.type, + conversationId: '', + tokenCount: { + inputTokens: opts.inputTokens ?? 0, + outputTokens: opts.outputTokens ?? 0, + }, + modelInfo: opts.model ? { modelName: opts.model } : undefined, + text: opts.text, + codeBlocks: opts.codeBlocks ?? '[]', + requestId: opts.requestId, + } + if (opts.createdAt !== null) { + valueObj.createdAt = opts.createdAt ?? new Date().toISOString() + } + const value = JSON.stringify(valueObj) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value) +} + +function insertComposerData(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + composerId: string + totalUsedTokens?: number | null + contextTokensUsed?: number | null + createdAt?: number +}): void { + const key = `composerData:${opts.composerId}` + const breakdown: Record = {} + if (opts.totalUsedTokens !== undefined) breakdown.totalUsedTokens = opts.totalUsedTokens + const value = JSON.stringify({ + promptTokenBreakdown: breakdown, + contextTokensUsed: opts.contextTokensUsed ?? undefined, + createdAt: opts.createdAt ?? undefined, + }) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value) +} + +function insertAgentKv(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + blobId: string + role: string + content: unknown + requestId?: string + model?: string +}): void { + const key = `agentKv:blob:${opts.blobId}` + const value = JSON.stringify({ + role: opts.role, + content: opts.content, + providerOptions: opts.requestId || opts.model + ? { cursor: { requestId: opts.requestId, modelName: opts.model } } + : undefined, + }) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value) +} + +async function collectCalls( + provider: ReturnType, + dbPath: string, + dateRange?: DateRange, +): Promise { + const source = { path: dbPath, project: 'test', provider: 'cursor' as const } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set(), dateRange).parse()) { + calls.push(call) + } + return calls +} + +function sortedKeys(call: ParsedProviderCall): string[] { + return Object.keys(call).sort() +} + +const CURSOR_CALL_KEYS = [ + 'bashCommands', + 'cacheCreationInputTokens', + 'cacheReadInputTokens', + 'cachedInputTokens', + 'costBasis', + 'costIsEstimated', + 'deduplicationKey', + 'inputTokens', + 'model', + 'outputTokens', + 'pricingModel', + 'provider', + 'reasoningTokens', + 'sessionId', + 'speed', + 'timestamp', + 'tools', + 'userMessage', + 'webSearchRequests', +] + +function wideRange(): DateRange { + return { + start: new Date('2025-01-01T00:00:00.000Z'), + end: new Date('2027-01-01T00:00:00.000Z'), + } +} + +describe.skipIf(skipReason !== null)('cursor golden tests', () => { + it('G1: arm A full shape with code-block language synthesis', async () => { + const composerId = 'g1-composer-1111-2222-3333-4444-555566667777' + const bubbleUuid = 'g1-bubble-1111-2222-3333-4444-555566667777' + const createdAt = '2026-06-15T10:00:00.000Z' + const assistantText = 'assistant reply text' + const dbPath = buildDb((db) => { + insertBubble(db, { + composerId, + bubbleUuid, + type: 2, + text: assistantText, + model: 'claude-4.6-sonnet', + createdAt, + codeBlocks: JSON.stringify([ + { languageId: 'ts' }, + { languageId: 'plaintext' }, + { languageId: 'ts' }, + ]), + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(CURSOR_CALL_KEYS) + expect(calls[0]).toEqual({ + provider: 'cursor', + model: 'claude-4.6-sonnet', + inputTokens: 0, + outputTokens: Math.ceil(assistantText.length / 4), + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'claude-4.6-sonnet', + tools: ['cursor:edit', 'lang:ts'], + bashCommands: [], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:bubble:bubbleId:${composerId}:${bubbleUuid}`, + userMessage: assistantText, + sessionId: composerId, + }) + }) + + it('G2: text source, both turns, userMessage composition', async () => { + const composerId = 'g2-composer-1111-2222-3333-4444-555566667777' + const createdAt = '2026-06-15T10:00:00.000Z' + const question = 'question text?' + const reply = 'reply text.' + const dbPath = buildDb((db) => { + insertBubble(db, { composerId, bubbleUuid: 'u1', type: 1, text: question, createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'a1', type: 2, text: reply, model: 'gpt-5', createdAt }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(sortedKeys(call)).toEqual(CURSOR_CALL_KEYS) + } + + expect(calls[0]).toEqual({ + provider: 'cursor', + model: 'gpt-5', + inputTokens: Math.ceil(question.length / 4), + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'gpt-5', + tools: [], + bashCommands: [], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:bubble:bubbleId:${composerId}:u1`, + userMessage: `${question} ${question}`, + sessionId: composerId, + }) + + expect(calls[1]).toEqual({ + provider: 'cursor', + model: 'gpt-5', + inputTokens: 0, + outputTokens: Math.ceil(reply.length / 4), + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'gpt-5', + tools: [], + bashCommands: [], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:bubble:bubbleId:${composerId}:a1`, + userMessage: `${question} ${reply}`, + sessionId: composerId, + }) + }) + + it('G3: meter conversation with composer-anchored record', async () => { + const composerId = 'g3-composer-1111-2222-3333-4444-555566667777' + const createdAtMs = Date.parse('2026-06-10T12:00:00.000Z') + const createdAt = new Date(createdAtMs).toISOString() + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 5000, createdAt: createdAtMs }) + insertBubble(db, { composerId, bubbleUuid: 'u1', type: 1, text: 'first question', createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'a1', type: 2, text: 'first reply', model: 'gpt-5', createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'u2', type: 1, text: 'second question', createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'a2', type: 2, text: 'second reply', model: 'gpt-5', createdAt }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + expect(calls).toHaveLength(3) + for (const call of calls) { + expect(sortedKeys(call)).toEqual(CURSOR_CALL_KEYS) + } + + expect(calls[0]).toEqual({ + provider: 'cursor', + model: 'gpt-5', + inputTokens: 0, + outputTokens: Math.ceil('first reply'.length / 4), + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'gpt-5', + tools: [], + bashCommands: [], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:bubble:bubbleId:${composerId}:a1`, + userMessage: 'first question first reply', + sessionId: composerId, + }) + + expect(calls[1]).toEqual({ + provider: 'cursor', + model: 'gpt-5', + inputTokens: 0, + outputTokens: Math.ceil('second reply'.length / 4), + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'gpt-5', + tools: [], + bashCommands: [], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:bubble:bubbleId:${composerId}:a2`, + userMessage: 'second question second reply', + sessionId: composerId, + }) + + expect(calls[2]).toEqual({ + provider: 'cursor', + model: 'gpt-5', + inputTokens: 5000, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'gpt-5', + tools: [], + bashCommands: [], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:composer-input:${composerId}`, + userMessage: '', + sessionId: composerId, + }) + }) + + it('G4: meter fallbacks (zero totalUsedTokens + context; absent breakdown)', async () => { + const composerA = 'g4-a-composer-1111-2222-3333-4444-555566667777' + const composerB = 'g4-b-composer-1111-2222-3333-4444-555566667777' + const createdAt = '2026-06-15T10:00:00.000Z' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId: composerA, totalUsedTokens: 0, contextTokensUsed: 300 }) + insertBubble(db, { composerId: composerA, bubbleUuid: 'u1', type: 1, text: 'prompt a', createdAt }) + insertBubble(db, { composerId: composerA, bubbleUuid: 'a1', type: 2, text: 'reply a', model: 'gpt-5', createdAt }) + + insertBubble(db, { composerId: composerB, bubbleUuid: 'u1', type: 1, text: 'prompt b', createdAt }) + insertBubble(db, { composerId: composerB, bubbleUuid: 'a1', type: 2, text: 'reply b', model: 'gpt-5', createdAt }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + const metered = calls.find(c => c.sessionId === composerA && c.deduplicationKey === `cursor:composer-input:${composerA}`) + expect(metered).toBeDefined() + expect(metered!.inputTokens).toBe(300) + + const textUser = calls.find(c => c.sessionId === composerB && c.deduplicationKey === `cursor:bubble:bubbleId:${composerB}:u1`) + expect(textUser).toBeDefined() + expect(textUser!.inputTokens).toBe(Math.ceil('prompt b'.length / 4)) + + const composerBInput = calls.find(c => c.deduplicationKey === `cursor:composer-input:${composerB}`) + expect(composerBInput).toBeUndefined() + }) + + it('G5: stream source, system boundary reset, dropped orphan tool row', async () => { + const composerId = 'g5-composer-1111-2222-3333-4444-555566667777' + const requestId = 'req-g5' + const createdAt = '2026-06-15T10:00:00.000Z' + const userPrompt = 'the full prompt with injected context' + const secondPrompt = 'second prompt' + const systemInfo = 'os info' + const dbPath = buildDb((db) => { + insertBubble(db, { composerId, bubbleUuid: 'u1', type: 1, text: '', requestId, createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'a1', type: 2, text: 'done', model: 'gpt-5', requestId, createdAt }) + insertAgentKv(db, { blobId: 'tool-orphan', role: 'tool', content: [{ type: 'text', text: 'tool dropped' }] }) + insertAgentKv(db, { blobId: 'user-1', role: 'user', content: userPrompt, requestId }) + insertAgentKv(db, { blobId: 'system-1', role: 'system', content: systemInfo, requestId }) + insertAgentKv(db, { blobId: 'user-2', role: 'user', content: secondPrompt, requestId }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(sortedKeys(call)).toEqual(CURSOR_CALL_KEYS) + } + + const userChars = userPrompt.length + secondPrompt.length + const contextChars = systemInfo.length + const streamInput = Math.ceil((userChars + contextChars) / 4) + + expect(calls[0]).toEqual({ + provider: 'cursor', + model: 'gpt-5', + inputTokens: 0, + outputTokens: Math.ceil('done'.length / 4), + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'gpt-5', + tools: [], + bashCommands: [], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:bubble:bubbleId:${composerId}:a1`, + userMessage: 'done', + sessionId: composerId, + }) + + expect(calls[1]).toEqual({ + provider: 'cursor', + model: 'gpt-5', + inputTokens: streamInput, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'gpt-5', + tools: [], + bashCommands: [], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:composer-input:${composerId}`, + userMessage: '', + sessionId: composerId, + }) + }) + + it('G6: arm C agentKv-only session uses DB mtime as timestamp', async () => { + const requestId = 'req-headless-g6' + const prompt = 'run the nightly data export' + const reply = 'export completed with 3 warnings' + const mtime = new Date('2026-05-20T08:30:00.000Z') + const dbPath = buildDb((db) => { + insertAgentKv(db, { blobId: 'akv-user', role: 'user', content: prompt, requestId }) + insertAgentKv(db, { blobId: 'akv-assistant', role: 'assistant', content: [{ type: 'text', text: reply }], requestId }) + }) + await utimes(dbPath, mtime, mtime) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(CURSOR_CALL_KEYS) + expect(calls[0]).toEqual({ + provider: 'cursor', + model: 'cursor-auto', + inputTokens: Math.ceil(prompt.length / 4), + outputTokens: Math.ceil(reply.length / 4), + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'claude-sonnet-4-5', + tools: [], + bashCommands: [], + timestamp: mtime.toISOString(), + speed: 'standard', + deduplicationKey: `cursor:agentKv:${requestId}`, + userMessage: '', + sessionId: requestId, + }) + }) + + it('G7: tools and bash attach to first emitted call only, no Set dedup', async () => { + const composerId = 'g7-composer-1111-2222-3333-4444-555566667777' + const requestId = 'req-g7' + const createdAt = '2026-06-15T10:00:00.000Z' + const dbPath = buildDb((db) => { + insertBubble(db, { composerId, bubbleUuid: 'u1', type: 1, text: 'do stuff', requestId, inputTokens: 0, outputTokens: 0, createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'a1', type: 2, text: 'doing stuff', model: 'gpt-5', requestId, inputTokens: 0, outputTokens: 900, createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'u2', type: 1, text: 'do more stuff', requestId: 'req-002', inputTokens: 0, outputTokens: 0, createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'a2', type: 2, text: 'doing more stuff', model: 'gpt-5', requestId: 'req-002', inputTokens: 0, outputTokens: 900, createdAt }) + insertAgentKv(db, { blobId: 'user-1', role: 'user', content: 'do stuff', requestId }) + insertAgentKv(db, { + blobId: 'assistant-1', + role: 'assistant', + requestId, + content: [ + { type: 'tool-call', toolName: 'Read', args: {} }, + { type: 'tool-call', toolName: 'Shell', args: { command: 'npm test' } }, + { type: 'tool-call', toolName: 'Shell', args: { command: 'yarn install' } }, + ], + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(sortedKeys(call)).toEqual(CURSOR_CALL_KEYS) + } + + expect(calls[0]).toEqual({ + provider: 'cursor', + model: 'gpt-5', + inputTokens: 0, + outputTokens: 900, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'gpt-5', + tools: ['Read', 'Bash', 'Bash'], + bashCommands: ['npm', 'yarn'], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:bubble:bubbleId:${composerId}:a1`, + userMessage: 'do stuff doing stuff', + sessionId: composerId, + }) + + expect(calls[1]).toEqual({ + provider: 'cursor', + model: 'gpt-5', + inputTokens: 0, + outputTokens: 900, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + costIsEstimated: true, + pricingModel: 'gpt-5', + tools: [], + bashCommands: [], + timestamp: createdAt, + speed: 'standard', + deduplicationKey: `cursor:bubble:bubbleId:${composerId}:a2`, + userMessage: 'do more stuff doing more stuff', + sessionId: composerId, + }) + }) + + it('G8: queue pops before skip in metered conversation', async () => { + const composerId = 'g8-composer-1111-2222-3333-4444-555566667777' + const createdAt = '2026-06-15T10:00:00.000Z' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 5000 }) + insertBubble(db, { composerId, bubbleUuid: 'u1', type: 1, text: 'first question', createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'a1', type: 2, text: 'first reply', model: 'gpt-5', createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'u2', type: 1, text: 'second question', createdAt }) + insertBubble(db, { composerId, bubbleUuid: 'a2', type: 2, text: 'second reply', model: 'gpt-5', createdAt }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + const first = calls.find(c => c.userMessage.includes('first reply')) + const second = calls.find(c => c.userMessage.includes('second reply')) + expect(first).toBeDefined() + expect(second).toBeDefined() + expect(first!.userMessage).toBe('first question first reply') + expect(second!.userMessage).toBe('second question second reply') + }) + + it('G9: skip asymmetry and stderr message', async () => { + const composerId = 'g9-composer-1111-2222-3333-4444-555566667777' + const createdAt = '2026-06-15T10:00:00.000Z' + const dbPath = buildDb((db) => { + insertBubble(db, { composerId, bubbleUuid: 'null-created', type: 2, text: 'null created', model: 'gpt-5', createdAt: null }) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run( + 'bubbleId:task-call_x\nfc_yyy:bubble-sub', + JSON.stringify({ + type: 2, + conversationId: '', + createdAt, + tokenCount: { inputTokens: 10, outputTokens: 5 }, + modelInfo: { modelName: 'gpt-5' }, + text: 'cr lf', + codeBlocks: '[]', + }), + ) + }) + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + expect(calls).toHaveLength(0) + expect(stderrSpy).toHaveBeenCalledTimes(1) + expect(stderrSpy).toHaveBeenCalledWith('codeburn: skipped 1 unreadable Cursor entries\n') + + stderrSpy.mockRestore() + }) + + it('G10: model fallbacks (scan inheritance and default -> cursor-auto)', async () => { + const composerA = 'g10-a-composer-1111-2222-3333-4444-555566667777' + const composerB = 'g10-b-composer-1111-2222-3333-4444-555566667777' + const createdAt = '2026-06-15T10:00:00.000Z' + const dbPath = buildDb((db) => { + insertBubble(db, { composerId: composerA, bubbleUuid: 'u1', type: 1, text: 'prompt', createdAt }) + insertBubble(db, { composerId: composerA, bubbleUuid: 'a1', type: 2, text: 'reply one', model: 'claude-4.6-sonnet', createdAt }) + insertBubble(db, { composerId: composerA, bubbleUuid: 'a2', type: 2, text: 'reply two', createdAt }) + + insertBubble(db, { composerId: composerB, bubbleUuid: 'u1', type: 1, text: 'prompt', createdAt }) + insertBubble(db, { composerId: composerB, bubbleUuid: 'a1', type: 2, text: 'reply', model: 'default', createdAt }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath, wideRange()) + + const inherited = calls.find(c => c.deduplicationKey === `cursor:bubble:bubbleId:${composerA}:a2`) + expect(inherited).toBeDefined() + expect(inherited!.model).toBe('claude-4.6-sonnet') + expect(inherited!.pricingModel).toBe('claude-4.6-sonnet') + + const defaulted = calls.find(c => c.deduplicationKey === `cursor:bubble:bubbleId:${composerB}:a1`) + expect(defaulted).toBeDefined() + expect(defaulted!.model).toBe('cursor-auto') + expect(defaulted!.pricingModel).toBe('claude-sonnet-4-5') + }) + + it('G11: cache round-trip preserves exact call shape', async () => { + const composerId = 'g11-composer-1111-2222-3333-4444-555566667777' + const createdAt = '2026-06-15T10:00:00.000Z' + const dbPath = buildDb((db) => { + insertBubble(db, { + composerId, + bubbleUuid: 'a1', + type: 2, + text: 'cached reply', + model: 'gpt-5', + createdAt, + inputTokens: 100, + outputTokens: 50, + }) + }) + + delete process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] + originalHome = process.env['HOME'] + process.env['HOME'] = tmpDir + + const readSpy = vi.spyOn(await import('../../src/cursor-cache.js'), 'readCachedResults') + + const provider = createCursorProvider(dbPath) + const first = await collectCalls(provider, dbPath, wideRange()) + const second = await collectCalls(provider, dbPath, wideRange()) + + expect(readSpy).toHaveReturnedWith(expect.anything()) + expect(second.length).toBeGreaterThan(0) + + // The cache survives at version 6, so a call written by one build and read + // back by another must be shape-identical. `toEqual` cannot see a key that + // is present with an `undefined` value — and JSON.stringify drops exactly + // those on the way into cursor-results.json — so it would compare the + // fresh and cached arrays as equal while the key sets diverged. Gate the + // key sets explicitly on both sides, and use toStrictEqual for the values. + expect(first).toStrictEqual(second) + for (const call of [...first, ...second]) { + expect(sortedKeys(call)).toEqual(CURSOR_CALL_KEYS) + } + + readSpy.mockRestore() + }) + + it('G12: paged and un-paged scans produce identical call arrays', async () => { + const dbPath = buildDb((db) => { + const old = '2026-01-01T00:00:00.000Z' + const recent = '2026-07-01T00:00:00.000Z' + insertBubble(db, { composerId: 'old-1', bubbleUuid: 'b1', type: 2, text: 'old one', model: 'gpt-5', createdAt: old, inputTokens: 10, outputTokens: 5 }) + insertBubble(db, { composerId: 'old-2', bubbleUuid: 'b1', type: 2, text: 'old two', model: 'gpt-5', createdAt: old, inputTokens: 10, outputTokens: 5 }) + insertBubble(db, { composerId: 'new-1', bubbleUuid: 'b1', type: 2, text: 'new one', model: 'gpt-5', createdAt: recent, inputTokens: 10, outputTokens: 5 }) + insertBubble(db, { composerId: 'new-2', bubbleUuid: 'b1', type: 2, text: 'new two', model: 'gpt-5', createdAt: recent, inputTokens: 10, outputTokens: 5 }) + }) + + const range: DateRange = { start: new Date('2026-06-01T00:00:00.000Z'), end: new Date('2027-01-01T00:00:00.000Z') } + + const provider = createCursorProvider(dbPath) + process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '100' + const unpaged = await collectCalls(provider, dbPath, range) + + process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '2' + const paged = await collectCalls(provider, dbPath, range) + + expect(unpaged).toEqual(paged) + expect(unpaged).toHaveLength(2) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 09ab6bea..25903e33 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -135,6 +135,10 @@ "types": "./dist/providers/cursor-agent/index.d.ts", "import": "./dist/providers/cursor-agent/index.js" }, + "./providers/cursor": { + "types": "./dist/providers/cursor/index.d.ts", + "import": "./dist/providers/cursor/index.js" + }, "./providers/quickdesk": { "types": "./dist/providers/quickdesk/index.d.ts", "import": "./dist/providers/quickdesk/index.js" diff --git a/packages/core/src/providers/cursor/decode.ts b/packages/core/src/providers/cursor/decode.ts new file mode 100644 index 00000000..29cc485c --- /dev/null +++ b/packages/core/src/providers/cursor/decode.ts @@ -0,0 +1,483 @@ +// @codeburn/core Cursor decoder: pure decode over the five row sets the host +// hands it. No fs / env / clock / sqlite / pricing / strip-ansi. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { + CursorAgentKvRow, + CursorAgentStream, + CursorBubbleRow, + CursorComposerMeta, + CursorComposerMetaRow, + CursorComposerScan, + CursorDecodedCall, + CursorInputSource, + CursorUserMessageRow, + CursorUserMessageQueue, +} from './types.js' + +export type CursorDecodeInput = { + bubbles: CursorBubbleRow[] + agentKvRows: CursorAgentKvRow[] + userMessageRows: CursorUserMessageRow[] + composerMetaRows: CursorComposerMetaRow[] + /** Host-supplied last-write time for agentKv-only sessions (DB mtime). */ + agentKvTimestamp: string + context: DecodeContext + /** Live dedup set the host mutates in place (the parser's fresh localSeen). */ + seenKeys?: Set +} + +export type CursorDecodeResult = { + calls: CursorDecodedCall[] + diagnostics: RecordDiagnostic[] + /** Count behind the host's "skipped N unreadable Cursor entries" line. */ + skippedRecords: number +} + +const CHARS_PER_TOKEN = 4 + +function estimateTokens(chars: number): number { + if (chars <= 0) return 0 + return Math.ceil(chars / CHARS_PER_TOKEN) +} + +// Clone of packages/cli/src/sqlite.ts:35-39 so the decoder can decode BLOB +// columns non-fatally without importing host-side sqlite code. +const textDecoder = new TextDecoder('utf-8', { fatal: false }) +function blobToText(value: Uint8Array | string | null | undefined): string { + if (value == null) return '' + if (typeof value === 'string') return value + return textDecoder.decode(value) +} + +/// Pulls the composer id out of a `bubbleId::` key. +/// Returns null when the composer segment contains a CR/LF, which is the +/// signature Cursor uses for tool-call sub-composer rows in real data — +/// e.g. `bubbleId:task-call_xxxx\nfc_yyyy:` is one key with a +/// literal newline between the `task-call_` and `fc_` halves. Those rows +/// are not standalone composers and would otherwise inflate the orphan +/// project's session count. +export function parseComposerIdFromKey(key: string | undefined): string | null { + if (!key) return null + const firstColon = key.indexOf(':') + if (firstColon < 0) return null + const secondColon = key.indexOf(':', firstColon + 1) + if (secondColon < 0) return null + const candidate = key.slice(firstColon + 1, secondColon) + if (!candidate) return null + // Reject any multi-line / control-char composer id. Real composer ids + // (UUIDs) and synthetic fixture ids are both single-line. + if (/[\r\n\x00]/.test(candidate)) return null + return candidate +} + +type CodeBlock = { languageId?: string } + +export function extractLanguages(codeBlocksJson: string | null): string[] { + if (!codeBlocksJson) return [] + try { + const blocks = JSON.parse(codeBlocksJson) as CodeBlock[] + if (!Array.isArray(blocks)) return [] + const langs = new Set() + for (const block of blocks) { + if (block.languageId && block.languageId !== 'plaintext') { + langs.add(block.languageId) + } + } + return [...langs] + } catch { + return [] + } +} + +function modelForDisplay(raw: string | null): string { + if (!raw || raw === 'default') return 'cursor-auto' + return raw +} + +function buildUserMessageMap(rows: CursorUserMessageRow[]): Map { + const map = new Map() + for (const row of rows) { + // Extract the composerId from the bubble key, matching the bubble arms. + // The JSON `conversationId` field is empty in current Cursor builds. + const composerId = parseComposerIdFromKey(row.bubble_key) + // Guard on the RAW field: empty TEXT ('') is falsy and is skipped; empty + // BLOB (new Uint8Array(0)) is truthy and is kept, decoding to ''. + if (!composerId || !row.text) continue + const text = blobToText(row.text) + const existing = map.get(composerId) + if (existing) { + existing.messages.push(text) + } else { + map.set(composerId, { messages: [text], pos: 0 }) + } + } + return map +} + +function takeUserMessage(queues: Map, conversationId: string): string { + const queue = queues.get(conversationId) + if (!queue || queue.pos >= queue.messages.length) return '' + const msg = queue.messages[queue.pos] + queue.pos += 1 + return msg +} + +function loadComposerMeta(rows: CursorComposerMetaRow[]): Map { + const map = new Map() + for (const r of rows) { + // `||` rather than `??`: a recorded-but-zero breakdown must fall through + // to the context meter instead of shadowing it. + const tokens = (r.used || r.ctx) ?? 0 + if (r.composer_id && tokens > 0) map.set(r.composer_id, { tokens, createdAt: r.created_at ?? null }) + } + return map +} + +function newAgentStream(): CursorAgentStream { + return { tools: [], bash: [], userChars: 0, contextChars: 0, assistantChars: 0, model: null } +} + +// agentKv rows store content as a plain string or a block array; count only +// the text inside blocks so the JSON envelope and non-text parts are not +// billed as prompt characters. +export function contentTextLength(raw: string): number { + const trimmed = raw.trimStart() + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as unknown + const blocks = Array.isArray(parsed) ? parsed : [parsed] + let len = 0 + for (const block of blocks) { + if (block == null || typeof block !== 'object') continue + const b = block as { text?: unknown; content?: unknown } + if (typeof b.text === 'string') len += b.text.length + else if (typeof b.content === 'string') len += b.content.length + } + return len + } catch { + return raw.length + } + } + return raw.length +} + +// Cursor logs the agent's stream (prompt, injected context, tool calls, reply +// deltas) in agentKv blobs keyed by requestId. Bubbles carry the same +// requestId, so the map built from the scanned bubbles joins each request to +// its conversation. Requests with no matching bubble are kept separately: +// they are real sessions (background runs, older builds) that would otherwise +// vanish from totals. +function loadAgentStreams( + rows: CursorAgentKvRow[], + requestToComposer: Map, +): { byComposer: Map; unjoined: Map } { + const byComposer = new Map() + const unjoined = new Map() + + const bucketFor = (requestId: string): CursorAgentStream => { + const composer = requestToComposer.get(requestId) + const map = composer ? byComposer : unjoined + const key = composer ?? requestId + const existing = map.get(key) + if (existing) return existing + const fresh = newAgentStream() + map.set(key, fresh) + return fresh + } + + // Only the turn-opening (user) agentKv row carries the requestId; rows that + // follow inherit it. Rows written BEFORE their request's id appears (the + // system prompt and opening user prompt at a conversation start) buffer + // until the next id, and a system row closes the previous request so + // interleaved sessions cannot inherit across a conversation boundary. + let currentRequestId: string | null = null + let pendingUserChars = 0 + let pendingContextChars = 0 + for (const row of rows) { + if (row.request_id) { + currentRequestId = row.request_id + if (pendingUserChars > 0 || pendingContextChars > 0) { + const bucket = bucketFor(currentRequestId) + bucket.userChars += pendingUserChars + bucket.contextChars += pendingContextChars + pendingUserChars = 0 + pendingContextChars = 0 + } + } + if (row.model && currentRequestId) { + const bucket = bucketFor(currentRequestId) + if (!bucket.model) bucket.model = row.model + } + // Guard on the RAW content field: empty TEXT is skipped; empty BLOB is kept. + if (!row.content) continue + + if (row.role === 'system') { + pendingContextChars += contentTextLength(blobToText(row.content)) + currentRequestId = null + continue + } + if (row.role === 'user') { + const len = contentTextLength(blobToText(row.content)) + if (currentRequestId) bucketFor(currentRequestId).userChars += len + else pendingUserChars += len + continue + } + if (row.role === 'tool') { + if (currentRequestId) bucketFor(currentRequestId).contextChars += contentTextLength(blobToText(row.content)) + continue + } + if (row.role !== 'assistant' || !currentRequestId) continue + + let content: unknown + try { + content = JSON.parse(blobToText(row.content)) + } catch { + continue + } + if (!Array.isArray(content)) continue + const bucket = bucketFor(currentRequestId) + for (const block of content as Array<{ type?: string; text?: unknown; toolName?: unknown; args?: { command?: unknown } }>) { + if (block == null || typeof block !== 'object') continue + if (typeof block.text === 'string') bucket.assistantChars += block.text.length + if (block.type !== 'tool-call' || typeof block.toolName !== 'string' || !block.toolName) continue + // Cursor's terminal tool is 'Shell'; emit the canonical 'Bash' so the + // cross-provider tool and command breakdowns merge. + bucket.tools.push(block.toolName === 'Shell' ? 'Bash' : block.toolName) + if (block.toolName === 'Shell' && typeof block.args?.command === 'string') { + // Store the raw command string; the host extracts base names with + // strip-ansi so that dependency never enters core. + bucket.bash.push(block.args.command) + } + } + } + return { byComposer, unjoined } +} + +function inputSource( + cid: string, + scans: Map, + composerMeta: Map, + agentStreams: Map, +): CursorInputSource { + if (scans.get(cid)?.hasRealTokens) return 'bubbleTokens' + if (composerMeta.has(cid)) return 'meter' + const stream = agentStreams.get(cid) + if ((stream?.userChars ?? 0) + (stream?.contextChars ?? 0) > 0) return 'stream' + return 'text' +} + +function buildScans(rows: CursorBubbleRow[]): { + scans: Map + requestToComposer: Map +} { + const scans = new Map() + const requestToComposer = new Map() + for (const row of rows) { + const cid = parseComposerIdFromKey(row.bubble_key) + if (!cid) continue + if (row.request_id) requestToComposer.set(row.request_id, cid) + let scan = scans.get(cid) + if (!scan) { + scan = { hasRealTokens: false, firstBubbleTs: null, assistantTextChars: 0, model: null } + scans.set(cid, scan) + } + if ((row.input_tokens ?? 0) > 0 || (row.output_tokens ?? 0) > 0) scan.hasRealTokens = true + if (!scan.firstBubbleTs && row.created_at) scan.firstBubbleTs = row.created_at + if (row.bubble_type !== 1) scan.assistantTextChars += row.text_length ?? 0 + if (!scan.model && row.model) scan.model = row.model + } + return { scans, requestToComposer } +} + +export function decodeCursor(input: CursorDecodeInput): CursorDecodeResult { + const { bubbles, agentKvRows, userMessageRows, composerMetaRows, agentKvTimestamp, seenKeys } = input + const localSeen = seenKeys ?? new Set() + const results: CursorDecodedCall[] = [] + let skipped = 0 + + const composerMeta = loadComposerMeta(composerMetaRows) + const { scans, requestToComposer } = buildScans(bubbles) + const { byComposer: agentStreams, unjoined } = loadAgentStreams(agentKvRows, requestToComposer) + const userMessages = buildUserMessageMap(userMessageRows) + const lastUserMsg = new Map() + + const toolsAttached = new Set() + + for (const row of bubbles) { + try { + const conversationId = parseComposerIdFromKey(row.bubble_key) + if (!conversationId) { + skipped++ + continue + } + const createdAt = row.created_at + if (!createdAt) continue + + // Pair each user turn with its own prompt (even when the turn itself + // emits nothing) so the assistant reply that follows classifies against + // the right question. + if (row.bubble_type === 1) { + lastUserMsg.set(conversationId, takeUserMessage(userMessages, conversationId)) + } + + let inputTokens = row.input_tokens ?? 0 + let outputTokens = row.output_tokens ?? 0 + if (inputTokens === 0 && outputTokens === 0) { + const textLen = row.text_length ?? 0 + if (row.bubble_type === 1) { + // Conversation-level input (meter or stream) is emitted once after + // this loop; per-bubble text only counts when it is the + // conversation's best available signal. + if (inputSource(conversationId, scans, composerMeta, agentStreams) === 'text' && textLen > 0) { + inputTokens = estimateTokens(textLen) + } + } else { + outputTokens = estimateTokens(textLen) + } + if (inputTokens === 0 && outputTokens === 0) continue + } + + // Use the SQLite row key (bubbleId:) as the dedup key. + // Cursor mutates token counts on the row in place when streaming + // completes — including tokens in the dedup key (the previous + // implementation) caused the same bubble to be counted twice once + // its tokens stabilized. + const dedupKey = `cursor:bubble:${row.bubble_key}` + if (localSeen.has(dedupKey)) continue + localSeen.add(dedupKey) + + // User bubbles (type=1) carry no modelInfo, so fall back to the + // conversation's model seen on its assistant bubbles or agent stream. + const effectiveModel = row.model ?? scans.get(conversationId)?.model ?? agentStreams.get(conversationId)?.model ?? null + + const userQuestion = lastUserMsg.get(conversationId) ?? '' + const assistantText = blobToText(row.user_text) + const userText = (userQuestion + ' ' + assistantText).trim() + + const languages = extractLanguages(blobToText(row.code_blocks)) + const hasCode = languages.length > 0 + + // Meter/stream conversations carry their agent tools on the synthetic + // conversation record below; the rest attach them to their first + // emitted call so they are counted exactly once. + let agentTurn: CursorAgentStream | undefined + const source = inputSource(conversationId, scans, composerMeta, agentStreams) + if ((source === 'text' || source === 'bubbleTokens') && !toolsAttached.has(conversationId)) { + agentTurn = agentStreams.get(conversationId) + if (agentTurn) toolsAttached.add(conversationId) + } + + results.push({ + provider: 'cursor', + model: modelForDisplay(effectiveModel), + rawModel: effectiveModel, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + speed: 'standard', + tools: [ + ...(hasCode ? ['cursor:edit', ...languages.map(l => `lang:${l}`)] : []), + ...(agentTurn?.tools ?? []), + ], + rawBashCommands: agentTurn?.bash ?? [], + timestamp: createdAt, + deduplicationKey: dedupKey, + userMessage: userText, + sessionId: conversationId, + }) + } catch { + skipped++ + } + } + + // One conversation-level input record per metered/stream conversation, + // anchored to the conversation's own start (composerData.createdAt) so the + // credited day never depends on the parse window or cache state, and keyed + // by composerId so re-parses and daily-cache gap fills dedupe instead of + // multiplying. The meter is the LATEST context size, not a per-turn sum; + // growth after the anchor day is finalized stays uncounted, which keeps the + // documented undercount-vs-admin-console tradeoff but never double counts. + for (const [cid, scan] of scans) { + const source = inputSource(cid, scans, composerMeta, agentStreams) + if (source !== 'meter' && source !== 'stream') continue + const stream = agentStreams.get(cid) + const meta = composerMeta.get(cid) + const inputTokens = source === 'meter' + ? meta?.tokens ?? 0 + : estimateTokens((stream?.userChars ?? 0) + (stream?.contextChars ?? 0)) + // Reply text normally lives on assistant bubbles; count the stream's + // reply deltas only when the bubbles carried none. + const outputTokens = scan.assistantTextChars > 0 ? 0 : estimateTokens(stream?.assistantChars ?? 0) + if (inputTokens === 0 && outputTokens === 0) continue + + const dedupKey = `cursor:composer-input:${cid}` + if (localSeen.has(dedupKey)) continue + localSeen.add(dedupKey) + + const createdAtMs = meta?.createdAt + const timestamp = typeof createdAtMs === 'number' && createdAtMs > 0 ? new Date(createdAtMs).toISOString() : scan.firstBubbleTs + if (!timestamp) continue + + const effectiveModel = scan.model ?? stream?.model ?? null + results.push({ + provider: 'cursor', + model: modelForDisplay(effectiveModel), + rawModel: effectiveModel, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + speed: 'standard', + tools: stream?.tools ?? [], + rawBashCommands: stream?.bash ?? [], + timestamp, + deduplicationKey: dedupKey, + userMessage: '', + sessionId: cid, + }) + } + + // Sessions recorded only in the agent stream (no bubble carries their + // requestId). agentKv stores no timestamps, so these reuse the DB file's + // mtime as a bounded "last write" time, like the pre-composer parser did. + for (const [requestId, stream] of unjoined) { + const inputTokens = estimateTokens(stream.userChars + stream.contextChars) + const outputTokens = estimateTokens(stream.assistantChars) + if (inputTokens === 0 && outputTokens === 0) continue + + const dedupKey = `cursor:agentKv:${requestId}` + if (localSeen.has(dedupKey)) continue + localSeen.add(dedupKey) + + results.push({ + provider: 'cursor', + model: modelForDisplay(stream.model), + rawModel: stream.model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + speed: 'standard', + tools: stream.tools, + rawBashCommands: stream.bash, + timestamp: agentKvTimestamp, + deduplicationKey: dedupKey, + userMessage: '', + sessionId: requestId, + }) + } + + return { calls: results, diagnostics: [], skippedRecords: skipped } +} diff --git a/packages/core/src/providers/cursor/index.ts b/packages/core/src/providers/cursor/index.ts new file mode 100644 index 00000000..afcc880d --- /dev/null +++ b/packages/core/src/providers/cursor/index.ts @@ -0,0 +1,28 @@ +// @codeburn/core Cursor provider. +// +// Two layers: +// - Rich pure decode (`decodeCursor`): host-facing, NOT part of the stable +// minimized surface. Pure over the five row sets the host hands it; carries +// content in-memory but no pricing and no bash base-name extraction. +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { decodeCursor, parseComposerIdFromKey, extractLanguages, contentTextLength } from './decode.js' +export type { CursorDecodeInput, CursorDecodeResult } from './decode.js' +export { toObservations } from './observations.js' +export type { + RichCursorSessionDecode, + CursorToObservationsContext, +} from './observations.js' +export type { + CursorDecodedCall, + CursorBubbleRow, + CursorAgentKvRow, + CursorUserMessageRow, + CursorComposerMetaRow, + CursorComposerMeta, + CursorUserMessageQueue, + CursorAgentStream, + CursorInputSource, + CursorComposerScan, +} from './types.js' diff --git a/packages/core/src/providers/cursor/observations.ts b/packages/core/src/providers/cursor/observations.ts new file mode 100644 index 00000000..ddbda94d --- /dev/null +++ b/packages/core/src/providers/cursor/observations.ts @@ -0,0 +1,94 @@ +// Minimizing transform: rich Cursor decode -> the strict observation envelope. +// Only opaque ids, fingerprints, enums, numbers, timestamps, and CANONICAL tool +// names cross into the output — never the user message, raw bash command, or +// project path. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { CursorDecodedCall } from './types.js' + +/** One Cursor session's rich decode, as the host holds it before minimization. */ +export interface RichCursorSessionDecode { + sessionId: string + /** Absolute project path; fingerprinted, never emitted raw. */ + projectPath: string + calls: CursorDecodedCall[] +} + +export interface CursorToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +// Canonical tool-name charset, mirroring core's CanonicalToolName schema. A name +// that does not match is dropped rather than emitted. For Cursor this filters +// the synthetic 'cursor:edit' and 'lang:' entries (both contain a +// colon) while letting through canonical names like 'Bash' and 'Read'. +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: CursorDecodedCall, turnIndex: number): CallObservation { + return { + provider: call.provider, + model: call.model, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + // Cursor calls are estimated from token buckets by the host's pricing table; + // they carry no provider-reported dollar figure. + costBasis: 'estimated', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation( + decode: RichCursorSessionDecode, + ctx: CursorToObservationsContext, +): SessionObservation { + const provider = ctx.provider ?? 'cursor' + const calls: CallObservation[] = decode.calls.map((call, i) => toCallObservation(call, i)) + + const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort() + const startedAt = timestamps[0] ?? '' + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1]! : '' + + const session: SessionObservation = { + sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId), + projectRef: projectRef(ctx.privacyKey, decode.projectPath), + providerId: provider, + startedAt, + ...(endedAt ? { endedAt } : {}), + calls, + turnCount: calls.length, + } + return session +} + +/** + * Map a rich Cursor decode (one or many sessions) into the minimized + * observation layer. Returns the `sessions` array plus any per-record + * `diagnostics`. + * + * Content-smuggling guarantee: no free text (user message, cwd, project path, + * raw command) is ever copied into the result. Only fingerprints, enums, + * numbers, timestamps, dedup keys, and canonical tool names cross the boundary. + */ +export function toObservations( + decode: RichCursorSessionDecode | RichCursorSessionDecode[], + ctx: CursorToObservationsContext, +): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } { + const decodes = Array.isArray(decode) ? decode : [decode] + const sessions = decodes.map(d => toSessionObservation(d, ctx)) + return { sessions, diagnostics: [] } +} diff --git a/packages/core/src/providers/cursor/types.ts b/packages/core/src/providers/cursor/types.ts new file mode 100644 index 00000000..be7861e8 --- /dev/null +++ b/packages/core/src/providers/cursor/types.ts @@ -0,0 +1,114 @@ +// Raw record + rich-decode types for the Cursor provider. +// +// Cursor is a Category D stateful multi-store provider. The host keeps all I/O, +// discovery, durable caching, project attribution, and pricing; core owns only +// the pure decode of the five row sets the host hands it. + +/** One row from the host's BUBBLE_QUERY_SINCE / BUBBLE_QUERY_PAGE fetch. */ +export type CursorBubbleRow = { + bubble_key: string + input_tokens: number | null + output_tokens: number | null + model: string | null + created_at: string | null + request_id: string | null + /** Raw BLOB/TEXT from CAST(substr(text,1,500) AS BLOB). */ + user_text: Uint8Array | string | null + text_length: number | null + /** 1 = user, otherwise assistant. */ + bubble_type: number | null + /** Raw JSON BLOB/TEXT from CAST(codeBlocks AS BLOB). */ + code_blocks: Uint8Array | string | null + /** Only populated on the paged scan path. */ + rid?: number +} + +/** One row from the host's AGENTKV_QUERY. */ +export type CursorAgentKvRow = { + role: string | null + /** Raw BLOB/TEXT content; kept as the raw union so empty-blob handling is preserved. */ + content: Uint8Array | string | null + request_id: string | null + model: string | null +} + +/** One row from the host's USER_MESSAGES_QUERY. */ +export type CursorUserMessageRow = { + bubble_key: string + created_at: string + /** Raw BLOB/TEXT text; kept as the raw union so empty-blob handling is preserved. */ + text: Uint8Array | string +} + +/** One row from the host's COMPOSER_META_QUERY. */ +export type CursorComposerMetaRow = { + composer_id: string + used: number | null + ctx: number | null + created_at: number | null +} + +/** Per-conversation metadata folded from CursorComposerMetaRow. */ +export type CursorComposerMeta = { + tokens: number + createdAt: number | null +} + +/// Per-conversation user-message buffer. We pop messages in arrival order via +/// the `pos` cursor — a previous implementation called Array.shift() which is +/// O(n) per call on large conversations and pinned multi-GB Cursor DBs at +/// minutes-of-parse for power users. The cursor walk is O(1). +export type CursorUserMessageQueue = { + messages: string[] + pos: number +} + +/** Accumulated agent-stream state for one conversation or unjoined request. */ +export type CursorAgentStream = { + tools: string[] + /** Raw command strings; bash base-name extraction stays host-side. */ + bash: string[] + userChars: number + contextChars: number + assistantChars: number + model: string | null +} + +/** What drives a conversation's input figure, decided per conversation. */ +export type CursorInputSource = 'bubbleTokens' | 'meter' | 'stream' | 'text' + +/** Per-conversation facts gathered in the pre-pass over bubble rows. */ +export type CursorComposerScan = { + hasRealTokens: boolean + firstBubbleTs: string | null + assistantTextChars: number + model: string | null +} + +/** + * Rich decode of one Cursor call, pre-pricing. Mirrors the host's + * ParsedProviderCall minus the host-owned cost fields (costBasis, + * costIsEstimated, pricingModel) and minus bash base-name extraction. + * + * `rawModel` is the effective model before the 'cursor-auto' display fallback so + * the host can compute its pricing model. + */ +export type CursorDecodedCall = { + provider: 'cursor' + model: string + rawModel: string | null + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + speed: 'standard' + tools: string[] + rawBashCommands: string[] + timestamp: string + deduplicationKey: string + userMessage: string + sessionId: string +} diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index e7a0ca9b..373e8ed8 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -165,6 +165,9 @@ const USER_MESSAGE_ALLOWLIST = new Set([ 'src/providers/warp/types.ts', 'src/providers/cursor-agent/decode.ts', 'src/providers/cursor-agent/types.ts', + 'src/providers/cursor/decode.ts', + 'src/providers/cursor/types.ts', + 'src/providers/cursor/index.ts', 'src/providers/quickdesk/decode.ts', 'src/providers/quickdesk/types.ts', 'src/providers/devin/decode.ts', diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index 3cf934dc..daa1765a 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -32,6 +32,7 @@ import { decodeGoose, toObservations as toGooseObservations } from '../src/provi import { decodeHermes, toObservations as toHermesObservations } from '../src/providers/hermes/index.js' import { decodeWarp, toObservations as toWarpObservations } from '../src/providers/warp/index.js' import { decodeCursorAgent, toObservations as toCursorAgentObservations } from '../src/providers/cursor-agent/index.js' +import { decodeCursor, toObservations as toCursorObservations } from '../src/providers/cursor/index.js' import { decodeQuickdesk, toObservations as toQuickdeskObservations } from '../src/providers/quickdesk/index.js' import { decodeDevin, toObservations as toDevinObservations } from '../src/providers/devin/index.js' import { decodeCopilot, toObservations as toCopilotObservations } from '../src/providers/copilot/index.js' @@ -46,6 +47,11 @@ import { } from '../src/providers/antigravity/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' +import type { + CursorAgentKvRow, + CursorBubbleRow, + CursorUserMessageRow, +} from '../src/providers/cursor/index.js' const here = dirname(fileURLToPath(import.meta.url)) const goldenEnvelope = JSON.parse( @@ -1746,3 +1752,167 @@ describe('content-smuggling guardrail: real antigravity decode -> toObservations expect(calls[0]!.model).toBe('unknown') }) }) + +describe('content-smuggling guardrail: real cursor decode -> toObservations is secret-free', () => { + // A hostile Cursor database planting secrets in every free-text field the + // decode reads: the bubble text (-> userMessage), a codeBlocks languageId + // (-> the synthetic `lang:` tool name, the sharpest vector since languageId is + // arbitrary attacker text), an agentKv user content blob, and a Shell + // tool-call's args.command (-> rawBashCommands). None may reach the envelope. + // + // The composer id is deliberately NOT a planted vector. Composer ids and + // request ids are the provider's own machine identifiers: they flow into + // sessionId and into the dedupKey, which the envelope keeps verbatim by + // design — exactly like every other provider's model and dedupKey. Hashing + // dedup keys uniformly is a schema-wide change, not a cursor-local one. + const cursorContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'cursor', + sourceRef: 'ref', + } + + function bubble(opts: Partial & { bubble_key: string }): CursorBubbleRow { + return { + input_tokens: null, + output_tokens: null, + model: null, + created_at: '2026-07-17T10:00:00.000Z', + request_id: null, + user_text: null, + text_length: null, + bubble_type: 2, + code_blocks: null, + ...opts, + } + } + + function decodeAndMinimize() { + const cid = 'composer-hostile' + const requestId = 'req-hostile' + + const bubbles: CursorBubbleRow[] = [ + bubble({ + bubble_key: `bubbleId:${cid}:u1`, + bubble_type: 1, + text_length: 100, + user_text: SECRETS.prompt, + }), + bubble({ + bubble_key: `bubbleId:${cid}:a1`, + bubble_type: 2, + text_length: 20, + user_text: 'reply text', + code_blocks: JSON.stringify([{ languageId: SECRETS.commandLine }]), + }), + ] + + const userMessageRows: CursorUserMessageRow[] = [ + { bubble_key: `bubbleId:${cid}:u1`, created_at: '2026-07-17T10:00:00.000Z', text: SECRETS.prompt }, + ] + + const agentKvRows: CursorAgentKvRow[] = [ + { role: 'user', content: SECRETS.apiKey, request_id: requestId, model: null }, + { + role: 'assistant', + content: JSON.stringify([{ type: 'tool-call', toolName: 'Shell', args: { command: SECRETS.commandLine } }]), + request_id: requestId, + model: null, + }, + { role: 'user', content: SECRETS.fileContent, request_id: requestId, model: null }, + ] + + const { calls } = decodeCursor({ + bubbles, + agentKvRows, + userMessageRows, + composerMetaRows: [], + agentKvTimestamp: '2026-07-17T10:00:00.000Z', + context: cursorContext, + }) + + const { sessions } = toCursorObservations( + { sessionId: cid, projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'cursor' }, + ) + + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core' as const, version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile Cursor rows', () => { + const envelope = decodeAndMinimize() + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + // Guards against a vacuous fixture: a dropped record would make the secret + // assertions below prove nothing. Two bubble calls plus the unjoined + // agentKv (arm C) call. + expect(envelope.sessions[0]?.calls).toHaveLength(3) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('every planted secret really did enter the decode it is guarding', () => { + const cid = 'composer-hostile' + const requestId = 'req-hostile' + const { calls } = decodeCursor({ + bubbles: [ + bubble({ bubble_key: `bubbleId:${cid}:u1`, bubble_type: 1, text_length: 100, user_text: SECRETS.prompt }), + bubble({ + bubble_key: `bubbleId:${cid}:a1`, + bubble_type: 2, + text_length: 20, + user_text: 'reply text', + code_blocks: JSON.stringify([{ languageId: SECRETS.commandLine }]), + }), + ], + agentKvRows: [ + { role: 'user', content: SECRETS.apiKey, request_id: requestId, model: null }, + { + role: 'assistant', + content: JSON.stringify([{ type: 'tool-call', toolName: 'Shell', args: { command: SECRETS.commandLine } }]), + request_id: requestId, + model: null, + }, + { role: 'user', content: SECRETS.fileContent, request_id: requestId, model: null }, + ], + userMessageRows: [ + { bubble_key: `bubbleId:${cid}:u1`, created_at: '2026-07-17T10:00:00.000Z', text: SECRETS.prompt }, + ], + composerMetaRows: [], + agentKvTimestamp: '2026-07-17T10:00:00.000Z', + context: cursorContext, + }) + const richStrings = allStrings(calls) + // The rich host-side decode DOES carry these; the minimizer is what + // contains them. Without this the secret assertions could pass simply + // because the fixture never reached the fields under guard. + expect(richStrings.some(s => s.includes(SECRETS.prompt))).toBe(true) + expect(richStrings).toContain(`lang:${SECRETS.commandLine}`) + expect(richStrings).toContain(SECRETS.commandLine) + // apiKey and fileContent are agentKv content: they are consumed as + // character counts only, so they must never appear even in the rich decode. + expect(richStrings.some(s => s.includes(SECRETS.apiKey))).toBe(false) + expect(richStrings.some(s => s.includes(SECRETS.fileContent))).toBe(false) + const streamCall = calls.find(c => c.sessionId === requestId) + expect(streamCall).toBeDefined() + expect(streamCall!.inputTokens).toBe( + Math.ceil((SECRETS.apiKey.length + SECRETS.fileContent.length) / 4), + ) + }) + + it('drops synthetic cursor:edit / lang:* tool names and keeps canonical Bash', () => { + const envelope = decodeAndMinimize() + const allToolNames = envelope.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(envelope.sessions[0]!.calls.length).toBeGreaterThan(0) + expect(allToolNames).toContain('Bash') + expect(allToolNames).not.toContain('cursor:edit') + expect(allToolNames).not.toContain(`lang:${SECRETS.commandLine}`) + }) +}) diff --git a/packages/core/tests/providers/cursor-decode.test.ts b/packages/core/tests/providers/cursor-decode.test.ts new file mode 100644 index 00000000..45f833d2 --- /dev/null +++ b/packages/core/tests/providers/cursor-decode.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it } from 'vitest' + +import { + contentTextLength, + decodeCursor, + extractLanguages, + parseComposerIdFromKey, + toObservations, +} from '../../src/providers/cursor/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' +import type { + CursorAgentKvRow, + CursorBubbleRow, +} from '../../src/providers/cursor/index.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'cursor', sourceRef: 'ref' } + +function b(opts: Partial & { bubble_key: string }): CursorBubbleRow { + return { + input_tokens: null, + output_tokens: null, + model: null, + created_at: '2026-07-17T10:00:00.000Z', + request_id: null, + user_text: null, + text_length: null, + bubble_type: 2, + code_blocks: null, + ...opts, + } +} + +const AGENT_KV_TS = '2026-07-17T10:00:00.000Z' + +// ----------------------------------------------------------------------------- +// C1 — parseComposerIdFromKey +// ----------------------------------------------------------------------------- +describe('cursor core decode: parseComposerIdFromKey', () => { + it('returns null when there is no colon', () => { + expect(parseComposerIdFromKey('bubbleId')).toBeNull() + }) + + it('returns null when there is only one colon', () => { + expect(parseComposerIdFromKey('bubbleId:composer')).toBeNull() + }) + + it('returns null when the composer segment is empty', () => { + expect(parseComposerIdFromKey('bubbleId::uuid')).toBeNull() + }) + + it('rejects a composer segment containing CR', () => { + expect(parseComposerIdFromKey('bubbleId:task-call_x\rfc_y:b')).toBeNull() + }) + + it('rejects a composer segment containing LF', () => { + expect(parseComposerIdFromKey('bubbleId:task-call_x\nfc_y:b')).toBeNull() + }) + + it('rejects a composer segment containing NUL', () => { + expect(parseComposerIdFromKey('bubbleId:task-call_x\x00fc_y:b')).toBeNull() + }) + + it('extracts a valid composer id', () => { + expect(parseComposerIdFromKey('bubbleId:composer-1:uuid')).toBe('composer-1') + }) +}) + +// ----------------------------------------------------------------------------- +// C2 — extractLanguages +// ----------------------------------------------------------------------------- +describe('cursor core decode: extractLanguages', () => { + it('excludes plaintext and preserves other languages', () => { + expect(extractLanguages(JSON.stringify([{ languageId: 'plaintext' }, { languageId: 'ts' }]))).toEqual(['ts']) + }) + + it('deduplicates languages while preserving order', () => { + expect(extractLanguages(JSON.stringify([{ languageId: 'ts' }, { languageId: 'ts' }, { languageId: 'js' }]))).toEqual([ + 'ts', + 'js', + ]) + }) + + it('returns empty array for non-array JSON', () => { + expect(extractLanguages(JSON.stringify({ languageId: 'ts' }))).toEqual([]) + }) + + it('returns empty array for malformed JSON', () => { + expect(extractLanguages('not json')).toEqual([]) + }) + + it('returns empty array for null input', () => { + expect(extractLanguages(null)).toEqual([]) + }) +}) + +// ----------------------------------------------------------------------------- +// C3 — contentTextLength +// ----------------------------------------------------------------------------- +describe('cursor core decode: contentTextLength', () => { + it('sums block.text in a block array', () => { + expect(contentTextLength(JSON.stringify([{ text: 'hello' }, { text: 'world' }]))).toBe(10) + }) + + it('falls back to block.content when text is absent', () => { + expect(contentTextLength(JSON.stringify([{ content: 'foo' }]))).toBe(3) + }) + + it('counts only text when both text and content are present', () => { + expect(contentTextLength(JSON.stringify([{ text: 'ab', content: 'xyz' }]))).toBe(2) + }) + + it('returns raw length when JSON starts with [ but cannot be parsed', () => { + const raw = '[not json' + expect(contentTextLength(raw)).toBe(raw.length) + }) + + it('returns raw length for a plain string', () => { + expect(contentTextLength('plain text')).toBe(10) + }) + + it('returns zero for a block with neither text nor content', () => { + expect(contentTextLength(JSON.stringify([{}]))).toBe(0) + }) +}) + +// ----------------------------------------------------------------------------- +// C4 — agentKv fold +// ----------------------------------------------------------------------------- +describe('cursor core decode: agentKv fold', () => { + it('flushes pending chars, resets on system rows, drops orphan tool rows, captures model once, and keeps empty Uint8Array but skips empty string', () => { + const agentKvRows: CursorAgentKvRow[] = [ + // Pending context/user chars before any requestId appears. + { role: 'system', content: 'sys-pending', request_id: null, model: null }, + { role: 'user', content: 'user-pending', request_id: null, model: null }, + // Empty TEXT row sets the requestId but is skipped; flush still happens. + { role: 'user', content: '', request_id: 'req-1', model: null }, + // Real user row: model captured here. + { role: 'user', content: 'after-flush', request_id: 'req-1', model: 'claude-4.6-sonnet' }, + // Second model is ignored once captured. + { role: 'user', content: 'ignored-model', request_id: 'req-1', model: 'gpt-5' }, + // System row resets currentRequestId to null and buffers its context for + // the next requestId (req-2) via the pending-flush mechanism. + { role: 'system', content: 'boundary', request_id: null, model: null }, + // Orphan tool row (no currentRequestId) is dropped. + { role: 'tool', content: 'orphan', request_id: null, model: null }, + // Empty BLOB is truthy and kept; empty TEXT is falsy and skipped. + { role: 'user', content: new Uint8Array(0), request_id: 'req-2', model: null }, + { role: 'user', content: '', request_id: 'req-2', model: null }, + { role: 'user', content: 'real', request_id: 'req-2', model: null }, + ] + + const { calls } = decodeCursor({ + bubbles: [], + agentKvRows, + userMessageRows: [], + composerMetaRows: [], + agentKvTimestamp: AGENT_KV_TS, + context, + }) + + // Both requests are unjoined (no bubbles mapped them to a composer). + expect(calls).toHaveLength(2) + + const req1 = calls.find(c => c.sessionId === 'req-1') + const req2 = calls.find(c => c.sessionId === 'req-2') + expect(req1).toBeDefined() + expect(req2).toBeDefined() + + // req-1: pending flushed + after-flush + ignored-model; model captured once. + expect(req1!.model).toBe('claude-4.6-sonnet') + expect(req1!.inputTokens).toBeGreaterThan(0) + + // req-2: 'boundary' context was buffered by the system row and flushed when + // req-2 appeared; 'real' added user chars; empty BLOB stayed, empty TEXT skipped. + const expectedInput = Math.ceil(('real'.length + 'boundary'.length) / 4) + expect(req2!.inputTokens).toBe(expectedInput) + expect(req2!.outputTokens).toBe(0) + }) +}) + +// ----------------------------------------------------------------------------- +// C5 — inputSource ladder +// ----------------------------------------------------------------------------- +describe('cursor core decode: inputSource ladder', () => { + const cid = 'cid-ladder' + + it('uses bubbleTokens when any bubble has a real tokenCount', () => { + const { calls } = decodeCursor({ + bubbles: [ + b({ bubble_key: `bubbleId:${cid}:u1`, bubble_type: 1, text_length: 40 }), + b({ + bubble_key: `bubbleId:${cid}:a1`, + bubble_type: 2, + input_tokens: 10, + output_tokens: 20, + }), + ], + agentKvRows: [], + userMessageRows: [], + composerMetaRows: [], + agentKvTimestamp: AGENT_KV_TS, + context, + }) + // Only the assistant bubble emits; per-bubble text estimate is bypassed. + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(10) + expect(calls[0]!.outputTokens).toBe(20) + }) + + it('uses meter when composerMeta has tokens and no real bubble tokens', () => { + const { calls } = decodeCursor({ + bubbles: [ + b({ bubble_key: `bubbleId:${cid}:u1`, bubble_type: 1, text_length: 40 }), + b({ bubble_key: `bubbleId:${cid}:a1`, bubble_type: 2, text_length: 20 }), + ], + agentKvRows: [], + userMessageRows: [], + composerMetaRows: [{ composer_id: cid, used: 100, ctx: null, created_at: 1_750_000_000_000 }], + agentKvTimestamp: AGENT_KV_TS, + context, + }) + // The assistant bubble still emits its output-text estimate; arm-B adds the + // conversation-level meter record. + expect(calls).toHaveLength(2) + const armB = calls.find(c => c.deduplicationKey === `cursor:composer-input:${cid}`) + expect(armB).toBeDefined() + expect(armB!.inputTokens).toBe(100) + }) + + it('uses stream when agentKv holds prompt/context and there is no meter', () => { + const { calls } = decodeCursor({ + bubbles: [ + b({ bubble_key: `bubbleId:${cid}:u1`, bubble_type: 1, text_length: 40, request_id: 'req-1' }), + b({ bubble_key: `bubbleId:${cid}:a1`, bubble_type: 2, text_length: 20, request_id: 'req-1' }), + ], + agentKvRows: [ + // System context buffers first, then the user row with requestId flushes it. + { role: 'system', content: 'ctx', request_id: null, model: null }, + { role: 'user', content: 'hello world', request_id: 'req-1', model: null }, + ], + userMessageRows: [], + composerMetaRows: [], + agentKvTimestamp: AGENT_KV_TS, + context, + }) + // The assistant bubble emits its output-text estimate; arm-B adds the stream + // estimate. agentKv is joined to the conversation, so no unjoined arm-C call. + expect(calls).toHaveLength(2) + const armB = calls.find(c => c.deduplicationKey === `cursor:composer-input:${cid}`) + expect(armB).toBeDefined() + // (11 + 3) / 4 rounded up = 4 + expect(armB!.inputTokens).toBe(Math.ceil(('hello world'.length + 'ctx'.length) / 4)) + }) + + it('uses text when nothing else is available', () => { + const { calls } = decodeCursor({ + bubbles: [ + b({ bubble_key: `bubbleId:${cid}:u1`, bubble_type: 1, text_length: 40 }), + b({ bubble_key: `bubbleId:${cid}:a1`, bubble_type: 2, text_length: 20 }), + ], + agentKvRows: [], + userMessageRows: [], + composerMetaRows: [], + agentKvTimestamp: AGENT_KV_TS, + context, + }) + // Both bubbles emit; no arm-B record. + expect(calls).toHaveLength(2) + expect(calls.some(c => c.deduplicationKey === `cursor:composer-input:${cid}`)).toBe(false) + const userCall = calls.find(c => c.inputTokens > 0 && c.outputTokens === 0) + const assistantCall = calls.find(c => c.outputTokens > 0 && c.inputTokens === 0) + expect(userCall).toBeDefined() + expect(assistantCall).toBeDefined() + expect(userCall!.inputTokens).toBe(Math.ceil(40 / 4)) + expect(assistantCall!.outputTokens).toBe(Math.ceil(20 / 4)) + }) +}) + +// ----------------------------------------------------------------------------- +// C6 — H4 mutation discriminator: arm B burns its dedup key before timestamp check +// ----------------------------------------------------------------------------- +describe('cursor core decode: arm B dedup key burn (H4)', () => { + it('adds the composer-input dedup key to seenKeys even when no call is emitted', () => { + const seenKeys = new Set() + const cid = 'cid-h4' + const { calls } = decodeCursor({ + bubbles: [b({ bubble_key: `bubbleId:${cid}:u1`, bubble_type: 1, created_at: null })], + agentKvRows: [], + userMessageRows: [], + composerMetaRows: [{ composer_id: cid, used: 50, ctx: null, created_at: null }], + agentKvTimestamp: AGENT_KV_TS, + context, + seenKeys, + }) + + expect(calls).toHaveLength(0) + expect(seenKeys.has(`cursor:composer-input:${cid}`)).toBe(true) + }) +}) + +// ----------------------------------------------------------------------------- +// C7 — toObservations +// ----------------------------------------------------------------------------- +describe('cursor core decode: toObservations minimizes correctly', () => { + it('produces a schema-valid envelope, filters synthetic tool names, orders turns, and fingerprints ids', () => { + const cid = 'cid-obs' + const { calls } = decodeCursor({ + bubbles: [ + b({ + bubble_key: `bubbleId:${cid}:a1`, + bubble_type: 2, + input_tokens: 100, + output_tokens: 50, + request_id: 'req-1', + code_blocks: JSON.stringify([{ languageId: 'ts' }, { languageId: 'plaintext' }]), + }), + ], + agentKvRows: [ + { + role: 'assistant', + content: JSON.stringify([{ type: 'tool-call', toolName: 'Shell', args: { command: 'ls' } }]), + request_id: 'req-1', + model: null, + }, + ], + userMessageRows: [], + composerMetaRows: [], + agentKvTimestamp: AGENT_KV_TS, + context, + }) + + const { sessions } = toObservations( + { sessionId: cid, projectPath: '/Users/victim/company/secret-plan.md', calls }, + { privacyKey: 'test-privacy-key', provider: 'cursor' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core' as const, version: '0.0.0-test' }, + sessions, + } + + const parsed = ObservationEnvelope.safeParse(envelope) + expect(parsed.success).toBe(true) + + const session = parsed.data!.sessions[0]! + expect(session.sessionRef).toMatch(/^[0-9a-f]{16}$/) + expect(session.projectRef).toMatch(/^[0-9a-f]{16}$/) + expect(session.calls).toHaveLength(calls.length) + + for (let i = 0; i < session.calls.length; i++) { + expect(session.calls[i]!.turnIndex).toBe(i) + } + + const allToolNames = session.calls.flatMap(c => c.toolNames) + // Synthetic Cursor tools contain ':' and must be dropped by CANONICAL_TOOL_NAME. + expect(allToolNames).not.toContain('cursor:edit') + expect(allToolNames).not.toContain('lang:ts') + // Canonical names survive. + expect(allToolNames).toContain('Bash') + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 8e99de43..f7d7b29d 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ 'src/providers/hermes/index.ts', 'src/providers/warp/index.ts', 'src/providers/cursor-agent/index.ts', + 'src/providers/cursor/index.ts', 'src/providers/quickdesk/index.ts', 'src/providers/devin/index.ts', 'src/providers/copilot/index.ts',