第09章:源码架构与仓库布局
这一章带你从仓库顶层一路下钻到单个包的构建配置。读完你会理解四件事:dsh 这个 monorepo 按什么原则组织;Profile 与 Bundle 如何用 package.json 自描述;为什么 TypeScript 编译被拆成 Host 与 Client 两个独立 program;以及 Typert 这个类型图生成器在构建链里扮演什么角色。
阅读本章前,建议先掌握第 8 章的 Cordis 插件范式。仓库官方文档也说得很直接:改动 packages/ 之前先读 architecture.md,它假定你已经了解 Cordis。
注意:dsh 仍处于 developer preview,仓库结构、包划分、构建链都在快速迭代,会有破坏性变更。本章描述以 master 分支为准。
9.1 仓库总览:monorepo 顶层目录
dsh 是一个 pnpm workspace 管理的 monorepo。顶层目录职责如下:
| 目录 | 职责 |
|---|---|
vendor/ |
vendored(内嵌)的 Cordis 及其生态源码:cordis/、cosmokit/、schemastery/、loader/、timer/ 等。这些包被重新作用域(rescope)为私有包 |
packages/ |
所有 @deepseek-ai/dsh-<pkg> workspace 包,按 packages/<group>/<pkg>/ 两级组织 |
apps/ |
顶层应用入口:apps/cli(dsh 命令)、apps/web(浏览器 GUI) |
python/ |
Python SDK:python/sdk/ 与 python/sdk-runtime/ |
native/ |
原生辅助程序:native/landlock-run,Landlock 沙箱的 native runner |
examples/ |
可运行的 agent 组合示例(headless-agent、acp-agent、jsonrpc-agent、web-cordis 等),每个都带 cordis.yml 入口与 .cordis.snapshot.yml 快照 |
website/ |
VitePress 文档站(.vitepress/) |
docs/ |
架构、子系统、教程等 Markdown 文档,部分由脚本生成 |
.agents/ |
Agent Notes 设计决策日志(notes/implemented/...),记录”为什么这么做” |
scripts/ |
仓库级构建与 CI gate 脚本 |
patches/ |
pnpm patch 补丁 |
assets/ |
静态资源 |
其中 packages/、docs/、.agents/ 是理解架构的三大入口:包结构回答”代码在哪”,docs/ 回答”怎么协作”,.agents/ 回答”为什么这样设计”。
如果你第一次打开这个仓库,推荐的阅读顺序是:README.md → docs/architecture.md → docs/development.md → packages/README.md,然后挑一个 examples/ 里的组合(比如 headless-agent)看它的 cordis.yml 与 composition.md,最后钻进 packages/core/ 的五个脊柱包。architecture.md 也建议”用 agent 探索代码库”——因为 Cordis 的插件树很难靠线性读文件理解。
.agents/ 特别值得单独说明。dsh 团队用 Agent Note 作为设计决策日志:每个已实现的决策(架构、feature、process、simplification 分类)写一篇短文档,正文里的代码或文档引用它而不是复述理由。例如 session/end-seed 事件的动机在 .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md。阅读源码时遇到”为什么”问题,去这里找答案。
9.2 pnpm workspace 与包命名约定
包的物理位置与 npm 名严格对应。根 AGENTS.md 的约定原文是:
- 每个 npm 包名为
@deepseek-ai/dsh-<name>,位于packages/<group>/<pkg>/。 - vendored 包重新作用域(见
docs/rescope.md)并标记private: true。 @deepseek-ai/cordis是每个 harness 包的 peerDependency(外加 devDependency)。
这带来一个直接后果:分组(group)不是 npm scope 的一部分。packages/core/session 的包名是 @deepseek-ai/dsh-session,而不是 @deepseek-ai/dsh-core-session。分组只存在于目录结构里,用于组织开发与文档,不进入运行时命名空间。
package.json 里的 dsh 字段是另一个关键约定。profile 与 bundle 都通过它自描述:dsh.profile 列出某个 profile 叠加了哪些 bundle,dsh.bundle 指向某个 bundle 的 patch 文件。举一个概念性的例子:
{
"name": "@deepseek-ai/dsh-profile-headless",
"dsh": {
"profile": ["@deepseek-ai/dsh-base"]
}
}
{
"name": "@deepseek-ai/dsh-base",
"dsh": {
"bundle": "./cordis.patch.yml"
}
}
也就是说,一个”产品”不是一个可执行文件,而是一组有序的 bundle 引用。这套体系属于第 5 章的 Profile/Bundle 内容,但它的声明方式就写在各包的 package.json 里,所以是理解仓库布局绕不开的一环。
9.3 packages/ 分组全览
官方 packages/README.md 维护着一张分组表,是理解代码版图的最快入口。下表据此整理,省略了几乎全部为 “Product — stable API” 的发布预期,仅在少数例外处标注:
| 分组 | 职责 |
|---|---|
core/ |
产品 API 脊柱:session 日志、system-prompt、工具注册表、agent 服务与具体循环(session、system-prompt、tools、agent、agent-loop、scope 等) |
api/ |
远程 BFF 装配与 Typert RPC 网关(含 remotes 分裂包,见 9.8) |
typert/ |
类型图生成、产物加载、运行时注册表 |
goal/ |
同会话目标(goal)的持久化与生命周期 |
schedule/ |
会话内定时 follow-up |
feedback/ |
人类反馈 |
identity/ |
共享匿名身份 |
llm/ |
LLM 能力族:抽象 service + provider 适配器(llm、token-meter、llm-retry、llm-deepseek、llm-pi-ai) |
e2b/ |
E2B provider(发布预期 POC) |
subprocess/ |
子进程能力族:Service Definition + 本地进程树 provider |
shell/ |
Bash 能力族:executor seam、本地实现、模型可见工具 |
terminal/ |
持久 PTY 能力族:owner 作用域会话、本地实现、模型可见工具 |
code-runtime/ |
代码执行能力族:Service Definition + worker-thread provider + Code Mode Consumer |
sandbox/ |
进程隔离 seam;bwrap / Landlock / Seatbelt 后端 |
fs/ |
文件系统能力族:seam、本地实现、文件工具、基于 bash 的发现工具 |
lsp/ |
LSP 能力族:seam、通用 stdio provider、lsp 工具 |
skill/ |
Skill 能力族:provider 注册表、本地 provider、模型可见目录/加载器 |
compaction/ |
Compaction 能力族:Service Definition + basic provider + command Consumer |
context/ |
模型可见的请求上下文(workspace instructions、time context 等) |
subagent/ |
子代理能力族:provider 注册表契约 + 模型可见委托工具 |
jobs/ |
通用后台任务运行时 + 模型可见 job_* 控制工具 |
workflow/ |
Workflow seam、worker-thread 引擎、模型可见 workflow/ralph 工具 |
web/ |
Web 能力族:seam、search/fetch provider、模型可见 web 工具 |
attachment/ |
持久附件身份、校验、本地内容寻址存储 |
spill/ |
Spill 能力族:存储 seam、本地实现、工具结果溢出策略 |
todo/ |
模型可见的 todo_write 工具 |
plan/ |
计划协作状态:直接进入命令 + 审阅退出 |
preset/ |
从 preset cordis.yml 组装每个会话的 agent 组合 |
guard/ |
循环卫生守卫:重复调用提醒 + tools/execute 截止时间强制器 |
bundle/ |
可安装的 dsh --profile patch 层 |
extensions/ |
agent 运行时自修改:活插件/服务检查与模型写的插件挂载/卸载 |
hooks/ |
Hook 桥 + 共享的 Claude Code / Codex 线协议库 |
session/ |
持久会话数据面:persistence seam + JSONL/SQLite 后端、projection seam、日志驱动的标题、报告 |
session-query/ |
会话检索族:逻辑语料、有界读取、血缘、事件关系、语义过滤、SQLite 全文检索 |
settings/ |
用户设置 seam + 文件后端 provider |
credentials/ |
凭据引用 seam + env 优先于 .env 的 provider |
storage/ |
非会话存储枢纽 + 后端 + domain 形态 |
workspace/ |
Workspace 实体 |
sdk/ |
进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端、server 插件 |
acp/ |
仅自动化用途的 Agent Client Protocol server |
interaction/ |
人机协作面:approval/interaction seam、permission preset、commands、ask-user 工具 |
boot/ |
共享的应用二进制启动胶水 |
host/ |
Web-GUI 的 host 半区:API gateway + HTTP 路由 server |
client/ |
Web-GUI 的浏览器半区:shell、wire、object services、slots、ui-* 插件 |
examples/ |
演示 bundle(agent-spine + CLI/ACP/JSON-RPC 入口)(发布预期 Support) |
test-support/ |
测试基础设施(testkit、invariant、replay、Loader smoke)(发布预期 Support) |
util/ |
跨组共享的零依赖低级工具(Branded<B>、Harness home/路径助手、timeout、retention)(发布预期 Support) |
这张表揭示了一个规律:绝大多数分组都是”能力族”(capability family)。一个能力族通常按三种角色拆分(第 11 章会展开 seam 概念):
- Service Definition:声明接口,谁都能依赖它。
- Service Provider:实现接口,可被替换。
- Consumer:消费接口,通常是模型可见的工具。
fs/ 是最典型的例子:一个 seam(fs/fs)、一个本地实现、若干模型可见工具、若干 bash 驱动的发现工具,各自独立演化。正因为按角色拆分,核心依赖方向才是单向的——扩展插件依赖 Service Definition,从不依赖具体 provider,所以 dsh-agent-loop 可以整体替换、文件系统 provider 可以指向远程沙箱而不必 fork 一堆 provider。
9.4 核心包与 ctx key 映射
Cordis 里每个 Service 挂在共享 Context 的某个 key 下。核心包与 key 的映射在 architecture.md 与各分组 README 里维护:
| 包 | 职责 | ctx key |
|---|---|---|
core/session |
append-only SessionEvent 日志 + 内存 store |
ctx.sessions |
core/system-prompt |
prompt 段与工具 schema 的装配 | ctx.systemPrompt |
core/tools |
作用域工具注册表 + 受守卫的执行管线 | ctx.tools |
core/agent |
Agent 接口、活注册表、agent/* 事件 |
ctx.agents |
core/agent-loop |
实现该接口的默认 driver | ctx.agentLoop |
core/agent-default-model |
Agent 入口共享的默认模型选择 | ctx.agentDefaultModel |
core/scope |
每个 agent 的作用域注册原语 | library,无 key |
llm/llm |
消息与流式词汇表 + 适配器 seam | ctx.llm |
llm/token-meter |
重放感知的 token 计量 | ctx.tokenMeter |
llm/llm-retry |
provider 作用域的重试策略 | 监听 agent/request-error |
llm/llm-deepseek |
DeepSeek 直连适配器 | 注册到 ctx.llm |
llm/llm-pi-ai |
多 provider 的 pi-ai 适配器 | 注册到 ctx.llm |
注意两处”没有 key”的包:core/scope 是纯库,只提供作用域原语,不挂 Service;llm/llm-retry 与两个适配器则消费/注册别人的 key,而不是自持一个 key。这体现 Cordis 的常态——一个包的贡献形式可以是”提供 key”(Service)、”监听事件”(consumer)、”注册到别人的 key”(provider)三种中的任意组合。
分组 README 各自维护包与 ctx key 的映射(官方原话是 “Group READMEs own package/ctx-key maps”)。core/ 组的完整清单(来自 packages/core/README.md):
| 包 | 角色 | ctx key |
|---|---|---|
scope/ |
作用域上下文注册原语 | library — 无 key |
session/ |
事件溯源会话日志与内存 store | ctx.sessions |
system-prompt/ |
prompt 与工具 schema 装配注册表 | ctx.systemPrompt |
tools/ |
作用域工具注册表与执行管线 | ctx.tools |
agent/ |
Agent 接口、注册表、事件词汇 | ctx.agents |
agent-default-model/ |
Agent 入口共享的默认模型选择 | ctx.agentDefaultModel |
agent-loop/ |
默认具体 agent driver | ctx.agentLoop |
llm/ 组的完整清单(来自 packages/llm/README.md):
| 包 | 角色 | ctx key |
|---|---|---|
llm/ |
LLM service + 共享流式词汇表 | ctx.llm |
token-meter/ |
重放感知的 token 计量 | ctx.tokenMeter |
llm-retry/ |
provider 作用域重试策略 | 监听 agent/request-error |
llm-deepseek/ |
DeepSeek 直连适配器 | 注册到 ctx.llm |
llm-pi-ai/ |
多 provider 的 pi-ai 适配器 | 注册到 ctx.llm |
llm/ 组还有一个特别之处:llm 包同时拥有 Service Definition 与 Consumer 两种角色——抽象 service、内容块词汇表、流式 chunk 装配器都由它自己承担,适配器只负责往 seam 上注册 provider 路由,重试与 token 计量则保持为独立的 consumer。
每个核心包都有一篇子系统文档,贴在 docs/subsystems/ 下,且多半带源码等价类型声明:
| 包 | 子系统文档 |
|---|---|
core/session |
docs/subsystems/session.md(还有 persistence.md 讲持久化) |
core/system-prompt |
docs/subsystems/system-prompt.md |
core/tools |
docs/subsystems/tools.md |
core/agent 与 core/agent-loop |
docs/subsystems/core.md |
core/scope |
docs/subsystems/scope.md |
llm/llm |
docs/subsystems/llm-streaming.md(token 计量见 token-meter.md) |
core/ 组还有一层更细的分工值得记住:agent 拥有公开契约,agent-loop 是它的默认实现;扩展插件依赖 dsh-agent 这个 seam,所以 driver 始终可替换。而”可运行的组合”不属于 core/,属于 examples/agent-spine-demo——core/ 只提供可替换的脊柱零件,不提供整机。
9.5 Profile 与 Bundle:包的另一种形态
packages/bundle/ 与 profile 概念回答了”一个 dsh 进程到底挂了哪些插件”这个问题。architecture.md 的表述是:运行中的 dsh 是一棵在 boot 时由有序层组合出来的插件树。
- profile 是一个命名组合,存在 Harness home 下。它列出自己叠加了哪些 bundle、装着哪些 out-of-tree 插件、保留用户自己的
cordis.patch.yml。web与headless作为模板发布。 - bundle 是 Cordis config 行及其挂载代码的分发格式,它插入的内容仍可被上层 patch。
每个 profile 的第一层都是 dsh-base:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。dsh-web-app 在它之上加浏览器应用,dsh-headless 加一个完全没有 server 的一次性 runner。
层按顺序应用到空的 entry 列表:先按 profile 列出的顺序应用每个 bundle,然后是 profile 的 cordis.patch.yml,再是 home 级 patch,最后是 --patch overlay。patch 按 id 定位某一行并整体替换它的 config,或插入新行。
用一个概念性的 patch 说明”按 id 定位”:假设某个 bundle 的 cordis.patch.yml 里有一行 id 为 llm 的模型配置,你的覆盖 patch 可以整行替换它,或新增一行:
- id: llm
plugin: '@deepseek-ai/dsh-llm-deepseek'
config:
model: deepseek-chat
apiKey: { env: DEEPSEEK_API_KEY }
- id: extra-tool
plugin: '@deepseek-ai/dsh-tool-skill'
config: { provider: local }
每层 patch 都看到并可以改写下层产出的行:上层的 cordis.patch.yml 覆盖 bundle,--patch 又覆盖它们。这解释了为什么一个发行版可以被用户”无侵入”地定制——你不用 fork 任何 bundle,只需加一层自己的 patch。
用一个概念性的 patch 说明”按 id 定位”:假设某个 bundle 的配置里有一行 id 为 llm 的模型配置,你的覆盖 patch 可以整行替换它,或新增一行:
- id: llm
plugin: '@deepseek-ai/dsh-llm-deepseek'
config:
model: deepseek-chat
apiKey: { env: DEEPSEEK_API_KEY }
- id: extra-tool
plugin: '@deepseek-ai/dsh-tool-skill'
config: { provider: local }
每层 patch 都看到并可以改写下层产出的行;上层的 cordis.patch.yml 覆盖 bundle,--patch 又覆盖它们。这解释了为什么一个发行版可以被用户”无侵入”地定制——你不用 fork 任何 bundle,只需加一层自己的 patch。
看自己机器实际 boot 出什么树:
dsh --profile web --dump-config
它打印的每一行都可以被你自己的 patch 替换。这就是”Everything is a plugin”在分发层的落点:产品形态是配置的产物,不是硬编码的产物。
9.6 TypeScript 两面工程:Host / Client aggregate
dsh 的 TypeScript 工程不是单一 program,而是被拆成两个隔离的 aggregate。普通包只在其中一个 aggregate 里注册:Host 包在 tsconfig.host.json,Client 包在 tsconfig.client.json。详见 development.md。
各 tsconfig 文件的角色:
| 文件 | 角色 | 是否形成 program |
|---|---|---|
tsconfig.json |
解决方案根:extends base、files: []、引用两个 aggregate。是 tsserver 发现入口,也是显式运行完整 Project Reference 图的入口 |
否 |
tsconfig.host.json |
Host aggregate:Host 包、examples、tests、scripts、website,以及 api/remotes 的 Host 项目 |
是 |
tsconfig.client.json |
Client aggregate:packages/client/* 及其测试、apps/web,以及 api/remotes 的 Client 项目 |
是 |
tsconfig.base.json |
共享 compilerOptions + 源 paths 映射;无 include,作为 vitest 的解析 facade |
否 |
tsconfig.base.client.json |
浏览器编译设置(jsx、DOM libs、types: []),被 Client aggregate 及每个 packages/client/* 继承 |
否 |
“形成 program”这列很关键:只有两个 aggregate 会各自编译成一个完整的 ts.Program。tsconfig.json 根只是把它们引用起来,自己不编译任何文件。
tsconfig.host.json 与 tsconfig.client.json 都通过继承 tsconfig.base.json 拿 paths 映射。paths 让静态分析与测试在干净 checkout 上就能解析 workspace 导入到 src,而不用先跑一遍 build。一个 Host 包注册进 aggregate 的方式,概念上是这样的:
{
"extends": "../tsconfig.base.json",
"references": [{ "path": "../core/session" }]
}
关键是它 extends base 而非根解决方案——每个包项目只看到自己那一面,永远不会把 Host 与 Client 的声明并进同一个 program。
9.7 为什么分两面:Context 的 declaration-merge 冲突
分两面的直接原因是类型系统层面的,不是部署层面的。原文表述:
Host 与 Client 保持两个 aggregate program,是因为双方在相同的 key 下对 Cordis 的
Context接口做 declaration-merge,却挂不同的 service;一个 program 同时看到两边的 merge 就会报冲突。
Cordis 的类型模型依赖 TypeScript 的 declaration merging:ctx.sessions、ctx.tools 这些属性不是定义在某个中心文件里,而是由各插件在自己的模块里通过 declare module 合并进 Context 接口。问题在于——Host 与 Client 都要在同一个 key(比如都叫 ctx.foo)下合并属性,但值是两套不同的 service 类型。把两边的声明塞进同一个 ts.Program,编译器自然报重复合并冲突。
关键点在于:冲突只存在于 ts.Program 内部。模块解析(module resolution)不会触发它,所以解决方案根可以同时引用两个 aggregate,一个 paths facade 也可以横跨两面。冲突纯粹是”把两套源文件放进同一个 program”才会出现。
由此衍生三条纪律(同样来自 development.md):
tsconfig.base.json永不添加include或files——它们会泄漏进每个继承它的包项目,收窄 facade 的”匹配全部”范围。- 构建全仓库
ts.Program的脚本,必须显式 seedtsconfig.host.json或tsconfig.client.json,永远不要用根解决方案——把两个 aggregate 压平成一个 program 会触发 Context merge 冲突。 - 新包只在一个 aggregate 里注册。同时拥有 Node loader 入口和浏览器入口不是拆包的理由——普通 Client 插件会在 Client 构建阶段产出两种运行时产物。
9.8 唯一例外 api/remotes:Host/Client 分裂包
全仓库唯一的例外是 packages/api/remotes,它拥有分裂的 Host 与 Client 两份 tsconfig。原因是它的 Host 入口必须参与 Host 的 Typert 类型图,而 Client 入口 import 的 /remote 声明又必须由 Host 的 tsdown 先生成出来。
所以 api/remotes 的包根 tsconfig.json 只是解决方案,两个 aggregate 与直接消费者分别引用 tsconfig.host.json / tsconfig.client.json。它的运作机制(见 api-remotes README):
- 业务服务在 Host 上用
@Remote或@RemoteScope声明可调用方法。 - Host 构建阶段生成 Host-for-Client 的类型声明与运行时贡献。
- Client 的
api-remotes组合把这些贡献装载到ctx.remote和作用域的agentCtx.remote命名空间下。
一句话概括:@Remote/@RemoteScope 方法通过 Typert 生成一份”Host 到 Client 的契约”,让浏览器半区能调用 Host 半区的业务服务。workspace 的 constraints gate 会遍历 Project Reference 图检查每个引用项目的编译面,并自动发现新的分裂包——但官方明确警告:不要把这个结构复制到其他包。
9.9 构建顺序与 DSH_BUILD_FACE
根构建严格按生成的依赖顺序执行:
tsc -b tsconfig.host.json
tsdown --env.DSH_BUILD_FACE host
tsc -b tsconfig.client.json
tsdown --env.DSH_BUILD_FACE client
pnpm run build:web
看懂这个顺序,就读懂了”先编译、后打包”的分工:
- tsc 阶段只负责产出
lib/types下的 JavaScript 与声明文件,不做打包。 - tsdown 阶段消费前一阶段 tsc 的产物,产出运行时 bundle。
- 两次 tsdown 使用同一份完整的 workspace 匹配,既不扫描构建产物去发现 Client 包,也不维护 Host/Client 的包过滤清单。
区分 Host 与 Client 阶段的开关就是 DSH_BUILD_FACE。包内的 tsdown 配置通过它选择当前阶段的入口:普通 Client 插件在 Client 阶段同时产出 Node loader 与浏览器 bundle;api-remotes 用 hostPhase: true 让 Host 入口提前产出,Client 阶段只产浏览器 bundle。
一个容易踩的坑:静态分析与测试通过 base 的 paths 映射解析到 src,在干净 checkout 上就能跑;而消费构建 lib/ 产物的 gate 必须显式声明对 build 的依赖。生成的 Host-for-Client Remote 声明是刻意为之的例外——公共的 typecheck、lint、doc-typecheck 命令会先跑生成,内部 *:contracts-ready 脚本则假设调用方已经依赖了 Typert 契约生成或完整构建。
9.10 Typert:类型图生成器在构建链中的位置
typert/ 组负责三件事:类型图生成、产物加载、运行时注册表。在构建链里它只在Host 的 tsdown 阶段运行,以 tsconfig.host.json 为种子:
- 分析 Host 侧类型。
- 生成 Host 反射产物(reflection artifacts)。
- 生成 Host-for-Client 的 Remote 投影。
Client 的 tsdown 不启动 Typert。因此 pnpm run typecheck 在 Client tsc 之前先完成整个 Host lib 阶段(含 Typert 契约生成),而 pnpm run build 继续走 Client tsdown 与 Web 构建。这条顺序决策记录在 .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md。
Typert 存在的意义与 Cordis 的声明式注册一脉相承:Cordis 的插件、事件、Service 都是静态可枚举的类型结构,Typert 从 TypeScript 类型图上把它们提取成运行时反射数据,供文档生成、契约生成、远程调用注册表消费。它让”类型即事实”——改一处声明,反射产物与生成的契约跟着变,而不是靠手写的注册表二次同步。
9.11 依赖图与模块关系
依赖关系不是靠人肉维护的,而是由 docs/module-graph.md 生成(pnpm run gen-module-graph,CI 里 freshness-gated)。packages/README.md 的依赖一节把核心规则凝练成两句:
- 扩展插件依赖 Service Definition,从不依赖具体 provider。
dsh-agent-loop可替换;UI、hook、工具插件都依赖dsh-agent。组合 bundle(含dsh-agent-spine-demo)可以依赖脊柱插件。 - 能力在独立演化时,把 Service Definition / Service Provider / Consumer 三种角色分开。
理解这条规则,就能从 import 方向一眼判断一个包的定位:大量被 import 的包通常是 Service Definition 或契约;几乎只 import 别人、很少被 import 的包通常是 Consumer;实现细节被隔离在 Provider 里,方便替换。
9.12 代码约定与日常命令
根 AGENTS.md 的 Conventions 一节给出几条硬性约定:
- ESM everywhere:所有包
"type": "module"。 - 跨包导入用包名(
@deepseek-ai/dsh-session),包内相对导入带.ts后缀。 - 配置子进程在纯 Node 下跑构建好的
lib/;源码回归测试用各自声明的 launcher。dshCLI 的源码启动走 tsx 的 ESM-only hook(node --import tsx/esm),所以它触及的模块必须保持 ESM,不能有 CJS-only 导出。 - Raw/Web 的
cordis.yml裸插件必须出现在其 resolver manifest 的dependencies里,verify-cordis-config强制校验。 - 源码面与产物面永不混用:静态 gate 与测试经 tsconfig
paths解析到src,消费lib/的 gate 显式声明依赖。
编译与注释也有统一标准:全仓库 strict: true + noImplicitAny;每个残留的 any 要解释为什么无法收窄;每个模块和导出为它的非显然契约写精炼 JSDoc,函数式导出带 @param/@returns,由 verify-export-jsdoc 强制执行。
TODO 标记用三个注释标签区分紧急度(由高到低):FIXME(应阻断发布)、TODO(有资源后尽快修)、XXX(可能将来修,最低优先级)。挑对标签,让扫代码的人能一眼分辨”发布阻断”和”someday-maybe”。
还有一个对文档读者重要的机制:docs/subsystems/*.md 里贴着源码等价类型声明的代码块用 ` ```ts type-equiv ` 围栏标记,并注册在 scripts/type-equiv.manifest.json 里,pnpm run verify-type-equiv 会用 TypeScript parser 提取源码声明,断言文档粘贴没有漂移。所以你在文档里看到的类型签名,是被 gate 保真的源码原文,而不是手抄。
日常命令方面,development.md 给出的最小闭环是:
pnpm install # 首次或依赖变更后
pnpm run typecheck # 干净 checkout 后跑一次,退出码 0 即就绪
pnpm run build # 任何消费 lib/ 产物的本地检查之前
pnpm run check:all # 可选的全量本地 gate 集
pnpm run hygiene 包含 publint(对照构建出的 lib/*.js 校验包入口)与 verify-node-next-types(用临时 NodeNext consumer 校验构建出的声明)。全新 worktree 在 pnpm run build 之前没有任何打包 JS 或声明,普通 commit/push 不需要 build,除非所选检查消费它。
三个演示入口也值得知道,它们都需要 DEEPSEEK_API_KEY:
pnpm dsh --profile headless "summarize this workspace" # 一次性 headless agent
pnpm run demo:cordis # 自引用 cordis 演示(web 或 acp)
pnpm run demo:acp # ACP 自动化 server(JSON-RPC stdio)
Git 钩子用 lefthook 做轻量本地检查点:pre-commit 校验 staging 的 i18n pairing 记录、用 Oxlint 校验并修复 staged 文件、必要时重新生成 THIRD_PARTY_NOTICES.md、检查空白错误、跑 vendor manifest 守卫;pre-push 跑 pnpm run typecheck(完成 Host lib 阶段,含 Typert 契约生成)。钩子刻意不跑测试、快照、文档检查、构建或 hygiene——这些留给 CI 与贡献者本地按需执行。
文档层面还有一套中英双语配对契约:README.md / README.zh.md、CONTRIBUTING.md / CONTRIBUTING.zh.md 这类文件成对维护,靠一个 Git pairing merge driver 从已确认的祖先、当前、对方三个 blob 派生冲突的 .i18n.yaml 记录,在 owner 冲突、非文本合并配置或非法记录上 fail-closed。这套机制与 dsh-translation-pairing 驱动一起,保证双语文档不会在合并时悄悄脱配(见 docs/i18n/README.md 与对应的 Agent Note)。
9.13 文档与代码生成体系
dsh 文档的一大特色是”文档即产物”:大量文档不是手写的,而是从源码生成并用 CI gate 保真。常见的一类生成脚本:
| 脚本 | 产物 | 说明 |
|---|---|---|
gen-module-graph |
docs/module-graph.md |
模块依赖图,freshness-gated |
gen-doc-graphs |
docs/agent-lifecycle.md 等 |
Mermaid 时序图,文件头标注”do not edit by hand” |
gen-cordis-catalog |
各 docs/subsystems/*.md 的 Cordis API 段 |
从源码提取 ctx 与事件签名 |
gen-persistence-catalog |
docs/persistence-catalog.md |
枚举每个日志事件及其负载、surface 徽标、声明位置 |
verify-type-equiv |
校验 ` ```ts type-equiv ` 块 | 断言文档类型粘贴与源码一致 |
verify-cordis-catalog、verify-type-equiv 都由 pnpm run doc-sync 统一驱动。这个体系的意义在于:文档不会因为”忘了更新”而漂移——只要源码变了,对应 gate 就失败,逼着作者在同一笔变更里更新文档粘贴。对读者来说,这意味着 docs/subsystems/ 里的类型签名可以当成源码来读。
doc-typecheck 对可编译的围栏应用同样的派生规则,同时跳过两种源码等价围栏的编译与它的 opt-out 比例。双语侧还有一个细节:成对的 .zh.md 块只有在整个受追踪围栏序列与英文侧逐字节相同且顺序相同时,才能复用无后缀兄弟文件的 manifest 条目——所以中英文档的类型粘贴同样被 gate 锁死,不会各说各话。
9.14 如何阅读这份代码库
把前面的碎片串成一条可执行的路线:
- 先读 cordis-primer.md 与 cordis-tutorial,把”插件、service、typed event、reversible effect”这几个词的含义钉死。
- 读 architecture.md 的 “Core packages” 与 “Events” 两节,对着 9.4 的表记住
ctxkey 与事件域。 - 看一个
examples/组合的cordis.yml,理解”产品 = 有序插件树”。 - 钻
packages/core/session与packages/core/agent-loop,对照docs/subsystems/session.md、docs/subsystems/core.md与docs/agent-lifecycle.md读(这正是第 10 章的内容)。 - 遇到”为什么”,回
.agents/notes/找对应 Agent Note。
这条路线从抽象(Cordis)到具体(session/agent-loop),每一步都有生成文档兜底,是官方推荐的探索顺序的落地版。
9.15 入口与示例:apps 与 examples
apps/ 与 examples/ 是理解”包如何变成可运行程序”的两块拼图。
apps/ 是产品入口。 apps/cli 是 dsh 命令本身——npm 用户用 npx @deepseek-ai/dsh web 启动 Web UI(默认 http://127.0.0.1:3080);源码 checkout 里则用 pnpm dsh web。apps/web 是浏览器 GUI 的打包目标。CLI 的源码启动走 tsx 的 ESM-only hook(node --import tsx/esm),这也是为什么它触及的模块必须保持 ESM。
examples/ 是可运行组合。 顶层 examples/ 下的每个目录都是一个能直接跑的 agent 组合,各自携带 cordis.yml 入口与 .cordis.snapshot.yml 快照:
| 示例 | 形态 |
|---|---|
headless-agent |
一次性 headless agent,含 advanced / compaction / goal / pty / e2b 等多套 cordis.yml 变体 |
acp-agent |
ACP 自动化 server,含 image / fs / code-mode / subagent 等变体 |
jsonrpc-agent |
JSON-RPC 入口 |
mcp-memory |
MCP 记忆集成 |
web-cordis / web-schedule |
Web 相关演示 |
而 packages/examples/ 装的是演示 bundle:agent-spine-demo(脊柱组合)、acp-demo、jsonrpc-demo——它们是被其他叶子引用的”整机零件”,发布预期是 Support。
两者分工:顶层 examples/ 是”给用户看的可运行入口”,packages/examples/ 是”给组合引用的可复用 bundle”。读源码时,前者适合当”第一个能跑起来的入口”,后者适合当”脊柱怎么拼”的参考答案。
9.16 本章小结
- 仓库是 pnpm monorepo,
packages/按<group>/<pkg>组织,包名统一@deepseek-ai/dsh-<pkg>,分组不进入 npm scope。 - 顶层目录职责清晰:
vendor/是内嵌 Cordis,apps/是入口,python/与native/是跨语言部分,examples/是可运行组合,.agents/是设计决策日志。 packages/绝大多数分组是”能力族”,按 Service Definition / Service Provider / Consumer 三角色拆分,依赖方向单向。- profile 与 bundle 通过
package.json的dsh字段自描述;dsh-base是每个 profile 的第一层。 - 核心包通过
ctxkey 暴露:ctx.sessions、ctx.systemPrompt、ctx.tools、ctx.agents、ctx.agentLoop、ctx.llm,每个都有对应子系统文档。 - TypeScript 拆成 Host 与 Client 两个 aggregate,根因是双方在同一 key 下 declaration-merge
Context会冲突;冲突只存在于ts.Program内部。 api/remotes是唯一的分裂包,靠@Remote/@RemoteScope经 Typert 生成 Host-for-Client 契约。- 构建顺序为 tsc(Host)→ tsdown(Host)→ tsc(Client)→ tsdown(Client)→ web,
DSH_BUILD_FACE切换阶段入口。 - Typert 只在 Host tsdown 阶段运行,生成 Host 反射与 Host-for-Client Remote 投影。
- 代码约定:ESM everywhere、包名跨包导入、相对导入带
.ts后缀、strict + JSDoc、type-equiv文档保真。 - 文档大量由脚本生成并由 CI gate 保真;官方推荐从 Cordis primer 到 architecture 到示例组合再到核心包的阅读顺序。