---
title: SourceAdapter 内部链路：身份、内容哈希与重新导入
description: 对照 ChatCrystal 的注册表、导入器和远端载荷，验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。
image: "https://zengliangyi.com/og/blog/source-adapter-internals.png"
---

统一三个方法之后，新增来源仍可能在身份、变更判断或远端传输上失败。[SourceAdapter 入门](/blog/source-adapter-design/)介绍职责划分；这篇继续沿着固定实现的注册表、导入器和远端载荷，检查一个候选会话如何进入持久化路径。

## 接口统一输出，没有统一所有来源假设

[adapter.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/adapter.ts)声明 name、displayName、可选 parserVersion，以及异步 detect、scan、parse。scan 返回 id、source、filePath、fileSize、fileMtime、projectDir；parse 返回会话和规范化消息。具体字段定义在[共享类型](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/shared/types/index.ts)，接口本身不会验证外部 JSON。

“scan 只 stat 文件，不读取内容”并非所有适配器的实际行为。Cursor 的扫描要从 workspace 数据库找会话，再结合 global 数据库生成候选；Trae 要解码 ItemTable 中的会话列表。元数据是扫描的输出契约，不能据此推断扫描的 I/O 成本。

## 注册、探测与缓存是三个不同边界

[registry.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/registry.ts)使用 Map，重复 name 会警告并覆盖已有适配器。[index.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/index.ts)静态注册五个内置来源，没有动态上传插件的协议。

注册表的 detectAllSources 使用 Promise.all 并行探测。一个探测拒绝会使该聚合调用拒绝；这不等于收集每个来源的失败结果。importAll 则先按 enabledSources 过滤，顺序执行 detect、scan，并把所有候选收集完，再进入逐候选解析。该路径的 detect、scan 不在逐候选 try/catch 内。

[Cursor 适配器](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/adapters/cursor.ts)缓存的是 workspace 扫描结果，过期点设为扫描完成后五秒。它可以减少相邻 detect、scan 的重复工作，但不是会话内容缓存，也没有缓存正在执行的 Promise。不要把这个 TTL 写成并发请求合并或数据实时性的保证。

## 元数据、内容和解析版本分别回答什么问题

[importAll](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/import.ts)先查询已有 id/source 的文件大小、修改时间和内容哈希。大小与时间都相同就跳过，尚未调用 parse。因此只增加 parserVersion，不保证旧会话会重新解析；大小和时间相同也不构成内容相同的证明。

进入解析后，少于两条消息的结果会跳过。已有会话的内容哈希相同走元数据更新；哈希变化走替换；新会话走插入。这些数据库修改使用 SQL 事务，批次结束再 saveDatabase。SQL 回滚与数据库文件写入、向量清理的完成状态仍需分别验收。

[importPayload.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/importPayload.ts)的哈希输入是来源及按顺序排列的消息字段：index、type、role、content、hasToolUse、hasCode、thinking。消息 ID、时间戳、会话标题和 parserVersion 不在其中。忽略这些字段是这份实现的比较策略，不能称为对原始文件全部变化的检测。

## 用离线样本验证身份与哈希

保存为 `lesson.mjs`，用 Node 24+ 运行，无需依赖。以下复现哈希字段选择和远端消息命名规则，**没有数据库、文件扫描或完整远端导入验证器**。



```js
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 路径。现有[配置加载](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/config.ts)还会补回缺失的内置来源。

## 把重新导入当作一组验收场景

至少分别检查：内容改变但文件大小和时间相同；只改变时间；只改变解析版本；不同来源复用相同原始 ID；重复注册；探测或扫描抛错；单候选解析抛错。逐候选 catch 可以记录解析失败并继续，但记录错误本身还依赖数据库可用，不能升级成无条件容错保证。

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

分享：

##  相关文章

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

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵，检查工具标记、思考文本、顺序与快照覆盖范围。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/ai-conversation-parser-boundaries)    [##  AI SDK Provider 抽象：能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出，用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#TypeScript](/blog/ai-sdk-provider-abstraction)    [##  代码规范工具：核对 Biome、ESLint 与格式化的真实分工

阅读 ChatCrystal 的实际脚本与 Flat Config，用离线 ESLint 诊断验证规则，再区分语法检查、类型检查、格式化和 CI 门禁。          曾亮懿       2026年10月6日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/lint-format-tooling)

##  从文章到代码

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

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "SourceAdapter 内部链路：身份、内容哈希与重新导入",
  "description": "对照 ChatCrystal 的注册表、导入器和远端载荷，验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。",
  "url": "https://zengliangyi.com/blog/source-adapter-internals/",
  "image": "https://zengliangyi.com/images/blog/source-adapter-internals.svg",
  "datePublished": "2026-10-07T00:00:00.000Z",
  "dateModified": "2026-10-07T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/source-adapter-internals/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "SourceAdapter 内部链路：身份、内容哈希与重新导入",
      "item": "https://zengliangyi.com/blog/source-adapter-internals/"
    }
  ]
}
```
