# Exploring Cursor History

> Finds and explores Cursor IDE conversation history stored locally in SQLite databases and plaintext agent transcripts. Use when the user asks to find, search, read, or export a Cursor chat session, agent conversation, composer thread, or transcript.

- Skill: `jlreyes/exploring-cursor-history` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add jlreyes/exploring-cursor-history`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jlreyes/exploring-cursor-history/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: jlreyes (https://skillmd.com/u/jlreyes)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jlreyes/exploring-cursor-history

---


# Exploring Cursor History

Cursor stores IDE conversations in SQLite (`state.vscdb`), with the **global DB as the single source of truth** — workspace DBs only hold legacy metadata (pre-3.0). The CLI (`agent`) stores its own sessions under a root that defaults to `~/.cursor`. Current official releases are Desktop 3.19 / CLI 2026.09.02-c22c1a3; local schema evidence is Desktop 3.15.6 and CLI through 2026.08.04 because current live generation did not complete.

## Storage locations

IDE paths are shown for macOS (`~/Library/Application Support/Cursor` → `~/.config/Cursor` on Linux, `%APPDATA%\Cursor` on Windows). The CLI root defaults to `~/.cursor`; substitute the XDG or `CURSOR_CONFIG_DIR` override when configured.

| Path | What it holds |
|------|---------------|
| `…/Cursor/User/globalStorage/state.vscdb` | (IDE) All conversation content + metadata — `cursorDiskKV` key/value table plus a `composerHeaders` index table. Can be ~1 GB |
| `…/Cursor/User/globalStorage/conversation-search.db` | (IDE) FTS5 full-text index over conversation titles + bodies, added in Cursor 3.11. Fastest search surface; best-effort, not exhaustive |
| `…/Cursor/User/workspaceStorage/<hash>/state.vscdb` | (IDE) Legacy per-workspace conversation metadata (stopped updating at Cursor 3.0); `workspace.json` sibling maps hash → folder (`folder` or `workspace` key) |
| `~/.cursor/projects/<path-slug>/agent-transcripts/<id>/<id>.jsonl` | **Plaintext JSONL transcript** of agent conversations — IDE agent threads *and* CLI `agent` sessions. Only exists for recent conversations (from ~Cursor 3.0); sub-agents live in a `subagents/<subagentId>.jsonl` sibling dir |
| `~/.cursor/chats/<md5>/<sessionId>/store.db` | CLI (`agent`) session store + `meta.json`. `<md5>` = md5 of the workspace absolute path |
| `~/.cursor/plans/*.plan.md` | Plan-mode artifacts (markdown) |
| `~/.cursor/prompt_history.json` | Rolling array of recent typed prompts (small; IDE only) |

## Schema (quick reference)

Global DB, `cursorDiskKV` table (key/value):

- `composerData:<composerId>` — one row per conversation: `name`, `subtitle`, `unifiedMode` (`agent`/`chat`/`plan`/`edit`), `createdAt`/`lastUpdatedAt` (epoch ms). The last bubble's `createdAt` is exact only when present; it was absent for 447/990 nonempty conversations. Fall back to `lastUpdatedAt`/`createdAt` as approximate. `fullConversationHeadersOnly[]` is the ordered message list: `{bubbleId, type}` (1=user, 2=assistant). `workspaceIdentifier` is **almost never present** (4 of 2473 rows here) — attribute projects via the `composerHeaders` table, the legacy workspace lookup, or the `~/.cursor/projects` slug.
- `bubbleId:<composerId>:<bubbleId>` — one row per message: `text` (only on text turns), `type`, `createdAt` (ISO, often absent), `toolFormerData` (`{name, params, result, status}` — tool calls), `thinking.text` (reasoning), `codeBlocks`, `context` (file selections etc.)

Global DB, `composerHeaders` table (added ~Cursor 3.9): `(composerId, workspaceId, createdAt, lastUpdatedAt, isArchived, isSubagent, recency, checkpointAt, value)` — a forward-only index with a precomputed `recency` and real workspace attribution, but **only for conversations created after the migration** (26 rows vs 990 conversations with content here). Use it to filter/attribute recent chats; use `composerData:%` for the full history.

**Iterate via headers, not raw bubble rows** — orphan non-null bubbles from regenerated/deleted turns were observed; headers remain authoritative. Always open DBs read-only (`file:...?mode=ro`). In `sqlite3` shell arguments, escape JSON paths as `'\$.field'` so the shell doesn't mangle them.

All queries below assume:

```bash
GLOBAL_DB="$HOME/Library/Application Support/Cursor/User/globalStorage/state.vscdb"
SEARCH_DB="$HOME/Library/Application Support/Cursor/User/globalStorage/conversation-search.db"
```

## Resolve a partial composer ID

```bash
sqlite3 "file:$GLOBAL_DB?mode=ro" \
  "SELECT substr(key,14) FROM cursorDiskKV WHERE key LIKE 'composerData:1de03385%' LIMIT 1;"
```

## List recent conversations

```bash
sqlite3 -separator ' | ' "file:$GLOBAL_DB?mode=ro" "
  SELECT datetime(json_extract(c.value,'\$.createdAt')/1000,'unixepoch','localtime'),
         substr(c.key,14,12),
         json_extract(c.value,'\$.unifiedMode'),
         coalesce(nullif(json_extract(c.value,'\$.name'),''), json_extract(c.value,'\$.subtitle'), '?')
  FROM cursorDiskKV c
  WHERE c.key LIKE 'composerData:%'
    AND json_array_length(c.value,'\$.fullConversationHeadersOnly') > 0
  ORDER BY 1 DESC LIMIT 30;"
```

To sort by best-available last activity, order by the last bubble's timestamp (`[#-1]` = last array element), then fall back to composer timestamps:

```sql
coalesce(
  (SELECT unixepoch(json_extract(b.value,'$.createdAt')) * 1000 FROM cursorDiskKV b
   WHERE b.key = 'bubbleId:' || substr(c.key,14) || ':' ||
         json_extract(c.value,'$.fullConversationHeadersOnly[#-1].bubbleId')),
  json_extract(c.value,'$.lastUpdatedAt'),
  json_extract(c.value,'$.createdAt'))
```

For conversations created since the `composerHeaders` migration, that work is already done — and this is the only place with reliable workspace attribution and a subagent flag:

```bash
sqlite3 -separator ' | ' "file:$GLOBAL_DB?mode=ro" "
  SELECT datetime(recency/1000,'unixepoch','localtime'), substr(composerId,1,12), workspaceId,
         json_extract(value,'\$.unifiedMode'), json_extract(value,'\$.workspaceIdentifier.uri.fsPath')
  FROM composerHeaders WHERE isSubagent=0 ORDER BY recency DESC LIMIT 20;"
```

## Read a conversation transcript

One query — `json_each` walks the ordered header list and joins each bubble:

```bash
CID="<full composerId>"
sqlite3 "file:$GLOBAL_DB?mode=ro" "
  SELECT CASE json_extract(j.value,'\$.type') WHEN 1 THEN '## USER' ELSE '### ASSISTANT' END
         || coalesce('  ['||json_extract(b.value,'\$.toolFormerData.name')
                     ||' '||coalesce(json_extract(b.value,'\$.toolFormerData.status'),'')||']','')
         || char(10) || coalesce(nullif(json_extract(b.value,'\$.text'),''),'') || char(10)
  FROM cursorDiskKV c, json_each(c.value,'\$.fullConversationHeadersOnly') j
  LEFT JOIN cursorDiskKV b ON b.key = 'bubbleId:' || substr(c.key,14) || ':' || json_extract(j.value,'\$.bubbleId')
  WHERE c.key = 'composerData:$CID'
  ORDER BY j.key;"
```

Add `json_extract(b.value,'$.thinking.text')` for reasoning or `'$.createdAt'` for timestamps. Tool results can be large and may contain sensitive material; do not export them by default.

## Search conversations

Fastest path — the FTS5 index (`conversation_fts(title, body)`, joined to `conversations` on `fts_rowid`; `conversations.id` is the composerId):

```bash
sqlite3 -separator ' | ' "file:$SEARCH_DB?mode=ro" "
  SELECT datetime(c.updated_at/1000,'unixepoch','localtime'), substr(c.id,1,12), c.title,
         replace(snippet(conversation_fts,1,'«','»','…',12), char(10),' ')
  FROM conversation_fts f JOIN conversations c ON c.fts_rowid = f.rowid
  WHERE conversation_fts MATCH 'SEARCH_TERM'
  ORDER BY c.updated_at DESC LIMIT 20;"
```

Supports FTS5 syntax (`title:foo`, `a OR b`, `"exact phrase"`, `pref*`). It is **not exhaustive**: it indexes conversations up to a cap (`conversation_search_settings.effective_conversation_cap`) and ~1/3 of indexed rows here have an empty `body`. Fall back to a raw scan of the global DB when a known string isn't found (~0.9 s over a 932 MB DB):

```bash
sqlite3 "file:$GLOBAL_DB?mode=ro" "
  SELECT key, json_extract(value,'\$.type'), substr(json_extract(value,'\$.text'),1,120)
  FROM cursorDiskKV
  WHERE key LIKE 'bubbleId:%' AND value LIKE '%SEARCH_TERM%'
  LIMIT 20;"
```

The `LIKE` on raw `value` also matches `thinking` text and tool results; pull specific fields from the hits afterwards. The key embeds the composerId: `bubbleId:<composerId>:<bubbleId>`.

## List legacy (pre-3.0) conversations with workspace attribution

Conversations from before Cursor 3.0 (April 2026) have no `workspaceIdentifier`; their metadata lives in workspace DBs:

```bash
cd ~/Library/Application\ Support/Cursor/User/workspaceStorage
for d in */; do
  ws=$(jq -r '.folder // .workspace // empty' "$d/workspace.json" 2>/dev/null | sed 's|^file://||')
  sqlite3 "file:$d/state.vscdb?mode=ro" "
    SELECT json_extract(j.value,'\$.lastUpdatedAt') || '|' || substr(json_extract(j.value,'\$.composerId'),1,12)
           || '|' || coalesce(nullif(json_extract(j.value,'\$.name'),''), json_extract(j.value,'\$.subtitle'), '?')
           || '|' || '${ws##*/}'
    FROM ItemTable t, json_each(t.value,'\$.allComposers') j
    WHERE t.key='composer.composerData';" 2>/dev/null
