跳到正文
曾亮懿 ZengLiangYi Blog

SourceAdapter 内部链路:身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷,验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

统一三个方法之后,新增来源仍可能在身份、变更判断或远端传输上失败。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 可以记录解析失败并继续,但记录错误本身还依赖数据库可用,不能升级成无条件容错保证。

添加适配器的完成条件应覆盖发现、启用、身份、解析、变更策略和持久化,而不是“实现三个方法,再注册一次”两个动作。具体格式和损失边界可接着阅读多格式解析。

分享:

相关文章

五种 AI 对话解析器:字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵,检查工具标记、思考文本、顺序与快照覆盖范围。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

AI SDK Provider 抽象:能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出,用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #TypeScript

代码规范工具:核对 Biome、ESLint 与格式化的真实分工

阅读 ChatCrystal 的实际脚本与 Flat Config,用离线 ESLint 诊断验证规则,再区分语法检查、类型检查、格式化和 CI 门禁。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。