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

相关推荐
我是宝库3 小时前
SCI论文可不可以先用免费系统检测重复率和AI率?
人工智能·aigc·英文论文·sci论文·论文查重·turnitin系统·ithenticate
财经资讯数据_灵砚智能3 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年5月18日
人工智能·python·信息可视化·自然语言处理·ai编程
随风丶飘3 小时前
DeepSeek TUI 让后端告别窗口切来切去
java·ai编程
mask哥3 小时前
codex安装并配置第三方大模型api方法详解
人工智能·ai编程·codex·vibecoding
optimistic_chen3 小时前
【AI Agent 全栈开发】RAG(检索增强生成)
java·linux·运维·人工智能·ai编程·rag
xiami_world3 小时前
2026年团队AI工具栈架构指南:ChatGPT + Codex + AI白板智能体工程化落地方案
人工智能·ai·信息可视化·aigc·流程图
水煮白菜王3 小时前
JSONEditor 使用指南
前端·javascript·chrome·json
GISer_Jing3 小时前
从前端到AI Agent工程师:技能升级与职业跃迁指南
前端·人工智能·ai编程
crossoverJie4 小时前
手搓一个 Agent 驱动的项目 Wiki 生成方案
ai编程