done | sort -t'|' -rn | head -30
```

(Qualify `t.key` — `json_each` emits its own `key` column. Nothing newer than the 3.0 cutover appears here; always check the global DB first.)

## Safe text export (no SQLite at all)

Each line is one JSON record: role messages are `{role:"user"|"assistant", message:{content:[…]}}`; controls are `{type:"turn_ended",status:"success"}` or add `error` with `status:"aborted"`. This safe text export emits tool names only: inputs and results can expose commands, paths, queries, edits, or secrets. It is intentionally not byte-complete; inspect selected input fields only when explicitly needed. No `tool_result` part was observed in the 46-file corpus.

```bash
jq -r 'select(.role) | .role + ": " +
  ([.message.content[]
    | if .type=="text" then .text
      elif .type=="tool_use" then "[tool_use "+.name+"]"
      else "["+.type+"]" end] | join("\n"))' \
  ~/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl
```

`<slug>` = workspace absolute path with the leading `/` dropped and remaining `/` → `-`. In the observed corpus, 20/100 user records wrapped the prompt as `<timestamp>…</timestamp>\n<user_query>\n…\n</user_query>`. No `tool_result` part was observed; the JSONLs did contain `tool_use` calls.

## Cursor CLI (`agent`) sessions

A headless run writes both the JSONL transcript above **and** `~/.cursor/chats/<md5(workspacePath)>/<sessionId>/store.db` + `meta.json`. `<sessionId>` is the `session_id` from `--output-format json`. `--continue`/`--resume` rewrite the JSONL with the full conversation. List sessions without the TUI:

```bash
find ~/.cursor/chats -type f -name meta.json -print0 2>/dev/null |
  while IFS= read -r -d '' m; do
    jq -r --arg d "$(dirname "$m")" '"\(.updatedAtMs)  \($d|split("/")|last)  \(.cwd // "?")"' "$m"
  done | sort -rn
