SuperAPI AI Directory

exploring-cursor-history

Agents & models

View repository

Updated 10-15 23:33

About this skill

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:

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

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

List recent conversations

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:

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:

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:

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):

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):

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:

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.

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:

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 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.