多格式文件解析:JSONL / SQLite / Event Stream

本文面向:需要处理多种数据格式解析的开发者。

预计阅读时间:12 分钟

最终效果:理解 JSONL 流式解析、SQLite WASM 解析、Event Stream 重建三种策略,以及 SourceAdapter 统一接口背后的设计思路。

问题背景

Claude Code、Cursor、Codex CLI、Trae、GitHub Copilot ------ 这 5 个工具各自把对话记录存在不同地方、用不同格式:

工具 存储格式 文件位置
Claude Code JSONL ~/.claude/projects/<project>/<session>.jsonl
GitHub Copilot JSONL / JSON VS Code 的 workspaceStorage/chatSessions/
Codex CLI JSONL(事件流) ~/.codex/sessions/rollout-*.jsonl
Cursor SQLite VS Code 的 workspaceStorage/<hash>/state.vscdb
Trae SQLite VS Code 的 workspaceStorage/<hash>/state.vscdb

如果为每个工具写一套独立的解析脚本,代码会迅速失控。我们需要一个统一的插件接口。

统一接口:SourceAdapter

所有适配器实现同一个 TypeScript 接口:

typescript 复制代码
interface SourceAdapter {
  readonly name: string;          // 唯一标识,如 'claude-code'
  readonly displayName: string;   // UI 显示名,如 'Claude Code'
  readonly parserVersion?: string; // 解析器版本,用于远程导入去重

  detect(): Promise<SourceInfo | null>;       // 当前机器是否有这个数据源
  scan(): Promise<ConversationMeta[]>;        // 扫描所有会话文件(只拿元数据)
  parse(meta: ConversationMeta): Promise<ParsedConversation>;  // 解析单个会话
}

三层职责分离:detect() 判断数据源是否存在,scan() 快速遍历文件列表(不读内容),parse() 才真正解析文件。这样 scan 阶段可以做到很快,配合 file_size + file_mtime 的去重策略,跳过没有变化的文件。

格式一:JSONL 流式读取

JSONL(JSON Lines)是最常见的格式,每行一个独立的 JSON 对象。Claude Code 和 Copilot 都用这种格式,但内部结构差异很大。

readline 流式处理

Node.js 的 readline 模块配合 createReadStream 可以逐行读取文件,内存占用恒定,不会因为文件大而 OOM:

javascript 复制代码
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const fileStream = createReadStream(meta.filePath, { encoding: 'utf-8' });
const rl = createInterface({
  input: fileStream,
  crlfDelay: Number.POSITIVE_INFINITY,  // 处理 \r\n 换行
});

for await (const line of rl) {
  if (!line.trim()) continue;

  let parsed;
  try {
    parsed = JSON.parse(line);
  } catch {
    continue;  // 跳过格式错误的行
  }

  // ... 处理 parsed
}

关键细节:crlfDelay: Infinity 确保 Windows 的 \r\n 换行被正确处理,不会把一行拆成两行。

Claude Code 的噪音过滤

Claude Code 的 JSONL 里混杂了大量中间状态:streaming delta、tool progress、file snapshots 等。直接导入会得到几十倍的噪音数据。

过滤逻辑用一个 Set 做快速查找:

arduino 复制代码
const SKIP_TYPES = new Set([
  'file-history-snapshot', 'last-prompt', 'progress',
  'agent_progress', 'hook_progress', 'queue-operation',
  'message',    // streaming delta
  'tool_use',   // streaming tool delta
  'tool_result', 'thinking', 'text', 'tool_reference',
]);

function isRelevantMessage(line: RawMessage): boolean {
  if (!line.uuid) return false;               // 流式 delta 没有 uuid
  if (line.type && SKIP_TYPES.has(line.type)) return false;
  if (line.type === 'system') return false;   // 全部系统消息跳过
  return ['user', 'assistant'].includes(line.type ?? '');
}

保留 uuid 是第一个筛选条件 ------ 流式增量片段不会带 uuid,有 uuid 的才是完整消息。然后排除已知噪音类型,最后只保留 userassistant 两种角色。

Claude Code 的消息内容里还会嵌入系统标签,需要额外清洗:

