Reading Cursor's chat history out of SQLite

cursorsqliteattribution

Cursor keeps every conversation in one 4.4 GB SQLite file and records no working directory, so the repository has to be reconstructed from file URIs.

Cursor records no working directory for a conversation. Claude Code encodes the project in the transcript's directory name, and Codex writes a cwd into the first line of each rollout; Cursor writes neither, and the entire chat history for this machine — 641 conversations with at least one message in them, 107,077 messages — lives in one SQLite file, state.vscdb, currently 4,375,052,288 bytes. Getting from that file to "this conversation happened in the one large repo repo" is most of the work, and the obvious way to do it is wrong in two different ways.

The storage shape

On macOS the file is at ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb. On Linux it is under ~/.config/Cursor/User/globalStorage/, with a separate Flatpak path, and on Windows under %APPDATA%. It has three tables; everything interesting is in cursorDiskKV, a key-value store with 274,832 rows on this machine:

key prefixrows
agentKv116,018
bubbleId115,295
checkpointId17,000
codeBlockDiff12,122
messageRequestContext3,910
composerData981

Two of those matter. A composerData:<uuid> row is one conversation's spine: its name, createdAt and lastUpdatedAt in epoch millis, a status of completed or aborted, and fullConversationHeadersOnly — an ordered array of {bubbleId, type} giving the message order. Type 1 is the person, type 2 is the model. The messages are separate rows, bubbleId:<composerId>:<bubbleId>, each holding one message's text, its toolFormerData, and a tokenCount.

Fetch bubbles by the id order in that headers array, not with a where key like 'bubbleId:<composer>:%'. SQLite returns rows in no guaranteed order, and out of order a session's tool calls attach to the wrong prompt.

Of the 981 composerData rows, 11 have a NULL value column. node:sqlite returns those as JavaScript null, and JSON.parse(null) does not throw — it stringifies its argument, parses "null", and hands back null. The try/catch around the parse sees nothing wrong; the next property read throws. 329 more rows are composers that were opened and never used. That leaves 641 real conversations.

No dependency needed

Node 22 ships node:sqlite. It is flagged experimental and warns on first use, but it removes the choice between a native dependency and shelling out to a sqlite3 binary that is not on every machine.

import { DatabaseSync } from 'node:sqlite'
import { homedir } from 'node:os'
import { join } from 'node:path'

const db = new DatabaseSync(
  join(homedir(), 'Library/Application Support/Cursor/User/globalStorage/state.vscdb'),
  { readOnly: true },
)

const rows = db.prepare(
  "select key, value from cursorDiskKV where key like 'composerData:%'",
).all()

Open read-only and treat every failure as "no Cursor sessions" rather than a stack trace — Cursor is usually running while you read. Walking all 641 conversations and their 107,077 bubbles reads 1,329 MB of JSON and takes four to six seconds warm.

The real problem is the cwd

There is a workspaceIdentifier field carrying an fsPath. It is present on exactly 1 of the 641 conversations. The per-workspace databases under workspaceStorage know more: 58 of the 112 here carry a composer.composerData entry, and between them those name 385 of the 641 conversations. That leaves 256 unaccounted for, at a cost of 112 database opens.

What every conversation does carry is originalFileStates, keyed by absolute file:// URIs of every file it touched. 558 of the 641 have at least one; 4,274 URIs in total. So the repository has to be inferred from those paths.

The obvious inference is the longest common prefix, walked up to a directory that exists. It fails twice.

It splits one repo into many. A conversation that only touched files under src/components/crowdfund has that as its common prefix. Longest common prefix produces 136 distinct "projects" on this corpus, 30 of them inside the single one large repo checkout, covering 200 conversations:

 68  ~/code/app
 44  ~/code/app/src
 35  ~/code/app/src/components/crowdfund
  6  ~/code/app/src/components
  5  ~/code/app/src/components/pages/wallet/new-transaction

Across the corpus, 109 of the 136 keys are a subdirectory of a checkout rather than its root, covering 316 conversations. Every per-project rate computed on that is computed on fragments.

It climbs above the repo. Touch two checkouts in one conversation and the prefix is whatever encloses both. Here that is the home directory: 38 conversations resolved to ~ as though it were a project.

Resolving each file to its nearest ancestor containing .git fixes the first failure and exposes a subtler version of the second. ~/git on this machine is itself a checkout, and it holds 63 others. Any file whose own subtree has no .git walks all the way up into it. With per-file git roots and a straight majority vote, 44 conversations — 7,611 messages, spanning seven sibling directories — were filed under a project literally named git. The vote cannot fix this, because every candidate it is choosing between is already ~/git.

What works

Three rules, in order. Resolve each touched file to its nearest ancestor holding .git. Let the checkout owning the most files win, breaking ties toward the longer path so a nested checkout beats its parent. And treat a directory holding two or more child checkouts as a container, not a project: for a file beneath one, the useful label is the child directory it sits in.

approachdistinct projectsone large repo conversationsworst artefact
longest common prefix136200, split across 30 keys38 filed under $HOME
nearest .git, majority vote3620544 filed under git
plus the container rule41205none observed

What stays unattributed

90 of the 641 conversations get no project. 83 touched no files at all — they were questions, not edits — and 7 touched files that resolve to no checkout on this machine any more. Those stay blank rather than being pushed up to the nearest plausible directory. A conversation that never opened a file has no repository, and inventing one would put dozens of zero-file chats into somebody's per-project rework rate.

Two more traps from the same adapter. Cursor versions its tool names by suffix: edit_file became edit_file_v2, run_terminal_cmd became run_terminal_command_v2. 16,698 of the 67,404 tool calls here carry a _vN suffix, and stripping it before the lookup resolves all but two. Leave it on and they fall through unrecognised, alongside the 10,840 calls Cursor recorded with no name at all — 28,292 unrecognised between them, 42% of all tool calls. 6,439 of the suffixed ones are edit_file_v2, spread over 274 conversations, so write-detection that gates on the mapped name loses 6,439 real file edits.

Second, every bubble carries a tokenCount and only 2,628 of the 107,077 are non-zero — 2.5%. A recorded zero is not a measurement of zero, so the numbers say so out loud:

harnesses   claude-code 1288 (26.25B cr) · codex 438 (4.64B cr) · cursor 641 (0 cr)

not in these numbers
  cursor: counted, but token data is partial — its spend is a floor, not a total