---
title: 多格式对话解析：JSONL、事件与 SQLite 的读取边界
description: 从 ChatCrystal 的真实解析器出发，用离线样本验证行读取、事件筛选和数据库快照，区分格式解析、消息归一化与一致性保证。
image: "https://zengliangyi.com/og/blog/multi-format-conversation-parsing.png"
---

把文件读成对象，只完成了解析的第一步。对话导入还要识别哪些记录是消息、恢复顺序、关联工具调用，并决定读取到的数据库状态是否完整。本文对照 ChatCrystal 的固定实现，用合成样本分别验证行边界和快照边界，不读取个人对话目录。

## 先按存储格式定位读取路径

当前解析器有三个不同入口，而不是一个通用 JSON.parse 就能覆盖所有工具。

| 路径 | 当前读取方式 | 需要另行核对的语义 |

| --- | --- | --- |

| Claude Code、Codex JSONL | createReadStream 配合 readline | 事件类型、消息身份、工具关联与去重范围 |

| Copilot JSON / JSONL | JSON 整体读取，JSONL 只取第一行 | 快照包装及 requests 中的消息内容 |

| Cursor、Trae vscdb | 读取数据库主文件字节，交给 sql.js | 键与值的内部结构、数据库快照与时间来源 |

统一输出接口见 [SourceAdapter 设计](/blog/source-adapter-design/)。共同接口不代表输入格式相同，也不会替代 JSON 字段的运行时检查。

## 行读取降低输入缓冲，不保证固定总内存

[Claude Code 解析器](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/adapters/claude-code.ts)逐行读取，损坏的 JSON 行会跳过；保留符合角色和相关性规则的记录，清理部分 XML 内容，最后累积消息并排序。输入没有先整体放进一个字符串，但消息数组仍然增长，单行本身也可能很大。因此不能把使用 readline 写成“内存始终恒定”或“不会 OOM”。

保存以下 `lines.mjs`，用 Node 24+ 运行，不需依赖。它只演示**教学行边界和筛选规则**，不是任何工具的完整解析器。流的两个分块故意在一条 JSON 的中间断开，换行使用 CRLF。



```js
import   assert   from   'node:assert/strict'  ;  
  import   { Readable }   from   'node:stream'  ;  
  import   { createInterface }   from   'node:readline'  ;  
  
  const   input   =   [  
    '{"type":"message","text":"first"}'  ,  
    '{broken'  ,  
    '{"type":"progress","text":"noise"}'  ,  
    '{"type":"message","text":"second"}'  ,  
  ].  join  (  '  \r\n  '  );  
  const   stream   =   Readable.  from  ([input.  slice  (  0  ,   18  ), input.  slice  (  18  )]);  
  const   lines   =   createInterface  ({ input: stream, crlfDelay:   Infinity   });  
  const   messages   =   [];  
  let   malformed   =   0  ;  
  for   await   (  const   line   of   lines) {  
    if   (  !  line.  trim  ())   continue  ;  
    let   event;  
    try   {  
      event   =   JSON  .  parse  (line);  
    }   catch   {  
      malformed  ++  ;  
      continue  ;  
    }  
    if   (event?.type   ===   'message'   &&   typeof   event.text   ===   'string'  ) {  
      messages.  push  (event.text);  
    }  
  }  
  assert.  deepEqual  (messages, [  'first'  ,   'second'  ]);  
  assert.  equal  (malformed,   1  );  
  console.  log  (  'line framing, selection, and malformed count pass'  );
```



把 malformed 的期望改为 0，程序应失败；恢复为 1 后通过。这个计数是教学代码添加的诊断，不代表现有解析器已经为每条损坏记录留下日志。检查只覆盖这组构造输入，没有证明所有非法 JSON 或超长行都能安全恢复。

## 事件序列需要格式自己的归一化规则

[Codex 解析器](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/adapters/codex.ts)分别处理 session_meta、event_msg 和 response_item。相同回答可能在不同事件中出现：当前代码在 response_item 的 assistant 文本与上一条 assistant 文本完全相同时跳过重复。这个局部规则不等于全局事件去重；工具调用还会标记上一条 assistant 的 hasToolUse，不能只保留具有文本字段的事件就声称恢复了所有工具语义。

[Copilot 解析器](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/adapters/copilot.ts)的 JSONL 路径读取第一行快照，接受当前快照包装或旧对象格式，再提取 requests 中的用户消息和响应。它没有重放后续行的增量补丁；第一行为空、损坏或包装不符合预期时也不能假定后续行会补救。排查“消息变少”时应先确认具体读取路径。

适合公开回归测试的样本应分别包含双重回答事件、仅工具调用、损坏行、未知事件和时间缺失。期望结果要写清顺序、角色、文本及工具标记，不能只断言“没有抛异常”。

## 数据库字节副本有明确读取时刻

当前 [vscdb.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/vscdb.ts)通过 readFileSync 读取主文件，并用 sql.js 创建内存数据库。失败后等待 500ms 再尝试一次，第二次失败返回 null。它没有读取 WAL，也没有协调源程序建立一致性备份；一次重试不能证明所有锁竞争或读取损坏都已解决。