ini 复制代码
function sanitizeContent(text: string): string {
  let result = text;
  result = result.replace(/<system-reminder>[\s\S]*?</system-reminder>/g, '');
  result = result.replace(/<command-name>[^<]*</command-name>/g, '');
  result = result.replace(/<command-message>[^<]*</command-message>/g, '');
  // ... 更多标签
  return result.trim();
}

Copilot 的双层 JSONL

Copilot 的 JSONL 文件结构不一样。每行是一个 CopilotSessionSnapshot,但关键数据在 kind: 0 的首行快照里,后续行是 UI 状态补丁(kind: 1),不需要解析。

所以 Copilot 适配器只读第一行:

csharp 复制代码
// 只读第一行快照,忽略后续 UI 补丁
const snapshotText = await readFirstLine(meta.filePath);

readFirstLine 的实现也很简洁 ------ 拿到第一行后立即关闭流:

typescript 复制代码
async function readFirstLine(filePath: string): Promise<string | null> {
  const stream = createReadStream(filePath, { encoding: 'utf-8' });
  const rl = createInterface({ input: stream, crlfDelay: Infinity });

  for await (const line of rl) {
    rl.close();
    stream.destroy();
    return line;
  }
  return null;
}

快照里的数据结构是请求-响应配对的,一个 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 打开内存中的副本,完全绕过文件锁:

javascript 复制代码
import { readFileSync } from 'node:fs';
import initSqlJs from 'sql.js';

let sqlJsInstance = null;

async function getSqlJs() {
  if (!sqlJsInstance) {
    sqlJsInstance = await initSqlJs();
  }
  return sqlJsInstance;
}

async function openVscdb(dbPath: string) {
  try {
    const SQL = await getSqlJs();
    const buf = readFileSync(dbPath);          // 读进内存
    return new SQL.Database(buf);               // 内存中打开
  } catch {
    // 文件锁定或损坏 ------ 等 500ms 重试一次
    await new Promise(r => setTimeout(r, 500));
    const SQL = await getSqlJs();
    const buf = readFileSync(dbPath);
    return new SQL.Database(buf);
  }
}

重试机制很重要:VS Code 在写入数据库时会短暂锁定文件,一次重试基本能解决问题。

Cursor:ItemTable + cursorDiskKV

Cursor 的数据分布在两个地方:

  1. 工作区级 state.vscdb ------ ItemTable 里存着 composer.composerData,记录该工作区所有 composer 会话的 ID 列表
  2. 全局 state.vscdb ------ cursorDiskKV 表里存着每个 bubble(消息气泡)的实际内容

解析流程是先从工作区 DB 获取 composer ID 列表,再从全局 DB 的 cursorDiskKV 表按 bubbleId:<composerId>:* 的 pattern 查询所有气泡:

ini 复制代码
const result = db.exec(
  `SELECT [key], value FROM cursorDiskKV
   WHERE [key] LIKE 'bubbleId:${composerId}:%'`
);

for (const row of result[0].values) {
  const bubble = JSON.parse(row[1] as string);

  // 检查 schema 版本
  if (bubble._v && bubble._v > 3) {
    console.warn(`[Cursor] Unknown bubble schema version: ${bubble._v}`);
  }

  const msgType = bubble.type === 1 ? 'user' : 'assistant';
  // ...
}

这里有一个防御性检查:bubble._v > 3 时打 warning。Cursor 的数据格式会随版本迭代变化,当遇到未知的 schema 版本时,我们记录日志而不是直接崩溃,保证向前兼容。

还有一个边界情况:某些 composer 有气泡数据但没有对应的工作区条目(比如工作区被删除了)。我们通过扫描全局 DB 里的 bubbleId:* 前缀来发现这些"孤儿"会话。

Trae:单一 KV 存储

Trae 比 Cursor 简单。它用一个 key 为 memento/icube-ai-agent-storage 的 KV 条目存下所有会话数据:

ini 复制代码
const result = db.exec(
  "SELECT value FROM ItemTable WHERE [key] = 'memento/icube-ai-agent-storage'"
);
const data = JSON.parse(result[0].values[0][0] as string);

返回的 JSON 里有一个 list 数组,每个元素是一个会话,包含 messages 数组。注意 value 可能是字符串也可能是 Uint8Array(sql.js 对 blob 和 text 的处理),需要做类型判断:

csharp 复制代码
const raw = result[0].values[0][0];
const str = typeof raw === 'string'
  ? raw
  : Buffer.from(raw as Uint8Array).toString('utf8');

