Cursor 的 state.vscdb 解析踩坑记

本文面向:想了解 SQLite 数据库逆向工程细节的开发者,或在解析 VS Code 系 IDE 数据时遇到问题的人。 预计阅读时间:6 分钟


为什么要写这篇

Cursor 的对话数据存在 SQLite 数据库里,但这个数据库没有官方文档,没有 schema 说明,甚至表名都不按常理出牌。ChatCrystal 在实现 Cursor 适配器时踩了不少坑,这篇把关键的几个记下来。

坑 1:两个数据库,数据分散

你以为一个数据库就够了?不,Cursor 把数据拆成了两个:

bash 复制代码
~/.config/Cursor/User/
├── workspaceStorage/<hash>/state.vscdb   ← Composer 元数据(ID、时间、项目路径)
└── globalStorage/state.vscdb             ← 对话内容(Bubble 数据)

元数据和内容不在同一个库里。

工作区数据库存了「这个项目有哪些对话」,全局数据库存了「每条对话说了什么」。你需要先从工作区拿到 composerId,再去全局库里查对应的 bubble。

yaml 复制代码
工作区 DB:
  composer.composerData → {allComposers: [{composerId: "abc", createdAt: ...}]}

全局 DB:
  cursorDiskKV → key: "bubbleId:abc:001" → value: {type: 1, text: "你好"}
  cursorDiskKV → key: "bubbleId:abc:002" → value: {type: 2, text: "你好!有什么..."}

坑 2:表名是 camelCase

VS Code 系 IDE 的 SQLite 数据库里,表名是 ItemTable(PascalCase),但 Cursor 的全局 KV 存储用的是 cursorDiskKV(camelCase)。

sql 复制代码
-- 工作区 DB:标准 VS Code 表名
SELECT value FROM ItemTable WHERE [key] = 'composer.composerData';

-- 全局 DB:Cursor 自定义表名
SELECT [key], value FROM cursorDiskKV WHERE [key] LIKE 'bubbleId:abc:%';

如果你用操作 ItemTable 的代码去查 cursorDiskKV,会得到一个莫名其妙的 "no such table" 错误。

坑 3:key 格式是多段冒号分隔

全局库里 bubble 的 key 格式是:

ruby 复制代码
bubbleId:<composerId>:<bubbleId>

比如 bubbleId:abc123:def456。你不能简单地用 = 匹配,需要用 LIKE 前缀匹配来查某个 composer 的所有 bubble:

sql 复制代码
SELECT [key], value FROM cursorDiskKV WHERE [key] LIKE 'bubbleId:abc123:%'

从 key 里提取 composerId:

sql 复制代码
SELECT DISTINCT SUBSTR([key], 10, INSTR(SUBSTR([key], 10), ':') - 1)
FROM cursorDiskKV WHERE [key] LIKE 'bubbleId:%'

SUBSTR([key], 10) 跳过 bubbleId: 前缀(9 个字符 + 冒号 = 从第 10 位开始),INSTR(..., ':') 找到下一个冒号的位置。

坑 4:schema 版本 _v 可能更新

Bubble 数据里有一个 _v 字段表示 schema 版本:

json 复制代码
{
  "_v": 3,
  "type": 1,
  "text": "帮我看看这段代码"
}

目前 _v: 3 是主流版本。但 Cursor 更新后可能会变成 _v: 4_v: 5。ChatCrystal 在解析时会检查版本号,遇到未知版本只打 warning,不会崩溃:

typescript 复制代码
if (bubble._v && bubble._v > 3) {
  console.warn(`[Cursor] Unknown bubble schema version: ${bubble._v}`);
}

遇到格式警告时可以安全忽略,已支持的格式不会受影响。

坑 5:空助手消息是流式中间态

解析时你会发现很多 assistant 类型的 bubble 的 text 是空的:

json 复制代码
{"_v": 3, "type": 2, "text": "", "createdAt": "2026-05-10T10:30:01Z"}

这些是流式传输的中间状态。Cursor 在 AI 回复过程中会不断创建新的 bubble,先把空壳写进去,再逐步填充内容。如果对话正常结束,最终会有一个有内容的 bubble。但中途退出的话,可能只剩空壳。

ChatCrystal 直接跳过空的 assistant bubble:

typescript 复制代码
if (msgType === "assistant" && !text) continue;

用户消息不需要这个过滤,因为用户消息是完整写入的。

坑 6:孤立对话不会丢失

你可能在某个项目里用 Cursor 聊了很久,后来删掉了那个项目目录。工作区数据库跟着没了,但全局数据库里的 bubble 数据还在。

