当你在 Claude Code 里讨论架构、在 Cursor 里排查问题、在 Copilot 里继续实现时,相关记录会散落在不同存储中。统一检索之前,需要先回答:什么算一条消息,什么算一次会话,哪些内容值得留下?
ChatCrystal 用不同适配器读取这些记录,再把它们交给共同的导入、笔记和检索流程。本文按 2026 年 9 月 23 日的实现修订,源码参照提交 7642768。这里描述的是项目已处理的格式,不是各工具承诺长期稳定的公开接口,也不是无损备份规范。
Claude Code:逐行读取,但不能逐行当成对话
默认扫描位置是 ~/.claude/projects/。JSONL 的每一行可以独立解析,却不意味着每行都能直接展示给读者:同一文件里还会出现系统记录、进度、文件历史快照以及工具相关内容。
下面是用于说明字段关系的最小示例,不是完整日志:
{"type":"user","uuid":"u1","message":{"role":"user","content":"解释这个错误"}}
{"type":"assistant","uuid":"a1","parentUuid":"u1","message":{"role":"assistant","content":[{"type":"text","text":"先检查输入类型。"}]}}
当前适配器要求记录具有 uuid,筛选 user 和 assistant,并保留 parentUuid。这是一种面向知识提炼的过滤策略:被跳过的记录不一定毫无价值,只是不属于当前导入结果希望保留的对话正文。
解析时要区分两层类型:外层记录的 type 和 message.content 数组内的块类型。content 可以是字符串,也可以同时包含文本和工具块。不能简单调用 String(content),也不能把所有工具结果直接当成人类输入。
系统附加文本的清理也应有明确范围。例如清理特定的系统标签,不代表可以删除所有尖括号内容;用户可能正在讨论 HTML。会话路径、记录字段和父子关系都可能提供元数据,不能假设项目和时间只能从文件名推断。
Codex:从事件流中选择消息来源
默认扫描位置是 ~/.codex/sessions/。这里同样是 JSONL,但记录围绕事件组织。需要同时检查顶层事件类型和 payload.type。
| 记录 | 当前适配器的用途 |
|---|---|
session_meta |
提取会话元数据 |
event_msg + user_message |
提取实际用户输入 |
event_msg + agent_message |
提取助手回复 |
response_item + message |
处理完整消息对象 |
response_item + function_call / custom_tool_call |
标记助手消息使用了工具 |
function_call 在这里是载荷类型,不能把它与 session_meta 并列理解为同一层字段。另一个常见问题是重复:同一次输入可能在多个事件表示中出现。当前适配器优先使用 event_msg/user_message,跳过 response_item 中的用户消息,并对助手文本做重复处理。
这里的去重有顺序限制:处理 response_item/message 时,会将文本与最近一条助手消息比较;处理 event_msg/agent_message 时则直接追加。因此,如果相同文本先出现在 response_item、后出现在 event_msg,仍可能重复。它并不是按事件 ID 进行的全局去重。
实现最后会按时间戳排序,但排序本身不等于完整恢复因果关系。缺失时间戳、相同时间戳以及格式升级,都需要独立的测试样本。导入后的可读消息也不等于可完整回放的工具执行日志。
Cursor:SQLite 只是容器,KV 才是要理解的模型
Cursor 使用 VS Code 风格的数据目录。Linux 常见根目录为 ~/.config/Cursor/User/,其他系统的位置不同。两个位置尤其重要:
workspaceStorage/<workspace>/state.vscdb
└─ 工作区与 Composer 元数据
globalStorage/state.vscdb
└─ 全局会话与 bubble 数据
常见访问入口包括 ItemTable 中的 composer.composerData,以及 cursorDiskKV 中形如 bubbleId:<composerId>:<bubbleId> 的键。SQLite 提供查询能力,但业务结构仍藏在 JSON 值和键的命名规则里。两个库、两张表不能简单互换。
需要处理的边界包括:
- 元数据和消息内容分离,必须正确关联 ID。
- 空助手消息可能只是中间状态,不应生成空笔记。
- 已存在于全局库中的会话,不一定还能从工作区元数据找到。
- 值的类型、嵌套字段和版本标记可能变化,应记录异常而不是默默丢失数据。
ChatCrystal 通过 sql.js 读取数据库文件的内存副本,不是直接连接正在运行的 Cursor 数据库。这样避免了直接写入源库,但不能据此保证读取的是最新一致快照:正在写入的数据库及其 WAL 需要考虑一致性。排障时应先确认源文件状态;不要修改原始数据库来迁就解析器。
内存副本也有成本。读入文件和实例化数据库都会消耗内存,大量工作区应逐个处理并及时关闭实例,而不是把所有库常驻内存。
Trae:同是 SQLite,消息模型却不同
当前适配器读取工作区 state.vscdb 的 ItemTable,查找 memento/icube-ai-agent-storage。不能因为文件扩展名相同,就复用 Cursor 的 bubble 查询。
当前 TypeScript 模型包含具有 sessionId 和 messages 的会话。助手消息可能直接包含 content,也可能在 agentTaskContent 中包含提案和计划步骤。因此,把整个存储描述成一个统一的“任务消息数组”会遗漏实际结构。
提取顺序是先看直接文本,再按已知结构读取嵌套内容。计划步骤、工具名和推理相关字段各有意义,不能通过把对象序列化为 JSON 来代替正文提取。适配器需要区分“结构存在但没有可读内容”和“解析失败”,否则后续摘要很难判断输入为什么为空。
GitHub Copilot:快照与补丁不能混为一谈
当前扫描范围包括工作区的 chatSessions,以及全局的 emptyWindowChatSessions,并兼容 .json 和 .jsonl 文件。
在当前适配器处理的 JSONL 格式中,首行是 kind: 0 的快照,后续可能是 kind: 1 的状态补丁。不能假设每行都是一份完整会话,然后简单保留最后一行。
{
"kind": 0,
"v": {
"sessionId": "example-session",
"requests": [
{"message": {"text": "解释这个函数"}, "response": [{"kind": "text", "value": "函数负责读取配置。"}]}
]
}
}
这是简化示意。response 属于每个 request,而不是独立的顶层数组。响应条目还可能包含工具调用和其他类型,需要分类提取。
当前实现读取 JSONL 首行快照,并没有重放后续全部补丁。这是重要的能力边界:如果更新只存在于补丁中,导入结果可能不能反映最终状态。需要完整还原时,应先补足补丁处理和回归样本,再声称能够恢复整个会话。
统一接口,保留差异
| 数据源 | 主要容器 | 核心解析工作 |
|---|---|---|
| Claude Code | JSONL 消息记录 | 过滤记录、提取内容块、保留消息关联 |
| Codex | JSONL 事件流 | 选择事件来源、处理重复、组装消息 |
| Cursor | SQLite KV | 关联工作区元数据与全局消息 |
| Trae | SQLite KV | 读取会话和嵌套任务内容 |
| Copilot | JSON / JSONL 快照与补丁 | 识别快照结构,明确未重放补丁的边界 |
ChatCrystal 的 SourceAdapter 将工作拆成 detect()、scan() 和 parse():检测来源、扫描元数据、解析成统一结果。这样上层导入流程不必知道 bubble 或任务字段的细节。
统一输出不意味着统一丢弃信息。至少应保留来源、原始 ID、时间及可追溯线索;解析失败和确实没有正文也应能够区分。扫描阶段的文件大小和修改时间有助于增量导入,但一个文件变化不等于所有内容都是新的。
验证解析器时,应先覆盖哪些情况
最小测试集不应该只有一段正常问答,还应包含:坏 JSON 行、空文件、只有工具结果的消息、重复事件、缺失时间戳、孤立 Composer、未知字段、Windows 与 Unix 路径,以及 Copilot 补丁更新。
对每个样本明确预期:导入几条消息、哪些内容保留、哪些内容跳过、是否给出诊断。使用脱敏或人工构造的数据,避免把真实项目内容带进测试仓库。
这些边界决定了知识库的输入质量。检索再好,也无法补回导入时悄悄丢掉的关键信息。
接下来可以阅读本地向量数据库选型,了解统一内容如何进入检索层;导出后的笔记管理见 Obsidian 工作流。