```

`store.db` is a content-addressed blob store (`blobs`, `meta`); the JSONL is the readable surface — see [data-model.md](data-model.md#cli-agent-store-storedb) before touching it.

## Tips

- Tool-call turns have empty `text` — render `toolFormerData.name` instead. The legacy `toolResults` / `suggestedCodeBlocks` / `assistantSuggestedDiffs` fields still exist on the 56,085 versioned bubble objects but are **always empty arrays**; don't read them.
- Tool names differ per surface: the DB uses internal names (`read_file_v2`, `ripgrep_raw_search`, `run_terminal_command_v2`); observed JSONL display names are `AwaitShell`, `Delete`, `GetMcpTools`, `Glob`, `Grep`, `Read`, `ReadLints`, `Shell`, `StrReplace`, `TodoWrite`, `WebFetch`, `WebSearch`, and `Write`.
- The JSONL mirror is *not* a complete history — only 33 of 990 conversations with content had one here (it starts around Cursor 3.0). Use the DB when a conversation is missing.
- Sub-agent threads are full `composerData` rows too (listed in the parent's `subagentComposerIds`), so they show up in "list recent conversations" — filter with `composerHeaders.isSubagent` when you only want top-level chats.
- Map a composerId to its project via `composerHeaders.workspaceId` / `value.workspaceIdentifier.uri.fsPath`, the legacy workspace lookup, or by which `~/.cursor/projects/<slug>/agent-transcripts/` directory contains it.
- Current help visibly lists `agent create-chat`, `agent ls`, `agent resume`, and `agent persist list|attach|stop`; persist manages detached processes, not export. On the older 2026.08.04 CLI, `ls`/`resume` were Ink TUIs that could hang under piped stdin. There is no export/history/sessions subcommand.
- Cloud/background agents (`bc-*` IDs) keep almost nothing locally — only the title is cached, in `conversation-search.db` as `source='cloud-cache'`; transcripts are server-side.
- A conversation that shows "Chat Too Old" in the UI is still fully readable from the DB; only its server `conversationState` token is lost.

