把文件读成对象,只完成了解析的第一步。对话导入还要识别哪些记录是消息、恢复顺序、关联工具调用,并决定读取到的数据库状态是否完整。本文对照 ChatCrystal 的固定实现,用合成样本分别验证行边界和快照边界,不读取个人对话目录。
先按存储格式定位读取路径
当前解析器有三个不同入口,而不是一个通用 JSON.parse 就能覆盖所有工具。
| 路径 | 当前读取方式 | 需要另行核对的语义 |
|---|---|---|
| Claude Code、Codex JSONL | createReadStream 配合 readline | 事件类型、消息身份、工具关联与去重范围 |
| Copilot JSON / JSONL | JSON 整体读取,JSONL 只取第一行 | 快照包装及 requests 中的消息内容 |
| Cursor、Trae vscdb | 读取数据库主文件字节,交给 sql.js | 键与值的内部结构、数据库快照与时间来源 |
统一输出接口见 SourceAdapter 设计。共同接口不代表输入格式相同,也不会替代 JSON 字段的运行时检查。
行读取降低输入缓冲,不保证固定总内存
Claude Code 解析器逐行读取,损坏的 JSON 行会跳过;保留符合角色和相关性规则的记录,清理部分 XML 内容,最后累积消息并排序。输入没有先整体放进一个字符串,但消息数组仍然增长,单行本身也可能很大。因此不能把使用 readline 写成“内存始终恒定”或“不会 OOM”。
保存以下 lines.mjs,用 Node 24+ 运行,不需依赖。它只演示教学行边界和筛选规则,不是任何工具的完整解析器。流的两个分块故意在一条 JSON 的中间断开,换行使用 CRLF。
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 解析器分别处理 session_meta、event_msg 和 response_item。相同回答可能在不同事件中出现:当前代码在 response_item 的 assistant 文本与上一条 assistant 文本完全相同时跳过重复。这个局部规则不等于全局事件去重;工具调用还会标记上一条 assistant 的 hasToolUse,不能只保留具有文本字段的事件就声称恢复了所有工具语义。
Copilot 解析器的 JSONL 路径读取第一行快照,接受当前快照包装或旧对象格式,再提取 requests 中的用户消息和响应。它没有重放后续行的增量补丁;第一行为空、损坏或包装不符合预期时也不能假定后续行会补救。排查“消息变少”时应先确认具体读取路径。
适合公开回归测试的样本应分别包含双重回答事件、仅工具调用、损坏行、未知事件和时间缺失。期望结果要写清顺序、角色、文本及工具标记,不能只断言“没有抛异常”。
数据库字节副本有明确读取时刻
当前 vscdb.ts通过 readFileSync 读取主文件,并用 sql.js 创建内存数据库。失败后等待 500ms 再尝试一次,第二次失败返回 null。它没有读取 WAL,也没有协调源程序建立一致性备份;一次重试不能证明所有锁竞争或读取损坏都已解决。
如果源数据库使用 WAL 模式,已提交内容可能仍位于 WAL。只复制主文件并不能据此保证获得最新完整状态,参见 SQLite WAL 文档。这描述的是读取策略的边界,不是声称本次练习已重现某个用户的数据丢失。
在新的临时目录安装依赖,保存下面的 snapshot.mjs 后用 Node 24+ 运行:
npm init -y
npm install --ignore-scripts --no-audit --no-fund sql.js@1.14.2
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扫描有会话创建时间时使用它,否则回退到数据库修改时间;Trae扫描优先使用会话更新时间。二者不能统一描述成“文件修改时间就是每条消息的时间”。同一数据库里的多个会话还可能共享数据库文件大小。
归一化输出解决了下游读取形式,却不保证身份无碰撞、遗漏可观测或新格式完全兼容。扫描、解析、保存应分别保留样本与失败证据;内存和耗时另用批量导入测量方法实测。本文的离线断言没有产生性能结论。