本文面向:想了解 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 的处理方式是:
- 扫描全局库里所有
bubbleId:*的 key - 提取出所有 composerId
- 过滤掉已经在工作区列表里的
- 对剩余的 composerId,检查是否有至少一条有文本内容的 bubble
- 有内容的就导入,项目名显示为空
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 格式不统一 | 同时检查 thinking 和 text 字段 |
| 路径 URL 编码 | decodeURIComponent + 去掉 file:/// |
下一步
- Cursor 对话导入:解析 SQLite 里的宝藏 --- Cursor 数据源的完整导入流程
- Claude Code JSONL 里的系统标签噪音如何过滤 --- 另一个数据源的解析细节
- Codex CLI / Trae / Copilot 数据源接入 --- 三个数据源的解析细节