Trae 的助手消息存储也比较特殊。真正的回复内容可能在 agentTaskContent 里,而不是 content 字段。需要递归提取 proposalguideline.planItemsfinish 步骤的 thought 等。

格式三:Event Stream 重建

Codex CLI 的 JSONL 文件不是简单的消息列表,而是一个带类型的事件流。每行是一个事件,包含 typepayload

json 复制代码
{"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),两者内容相同但格式不同。

去重策略是记住最后一条助手消息的内容,遇到新消息时对比:

ini 复制代码
let lastAssistantMsg = null;

// event_msg/agent_message 处理
if (parsed.type === 'event_msg' && payloadType === 'agent_message') {
  const msg = { content: text, /* ... */ };
  messages.push(msg);
  lastAssistantMsg = msg;
}

// response_item/message 处理
if (parsed.type === 'response_item' && payloadType === 'message') {
  const fullText = /* 提取 output_text blocks */;
  if (lastAssistantMsg && lastAssistantMsg.content === fullText) {
    continue;  // 跳过重复
  }
  // ...
}

工具调用的标记也有意思。response_item/function_call 事件本身不产生消息,但需要回溯标记前一条助手消息的 hasToolUse = true

ini 复制代码
if (payloadType === 'function_call' || payloadType === 'custom_tool_call') {
  if (lastAssistantMsg) {
    lastAssistantMsg.hasToolUse = true;
  }
}

Codex CLI 还有一个 session_index.jsonl 文件存储会话的显示名称(slug),解析时需要额外加载这个索引来补充元数据。

去重与增量导入

解析只是第一步。导入时还需要高效的去重策略,避免重复解析没变化的文件。

ChatCrystal 的去重基于 (id, source) 的复合主键加上 file_size + file_mtime 的变更检测:

scss 复制代码
const existing = db.exec(
  "SELECT file_size, file_mtime FROM conversations WHERE id = ? AND source = ?",
  [meta.id, meta.source]
);

if (existing.length > 0) {
  const [existingSize, existingMtime] = existing[0].values[0];
  if (Number(existingSize) === meta.fileSize &&
      existingMtime === meta.fileMtime) {
    progress.skipped++;
    continue;  // 文件没变,跳过
  }
}

这个策略的好处是 scan 阶段只需要 stat() 获取文件大小和修改时间,不需要读文件内容。对于包含数千个会话文件的目录,这能把 scan 时间从分钟级降到秒级。

对于 SQLite 数据源(Cursor、Trae),情况比较特殊 ------ 多个会话共享同一个 .db 文件。这意味着文件的 mtime 会因为任何会话的更新而变化。我们的做法是用会话自身的创建时间(createdAt)作为 fileMtime,而不是数据库文件的修改时间。

错误处理策略

面对真实用户数据,各种异常都会出现。每个适配器都遵循相同的原则:不因单个文件的错误阻断整个导入流程

JSONL 解析中,JSON.parse 的异常被 catch 后直接 continue 跳过该行。SQLite 打开失败会重试一次。工作区目录损坏被跳过。每个错误都会写入 import_log 表,导入完成后可以在日志中查看。

csharp 复制代码
try {
  const parsed = await adapter.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...

如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。

相关推荐
前端市界1 小时前
使用 `acme.sh` + 阿里云 DNS API 申请 Let’s Encrypt 通配符证书,并配置 Nginx 自动续期
后端
卷无止境1 小时前
SimPy Events 深度解析:仿真世界的时间引擎
后端
Oo_行者_oO1 小时前
Spring Cloud 实现文件服务预览与静态资源映射
后端·spring
边界条件╝1 小时前
微前端进阶(一)
前端
ZC跨境爬虫1 小时前
跟着 MDN 学CSS day_34:(CSS 布局全面解析)
前端·css·ui·html·tensorflow
万少1 小时前
湖南卫视的秘密武器曝光!芒果灵创,专业AI影视创作平台
前端·javascript·后端
边界条件╝1 小时前
微前端进阶(三)
前端
金銀銅鐵1 小时前
[Java] 自己写程序,来解析方法的 descriptor
java·后端
Yang96111 小时前
0.5 米超短盲区!鼎讯信通 GO-50PRO 光时域反射仪科普
开发语言·后端·golang