跳转至

第 2 章:分层与依赖 —— 从 Schema 原子到多端产品

1. Workspace 不是按“业务页面”切包

OpenCode 的 package 划分更像一条依赖漏斗:越靠下越稳定、越靠上越接近产品宿主。

flowchart BT
  S["schema: 可编码领域形状"]
  P["protocol: API / error / transport contract"]
  L["llm: canonical message / provider event"]
  C["core: Effect services + DB + Session V2"]
  SV["server: middleware / HTTP / location"]
  OC["opencode: CLI + product orchestration"]
  CL["client: generated promise API"]
  SDK["sdk-next: client + core + server composition"]
  UI["tui / app / web / desktop"]
  S --> P
  S --> L
  P --> SV
  L --> C
  S --> C
  C --> SV
  C --> OC
  SV --> OC
  P --> CL
  CL --> UI
  C --> SDK
  SV --> SDK
  SDK --> UI

仓库 AGENTS.md 给出的原则可以压缩成一句话:Schema → Core / Protocol → Server;Client 只能依赖 Schema / Protocol,不能反向依赖 Core / Server;sdk-next 才组合 Client、Core、Server。

2. 每一层的“原子”和“分子”

2.1 Schema:可以存、传、回放的形状

packages/schema/src 定义 Session、Message、Part、Prompt、Event、Permission、Project、Workspace 等数据。它通过 Effect Schema 建立编码与解码关系,ID 也有前缀和格式约束。

Schema 的价值不只是类型检查:

  • HTTP body 和 query 可以由同一份定义解码;
  • SQLite projector 可以把事件转成同样的结构;
  • SDK codegen 可以从 API 定义生成客户端;
  • Event Manifest 可以把事件变成可订阅的联合类型。

2.2 Protocol:把领域形状变成 API 契约

packages/protocol/src 放跨 server/client 的错误和 API 描述。它不应该知道具体的 SessionRunner,但可以描述 SessionNotFoundError 这样的稳定错误边界。

2.3 LLM:给模型世界一个中间表示

packages/llm/src/schema/messages.ts 把消息、文本、reasoning、tool-call、tool-result、ToolDefinition 和 LLMRequest 标准化。provider 适配器只需要把 canonical request 翻译成自己的 wire format,再把流翻译回 LLMEvent。

2.4 Core:可复用的领域运行时

Core 是真正的“骨骼”:

  • packages/core/src/session:Session V2、History、Input、Runner、Compaction;
  • packages/core/src/system-context:上下文 Source、Registry、Baseline;
  • packages/core/src/tool:跨宿主可用的工具与输出治理;
  • packages/core/src/database:Effect + Drizzle + SQLite;
  • packages/core/src/projectlocationworkspace:作用域与持久化;
  • packages/core/src/providercatalog:模型和 provider 的领域表示。

2.5 Server / OpenCode:产品化装配

packages/server 里是通用 HTTP / Location 中间件;packages/opencode 里则是具体的 CLI、旧版服务、配置发现、插件加载、MCP、LSP、路由 handlers 和应用 runtime。这样核心服务可被不同宿主使用,但产品入口仍然集中管理。

3. 为什么 TUI 不直接 import 后端

specs/tui-package.md 把 TUI 抽取的目标写得很清楚:TUI 通过 @opencode-ai/sdk 获取 Session、Message、File、Provider、Agent、Permission 等数据,缺少的能力要先加到 server API 和 generated SDK,而不是直接 import packages/opencode 内部实现。

这是一个很实用的架构测试:如果一个 UI 功能必须直接读取后端 service,说明 API 边界还没有表达完整的产品能力。

4. Effect Layer 是依赖注入图

AppLayer 不是“把很多服务放进数组”。每一个 LayerNode 表示一个服务的构造函数和依赖,Layer.provideMerge 让共享的 Node / observability / runtime 被合并。效果是:

数据库、FS、配置、认证
  → Project / Provider / Agent
  → Session / Context / LLM / Tools
  → HTTP handlers / CLI / TUI host

当测试要替换 Database、LLM 或 Provider 时,不需要改业务函数签名,只需要提供另一层实现。这也是 Effect.Service 比全局单例更适合 OpenCode 的原因。

5. V1 / V2 为什么会在依赖图中同时出现

当前仓库不是一次性重写,而是在迁移:

  • V1 的 packages/opencode/src/session/* 仍承担大量产品功能和兼容入口;
  • V2 的 packages/core/src/session/* 把 durable inbox、Location-scoped runner、Context Epoch 和 Event V2 作为新的规范化方向;
  • packages/opencode/src/event-v2-bridge.ts、projector 和 API 层负责把两边逐步接起来。

这解释了为什么阅读时会遇到 SessionV1SessionV2MessageV2 和两套 event schema。正确做法不是把重复代码马上合并,而是先确认每个类型属于哪个状态模型。

6. 三个可迁移的设计方法

方法 1:依赖漏斗

让底层只依赖稳定数据和小接口,让上层决定宿主、显示和网络。越靠近 UI,依赖越多;越靠近 Schema,依赖越少。

方法 2:语义边界优先于文件边界

SessionRunnerSystemContextRegistryToolRegistry 都不是单个文件,而是由 schema、store、service、event 和测试共同组成的边界。看源码要按语义组,而不是只看文件名。

方法 3:用 API 反推领域模型

SessionApi 的路由反向看:如果 API 要支持 prompt、abort、compact、revert、permission、find file、share,那么后端必然需要对应的 Session、Input、Compaction、Revert、Permission、File 和 Share 服务。

本章小结

OpenCode 的分层不是“为了好看”,而是为了解决三个变化维度:模型供应商会变、客户端宿主会变、Session 执行语义会迁移。Schema / Protocol 稳定跨边界,Core 承载领域状态,Server 提供运行时,UI 只消费契约。

源码锚点