多格式文件解析:JSONL / SQLite / Event Stream
本文面向需要处理多种数据格式解析的开发者。预计阅读时间12 分钟最终效果理解 JSONL 流式解析、SQLite WASM 解析、Event Stream 重建三种策略以及 SourceAdapter 统一接口背后的设计思路。问题背景Claude Code、Cursor、Codex CLI、Trae、GitHub Copilot —— 这 5 个工具各自把对话记录存在不同地方、用不同格式工具存储格式文件位置Claude CodeJSONL~/.claude/projects/project/session.jsonlGitHub CopilotJSONL / JSONVS Code 的workspaceStorage/chatSessions/Codex CLIJSONL事件流~/.codex/sessions/rollout-*.jsonlCursorSQLiteVS Code 的workspaceStorage/hash/state.vscdbTraeSQLiteVS Code 的workspaceStorage/hash/state.vscdb如果为每个工具写一套独立的解析脚本代码会迅速失控。我们需要一个统一的插件接口。统一接口SourceAdapter所有适配器实现同一个 TypeScript 接口interfaceSourceAdapter{readonlyname:string;// 唯一标识如 claude-codereadonlydisplayName:string;// UI 显示名如 Claude CodereadonlyparserVersion?:string;// 解析器版本用于远程导入去重detect():PromiseSourceInfo|null;// 当前机器是否有这个数据源scan():PromiseConversationMeta[];// 扫描所有会话文件只拿元数据parse(meta:ConversationMeta):PromiseParsedConversation;// 解析单个会话}三层职责分离detect()判断数据源是否存在scan()快速遍历文件列表不读内容parse()才真正解析文件。这样 scan 阶段可以做到很快配合file_sizefile_mtime的去重策略跳过没有变化的文件。格式一JSONL 流式读取JSONLJSON Lines是最常见的格式每行一个独立的 JSON 对象。Claude Code 和 Copilot 都用这种格式但内部结构差异很大。readline 流式处理Node.js 的readline模块配合createReadStream可以逐行读取文件内存占用恒定不会因为文件大而 OOMimport{createReadStream}fromnode:fs;import{createInterface}fromnode:readline;constfileStreamcreateReadStream(meta.filePath,{encoding:utf-8});constrlcreateInterface({input:fileStream,crlfDelay:Number.POSITIVE_INFINITY,// 处理 \r\n 换行});forawait(constlineofrl){if(!line.trim())continue;letparsed;try{parsedJSON.parse(line);}catch{continue;// 跳过格式错误的行}// ... 处理 parsed}关键细节crlfDelay: Infinity确保 Windows 的\r\n换行被正确处理不会把一行拆成两行。Claude Code 的噪音过滤Claude Code 的 JSONL 里混杂了大量中间状态streaming delta、tool progress、file snapshots 等。直接导入会得到几十倍的噪音数据。过滤逻辑用一个Set做快速查找constSKIP_TYPESnewSet([file-history-snapshot,last-prompt,progress,agent_progress,hook_progress,queue-operation,message,// streaming deltatool_use,// streaming tool deltatool_result,thinking,text,tool_reference,]);functionisRelevantMessage(line:RawMessage):boolean{if(!line.uuid)returnfalse;// 流式 delta 没有 uuidif(line.typeSKIP_TYPES.has(line.type))returnfalse;if(line.typesystem)returnfalse;// 全部系统消息跳过return[user,assistant].includes(line.type??);}保留 uuid 是第一个筛选条件 —— 流式增量片段不会带 uuid有 uuid 的才是完整消息。然后排除已知噪音类型最后只保留user和assistant两种角色。Claude Code 的消息内容里还会嵌入系统标签需要额外清洗functionsanitizeContent(text:string):string{letresulttext;resultresult.replace(/system-reminder[\s\S]*?\/system-reminder/g,);resultresult.replace(/command-name[^]*\/command-name/g,);resultresult.replace(/command-message[^]*\/command-message/g,);// ... 更多标签returnresult.trim();}Copilot 的双层 JSONLCopilot 的 JSONL 文件结构不一样。每行是一个CopilotSessionSnapshot但关键数据在kind: 0的首行快照里后续行是 UI 状态补丁kind: 1不需要解析。所以 Copilot 适配器只读第一行// 只读第一行快照忽略后续 UI 补丁constsnapshotTextawaitreadFirstLine(meta.filePath);readFirstLine的实现也很简洁 —— 拿到第一行后立即关闭流asyncfunctionreadFirstLine(filePath:string):Promisestring|null{conststreamcreateReadStream(filePath,{encoding:utf-8});constrlcreateInterface({input:stream,crlfDelay:Infinity});forawait(constlineofrl){rl.close();stream.destroy();returnline;}returnnull;}快照里的数据结构是请求-响应配对的一个requests数组里每个元素包含message用户输入和response助手回复是一个响应项数组。响应项按kind字段区分类型text是正文thinking是思考过程toolInvocationSerialized标记工具调用。格式二SQLite WASM 解析Cursor 和 Trae 把对话存在 VS Code 的state.vscdb文件里。这是一个标准的 SQLite 数据库但有一个问题VS Code 运行时会锁定文件。sql.js 方案我们用 sql.js —— 一个编译为 WASM 的 SQLite 实现 —— 来读取数据库。核心思路是把整个 .db 文件读进内存然后用 sql.js 打开内存中的副本完全绕过文件锁import{readFileSync}fromnode:fs;importinitSqlJsfromsql.js;letsqlJsInstancenull;asyncfunctiongetSqlJs(){if(!sqlJsInstance){sqlJsInstanceawaitinitSqlJs();}returnsqlJsInstance;}asyncfunctionopenVscdb(dbPath:string){try{constSQLawaitgetSqlJs();constbufreadFileSync(dbPath);// 读进内存returnnewSQL.Database(buf);// 内存中打开}catch{// 文件锁定或损坏 —— 等 500ms 重试一次awaitnewPromise(rsetTimeout(r,500));constSQLawaitgetSqlJs();constbufreadFileSync(dbPath);returnnewSQL.Database(buf);}}重试机制很重要VS Code 在写入数据库时会短暂锁定文件一次重试基本能解决问题。CursorItemTable cursorDiskKVCursor 的数据分布在两个地方工作区级state.vscdb——ItemTable里存着composer.composerData记录该工作区所有 composer 会话的 ID 列表全局state.vscdb——cursorDiskKV表里存着每个 bubble消息气泡的实际内容解析流程是先从工作区 DB 获取 composer ID 列表再从全局 DB 的cursorDiskKV表按bubbleId:composerId:*的 pattern 查询所有气泡constresultdb.exec(SELECT [key], value FROM cursorDiskKV WHERE [key] LIKE bubbleId:${composerId}:%);for(constrowofresult[0].values){constbubbleJSON.parse(row[1]asstring);// 检查 schema 版本if(bubble._vbubble._v3){console.warn([Cursor] Unknown bubble schema version:${bubble._v});}constmsgTypebubble.type1?user:assistant;// ...}这里有一个防御性检查bubble._v 3时打 warning。Cursor 的数据格式会随版本迭代变化当遇到未知的 schema 版本时我们记录日志而不是直接崩溃保证向前兼容。还有一个边界情况某些 composer 有气泡数据但没有对应的工作区条目比如工作区被删除了。我们通过扫描全局 DB 里的bubbleId:*前缀来发现这些孤儿会话。Trae单一 KV 存储Trae 比 Cursor 简单。它用一个 key 为memento/icube-ai-agent-storage的 KV 条目存下所有会话数据constresultdb.exec(SELECT value FROM ItemTable WHERE [key] memento/icube-ai-agent-storage);constdataJSON.parse(result[0].values[0][0]asstring);返回的 JSON 里有一个list数组每个元素是一个会话包含messages数组。注意 value 可能是字符串也可能是Uint8Arraysql.js 对 blob 和 text 的处理需要做类型判断constrawresult[0].values[0][0];conststrtypeofrawstring?raw:Buffer.from(rawasUint8Array).toString(utf8);Trae 的助手消息存储也比较特殊。真正的回复内容可能在agentTaskContent里而不是content字段。需要递归提取proposal、guideline.planItems中finish步骤的thought等。格式三Event Stream 重建Codex CLI 的 JSONL 文件不是简单的消息列表而是一个带类型的事件流。每行是一个事件包含type和payload{type:session_meta,payload:{id:...,cwd:/path}}{type:event_msg,payload:{type:user_message,message:...}}{type:event_msg,payload:{type:agent_message,message:...}}{type:response_item,payload:{type:message,role:assistant,content:[...]}}{type:response_item,payload:{type:function_call,name:shell}}关键挑战是同一个助手回复可能有多个事件表示event_msg/agent_message是纯文本版本response_item/message是结构化版本含 content blocks两者内容相同但格式不同。去重策略是记住最后一条助手消息的内容遇到新消息时对比letlastAssistantMsgnull;// event_msg/agent_message 处理if(parsed.typeevent_msgpayloadTypeagent_message){constmsg{content:text,/* ... */};messages.push(msg);lastAssistantMsgmsg;}// response_item/message 处理if(parsed.typeresponse_itempayloadTypemessage){constfullText/* 提取 output_text blocks */;if(lastAssistantMsglastAssistantMsg.contentfullText){continue;// 跳过重复}// ...}工具调用的标记也有意思。response_item/function_call事件本身不产生消息但需要回溯标记前一条助手消息的hasToolUse trueif(payloadTypefunction_call||payloadTypecustom_tool_call){if(lastAssistantMsg){lastAssistantMsg.hasToolUsetrue;}}Codex CLI 还有一个session_index.jsonl文件存储会话的显示名称slug解析时需要额外加载这个索引来补充元数据。去重与增量导入解析只是第一步。导入时还需要高效的去重策略避免重复解析没变化的文件。ChatCrystal 的去重基于(id, source)的复合主键加上file_sizefile_mtime的变更检测constexistingdb.exec(SELECT file_size, file_mtime FROM conversations WHERE id ? AND source ?,[meta.id,meta.source]);if(existing.length0){const[existingSize,existingMtime]existing[0].values[0];if(Number(existingSize)meta.fileSizeexistingMtimemeta.fileMtime){progress.skipped;continue;// 文件没变跳过}}这个策略的好处是 scan 阶段只需要stat()获取文件大小和修改时间不需要读文件内容。对于包含数千个会话文件的目录这能把 scan 时间从分钟级降到秒级。对于 SQLite 数据源Cursor、Trae情况比较特殊 —— 多个会话共享同一个 .db 文件。这意味着文件的 mtime 会因为任何会话的更新而变化。我们的做法是用会话自身的创建时间createdAt作为fileMtime而不是数据库文件的修改时间。错误处理策略面对真实用户数据各种异常都会出现。每个适配器都遵循相同的原则不因单个文件的错误阻断整个导入流程。JSONL 解析中JSON.parse的异常被 catch 后直接continue跳过该行。SQLite 打开失败会重试一次。工作区目录损坏被跳过。每个错误都会写入import_log表导入完成后可以在日志中查看。try{constparsedawaitadapter.parse(meta);// ... 正常处理}catch(err){progress.errors;db.run(INSERT INTO import_log (file_path, status, message) VALUES (?, error, ?),[meta.filePath,err.message]);}消息数不足 2 条的会话也会被跳过 —— 一条消息的会话通常没有总结价值。设计复盘回顾整个解析系统几个设计决策经受住了时间考验插件注册机制让新增数据源只需要写一个文件。注册发生在模块加载时一行registerAdapter(copilotAdapter)就够了。scan/parse 分离让变更检测变得廉价。scan 只拿文件列表和 stat 信息parse 才真正读内容。配合 file_size file_mtime 的去重大量文件可以秒级跳过。内存中的 SQLite 副本绕过了 VS Code 的文件锁问题代价是一次性把整个 .db 文件读进内存。对于通常只有几十 MB 的 vscdb 文件来说完全可以接受。向前兼容的 schema 检查如 Cursor 的_v版本号让我们在工具升级格式后不会立即崩溃而是打 warning 并尽力解析。这套系统目前覆盖了 5 个数据源、3 种文件格式新增一个数据源的典型开发时间是半天。如果你也在做类似的多源数据聚合希望这些解析策略对你有帮助。项目地址github.com/ZengLiangYi/ChatCrystal如有疑问欢迎在 GitHub Issues 或私信交流很乐意解答。