这些没有对应工作区 entry 的对话叫「孤立对话」。ChatCrystal 的处理方式是:

  1. 扫描全局库里所有 bubbleId:* 的 key
  2. 提取出所有 composerId
  3. 过滤掉已经在工作区列表里的
  4. 对剩余的 composerId,检查是否有至少一条有文本内容的 bubble
  5. 有内容的就导入,项目名显示为空
typescript 复制代码
// 从 key 里提取所有 composerId
const result = db.exec(
  "SELECT DISTINCT SUBSTR([key], 10, INSTR(SUBSTR([key], 10), ':') - 1) FROM cursorDiskKV WHERE [key] LIKE 'bubbleId:%'"
);

// 过滤掉已知的
const candidates = result[0].values
  .map(r => r[0] as string)
  .filter(id => !knownIds.has(id));

// 检查是否有实际内容
for (const composerId of candidates) {
  const bubbles = db.exec(
    `SELECT value FROM cursorDiskKV WHERE [key] LIKE 'bubbleId:${composerId}:%' LIMIT 20`
  );
  const hasContent = bubbles[0].values.some(row => {
    const bubble = JSON.parse(row[0] as string);
    return (bubble.text || "").trim().length > 0;
  });
  if (hasContent) valid.push(composerId);
}

LIMIT 20 是一个优化------不需要检查所有 bubble,只要找到一条有内容的就够了。

坑 7:thinking 块有多种格式

Cursor 的思考过程存储在 allThinkingBlocks 数组里,但每个块的结构不统一:

json 复制代码
{
  "allThinkingBlocks": [
    {"thinking": "让我分析一下这段代码..."},
    {"text": "这是一个递归函数..."}
  ]
}

有的块用 thinking 字段,有的用 text 字段。ChatCrystal 两个都检查:

typescript 复制代码
if (bubble.allThinkingBlocks && bubble.allThinkingBlocks.length > 0) {
  const thinkingTexts = bubble.allThinkingBlocks
    .map(b => b.thinking || b.text || "")
    .filter(Boolean);
  if (thinkingTexts.length > 0) {
    thinking = thinkingTexts.join("\n");
  }
} else if (bubble.thinking) {
  thinking = bubble.thinking;
}

另外,有些 bubble 没有 allThinkingBlocks,但有一个顶层的 thinking 字段。这是旧版格式,也要兼容。

坑 8:workspace.json 的路径编码

工作区的项目路径存在 workspace.json 里,但它是 URL 编码的:

json 复制代码
{"folder": "file:///c%3A/Users/Rayner/Project/MyApp"}

需要先去掉 file:/// 前缀,再 decodeURIComponent 解码:

typescript 复制代码
const rawFolder = wsJson.folder || "";
const folder = decodeURIComponent(rawFolder.replace(/^file:\/\/\//, ""));

如果不解码,路径里的空格(%20)、冒号(%3A)都会是乱码。

总结

解决方案
两个数据库 先查工作区拿 ID,再查全局拿内容
表名不一致 工作区用 ItemTable,全局用 cursorDiskKV
key 多段冒号 LIKE 'bubbleId:%' 前缀匹配
schema 版本变化 检查 _v,未知版本打 warning 不崩溃
空助手消息 跳过 text 为空的 assistant bubble
孤立对话 扫描全局库所有 bubbleId,过滤已知的
thinking 格式不统一 同时检查 thinkingtext 字段
路径 URL 编码 decodeURIComponent + 去掉 file:///

下一步


项目地址:github.com/ZengLiangYi...

相关推荐
Larcher3 小时前
# 告别“古法编程”:吴恩达 AI 课程学习笔记与生日贺卡项目实战
前端·github·ai编程
ZengLiangYi3 小时前
Codex CLI / Trae / Copilot 数据源接入
ai编程
Json_3 小时前
Claude Code 使用指南:高频命令、快捷键、核心功能与实战技巧详解
agent·ai编程·claude
crossoverJie3 小时前
OpenAI 三连发:GPT-5.5、Codex移动端、DeployCo,AI编程进入新阶段
人工智能·gpt·ai编程
qingyulee3 小时前
提示词工程
ai编程
树獭非懒7 小时前
AI大模型小白手册 | Function Calling-大模型与真实世界交互的桥梁
人工智能·llm·ai编程
pngyul8 小时前
后端微服务的 monorepo-like workspace 方案
ai编程
冰凌时空9 小时前
iOS 架构模式全景图:MVC / MVVM / VIPER / Clean Architecture 选型指南
ios·openai·ai编程