znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第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/clidsh 命令)、apps/web(浏览器 GUI)
python/ Python SDK:python/sdk/python/sdk-runtime/
native/ 原生辅助程序:native/landlock-run,Landlock 沙箱的 native runner
examples/ 可运行的 agent 组合示例(headless-agentacp-agentjsonrpc-agentweb-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.mddocs/architecture.mddocs/development.mdpackages/README.md,然后挑一个 examples/ 里的组合(比如 headless-agent)看它的 cordis.ymlcomposition.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 服务与具体循环(sessionsystem-prompttoolsagentagent-loopscope 等)
api/ 远程 BFF 装配与 Typert RPC 网关(含 remotes 分裂包,见 9.8)
typert/ 类型图生成、产物加载、运行时注册表
goal/ 同会话目标(goal)的持久化与生命周期
schedule/ 会话内定时 follow-up
feedback/ 人类反馈
identity/ 共享匿名身份
llm/ LLM 能力族:抽象 service + provider 适配器(llmtoken-meterllm-retryllm-deepseekllm-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/agentcore/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.ymlwebheadless 作为模板发布。
  • 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.Programtsconfig.json 根只是把它们引用起来,自己不编译任何文件。

tsconfig.host.jsontsconfig.client.json 都通过继承 tsconfig.base.jsonpaths 映射。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.sessionsctx.tools 这些属性不是定义在某个中心文件里,而是由各插件在自己的模块里通过 declare module 合并进 Context 接口。问题在于——Host 与 Client 都要在同一个 key(比如都叫 ctx.foo)下合并属性,但值是两套不同的 service 类型。把两边的声明塞进同一个 ts.Program,编译器自然报重复合并冲突。

关键点在于:冲突只存在于 ts.Program 内部。模块解析(module resolution)不会触发它,所以解决方案根可以同时引用两个 aggregate,一个 paths facade 也可以横跨两面。冲突纯粹是”把两套源文件放进同一个 program”才会出现。

由此衍生三条纪律(同样来自 development.md):

  1. tsconfig.base.json 永不添加 includefiles——它们会泄漏进每个继承它的包项目,收窄 facade 的”匹配全部”范围。
  2. 构建全仓库 ts.Program 的脚本,必须显式 seed tsconfig.host.jsontsconfig.client.json永远不要用根解决方案——把两个 aggregate 压平成一个 program 会触发 Context merge 冲突。
  3. 新包只在一个 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-remoteshostPhase: true 让 Host 入口提前产出,Client 阶段只产浏览器 bundle。

一个容易踩的坑:静态分析与测试通过 base 的 paths 映射解析到 src,在干净 checkout 上就能跑;而消费构建 lib/ 产物的 gate 必须显式声明对 build 的依赖。生成的 Host-for-Client Remote 声明是刻意为之的例外——公共的 typechecklintdoc-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。dsh CLI 的源码启动走 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-pushpnpm run typecheck(完成 Host lib 阶段,含 Typert 契约生成)。钩子刻意不跑测试、快照、文档检查、构建或 hygiene——这些留给 CI 与贡献者本地按需执行。

文档层面还有一套中英双语配对契约:README.md / README.zh.mdCONTRIBUTING.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-catalogverify-type-equiv 都由 pnpm run doc-sync 统一驱动。这个体系的意义在于:文档不会因为”忘了更新”而漂移——只要源码变了,对应 gate 就失败,逼着作者在同一笔变更里更新文档粘贴。对读者来说,这意味着 docs/subsystems/ 里的类型签名可以当成源码来读。

doc-typecheck 对可编译的围栏应用同样的派生规则,同时跳过两种源码等价围栏的编译与它的 opt-out 比例。双语侧还有一个细节:成对的 .zh.md 块只有在整个受追踪围栏序列与英文侧逐字节相同且顺序相同时,才能复用无后缀兄弟文件的 manifest 条目——所以中英文档的类型粘贴同样被 gate 锁死,不会各说各话。

9.14 如何阅读这份代码库

把前面的碎片串成一条可执行的路线:

  1. 先读 cordis-primer.mdcordis-tutorial,把”插件、service、typed event、reversible effect”这几个词的含义钉死。
  2. architecture.md 的 “Core packages” 与 “Events” 两节,对着 9.4 的表记住 ctx key 与事件域。
  3. 看一个 examples/ 组合的 cordis.yml,理解”产品 = 有序插件树”。
  4. packages/core/sessionpackages/core/agent-loop,对照 docs/subsystems/session.mddocs/subsystems/core.mddocs/agent-lifecycle.md 读(这正是第 10 章的内容)。
  5. 遇到”为什么”,回 .agents/notes/ 找对应 Agent Note。

这条路线从抽象(Cordis)到具体(session/agent-loop),每一步都有生成文档兜底,是官方推荐的探索顺序的落地版。

9.15 入口与示例:apps 与 examples

apps/examples/ 是理解”包如何变成可运行程序”的两块拼图。

apps/ 是产品入口。 apps/clidsh 命令本身——npm 用户用 npx @deepseek-ai/dsh web 启动 Web UI(默认 http://127.0.0.1:3080);源码 checkout 里则用 pnpm dsh webapps/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/ 装的是演示 bundleagent-spine-demo(脊柱组合)、acp-demojsonrpc-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.jsondsh 字段自描述;dsh-base 是每个 profile 的第一层。
  • 核心包通过 ctx key 暴露:ctx.sessionsctx.systemPromptctx.toolsctx.agentsctx.agentLoopctx.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 到示例组合再到核心包的阅读顺序。