Cursor 的 state.vscdb 解析踩坑记

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

预计阅读时间:6 分钟


为什么要写这篇

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

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

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

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

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

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

复制代码
工作区 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 格式是:

复制代码
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/ChatCrystal

相关推荐
库拉大叔6 小时前
GPT-5.5 多模态能力实战:2026 年 AI 工具进阶使用指南
人工智能·gpt·aigc
JavaGuide6 小时前
GitHub 6.2 万 Star!Claude Code / Codex 的项目知识图谱工具火了。
github·ai编程·claude
薛定谔的狗哦7 小时前
别再Vibe Coding了:了解SDD(Spec-Driven Development)在AI编程中的重要性
ai编程
wuhen_n7 小时前
RAG 第一步:多格式文档加载与文本预处理实战
前端·langchain·ai编程
Sirius Wu7 小时前
Agent Skill能力建设
人工智能·深度学习·机器学习·ai·语言模型·aigc
AI兴球8 小时前
2026 AI编程能力实测排名
ai编程
千云8 小时前
ClaudeCode Skill生成教学培训文档,助力新人快速学习项目
人工智能·后端·ai编程
92year8 小时前
Miasma蠕虫实战拆解:你的AI编码助手正在被武器化
aigc
Python私教9 小时前
我把AI写作压成一条流水线:从写一篇到搭一条稳定产线
aigc·agent·claude
一拳小和尚LXY9 小时前
我开发了一款免费 Chrome 插件 TabScribe:一键复制所有标签页为 Markdown/JSON,完全离线零追踪
前端·chrome·json