本文面向:需要从多个 AI 编程工具导入对话、被数据格式差异困扰的开发者。
预计阅读时间:10 分钟
最终效果:理解 5 种工具数据格式差异的根源,以及 ChatCrystal 如何用 SourceAdapter 适配器模式把差异隔离在边界内。
五种格式的现实
ChatCrystal 支持从 5 个 AI 编程工具导入对话:Claude Code、Codex CLI、Cursor、Trae、GitHub Copilot。如果你打开它们的数据目录,会看到:
- Claude Code:JSONL 文件,每行一个 JSON 对象
- Codex CLI:JSONL 文件,事件流格式
- Cursor:SQLite 数据库(state.vscdb)
- Trae:SQLite 数据库(state.vscdb)
- Copilot:JSONL 文件,会话快照格式
同样是"AI 编程对话",为什么格式差这么多?
历史包袱:每个工具有自己的起点
这 5 个工具不是从零开始设计的,它们各自有不同的技术起点:
Claude Code:终端原生
Claude Code 是 Anthropic 的 CLI 工具,从终端启动。它的对话记录自然地选择了 JSONL(JSON Lines)格式------每行一个 JSON 对象,追加写入,零解析开销。
json
{"uuid":"abc-123","type":"summary","timestamp":"2025-01-15T10:30:00Z","message":{"role":"user","content":"帮我修复这个 bug"}}
{"uuid":"def-456","parentUuid":"abc-123","type":"assistant","timestamp":"2025-01-15T10:30:05Z","message":{"role":"assistant","content":[{"type":"text","text":"让我看看代码..."}]}}
JSONL 的优势是流式友好:进程崩溃不会损坏已有数据,最后几行可能不完整但前面的都安全。缺点是查询不方便------要找到特定对话,得扫描文件。
Codex CLI:事件溯源
Codex CLI(OpenAI 的编码代理)采用事件流格式。它不是存储"对话",而是存储"事件"------每个用户输入、模型回复、工具调用都是一个独立事件:
json
{"timestamp":"2025-01-15T10:30:00Z","type":"session_meta","payload":{"id":"session-xyz","cwd":"/home/user/project"}}
{"timestamp":"2025-01-15T10:30:01Z","type":"event_msg","payload":{"role":"user","content":"帮我修复这个 bug"}}
{"timestamp":"2025-01-15T10:30:06Z","type":"response_item","payload":{"role":"assistant","content":[{"type":"output_text","text":"让我看看..."}]}}
这种设计适合 Codex 的代理模式------一个任务可能涉及多轮工具调用、代码执行、文件修改,事件流能完整记录这些非线性交互。但对于 ChatCrystal 来说,需要从事件流中重建对话线程,这比直接读取对话列表复杂得多。
Cursor / Trae:VS Code 生态
Cursor 和 Trae 都是基于 VS Code 的 fork。它们继承了 VS Code 的数据存储方式------使用 SQLite 数据库的键值存储(state.vscdb)。
css
state.vscdb 结构:
┌──────────────────────────────────────────────┐
│ TableItemTable │
├──────────────┬───────────────────────────────┤
│ key │ value │
├──────────────┼───────────────────────────────┤
│ composer.1 │ {"allComposers":[...]} │
│ bubbleId:abc123:def456 │ {"role":"user","content":...} │
│ bubbleId:abc123:ghi789 │ {"role":"assistant",...} │
└──────────────┴───────────────────────────────┘
Cursor 把对话元数据(composer heads)和消息内容(bubbles)分开存储在不同的键下。Trae 类似,但用 memento/icube-ai-agent-storage 一个键存所有会话数据,内部是嵌套的 JSON 结构。
为什么用 SQLite?因为 VS Code 生态本身就用 SQLite 做本地状态管理------扩展设置、搜索索引、工作区状态都在 state.vscdb 里。Cursor 和 Trae 作为 fork,自然沿用了这个基础设施。
Copilot:会话快照
GitHub Copilot Chat 在 VS Code 中运行,但它的对话数据存储在 workspaceStorage 的 JSONL 文件中:
json
{"kind":0,"v":{"version":2,"sessionId":"abc","creationDate":1705312200000,"requests":[{"requestId":"r1","timestamp":1705312201000,"message":{"text":"帮我写个函数"},"response":[{"kind":"text","text":"好的,这是..."}]}]}}
kind:0 标记这是一个快照。每次对话更新,Copilot 会追加一个新的快照行,包含完整的对话历史。这意味着同一个会话文件可能有多个快照,需要取最新的一条。
设计优先级的差异
格式差异的根源是每个工具的设计优先级不同:
| 工具 | 优先级 | 格式选择 |
|---|---|---|
| Claude Code | 追加写入、崩溃安全 | JSONL 流式追加 |
| Codex CLI | 事件溯源、非线性交互 | 事件流 |
| Cursor | VS Code 生态集成 | SQLite KV |
| Trae | VS Code 生态集成 | SQLite KV |
| Copilot | 快照式持久化 | JSONL 快照 |
没有"最好的"格式,只有"最适合该工具场景的"格式。Claude Code 不会为了方便第三方解析而改用 SQLite;Cursor 不会为了流式写入而放弃 VS Code 的状态管理架构。
对知识管理的影响
格式不统一对知识管理工具有三个直接影响:
1. 路径发现
每个工具把数据存在不同位置:
javascript
Claude Code: ~/.claude/projects/**/*.jsonl
Codex CLI: ~/.codex/sessions/**/*.jsonl
Cursor: %APPDATA%/Cursor/User/workspaceStorage/*/state.vscdb
Trae: %APPDATA%/Trae/User/workspaceStorage/*/state.vscdb
Copilot: %APPDATA%/Code/User/workspaceStorage/*/chatSessions/*.jsonl
%APPDATA%/Code/User/globalStorage/emptyWindowChatSessions/*.jsonl
路径模式不同,文件扩展名不同,甚至同一个工具在不同操作系统上的路径都不同(Windows 用 %APPDATA%,macOS 用 ~/Library/Application Support,Linux 用 ~/.config)。
2. 消息结构
即使成功找到了数据文件,消息结构也完全不同:
- Claude Code 的
message.content可以是字符串或 ContentBlock 数组 - Codex 的消息嵌套在
event_msg/response_item的 payload 中 - Cursor 的消息存储为 bubble 对象,需要根据 composer ID 关联
- Trae 的 assistant 消息可能在
agentTaskContent.proposal中 - Copilot 的回复在
response数组中,每项有kind字段区分类型
3. 噪声过滤
每个工具有不同的"噪声"------不是对话内容的系统消息、进度指示、流式增量:
- Claude Code 有
system-reminder、command-name等 XML 标签需要剥离 - Claude Code 有
file-history-snapshot、progress、message(流式增量)等类型需要跳过 - Codex 有
turn_context等辅助事件需要过滤 - Cursor 的 bubble 可能有空内容需要跳过
ChatCrystal 的适配器模式
ChatCrystal 用适配器模式(Adapter Pattern) 解决了格式统一问题。核心是一个 SourceAdapter 接口:
typescript
interface SourceAdapter {
readonly name: string; // 'claude-code'
readonly displayName: string; // 'Claude Code'
// 检测当前机器上是否有该数据源
detect(): Promise<SourceInfo | null>;
// 扫描所有对话文件,返回元数据(不解析内容)
scan(): Promise<ConversationMeta[]>;
// 解析单个对话,返回统一格式
parse(meta: ConversationMeta): Promise<ParsedConversation>;
}
三个方法覆盖了完整的导入流程:
- detect() :检查数据目录是否存在、是否有数据文件
- scan() :列出所有对话文件,返回 id、路径、大小、修改时间等元数据
- parse() :读取单个文件,过滤噪声,重建对话线程,输出统一的
ParsedConversation
ParsedConversation 是统一的输出格式:
yaml
interface ParsedConversation {
id: string;
slug: string | null;
source: string;
projectDir: string;
projectName: string;
cwd: string | null;
gitBranch: string | null;
messages: ParsedMessage[];
firstMessageAt: string;
lastMessageAt: string;
}
interface ParsedMessage {
id: string;
parentUuid: string | null;
type: 'user' | 'assistant' | 'system';
role: string | null;
content: string;
hasToolUse: boolean;
hasCode: boolean;
thinking: string | null;
timestamp: string;
}
不管底层是 JSONL 还是 SQLite,是事件流还是快照,parse() 的输出永远是这个结构。下游的摘要生成、Embedding 计算、搜索索引都不需要知道数据来自哪个工具。
实现细节:每个适配器的挑战
Claude Code 适配器
最大的挑战是噪声过滤 。Claude Code 的 JSONL 包含大量非对话内容:文件历史快照、流式增量、工具进度。适配器维护了一个 SKIP_TYPES 集合,跳过这些类型的消息。
另一个挑战是内容块类型 。Claude Code 的 message.content 可能是纯文本字符串,也可能是 ContentBlock 数组(包含 text、thinking、tool_use 等类型)。适配器需要递归处理这些块,提取有意义的文本内容。
Codex 适配器
Codex 的事件流需要重建对话线程 。事件按时间顺序排列,但对话可能是非线性的------用户可能在工具调用中途插入新指令。适配器按事件类型分组,将 event_msg 和 response_item 配对为完整的对话轮次。
还需要加载会话索引 :Codex 的 session_index.jsonl 文件记录了会话 ID 到线程名称的映射,用于生成有意义的对话标题。
Cursor / Trae 适配器
最大的挑战是SQLite 键值存储的解析。对话数据不是一个结构化的表,而是分散在多个键下的 JSON 字符串。适配器需要:
- 扫描所有 workspaceStorage 目录,找到 state.vscdb 文件
- 打开 SQLite 数据库,查询 composer 相关的键
- 解析 JSON,提取 composer heads(元数据)和 bubbles(消息内容)
- 按 composer ID 关联消息,重建对话顺序
Cursor 和 Trae 共享类似的 SQLite 结构,但键名和内部 JSON 格式不同,所以分别有独立的适配器实现。
Copilot 适配器
Copilot 的 JSONL 格式相对规整,但需要处理多快照问题。同一个会话文件可能有多行(多个快照),每行都包含完整的对话历史。适配器只取最后一行(最新快照)来解析。
还需要处理双路径问题:工作区对话在 workspaceStorage 下,全局对话在 globalStorage/emptyWindowChatSessions 下。适配器需要扫描两个位置。
新增数据源:插件化
适配器模式让新增数据源变得简单。要支持一个新的 AI 编程工具,只需要:
- 实现
SourceAdapter接口(detect、scan、parse 三个方法) - 在适配器注册表中添加一行
- 在 import service 中调用
不需要修改摘要生成、Embedding 计算、搜索索引等任何下游代码。这就是适配器模式的价值------把"数据格式差异"隔离在适配器内部,让核心流程保持统一。
小结
5 个工具、5 种格式,这不是任何人"设计失败"的结果。每个工具根据自己的技术起点和设计优先级,选择了最适合的数据格式。JSONL 适合流式追加,SQLite 适合键值查询,事件流适合非线性记录。
ChatCrystal 不能要求这些工具统一格式------这不是 ChatCrystal 能影响的事情。但它可以通过适配器模式,在自己的边界内实现统一。SourceAdapter 接口就像一个翻译层:对外,它理解 5 种方言;对内,它只说一种语言。
如果你正在开发需要处理多个数据源的工具,适配器模式是值得优先考虑的架构选择。把差异隔离在边界处,让核心逻辑保持简洁。
项目地址:github.com/ZengLiangYi...
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。