第 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/project、location、workspace:作用域与持久化;packages/core/src/provider、catalog:模型和 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 层负责把两边逐步接起来。
这解释了为什么阅读时会遇到 SessionV1、SessionV2、MessageV2 和两套 event schema。正确做法不是把重复代码马上合并,而是先确认每个类型属于哪个状态模型。
6. 三个可迁移的设计方法¶
方法 1:依赖漏斗¶
让底层只依赖稳定数据和小接口,让上层决定宿主、显示和网络。越靠近 UI,依赖越多;越靠近 Schema,依赖越少。
方法 2:语义边界优先于文件边界¶
SessionRunner、SystemContextRegistry、ToolRegistry 都不是单个文件,而是由 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 只消费契约。