使用者痛点:
-
多源、多格式导致代码膨胀:- 为每个工具写独立脚本,维护成本高。
-
内存使用过大:- 大文件一次性读入容易 OOM。
-
N+1 查询噪音:- 例如 Claude Code 的中间状态数据冗余,直接导入会产生数十倍噪声。
-
ID 冲突与重复导入:- 同一会话在不同工具中可能出现多次需要去重和增量更新。说起来,
-
- VS Code 在写数据库时锁定 .db 文件。普通读取会报错,
-
从统一接口来看,SourceAdapter
Tous les adaptateurs implémentent la même interface TypeScript :
interface SourceAdapter {
readonly name: string;// 唯一标识,如 'claude-code'
readonly displayName: string;// UI 显示名,如 'Claude Code'
readonly parserVersion?: string,// 解析器版本,用于远程导入去重
从detect来看。Promise;// 当前机器是否有这个数据源
scan的观点是,Promise;// 扫描所有会话文件
parse这方面,Promise;// 解析单个会话
}
三层职责分离的观点是,detect 判断是否存在;scan 快速遍历文件列表;parse 真正读取内容。这样 scan 阶段可极快。配合 file_size + file_mtime 去重,可跳过未变更文件。
说到格式一,JSONL 流式读取
JSONL是最常见的行级 JSON 格式。怎么说呢,Claude Code 与 Copilot 都采用此方式。但内部结构差异很大,说起来,
readline 流式处理
Node.js 的 alert?No sorry—here’s correct code:
import { createReadStream } from 'node:fs';import { createInterface } from 'node:readline';const fileStream = createReadStream;按理说,const rl = createInterface({
再看input,fileStream。crlfDelay:Number.POSITIVE_INFINITY,// Windows \r
});for await{
if) continue;let parsed,try{
parsed = JSON.parse;怎么说呢,}catch{
continue;// 跳过错误行
}
// …处理 parsed
}
Claude Code 的噪音过滤
Claude Code 的 JSONL 混杂了大量中间状态。直接导入会得到几十倍噪声。我们用一个 Set 做快速查找来过滤无关行:
const SKIP_TYPES=new Set();function isRelevantMessage:boolean{
if return false;// stream delta 无 uuid
if) return false;if return false;// 全部程序消息跳过
return .includes;}
A key filter step is checking for a UUID – only complete messages carry one.
清洗程序标签
Claude Code 的消息里还嵌有程序标签。需要进一步清洗,例如 ,,等。
function sanitizeContent:string{
let result=text;不过,result=result.replace;result=result.replace;result=result.replace;/* ,更多标签…*/
return result.trim;}
Copilot 的双层 JSONL
COPILOT 文件第一行是快照。后续行是 UI 状态补丁不需要解析,只读第一行即可。
//只读第一行快照,忽略后续UI补丁comment
const snapshotText = await readFirstLine;按理说,async function readFirstLine:Promise
{
const stream=createReadStream;const rl=createInterface;怎么说呢,for await{
rl.close;stream.destroy;return line,}
return null;}
The snapshot structure contains request-response pairs – each request has a message and an array of responses . Response items are distinguished by ir “kind”: text。thinking,toolInvocationSerialized.
再看格式二,SQLite WASM 解析
Cursor 与 Trae 把对话存进 VS Code 的 state.vscdb。这是标准 SQLite 数据库。但 VS Code 在运行时锁定该文件,使得普通读取失败。我们使用 sql.js绕过文件锁——将整个 .db 文件读进内存后再打开副本。
sql.js 方案
//将整个 db 文件读进内存,接下来用 sql.js 打开内存中的副本comment
import {readFileSync} from 'node:fs';import initSqlJs from ‘sql.js’;let sqlJsInstance=null;async function getSqlJs{
if{sqlJsInstance = await initSqlJs;}
return sqlJsInstance;}
async function openVscdb{
try{
const SQL = await getSqlJs;const buf = readFileSync;//读进内存
return new SQL.Database;//内存中打开
}catch{
await new Promise);//短暂重试一次
const SQL = await getSqlJs;const buf = readFileSync;return new SQL.Database;}
}
说到Cursor,ItemTable + cursorDiskKV
-
工作区级 state.vscdb —— ItemTable: 包含 composerData 列表。)
-
全局 state.vscdb —— cursorDiskKV: 每条记录对应一个气泡的实际内容。怎么说呢,
-
从流程来看。先从工作区 DB 获取 composer ID 列表,再从全局 DB 按 pattern 查询气泡。
//查询气泡列表
const result=db.exec;
for{
const bubble=json.parse;//值是字符串或 Uint8Array
/* schema 检查 */
if console.warn;/* 消息类型 */
const msgType=bubble.type===?,'user':'assistant';/* ,处理…*/
}
*特殊情况* :某些 composer 有气泡但无对应工作区条目。通过扫描全局 DB 前缀 `bubbleId:*` 可以捕获这些孤儿会话。
-
错误与异常处理: 'cursorDiskKV' 表不存在 → 跳过;老实说,磁盘损坏 → 写日志而不崩溃。因为多个工具共用同一个 .db,任何单个错误都不能影响整体导入流程。
-
性能注意事项: 'state.vscdb' 通常只有几十 MB。读入内存后就可以完成查询,不需要逐条 I/O 操作,从而避免 VS Code 锁定导致的频繁失败与 retry 重试开销。
Trae 简化 KV 存储
Trae 将所有会话信息保存在一个键值对里:
ts
const result=db.exec(
"SELECT value FROM ItemTable WHERE ='memento/icube-ai-agent-storage'"
);const rawData=result.values;const strValue=
typeof rawData==='string'?rawData:
Buffer.from.toString;const jsonData=json.parse;// jsonData.list 数组。每个元素是一个完整会话,包括 messages 数组。Trae 助手消息的真实回复往往在 `agentTaskContent` 字段,而非 `content` 字段。需要递归提取 `proposal`。`guideline.planItems.finish.thought` 等子字段,以获得完整文本。
从格式三来看。Event Stream 重建
Codex CLI 的 JSONL 并非简单消息列表,而是一系列带类型的事件流。按理说,至于示例,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 |
结构化内容块 |
两者内容相同,但格式不同。
去重策略
记住最终一次助手消息内容,当遇到新消息时比对:
ts
let lastAssistantMsg=null;
// eventmsg/agentmessage
if{
const msg={content:text};messages.push;lastAssistantMsg=msg;}
// response_item/message
if{
const fullText=getOutputTextBlocks;if{
continue;// 跳过重复
}
}
标记工具调用
当收到 response_item/function_call 或 custom_tool_call 时将前一次助手消息标记为使用了工具:
ts
if{
if{lastAssistantMsg.hasToolUse=true;}
}
Codex CLI 附带 用于补充显示名称,解析时需额外加载此索引。
去重与增量导入
单纯解析并不足够,还需高效去重和增量导入以避免重复工作。
ChatCrystal 使用 `主键。并结合+` 检测变化:
ts
const existing=db.exec(
"SELECT filesize,filemtime FROM conversations WHERE id=?AND source=,",);说起来,
if{
const =existing.values;if===meta.fileSize && mtime===meta.fileMtime){
progress.skipped++;continue,// 文件未变动,跳过
}
}
优势在于 scan 阶段只做 stat。无需读取文件内容,即可决定是否需要 parse,从而把大量目录扫描时间从分钟级降至秒级。
对于 SQLite 数据源。由于多个会话共享同一个 .db 文件,其 mtime 随任何更新而改变。我们使用 每个会话自身创建时间 来代替 db 文件修改时间作为 。
错误处理策略
面对真实使用者数据,各种异常随处可见。每个适配器遵循统一原则:
-
不因单个文件错误阻断整个流程;
-
对于 JSON 行错误直接跳过;
-
SQLite 打开失败则尝试一次重试;
-
工作区目录损坏则记录日志并继续;
-
每次错误均写入
表,以便事后排查。
ts
try{
const parsed = await adapter.parse;/* 正常处理... */
}catch{
progress.errors++;db.run
VALUES`,);}
若某会话缺少足够信息,亦可视为无效并跳过因为其价值有限。
设计复盘
回顾整程序统。以下设计决策尤为关键且经受住了实践考验:
-
插件制 — 新增数据源只需实现一个 Adapter 并注册,一行代码就可以完成集成。
-
scan/parse 分离 — Scan 快速获取元数据并做去重;Parse 真正读取内容,提高整体效率。
-
内存中的 SQLite 副本 — 避免 VS Code 锁定问题,对齐大多数场景下几十 MB 内存足够。不过,
-
向前兼容 Schema 检查 — 如 Cursor
_v 协议号。让版本升级不会立刻导致崩溃,仅发 warning 并尽力解析。话说回来,
-
统一错误日志 — 所有异常集中写日志。为运维提供可追踪性,老实说,
目前该程序覆盖了 15+ 数据源 和 30+ 文件格式。添加新源通常只需半天开发周期。如果你也在建立差不多多源聚合项目,希望上述策略能方便你落地与调整!