diff --git a/packages/storage/src/__tests__/cursor-session-adapter.test.ts b/packages/storage/src/__tests__/cursor-session-adapter.test.ts new file mode 100644 index 00000000000..efff20b685e --- /dev/null +++ b/packages/storage/src/__tests__/cursor-session-adapter.test.ts @@ -0,0 +1,492 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, test } from 'node:test'; +import { + ExternalSessionLimitError, + ExternalSessionNotFoundError, +} from '@maka/core/external-session'; +import { createExternalSessionAdapterRegistry } from '../external-session-adapters.js'; +import { + CURSOR_SESSION_ADAPTER_ID, + CursorSessionAdapter, + defaultCursorHome, +} from '../cursor-session-adapter.js'; + +const CLEANUPS: (() => Promise)[] = []; + +afterEach(async () => { + while (CLEANUPS.length > 0) { + await CLEANUPS.pop()!(); + } +}); + +interface FixtureBubble { + readonly bubbleId: string; + readonly type: number; + readonly text?: string; + readonly createdAt?: string; + readonly toolFormerData?: Record; +} + +interface FixtureComposer { + readonly composerId: string; + readonly name?: string; + readonly createdAt?: number; + readonly lastUpdatedAt?: number; + readonly headers?: readonly FixtureBubble[]; + /** Header ids whose bubble row the source pruned: key never written. */ + readonly omitBubbles?: readonly string[]; + /** Rows whose key is written but whose value is not JSON. */ + readonly corruptBubbles?: readonly string[]; + /** Pads the composer record with a foreign field of this many bytes. */ + readonly paddedBytes?: number; +} + +/** Builds a `state.vscdb` the way Cursor writes it: one KV table, JSON text values. */ +async function makeStateDb(composers: readonly FixtureComposer[]): Promise { + const home = await mkdtemp(join(tmpdir(), 'cursor-globalStorage-')); + CLEANUPS.push(() => rm(home, { recursive: true, force: true })); + const db = new DatabaseSync(join(home, 'state.vscdb')); + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)'); + for (const composer of composers) { + const record = { + _v: 13, + composerId: composer.composerId, + ...(composer.name !== undefined ? { name: composer.name } : {}), + ...(composer.createdAt !== undefined ? { createdAt: composer.createdAt } : {}), + ...(composer.lastUpdatedAt !== undefined ? { lastUpdatedAt: composer.lastUpdatedAt } : {}), + ...(composer.paddedBytes !== undefined + ? { foreignFutureField: 'p'.repeat(composer.paddedBytes) } + : {}), + fullConversationHeadersOnly: (composer.headers ?? []).map(({ bubbleId, type }) => ({ + bubbleId, + type, + })), + }; + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run( + `composerData:${composer.composerId}`, + JSON.stringify(record), + ); + for (const bubble of composer.headers ?? []) { + if (composer.omitBubbles?.includes(bubble.bubbleId)) continue; + const body: Record = { _v: 13, type: bubble.type }; + if (bubble.text !== undefined) body.text = bubble.text; + if (bubble.createdAt !== undefined) body.createdAt = bubble.createdAt; + if (bubble.toolFormerData !== undefined) body.toolFormerData = bubble.toolFormerData; + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run( + `bubbleId:${composer.composerId}:${bubble.bubbleId}`, + JSON.stringify(body), + ); + } + for (const bubbleId of composer.corruptBubbles ?? []) { + // Replaces the readable row: the header is intact, the body is not. + db.prepare('INSERT OR REPLACE INTO cursorDiskKV (key, value) VALUES (?, ?)').run( + `bubbleId:${composer.composerId}:${bubbleId}`, + '{"type": 2, "text":', + ); + } + } + db.close(); + return home; +} + +function adapterFor(home: string, overrides: Record = {}): CursorSessionAdapter { + return new CursorSessionAdapter({ cursorHome: home, ...overrides }); +} + +const ISO_A = '2026-02-06T23:10:05.886Z'; +const ISO_B = '2026-02-06T23:10:09.997Z'; +const ISO_C = '2026-02-06T23:10:12.181Z'; + +function fullFixture(): FixtureComposer[] { + return [ + { + composerId: 'composer-aaaa', + name: 'Fix the login flow', + createdAt: 1765597641911, + lastUpdatedAt: 1765597700000, + headers: [ + { bubbleId: 'b1', type: 1, text: 'the login button does nothing', createdAt: ISO_A }, + { + bubbleId: 'b2', + type: 2, + text: 'I will look at the handler.', + createdAt: ISO_B, + }, + { + bubbleId: 'b3', + type: 2, + text: '', + createdAt: ISO_C, + toolFormerData: { + tool: '35', + toolCallId: 'toolu_01', + name: 'read_file', + status: 'completed', + params: JSON.stringify({ path: 'src/login.ts' }), + result: 'export function onLogin() {}', + }, + }, + { + bubbleId: 'b4', + type: 2, + text: '', + createdAt: ISO_C, + toolFormerData: { + tool: '36', + toolCallId: 'toolu_02', + name: 'edit_file', + status: 'error', + params: JSON.stringify({ path: 'src/login.ts' }), + result: 'the file changed on disk', + }, + }, + { bubbleId: 'b5', type: 2, text: 'The handler was never bound.', createdAt: ISO_C }, + ], + }, + { + composerId: 'composer-bbbb', + name: 'Empty session', + createdAt: 1765597642000, + headers: [], + }, + { + composerId: 'composer-cccc', + name: 'Pruned session', + createdAt: 1765597643000, + // The header survives; its bubble row was pruned by the source. + headers: [{ bubbleId: 'gone', type: 1, text: 'lost prompt' }], + omitBubbles: ['gone'], + }, + ]; +} + +describe('CursorSessionAdapter', () => { + test('reports absent when no database exists', async () => { + const home = await mkdtemp(join(tmpdir(), 'cursor-empty-')); + CLEANUPS.push(() => rm(home, { recursive: true, force: true })); + const adapter = adapterFor(home); + assert.equal(await adapter.detect(), false); + assert.deepEqual(await adapter.listSessions(), []); + }); + + test('an unreadable database is reported, not answered as an empty catalog', async () => { + const home = await mkdtemp(join(tmpdir(), 'cursor-corrupt-')); + CLEANUPS.push(() => rm(home, { recursive: true, force: true })); + await writeFile(join(home, 'state.vscdb'), 'not a database'); + const adapter = adapterFor(home); + assert.equal(await adapter.detect(), true); + await assert.rejects(adapter.listSessions(), /could not be (opened|read)/u); + await assert.rejects(adapter.readSession('composer-x'), /could not be (opened|read)/u); + }); + + test('lists composers with names and stamps, query-filtered', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + assert.equal(await adapter.detect(), true); + + const all = await adapter.listSessions(); + assert.deepEqual( + all.map((session) => [session.id, session.name]), + [ + ['composer-aaaa', 'Fix the login flow'], + ['composer-bbbb', 'Empty session'], + ['composer-cccc', 'Pruned session'], + ], + ); + assert.equal(all[0]!.createdAt, 1765597641911); + assert.equal(all[0]!.updatedAt, 1765597700000); + + const filtered = await adapter.listSessions({ text: 'login' }); + assert.deepEqual( + filtered.map((session) => session.id), + ['composer-aaaa'], + ); + }); + + test('pages the catalog with an opaque cursor', async () => { + const home = await makeStateDb( + [1, 2, 3].map((index) => ({ + composerId: `composer-${index}`, + name: `session ${index}`, + createdAt: index, + })), + ); + const adapter = adapterFor(home); + const first = await adapter.listSessionPage({ limit: 2 }); + assert.equal(first.items.length, 2); + assert.equal(first.hasMore, true); + const second = await adapter.listSessionPage({ limit: 2, cursor: first.items[1]!.nextCursor! }); + assert.equal(second.items.length, 1); + assert.equal(second.hasMore, false); + assert.deepEqual( + second.items.map((item) => item.summary.id), + ['composer-3'], + ); + }); + + test('reads one session end to end: turns, tools, and the abort verdict', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + + const session = await adapter.readSession('composer-aaaa'); + assert.equal(session.metadata.name, 'Fix the login flow'); + const messages = session.messages; + assert.equal(messages[0]!.type, 'user'); + if (messages[0]!.type === 'user') { + assert.equal(messages[0]!.text, 'the login button does nothing'); + assert.equal(messages[0]!.ts, Date.parse(ISO_A)); + } + const toolCalls = messages.filter((message) => message.type === 'tool_call'); + assert.equal(toolCalls.length, 2); + if (toolCalls[0]!.type === 'tool_call') { + assert.equal(toolCalls[0]!.toolName, 'read_file'); + assert.deepEqual(toolCalls[0]!.args, { path: 'src/login.ts' }); + } + const results = messages.filter((message) => message.type === 'tool_result'); + assert.equal(results.length, 2); + if (results[0]!.type === 'tool_result') assert.equal(results[0]!.isError, false); + if (results[1]!.type === 'tool_result') assert.equal(results[1]!.isError, true); + const verdicts = messages.filter((message) => message.type === 'turn_state'); + // One turn: its tool errored, so the turn reads failed, not completed. + assert.equal(verdicts.length, 1); + if (verdicts[0]!.type === 'turn_state') assert.equal(verdicts[0]!.status, 'failed'); + // Every converted row namespaces generated ids; a tool call keeps its + // source toolCallId because the result pairs with it by that id. + for (const message of messages) { + if (message.type === 'tool_call') continue; + assert.match(message.id, /^cursor:composer-aaaa:/u); + } + const callIds = messages + .filter((message) => message.type === 'tool_call') + .map((message) => (message.type === 'tool_call' ? message.id : '')); + assert.deepEqual(callIds, ['toolu_01', 'toolu_02']); + }); + + test('an empty headers list is an empty session, not an error', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + const session = await adapter.readSession('composer-bbbb'); + assert.deepEqual(session.messages, []); + assert.equal(session.metadata.name, 'Empty session'); + }); + + test('a header whose bubble was pruned is a gap, not a failure', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + const session = await adapter.readSession('composer-cccc'); + assert.deepEqual(session.messages, []); + }); + + test('a corrupt bubble row degrades to a gap while readable ones still import', async () => { + const home = await makeStateDb([ + { + composerId: 'composer-dddd', + name: 'Half readable', + headers: [ + { bubbleId: 'keep', type: 1, text: 'still here' }, + { bubbleId: 'broken', type: 2, text: 'unreadable' }, + ], + corruptBubbles: ['broken'], + }, + ]); + const adapter = adapterFor(home); + const session = await adapter.readSession('composer-dddd'); + assert.deepEqual( + session.messages.map((message) => message.type), + ['user', 'turn_state'], + ); + }); + + test('a missing session reads as not found', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + await assert.rejects( + adapter.readSession('composer-zzzz'), + (error: unknown) => error instanceof ExternalSessionNotFoundError, + ); + }); + + test('a hostile session id is refused before any database read', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home); + await assert.rejects(adapter.readSession("x' OR '1'='1"), /not usable/u); + }); + + test('all five limit kinds refuse oversized transcripts', async () => { + const home = await makeStateDb(fullFixture()); + // records: the composer's header count caps the read before any bubble is + // fetched. + await assert.rejects( + adapterFor(home, { maxRows: 2 }).readSession('composer-aaaa'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'records', + ); + // record_bytes: one oversized bubble refuses the session outright. + const bigComposer: FixtureComposer[] = [ + { + composerId: 'composer-big', + name: 'Big bubble', + headers: [{ bubbleId: 'big', type: 1, text: 'x'.repeat(4096) }], + }, + ]; + const bigHome = await makeStateDb(bigComposer); + await assert.rejects( + adapterFor(bigHome, { maxRawBytes: 1024 }).readSession('composer-big'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'record_bytes', + ); + // transcript_bytes: the aggregate across in-budget rows refuses too. + const spreadComposer: FixtureComposer[] = [ + { + composerId: 'composer-spread', + name: 'Spread', + headers: [1, 2, 3].map((index) => ({ + bubbleId: `b${index}`, + type: 2, + text: 'y'.repeat(600), + })), + }, + ]; + const spreadHome = await makeStateDb(spreadComposer); + await assert.rejects( + adapterFor(spreadHome, { maxRawBytes: 1024 }).readSession('composer-spread'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'transcript_bytes', + ); + // messages: the converted message count caps the import. + await assert.rejects( + adapterFor(home, { maxMessages: 3 }).readSession('composer-aaaa'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'messages', + ); + // converted_bytes: the serialized size caps the import. + await assert.rejects( + adapterFor(home, { maxConvertedBytes: 512 }).readSession('composer-aaaa'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'converted_bytes', + ); + }); + + test('error messages carry no source paths or transcript content', async () => { + const home = await makeStateDb(fullFixture()); + const adapter = adapterFor(home, { maxRows: 2 }); + try { + await adapter.readSession('composer-aaaa'); + assert.fail('expected the limit to refuse the session'); + } catch (error) { + assert.ok(error instanceof Error); + assert.equal(error.message.includes(home), false); + assert.equal(error.message.includes('login'), false); + } + const badAdapter = new CursorSessionAdapter({ + cursorHome: home, + stateDbPath: join(home, 'absent.vscdb'), + }); + await assert.rejects(badAdapter.readSession('composer-aaaa'), /unavailable/u); + }); + + test('an oversized foreign title is sanitized, not verbatim', async () => { + const home = await makeStateDb([ + { composerId: 'composer-title', name: `t${'i'.repeat(200_000)}tle` }, + ]); + const adapter = adapterFor(home); + const sessions = await adapter.listSessions(); + assert.equal(sessions.length, 1); + assert.ok(sessions[0]!.name.length < 200_000); + }); + + test('joins the shared registry as the cursor source', async () => { + const home = await makeStateDb(fullFixture()); + const registry = createExternalSessionAdapterRegistry({ cursor: { cursorHome: home } }); + const adapter = registry.get(CURSOR_SESSION_ADAPTER_ID); + assert.ok(adapter); + assert.equal(await adapter.detect(), true); + }); + + test('the composer row itself is budgeted before it is read', async () => { + const home = await makeStateDb([ + { + composerId: 'composer-fat', + name: 'Fat composer', + headers: [], + // The composer record alone carries a padded foreign field that blows + // a 1 KB budget without any bubbles at all. + paddedBytes: 4096, + }, + ]); + await assert.rejects( + adapterFor(home, { maxRawBytes: 1024 }).readSession('composer-fat'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'record_bytes', + ); + }); + + test('the row budget counts the composer row against records', async () => { + const home = await makeStateDb(fullFixture()); + // composer + 5 bubbles = 6 source rows; a limit of 6 must pass, 5 refuse. + const session = await adapterFor(home, { maxRows: 6 }).readSession('composer-aaaa'); + assert.ok(session.messages.length > 0); + await assert.rejects( + adapterFor(home, { maxRows: 5 }).readSession('composer-aaaa'), + (error: unknown) => + error instanceof ExternalSessionLimitError && error.limit.kind === 'records', + ); + }); + + test('default discovery roots follow the platform', () => { + assert.equal( + defaultCursorHome('darwin', '/Users/u'), + '/Users/u/Library/Application Support/Cursor/User/globalStorage', + ); + assert.equal( + defaultCursorHome('linux', '/home/u'), + '/home/u/.config/Cursor/User/globalStorage', + ); + // Windows roots at %APPDATA%, which the Windows runtime always sets and + // POSIX installs leave unset — the fact the layout actually keys on. + assert.equal( + defaultCursorHome('win32', 'C:\\Users\\u', 'C:\\Users\\u\\AppData\\Roaming'), + 'C:\\Users\\u\\AppData\\Roaming/Cursor/User/globalStorage', + ); + assert.equal( + defaultCursorHome('win32', 'C:\\Users\\u'), + 'C:\\Users\\u/.config/Cursor/User/globalStorage', + ); + }); + + test('open and read failures stay free of the source path', async () => { + const home = await makeStateDb(fullFixture()); + await writeFile(join(home, 'state.vscdb'), 'not a database'); + const adapter = adapterFor(home); + const error = await adapter.listSessions().then( + () => null, + (cause: unknown) => cause, + ); + assert.ok(error instanceof Error); + assert.equal(error.message.includes(home), false, error.message); + assert.equal((error as NodeJS.ErrnoException).cause, undefined); + }); +}); diff --git a/packages/storage/src/cursor-session-adapter.ts b/packages/storage/src/cursor-session-adapter.ts new file mode 100644 index 00000000000..01c7d93963f --- /dev/null +++ b/packages/storage/src/cursor-session-adapter.ts @@ -0,0 +1,728 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Cursor sessions as Maka Sessions. +// +// State lives in one SQLite database, `state.vscdb` under Cursor's +// `User/globalStorage` directory. The database is Cursor-internal, not a +// public contract: every field read here is tolerated missing or foreign, and +// a record this build cannot read degrades instead of failing the import. +// Verified against Cursor builds writing `_v` 10 through 14. +// +// A conversation is key-paired rows in one KV table. `composerData:` holds +// the session record: the display name, wall-clock stamps, and +// `fullConversationHeadersOnly`, the ordered list of `bubbleId`s. Each +// `bubbleId::` row holds one turn message. Headers can +// outlive their bubbles (pruned by the source), so a missing bubble is a gap +// in the transcript, not a broken database. +import { stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { + ExternalSessionLimitError, + ExternalSessionNotFoundError, + externalSessionMatchesQuery, + sanitizeExternalSessionTitle, +} from '@maka/core/external-session'; +import type { + ExternalMakaSession, + ExternalSessionAdapter, + ExternalSessionCatalogPage, + ExternalSessionCatalogPageQuery, + ExternalSessionQuery, + ExternalSessionSummary, +} from '@maka/core/external-session'; +import type { StoredMessage } from '@maka/core/session'; +import { listOffsetExternalSessionCatalogPage } from './offset-external-session-catalog.js'; + +export const CURSOR_SESSION_ADAPTER_ID = 'cursor'; +/** + * What one Cursor transcript may cost before the import is refused. + * + * Source rows are the composer record plus its bubble rows; bytes cap encoded + * SQLite payload and rows cap the number of decoded source objects. These are + * memory bounds, not process-RSS promises: single bubbles were measured at + * over 800 KB, so `record_bytes` is sized by the same constant rather than a + * separate per-row figure. + */ +export const CURSOR_TRANSCRIPT_MAX_RAW_BYTES = 64 * 1024 * 1024; +export const CURSOR_TRANSCRIPT_MAX_ROWS = 250_000; +export const CURSOR_TRANSCRIPT_MAX_MESSAGES = 250_000; +export const CURSOR_TRANSCRIPT_MAX_CONVERTED_BYTES = 256 * 1024 * 1024; + +const EXTERNAL_SNAPSHOT_ABORT_SOURCE = 'external_session_snapshot'; + +/** + * Guards a source field before it reaches JavaScript. Memory bounds, not + * display limits: the display length is `sanitizeExternalSessionTitle`'s job. + */ +const CURSOR_CATALOG_ID_MAX_BYTES = 512; +const CURSOR_CATALOG_TITLE_MAX_BYTES = 64 * 1024; + +/** Guards the value interpolated into no SQL, but read back out of one. */ +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; + +export interface CursorSessionAdapterOptions { + /** Overrides the platform default Cursor data root. */ + cursorHome?: string; + /** Overrides the database file inside `cursorHome`. */ + stateDbPath?: string; + /** Maximum aggregate bytes across source rows loaded for one import. */ + maxRawBytes?: number; + /** Maximum source rows (composer record + bubbles) loaded for one import. */ + maxRows?: number; + /** Maximum Maka messages one import may convert. */ + maxMessages?: number; + /** Maximum serialized bytes retained across converted Maka messages. */ + maxConvertedBytes?: number; +} + +/** + * Cursor's app-data root per platform, the directory that holds + * `User/globalStorage`. macOS keys on the platform, Windows on `APPDATA` + * (which the Windows runtime always sets and POSIX installs leave unset), and + * everything else lands on the XDG layout — Cursor's documented roots. Pure so + * tests can pin all three without touching the real environment. + */ +export function defaultCursorHome( + platform: NodeJS.Platform, + home: string, + appData?: string, +): string { + if (platform === 'darwin') { + return `${home}/Library/Application Support/Cursor/User/globalStorage`; + } + if (appData !== undefined && appData.length > 0) { + return `${appData}/Cursor/User/globalStorage`; + } + return `${home}/.config/Cursor/User/globalStorage`; +} + +export class CursorSessionAdapter implements ExternalSessionAdapter { + readonly id = CURSOR_SESSION_ADAPTER_ID; + readonly #home: string; + readonly #stateDbPath?: string; + readonly #maxRawBytes: number; + readonly #maxRows: number; + readonly #maxMessages: number; + readonly #maxConvertedBytes: number; + + constructor(options: CursorSessionAdapterOptions = {}) { + this.#home = + options.cursorHome ?? defaultCursorHome(process.platform, homedir(), process.env.APPDATA); + this.#stateDbPath = options.stateDbPath; + this.#maxRawBytes = options.maxRawBytes ?? CURSOR_TRANSCRIPT_MAX_RAW_BYTES; + this.#maxRows = options.maxRows ?? CURSOR_TRANSCRIPT_MAX_ROWS; + this.#maxMessages = options.maxMessages ?? CURSOR_TRANSCRIPT_MAX_MESSAGES; + this.#maxConvertedBytes = options.maxConvertedBytes ?? CURSOR_TRANSCRIPT_MAX_CONVERTED_BYTES; + assertPositiveSafeInteger(this.#maxRawBytes, 'Cursor transcript byte limit'); + assertPositiveSafeInteger(this.#maxRows, 'Cursor transcript row limit'); + assertPositiveSafeInteger(this.#maxMessages, 'Cursor converted message count limit'); + assertPositiveSafeInteger(this.#maxConvertedBytes, 'Cursor converted message byte limit'); + } + + async detect(): Promise { + return (await this.#resolvedDatabasePath()) !== undefined; + } + + async listSessions(query: ExternalSessionQuery = {}): Promise { + return this.#readSessionPage(query); + } + + async listSessionPage( + query: ExternalSessionCatalogPageQuery, + ): Promise { + return listOffsetExternalSessionCatalogPage(query, (pageQuery) => this.listSessions(pageQuery)); + } + + async readSession(sessionId: string): Promise { + if (!SESSION_ID_PATTERN.test(sessionId)) { + throw new Error(`cursor session id is not usable: ${sessionId}`); + } + return this.#withDatabase((db) => + // One deferred snapshot for the size probe, the composer read, and the + // bubble walk: a Cursor writer committing between them must not leave + // the guard holding one row's size and the reader holding another's. + withReadSnapshot(db, () => { + // The composer record is itself a source row: size it before anything + // materializes it, and count it against both budgets, or a single fat + // record imports under a tiny budget. + const composerBytes = composerRecordBytes(db, sessionId); + if (composerBytes === undefined || composerBytes === 0) { + throw new ExternalSessionNotFoundError(); + } + if (composerBytes > this.#maxRawBytes) { + throw new ExternalSessionLimitError( + 'record_bytes', + this.#maxRawBytes, + `Cursor transcript contains a source record larger than ${this.#maxRawBytes} bytes`, + ); + } + // Sized above, so materializing the record now stays inside the budget. + const composer = readComposerRecord(db, sessionId); + if (!composer) throw new ExternalSessionNotFoundError(); + const name = sanitizeExternalSessionTitle(composer.name) || sessionId; + const headers = Array.isArray(composer.fullConversationHeadersOnly) + ? composer.fullConversationHeadersOnly + : []; + if (headers.length + 1 > this.#maxRows) { + throw new ExternalSessionLimitError( + 'records', + this.#maxRows, + `Cursor transcript exceeds ${this.#maxRows} source rows`, + ); + } + const bubbles = readBubbles(db, sessionId, headers, this.#maxRawBytes - composerBytes); + return { + sourceSessionId: sessionId, + metadata: { + name, + cwd: '', + }, + messages: convertTranscript(sessionId, bubbles, { + fallbackTs: composer.lastUpdatedAt ?? composer.createdAt ?? 0, + maxConvertedBytes: this.#maxConvertedBytes, + maxMessages: this.#maxMessages, + }), + }; + }), + ); + } + + async #resolvedDatabasePath(): Promise { + const path = this.#stateDbPath ?? join(this.#home, 'state.vscdb'); + // The path ships with spaces on every platform ("Application Support"); + // this adapter only ever opens it through the driver, never through a + // shell or a URI, so the spaces need no escaping here. + try { + return (await stat(path)).isFile() ? path : undefined; + } catch { + return undefined; + } + } + + async #withDatabase(read: (db: CursorDatabase) => T): Promise { + const path = await this.#resolvedDatabasePath(); + if (!path) throw new Error('cursor database is unavailable'); + let sqlite: typeof import('node:sqlite'); + try { + sqlite = await import('node:sqlite'); + } catch (cause) { + throw new Error('cursor sessions need node:sqlite, which is unavailable', { cause }); + } + let db: CursorDatabase; + try { + db = new sqlite.DatabaseSync(path, { readOnly: true }) as CursorDatabase; + } catch { + // Defense in depth: the Host translates adapter errors today, but the + // adapter contract is that its own errors never carry the source path + // or the driver's underlying error text. + throw new Error('cursor database could not be opened'); + } + try { + return read(db); + } catch (cause) { + if ( + cause instanceof ExternalSessionLimitError || + cause instanceof ExternalSessionNotFoundError || + (cause instanceof Error && cause.message.startsWith('cursor session ')) + ) { + throw cause; + } + throw new Error('cursor database could not be read'); + } finally { + try { + db.close(); + } catch { + // A close that fails leaves nothing for a reader to do; the process + // releases the handle either way, and throwing here would replace a + // usable result with an error about cleanup. + } + } + } + + async #readSessionPage(query: ExternalSessionQuery): Promise { + // Discovery is allowed to come up empty — the catalog lists whatever + // sources are present, and a Cursor install that never opened a composer + // is a normal state rather than a failure to report. + if (!(await this.#resolvedDatabasePath())) return []; + return await this.#withDatabase((db) => { + const required = ['key', 'value']; + const actual = tableColumns(db, 'cursorDiskKV'); + if (required.some((column) => !actual.has(column))) { + throw new Error('cursor `cursorDiskKV` table does not carry required columns'); + } + const requestedOffset = query.offset ?? 0; + const requestedLimit = query.limit ?? Number.MAX_SAFE_INTEGER; + if ( + !Number.isSafeInteger(requestedOffset) || + requestedOffset < 0 || + !Number.isSafeInteger(requestedLimit) || + requestedLimit < 0 + ) { + throw new Error('Invalid Cursor catalog page'); + } + if (requestedLimit === 0) return []; + // Projection in SQL: only the scalar metadata a summary needs leaves the + // database, never the whole composer JSON — one page costs its own rows, + // not the catalog. + const statement = db.prepare( + `SELECT coalesce(json_extract(value, '$.composerId'), substr(key, ${'composerData:'.length + 1})) AS cid, + json_extract(value, '$.name') AS name, + json_extract(value, '$.createdAt') AS created, + json_extract(value, '$.lastUpdatedAt') AS updated + FROM cursorDiskKV + WHERE key LIKE 'composerData:%' + AND length(CAST(key AS BLOB)) <= ${CURSOR_COMPOSER_KEY_MAX_BYTES} + AND json_valid(value) + ORDER BY key + LIMIT ? OFFSET ?`, + ); + const summaries: ExternalSessionSummary[] = []; + let matched = 0; + let rawOffset = 0; + const batchSize = Math.max(32, Math.min(256, requestedLimit * 2)); + while (summaries.length < requestedLimit) { + const raw = statement.all(batchSize, rawOffset); + for (const row of raw) { + const record = asRecord(row); + const id = stringOf(record?.cid); + if (id === undefined) continue; + const summary = toSummary({ + composerId: id, + name: stringOf(record?.name), + createdAt: numberOf(record?.created), + lastUpdatedAt: numberOf(record?.updated), + }); + if (!externalSessionMatchesQuery(summary, query)) continue; + if (matched++ < requestedOffset) continue; + summaries.push(summary); + if (summaries.length === requestedLimit) break; + } + rawOffset += raw.length; + if (raw.length < batchSize) break; + } + return summaries; + }); + } +} + +/** + * `composerData:` + uuid + NUL-safety margin. The bound rides in the SQL so a + * foreign key shape cannot pull an unbounded row set into memory. + */ +const CURSOR_COMPOSER_KEY_MAX_BYTES = CURSOR_CATALOG_ID_MAX_BYTES + 64; + +interface CursorDatabase { + prepare(sql: string): { + all(...params: unknown[]): unknown[]; + get(...params: unknown[]): unknown; + }; + exec(sql: string): void; + close(): void; +} + +interface ComposerRecord { + readonly composerId?: string; + readonly name?: string; + readonly createdAt?: number; + readonly lastUpdatedAt?: number; + readonly fullConversationHeadersOnly?: readonly unknown[]; +} + +/** One decoded bubble: the record this build reads, foreign fields ignored. */ +export interface CursorBubble { + readonly type?: unknown; + readonly text?: unknown; + readonly createdAt?: unknown; + readonly toolFormerData?: unknown; +} + +/** One deferred snapshot across the size probe, the composer read, and the + * bubble walk, so a concurrent Cursor writer cannot split them. + */ +function withReadSnapshot(db: CursorDatabase, read: () => T): T { + db.exec('BEGIN DEFERRED'); + try { + const result = read(); + db.exec('COMMIT'); + return result; + } catch (error) { + try { + db.exec('ROLLBACK'); + } catch { + // Preserve the read failure. Closing the read-only connection releases + // any transaction SQLite could not roll back explicitly. + } + throw error; + } +} + +function readComposerRecord(db: CursorDatabase, sessionId: string): ComposerRecord | undefined { + const raw = db + .prepare('SELECT value FROM cursorDiskKV WHERE key = ?') + .get(`composerData:${sessionId}`); + return parseComposerRow(raw); +} + +/** The encoded size of one composer record, read without materializing it. */ +function composerRecordBytes(db: CursorDatabase, sessionId: string): number | undefined { + const row = asRecord( + db + .prepare('SELECT length(CAST(value AS BLOB)) AS bytes FROM cursorDiskKV WHERE key = ?') + .get(`composerData:${sessionId}`), + ); + const bytes = numberOf(row?.bytes); + return bytes === undefined ? undefined : bytes; +} + +/** + * Walks a composer's headers in recorded order and fetches each bubble. + * + * A header whose bubble row is gone is a pruned transcript, not a broken + * database: the gap is skipped and the rest of the conversation imports. One + * bubble larger than the byte budget refuses the session (`record_bytes`) + * rather than importing a truncated message, and the running total refuses at + * `transcript_bytes`. + */ +function readBubbles( + db: CursorDatabase, + sessionId: string, + headers: readonly unknown[], + maxRawBytes: number, +): readonly CursorBubble[] { + const statement = db.prepare('SELECT value FROM cursorDiskKV WHERE key = ?'); + const bubbles: CursorBubble[] = []; + let totalBytes = 0; + for (const header of headers) { + const bubbleId = stringOf(asRecord(header)?.bubbleId); + if (bubbleId === undefined) continue; + const raw = statement.get(`bubbleId:${sessionId}:${bubbleId}`); + const value = stringOf(rawValue(raw)); + if (value === undefined) continue; + const bytes = Buffer.byteLength(value, 'utf8'); + if (bytes > maxRawBytes) { + throw new ExternalSessionLimitError( + 'record_bytes', + maxRawBytes, + `Cursor transcript contains a source record larger than ${maxRawBytes} bytes`, + ); + } + totalBytes += bytes; + if (totalBytes > maxRawBytes) { + throw new ExternalSessionLimitError( + 'transcript_bytes', + maxRawBytes, + `Cursor transcript exceeds ${maxRawBytes} source bytes`, + ); + } + const bubble = parseBubble(value); + if (bubble) bubbles.push(bubble); + } + return bubbles; +} + +function parseBubble(value: string): CursorBubble | undefined { + try { + const parsed = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as CursorBubble; + } + } catch { + // A bubble this build cannot decode is a gap in the transcript, not a + // failure of the import: the rest of the conversation still converts. + } + return undefined; +} + +/** + * Turns one Cursor composer's bubbles into Maka messages. + * + * Exported for the fixture tests, which exercise the mapping without a + * database: the conversion is where the source format is interpreted, and it + * is the part worth pinning. + */ +export function convertTranscript( + sessionId: string, + bubbles: readonly CursorBubble[], + limits: { + fallbackTs?: number; + maxConvertedBytes?: number; + maxMessages?: number; + } = {}, +): readonly StoredMessage[] { + const maxConvertedBytes = limits.maxConvertedBytes ?? CURSOR_TRANSCRIPT_MAX_CONVERTED_BYTES; + const maxMessages = limits.maxMessages ?? CURSOR_TRANSCRIPT_MAX_MESSAGES; + const fallbackTs = limits.fallbackTs ?? 0; + + const out: StoredMessage[] = []; + let convertedBytes = 0; + const append = (message: StoredMessage): void => { + if (out.length >= maxMessages) { + throw new ExternalSessionLimitError( + 'messages', + maxMessages, + `Cursor transcript converts to more than ${maxMessages} messages`, + ); + } + const encodedBytes = Buffer.byteLength(JSON.stringify(message), 'utf8'); + if (encodedBytes > maxConvertedBytes - convertedBytes) { + throw new ExternalSessionLimitError( + 'converted_bytes', + maxConvertedBytes, + `Cursor transcript converts to more than ${maxConvertedBytes} bytes`, + ); + } + convertedBytes += encodedBytes; + out.push(message); + }; + let sequence = 0; + const id = (kind: string): string => `cursor:${sessionId}:${kind}:${sequence++}`; + let turnSequence = 0; + + interface Turn { + turnId: string; + lastTs: number; + sawErrorTool: boolean; + sawCancelledTool: boolean; + } + let turn: Turn | undefined; + + const closeTurn = (): void => { + if (!turn) return; + if (turn.sawErrorTool) { + append({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'failed', + errorClass: 'cursor_error', + }); + } else if (turn.sawCancelledTool) { + append({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'aborted', + abortedAt: turn.lastTs, + abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE, + }); + } else { + append({ + type: 'turn_state', + id: id('turn-state'), + turnId: turn.turnId, + ts: turn.lastTs, + status: 'completed', + }); + } + turn = undefined; + }; + + for (const bubble of bubbles) { + const ts = bubbleTimestamp(bubble.createdAt) ?? fallbackTs; + + if (bubble.type === 1) { + // A user message closes whatever came before it either way: it is the + // boundary, whether or not it carries a prompt this import can use. + closeTurn(); + const text = stringOf(bubble.text) ?? ''; + if (text.length === 0) continue; + turn = { + turnId: `cursor:${sessionId}:turn:${turnSequence++}`, + lastTs: ts, + sawErrorTool: false, + sawCancelledTool: false, + }; + append({ type: 'user', id: id('user'), turnId: turn.turnId, ts, text }); + continue; + } + + if (bubble.type !== 2) continue; + + if (!turn) { + // An assistant bubble with no preceding user message: a resumed session + // whose opening prompt is not in this transcript. Give it a turn rather + // than dropping the content. + turn = { + turnId: `cursor:${sessionId}:turn:${turnSequence++}`, + lastTs: ts, + sawErrorTool: false, + sawCancelledTool: false, + }; + } + turn.lastTs = Math.max(turn.lastTs, ts); + + const text = stringOf(bubble.text); + if (text !== undefined && text.length > 0) { + append({ + type: 'assistant', + id: id('assistant'), + turnId: turn.turnId, + ts, + text, + contentOrder: ['text'], + modelId: 'cursor', + }); + } + + const tool = asRecord(bubble.toolFormerData); + if (!tool) continue; + const callId = stringOf(tool.toolCallId); + // A call with no id cannot be paired with its result. Minting one + // produces a row guaranteed not to match anything, which reads as a + // detached call rather than an absent one. + if (callId === undefined) continue; + const status = stringOf(tool.status); + append({ + type: 'tool_call', + id: callId, + turnId: turn.turnId, + ts, + toolName: stringOf(tool.name) ?? 'unknown', + args: parseJsonObject(tool.params), + }); + if (status === 'completed') { + append({ + type: 'tool_result', + id: id('tool-result'), + turnId: turn.turnId, + ts, + toolUseId: callId, + isError: false, + content: { kind: 'text', text: stringOf(tool.result) ?? '' }, + }); + continue; + } + if (status === 'error') { + turn.sawErrorTool = true; + append({ + type: 'tool_result', + id: id('tool-result'), + turnId: turn.turnId, + ts, + toolUseId: callId, + isError: true, + content: { + kind: 'text', + text: stringOf(tool.result) ?? 'cursor tool call failed', + }, + }); + continue; + } + // A cancelled (or otherwise unfinished) call had no answer when the + // session was written: no result, and the turn records the abort. + turn.sawCancelledTool = true; + } + + closeTurn(); + return out; +} + +function bubbleTimestamp(value: unknown): number | undefined { + // Bubbles stamp ISO strings; the composer record stamps epoch millis. Both + // are read, because a fixture (or a future Cursor) may write either. + if (typeof value === 'string' && value.length > 0) { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return numberOf(value); +} + +function parseJsonObject(value: unknown): Record { + if (typeof value === 'string' && value.length > 0) { + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Foreign params shape: the call still imports with empty args rather + // than failing the record. + } + } + return asRecord(value) ?? {}; +} + +function parseComposerRow(raw: unknown): ComposerRecord | undefined { + const value = rawValue(raw); + if (typeof value !== 'string') return undefined; + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + const record = parsed as Record; + return { + composerId: stringOf(record.composerId), + name: stringOf(record.name), + createdAt: numberOf(record.createdAt), + lastUpdatedAt: numberOf(record.lastUpdatedAt), + fullConversationHeadersOnly: Array.isArray(record.fullConversationHeadersOnly) + ? (record.fullConversationHeadersOnly as unknown[]) + : undefined, + }; + } + } catch { + // Foreign record shape: skipped by the caller, the way a catalog skips a + // row it cannot read rather than failing the whole listing. + } + return undefined; +} + +function toSummary(composer: ComposerRecord): ExternalSessionSummary { + const updatedAt = composer.lastUpdatedAt ?? composer.createdAt; + const id = composer.composerId ?? ''; + return { + id, + name: sanitizeExternalSessionTitle(composer.name) || id, + cwd: '', + ...(composer.createdAt !== undefined ? { createdAt: composer.createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + }; +} + +function rawValue(row: unknown): unknown { + return asRecord(row)?.value; +} + +function tableColumns(db: CursorDatabase, table: string): Set { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name?: unknown }[]; + return new Set( + rows.map((column) => (typeof column.name === 'string' ? column.name : '')).filter(Boolean), + ); +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function stringOf(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function numberOf(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function assertPositiveSafeInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be positive`); +} diff --git a/packages/storage/src/external-session-adapters.ts b/packages/storage/src/external-session-adapters.ts index b4bbdabc3b6..12d5da79b57 100644 --- a/packages/storage/src/external-session-adapters.ts +++ b/packages/storage/src/external-session-adapters.ts @@ -23,6 +23,10 @@ import { type ClaudeCodeSessionAdapterOptions, } from './claude-code-session-adapter.js'; import { CodexSessionAdapter, type CodexSessionAdapterOptions } from './codex-session-adapter.js'; +import { + CursorSessionAdapter, + type CursorSessionAdapterOptions, +} from './cursor-session-adapter.js'; import { OpenCodeSessionAdapter, type OpenCodeSessionAdapterOptions, @@ -31,6 +35,7 @@ import { export interface ExternalSessionAdapterOptions { codex?: CodexSessionAdapterOptions; claudeCode?: ClaudeCodeSessionAdapterOptions; + cursor?: CursorSessionAdapterOptions; opencode?: OpenCodeSessionAdapterOptions; } @@ -41,6 +46,7 @@ export function createExternalSessionAdapterRegistry( return new ExternalSessionAdapterRegistry([ new CodexSessionAdapter(options.codex), new ClaudeCodeSessionAdapter(options.claudeCode), + new CursorSessionAdapter(options.cursor), new OpenCodeSessionAdapter(options.opencode), ]); }