Importing Cursor history involves more than finding a chat text file. The current ChatCrystal adapter obtains session clues from workspaces and messages from a global database. Checking that both locations belong to the same user data is more useful than relying on an “import complete” message.
This article follows the Cursor adapter in ChatCrystal at commit 7642768. It describes handling of known storage shapes, not a stable public export format promised by Cursor.
Find the matching User directory
The adapter uses these default base directories:
| Platform | Base directory |
|---|---|
| Windows | %APPDATA%\Cursor\User |
| macOS | ~/Library/Application Support/Cursor/User |
| Linux | ~/.config/Cursor/User |
CURSOR_DATA_DIR overrides the base directory. Point it at the User level, rather than one hashed workspace directory. Like other local sources, the application server loads this variable at startup. Setting it before a client import command does not reconfigure an existing server.
User/
├── workspaceStorage/<workspace>/
│ ├── workspace.json
│ └── state.vscdb
└── globalStorage/
└── state.vscdb
Do not combine databases from different machines or users. If moving directories, retain the workspace description alongside its matching database.
Workspace clues
Workspace scanning requires both workspace.json and state.vscdb. The adapter reads folder from the former, strips file:/// and URL-decodes it, then reads allComposers from the latter’s ItemTable entry composer.composerData. This simple conversion turns file:///tmp/project into tmp/project; it does not guarantee a preserved POSIX absolute path. Check project display names separately from usable filesystem paths.
The list supplies identifiers such as composerId. Project display names come from the final segment of the decoded folder path, rather than the hashed directory. Workspaces that cannot be opened or parsed are skipped, so the list does not establish complete history coverage.
A stored Composer name is also not necessarily the imported title. The current parse() returns slug: null; it does not use each Composer’s name to populate that field.
Messages from the global database
The global state.vscdb contains a cursorDiskKV table with keys shaped like bubbleId:<composerId>:<bubbleId>. The adapter finds messages by conversation ID and extracts text and supporting fields from their JSON values.
This constructed example illustrates fields; it is not an original record from a real import:
{
"_v": 3,
"type": 1,
"text": "Why is this request running twice?",
"createdAt": "2026-10-01T10:00:00Z"
}
The current code treats type === 1 as user and other values as assistant, skipping assistant bubbles without body text. Thinking is stored separately; tool and code fields set flags. This does not establish complete preservation of tool outputs or editor state.
When _v exceeds 3, the code warns and continues parsing known fields. That does not prove full compatibility with a new format. Inspect actual messages rather than universally treating version warnings as harmless.
Orphan discovery has limits
The adapter also looks for global sessions absent from known workspace Composer lists. This can recover some history whose workspace was removed while messages remain. It is not a recovery tool with guaranteed coverage.
The candidate check inspects at most 20 bubbles per session for nonempty text, without a SQL ordering clause. Missing text in that sample, an unreadable global database, or changed key shapes can affect discovery. Once a candidate is found, parsing reads all matching bubbles for that session.
The database reader loads the main database file into a sql.js memory instance and retries once after a short delay on failure. It does not precisely classify read failures, corruption, and unsupported shapes, or replay SQLite WAL here. Missing records do not directly establish that Cursor deleted them.
Verify a real conversation
Using the default local connection:
crystal import
crystal --json conversations --source cursor --limit 20
The second command returns an object containing items. Select a real conversation whose content you know, inspect its ID, project, and message count, then read actual messages through application details or GET /api/conversations/<id>. Local import has source-option limitations described in the Claude Code import guide; do not promise that the option restricts scanning to Cursor.
Include a normal workspace, history present only in the global database, and a file known to contain malformed data or empty assistant messages. Evaluate retained content, without treating unmeasured claims about seconds per batch as acceptance evidence.
Recheck updated history
Import uses file metadata and parsed content hashes to decide whether to skip or replace records. For known workspace sessions, the current Cursor scan prefers Composer creation time as fileMtime and uses global database size as fileSize. Metadata checks therefore cannot be promised to identify every content change, especially when the global file size stays unchanged.
Test an update to an imported session and confirm that new messages reach the application. Otherwise, retain the session ID, file state, and version as investigation evidence. Notes still require summarization and embedding; a changed imported record does not prove that an old note remains correct. See the getting-started workflow for configuration and single-note verification.