相同文件扩展名不保证相同消息语义,相同 SQLite 容器也不保证相同表和键。ChatCrystal 在适配器边界内统一输出,保留的内容仍受具体解析规则限制。输出边界检查了信息取舍;这里回到格式与定位,解释为什么只用“读取 JSONL”或“打开 state.vscdb”还不够。
五个固定入口,不是五份永久存储规范
下表描述固定提交的实现,不把第三方工具的当前所有版本或设计动机当作已验证事实。
| 来源 | 当前适配器读取的形状 | 定位与解释成本 |
|---|---|---|
| Claude Code | 项目 JSONL 中带 uuid 的 user/assistant,content 可为字符串或块数组 | 识别消息类型、parentUuid 与选择性内容提取 |
| Codex | rollout JSONL 的 session_meta、event_msg、response_item 特定分支 | 区分事件、局部重复与工具标记,不是完整线程重放 |
| Cursor | workspace ItemTable 的 composer.composerData;global cursorDiskKV 的 bubbleId 前缀 | 会话索引和消息分属两处,另有孤立会话扫描 |
| Trae | ItemTable 的 memento/icube-ai-agent-storage;data.list 按 sessionId 定位 | 读取嵌套消息,按 turnIndex 排序并构造递增时间 |
| Copilot | .jsonl 的字面首行快照,或旧 .json 整文件 | kind=0/v 包装与旧形状分开,后续行不在当前重放路径 |
parser/index.ts静态注册这五项。Claude/Codex 默认项目或 sessions 路径以及 Cursor/Trae/Copilot 的平台分支,均由各自实现决定;可配置覆盖路径也必须纳入定位验收。不要把一个 Windows 示例路径当作全部平台和部署方式。
JSONL 是分帧容器,不是数据模型
逐行 JSON 适合追加与逐行处理,仍有解析开销。进程崩溃、缓冲未落盘或最后一行不完整,都需要具体持久化证据;“前面所有行必安全”不能从扩展名推出。
Claude adapter读取带 uuid 的有效 user/assistant 分支,并选择性提取块。Codex adapter选择特定事件,局部去重并把工具调用标记到最近助手;它没有从全部事件恢复完整因果线程。标题还可能借助 session_index.jsonl,而非只来自消息正文。
Copilot adapter对 JSONL 读取字面第一行,检查 kind 并取 v;不会把最后一行当最新完整快照。旧 JSON 另走整文件分支。后续补丁的实际作用需在上游版本与样本中核实,不能把没有读取的信息称为永久无价值。
SQLite 相同,消息定位仍不同
Cursor adapter从工作区索引取得 composer heads,从全局 cursorDiskKV 读 bubbles,也尝试扫描没有工作区索引的孤立 composer。原稿的 TableItemTable 不是这里真实的表名。Trae adapter在 ItemTable 中读嵌套 data.list,不能套用同一个 composer/bubble 查询。
SQLite 的 SQL 与事务能力不保证复制一个运行中的主文件就得到所有最新消息。ChatCrystal 的读取是主文件字节快照;没有在该路径合并原生 WAL。取得一致样本与理解键内 JSON 是两个不同步骤。
用两份内存库列出关联与遗漏证据
保存 lesson.mjs,以 Node 24+ 运行,无额外 npm 依赖。它新建两个 Node SQLite 内存库,采用 Cursor 风格的表与键,仅做合成清单:不读取 state.vscdb、不调用真实适配器、不恢复完整会话,也不模拟实时写入或原生 WAL。
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
const workspace = new DatabaseSync(':memory:');
const global = new DatabaseSync(':memory:');
try {
workspace.exec('CREATE TABLE ItemTable(key TEXT PRIMARY KEY, value TEXT)');
global.exec('CREATE TABLE cursorDiskKV(key TEXT PRIMARY KEY, value TEXT)');
workspace
.prepare('INSERT INTO ItemTable VALUES (?, ?)')
.run(
'composer.composerData',
JSON.stringify({ allComposers: [{ composerId: 'known', name: 'fixture session' }] })
);
const insert = global.prepare('INSERT INTO cursorDiskKV VALUES (?, ?)');
insert.run('bubbleId:known:u1', JSON.stringify({ type: 1, text: 'question', _v: 3 }));
insert.run('bubbleId:known:a1', JSON.stringify({ type: 2, text: 'answer', _v: 3 }));
insert.run('bubbleId:orphan:a2', JSON.stringify({ type: 2, text: 'unindexed answer', _v: 99 }));
insert.run('bubbleId:known:bad', 'broken JSON');
const row = workspace
.prepare('SELECT value FROM ItemTable WHERE key = ?')
.get('composer.composerData');
const heads = new Map(JSON.parse(row.value).allComposers.map((head) => [head.composerId, head]));
const groups = new Map();
const errors = [];
const unknownVersions = [];
for (const row of global
.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'")
.all()) {
const [, id, messageId] = row.key.split(':');
let bubble;
try {
bubble = JSON.parse(row.value);
} catch {
errors.push(row.key);
continue;
}
if ((bubble._v ?? 0) > 3) unknownVersions.push(row.key);
const group = groups.get(id) ?? { id, head: heads.get(id) ?? null, messages: [] };
group.messages.push({ messageId, bubble });
groups.set(id, group);
}
assert.equal(groups.get('known').messages.length, 2);
assert.equal(groups.get('known').head.name, 'fixture session');
assert.ok(groups.has('orphan'));
assert.equal(groups.get('orphan').head, null);
assert.equal(groups.get('orphan').messages.length, 1);
assert.deepEqual(errors, ['bubbleId:known:bad']);
assert.deepEqual(unknownVersions, ['bubbleId:orphan:a2']);
assert.throws(() => global.prepare('SELECT value FROM ItemTable').all(), /no such table/);
console.log('two-store inventory, orphan data, and unknown-version evidence pass');
} finally {
workspace.close();
global.close();
}
known 有索引和两条有效消息,orphan 只有全局消息。坏 JSON 被单独记录,未知 _v=99 也保留证据;版本警告不等于兼容成功。把消息库误当含 ItemTable 的库,查询确实报表不存在。
若在分组前丢弃没有 head 的 id,孤立组存在断言失败;去掉未知版本记录,其列表断言失败。恢复后通过。真实 Cursor 孤立扫描有自己的窗口、缓存和样本限制,不能把这个小清单当作它的完整实现。
统一输出需要版本与损失记录
SourceAdapter隔离 detect/scan/parse 的入口形状,但 scan 可能查询或读取内容,接口不是外部数据的运行时 schema 验证。新增来源还涉及共享来源联合类型、配置、远端载荷与 watcher/UI,并非注册一行就自动完成。
统一输出方便下游摘要与检索;它没有承诺保留所有工具、思考、父引用、附件和真实时间。为每个支持版本保存来源、输入副本、提取规则和遗漏原因,再检查输出与导入后的状态。跨工具迁移的选择可以继续看对话管理方案。