如果源数据库使用 WAL 模式，已提交内容可能仍位于 WAL。只复制主文件并不能据此保证获得最新完整状态，参见 [SQLite WAL 文档](https://www.sqlite.org/wal.html)。这描述的是读取策略的边界，不是声称本次练习已重现某个用户的数据丢失。

在新的临时目录安装依赖，保存下面的 `snapshot.mjs` 后用 Node 24+ 运行：



```sh
npm   init   -y  
  npm   install   --ignore-scripts   --no-audit   --no-fund   sql.js@1.14.2
```





```js
import   assert   from   'node:assert/strict'  ;  
  import   initSqlJs   from   'sql.js'  ;  
  
  const   SQL   =   await   initSqlJs  ();  
  const   live   =   new   SQL  .  Database  ();  
  let   copy;  
  try   {  
    live.  run  (  'CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT)'  );  
    live.  run  (  "INSERT INTO kv VALUES ('message', 'first')"  );  
    const   bytes   =   live.  export  ();  
    copy   =   new   SQL  .  Database  (bytes);  
    live.  run  (  "UPDATE kv SET value = 'second' WHERE key = 'message'"  );  
    const   read   =   (  db  )   =>   db.  exec  (  'SELECT value FROM kv'  )[  0  ].values[  0  ][  0  ];  
    assert.  equal  (  read  (copy),   'first'  );  
    assert.  equal  (  read  (live),   'second'  );  
    console.  log  (  'exported bytes are a separate snapshot'  );  
  }   finally   {  
    copy?.  close  ();  
    live.  close  ();  
  }
```



把 read(copy) 的期望改成 second 应失败；恢复 first 后通过。这个内存数据库实验只证明导出的字节不会随原实例更新，**没有测试文件锁、WAL、并发写入或现有 openVscdb 的恢复行为**。

## 从 SQLite 键定位到消息内容

拿到数据库字节后，还需要选择正确的表、键和会话。下面是固定版本的读取路径，不是所有 vscdb 文件都必须遵循的通用规范。

| 来源 | 会话定位 | 消息提取 |

| --- | --- | --- |

| Cursor | 工作区 ItemTable 的 composer.composerData 中读取 allComposers；扫描还会发现没有工作区条目的孤立会话 | 全局 cursorDiskKV 按 bubbleId:会话ID: 前缀匹配气泡，逐条解析 value |

| Trae | ItemTable 的 memento/icube-ai-agent-storage 值解析为对象，在 list 中按 sessionId 定位 | 读取该会话 messages，按 turnIndex 排序，提取用户或助手内容 |

Cursor 的 bubble.type 为 1 时作为用户，否则作为助手；空助手文本跳过，最终按消息时间排序。遇到较新的 bubble._v 只会警告，这不能证明新版本结构完全兼容。

Trae 的 SQL 值可以是字符串，也可以是需要按 UTF-8 解码的 Uint8Array。助手有直接 content 时优先使用，否则从 agentTaskContent 的 proposal 和 guideline.planItems 中 finish 步骤的 thought 等位置提取回复，另行收集 thinking。排序时间在必要时按会话起始时间合成递增值，不能把它都当作原始消息的精确发生时刻。

这些是下面链接源码中的当前行为。上面的快照练习没有执行这两个适配器；验证格式接入还应构造对应表、键、TEXT/BLOB 值、缺失会话和空消息样本，再断言实际适配器输出。

## 元数据与消息时间也要分别检查

[Cursor](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/adapters/cursor.ts)扫描有会话创建时间时使用它，否则回退到数据库修改时间；[Trae](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/adapters/trae.ts)扫描优先使用会话更新时间。二者不能统一描述成“文件修改时间就是每条消息的时间”。同一数据库里的多个会话还可能共享数据库文件大小。

归一化输出解决了下游读取形式，却不保证身份无碰撞、遗漏可观测或新格式完全兼容。扫描、解析、保存应分别保留样本与失败证据；内存和耗时另用[批量导入测量方法](/blog/conversation-import-memory-budget/)实测。本文的离线断言没有产生性能结论。

分享：

##  相关文章

[##  AI 对话格式差异：事件、快照与跨库消息定位

对照五个固定适配器，区分 JSONL 容器、事件语义与 SQLite 键布局，用两个内存数据库观察消息索引、孤立数据和未知版本。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-format-divergence)    [##  AI 对话管理方案：四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入，区分原始副本、可读导出与知识库，并用合成清单验证身份和信息保留。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-management-options)    [##  sql.js WASM 持久化：内存、导出、外键与保存点

对照 ChatCrystal 的数据库生命周期，运行 sql.js 1.14.2 样本，区分 SQL 提交、二进制导出、文件写入、外键恢复和嵌套回滚。          曾亮懿       2026年10月7日      阅读约 5 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/sqljs-wasm-persistence)

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "多格式对话解析：JSONL、事件与 SQLite 的读取边界",
  "description": "从 ChatCrystal 的真实解析器出发，用离线样本验证行读取、事件筛选和数据库快照，区分格式解析、消息归一化与一致性保证。",
  "url": "https://zengliangyi.com/blog/multi-format-conversation-parsing/",
  "image": "https://zengliangyi.com/images/blog/multi-format-conversation-parsing.svg",
  "datePublished": "2026-10-06T00:00:00.000Z",
  "dateModified": "2026-10-06T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/multi-format-conversation-parsing/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "多格式对话解析：JSONL、事件与 SQLite 的读取边界",
      "item": "https://zengliangyi.com/blog/multi-format-conversation-parsing/"
    }
  ]
}
```
