统一三个方法之后,新增来源仍可能在身份、变更判断或远端传输上失败。SourceAdapter 入门介绍职责划分;这篇继续沿着固定实现的注册表、导入器和远端载荷,检查一个候选会话如何进入持久化路径。
接口统一输出,没有统一所有来源假设
adapter.ts声明 name、displayName、可选 parserVersion,以及异步 detect、scan、parse。scan 返回 id、source、filePath、fileSize、fileMtime、projectDir;parse 返回会话和规范化消息。具体字段定义在共享类型,接口本身不会验证外部 JSON。
“scan 只 stat 文件,不读取内容”并非所有适配器的实际行为。Cursor 的扫描要从 workspace 数据库找会话,再结合 global 数据库生成候选;Trae 要解码 ItemTable 中的会话列表。元数据是扫描的输出契约,不能据此推断扫描的 I/O 成本。
注册、探测与缓存是三个不同边界
registry.ts使用 Map,重复 name 会警告并覆盖已有适配器。index.ts静态注册五个内置来源,没有动态上传插件的协议。
注册表的 detectAllSources 使用 Promise.all 并行探测。一个探测拒绝会使该聚合调用拒绝;这不等于收集每个来源的失败结果。importAll 则先按 enabledSources 过滤,顺序执行 detect、scan,并把所有候选收集完,再进入逐候选解析。该路径的 detect、scan 不在逐候选 try/catch 内。
Cursor 适配器缓存的是 workspace 扫描结果,过期点设为扫描完成后五秒。它可以减少相邻 detect、scan 的重复工作,但不是会话内容缓存,也没有缓存正在执行的 Promise。不要把这个 TTL 写成并发请求合并或数据实时性的保证。
元数据、内容和解析版本分别回答什么问题
importAll先查询已有 id/source 的文件大小、修改时间和内容哈希。大小与时间都相同就跳过,尚未调用 parse。因此只增加 parserVersion,不保证旧会话会重新解析;大小和时间相同也不构成内容相同的证明。
进入解析后,少于两条消息的结果会跳过。已有会话的内容哈希相同走元数据更新;哈希变化走替换;新会话走插入。这些数据库修改使用 SQL 事务,批次结束再 saveDatabase。SQL 回滚与数据库文件写入、向量清理的完成状态仍需分别验收。
importPayload.ts的哈希输入是来源及按顺序排列的消息字段:index、type、role、content、hasToolUse、hasCode、thinking。消息 ID、时间戳、会话标题和 parserVersion 不在其中。忽略这些字段是这份实现的比较策略,不能称为对原始文件全部变化的检测。
用离线样本验证身份与哈希
保存为 lesson.mjs,用 Node 24+ 运行,无需依赖。以下复现哈希字段选择和远端消息命名规则,没有数据库、文件扫描或完整远端导入验证器。
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
function contentHash(parsed) {
const canonical = {
source: parsed.source,
messages: parsed.messages.map((message, index) => ({
index,
type: message.type,
role: message.role,
content: message.content,
hasToolUse: message.hasToolUse,
hasCode: message.hasCode,
thinking: message.thinking,
})),
};
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
}
function namespace(source, id) {
return id.startsWith(source + ':') ? id : source + ':' + id;
}
function remoteMessages(conversationId, messages) {
const ids = new Map(messages.map((m) => [m.id, conversationId + ':' + m.id]));
return messages.map((m) => ({
...m,
id: ids.get(m.id),
parentUuid: m.parentUuid
? (ids.get(m.parentUuid) ?? conversationId + ':' + m.parentUuid)
: null,
}));
}
const parsed = {
source: 'codex',
messages: [
{
id: 'm1',
parentUuid: null,
type: 'user',
role: 'user',
content: 'question',
hasToolUse: false,
hasCode: false,
thinking: null,
timestamp: '2026-01-01T00:00:00Z',
},
{
id: 'm2',
parentUuid: 'm1',
type: 'assistant',
role: 'assistant',
content: 'answer',
hasToolUse: true,
hasCode: false,
thinking: null,
timestamp: '2026-01-01T00:00:01Z',
},
],
};
const changedIds = structuredClone(parsed);
changedIds.messages[0].id = 'different';
changedIds.messages[0].timestamp = '2026-02-01T00:00:00Z';
assert.equal(contentHash(changedIds), contentHash(parsed));
const changedThinking = structuredClone(parsed);
changedThinking.messages[1].thinking = 'fixture reasoning';
assert.notEqual(contentHash(changedThinking), contentHash(parsed));
assert.notEqual(
contentHash({ ...parsed, messages: [...parsed.messages].reverse() }),
contentHash(parsed)
);
const id = namespace('codex', '7');
assert.equal(namespace('codex', id), id);
assert.notEqual(namespace('cursor', '7'), id);
const normalized = remoteMessages(id, parsed.messages);
assert.equal(normalized[1].parentUuid, normalized[0].id);
assert.equal(contentHash({ ...parsed, messages: normalized }), contentHash(parsed));
console.log('identity and canonical hash boundaries pass');
从 canonical 删除 thinking 字段,变化样本的断言就会失败;恢复后通过。再把 parentUuid 改成原始父 ID,父子关系断言会失败。这两个反例分别覆盖内容选择和身份重写,不能相互替代。
远端命名规则不等于任意输入无碰撞
远端载荷保留 sourceConversationId,并生成带来源前缀的 conversationId;消息 ID 和非空 parentUuid 也加会话前缀。已经带相同来源前缀的会话 ID 保持不变,所以 7 和 codex:7 会映射到同一个结果。这个规则具有幂等性,但不能据此宣称所有任意原始 ID 都一一对应。
本地 importAll 与远端 normalizeParsedConversationForRemote 是不同入口,不应把远端命名规则描述成全部本地导入都会执行的步骤。新增来源还要核对共享 ChatCrystalSource 联合类型、SUPPORTED_IMPORT_SOURCES、配置默认值、远端载荷检查及 watcher/UI 路径。现有配置加载还会补回缺失的内置来源。
把重新导入当作一组验收场景
至少分别检查:内容改变但文件大小和时间相同;只改变时间;只改变解析版本;不同来源复用相同原始 ID;重复注册;探测或扫描抛错;单候选解析抛错。逐候选 catch 可以记录解析失败并继续,但记录错误本身还依赖数据库可用,不能升级成无条件容错保证。
添加适配器的完成条件应覆盖发现、启用、身份、解析、变更策略和持久化,而不是“实现三个方法,再注册一次”两个动作。具体格式和损失边界可接着阅读多格式解析。