这是《DeepSeek Harness 权威指南 》系列第 14 篇,也是二开线(B 线)的第 7 篇。源码基线为 deepseek-harness @ 47f9438(v0.1.0-rc.5)。

B3 里你做了一个能在源码树里跑的插件项目;B5 里你看到了 loader 如何组装插件。这篇回答收尾问题:怎么把一个本地插件整理成符合官方规范、可以被任何 profile 装载的可交付包?

插件包的流水线

图:源码三件套(package.json 不变式、tsconfig 布局、named-export 模块契约)构建出 lib 产物;消费方通过进程内挂载、Loader+Include 组合或 bundle patch 行加载。

一、先跑起来:一个完整的包骨架

作者本机项目(rex-hugo/.tmp-research/dsh-b6-packaging完整代码见 GitHub:rex-dhs-core/dsh-b6-packaging

dsh-b6-packaging/
├── dsh-hello-tool/
│   ├── package.json       # 官方不变式形状(scope 换成 @rex-local)
│   ├── tsconfig.json      # 镜像官方项目布局
│   └── src/index.ts       # name/inject/Config/apply named-export 插件
├── verify-manifest.ts     # constraints-lite:复刻 cookbook 不变式检查
├── load-composition.ts    # 真实 Loader + Include + cordis.yml 组合
└── cordis.yml             # 一行入口:按包名装载 dsh-hello-tool

运行:

node --import tsx E:/coding/rex-hugo/.tmp-research/dsh-b6-packaging/verify-manifest.ts
node --import tsx E:/coding/rex-hugo/.tmp-research/dsh-b6-packaging/load-composition.ts

1. 插件模块:named-export 契约

dsh-hello-tool/src/index.ts 是完整插件(无 I/O、确定性输出):

import { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import z from '@deepseek-ai/schemastery'

export const name = 'hello-tool'
export const inject = ['tools']

export interface Config {
  /** Prefix for every generated greeting. */
  greeting: string
}

export const Config: z<Config> = z.object({
  greeting: z.string().required(),
})

export function apply(ctx: Context, config: Config): void {
  ctx.tools.register(defineTool({
    name: 'hello',
    description: `Return "${config.greeting}, <name>!".`,
    parameters: {
      name: { type: 'string', required: true, description: 'Name to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `${config.greeting}, ${args.name}!`
    },
  }))
}

这个形状不是作者发明的,它逐字对齐官方一方的包 dsh-tool-todopackages/todo/tool-todo/src/index.ts:22-23, 41-43, 128):

export const name = 'tool-todo'
export const inject = ['tools']
...
export const Config: z<Config> = z.object({...})
...
export function apply(ctx: Context, config: Config): void {

模块头注释把原因说得很清楚(同文件 1-6 行):

Named exports preserve loader injection metadata.

即:loader 依赖命名导出 name / inject / Config / apply 来完成依赖注入;包不需要 default export。注意 Config 的 schema 用 @deepseek-ai/schemastery(default import),与官方一致。

2. manifest 不变式检查

verify-manifest.tsconstraints-lite:它复刻 docs/cookbook/adding-a-package.zh.md §1 的不变式清单,对 dsh-hello-tool/package.json 逐条断言。它不是官方 pnpm run constraints(本机 pnpm shim 不可用,无法运行仓库脚本),作者把它明确标注为复刻检查。运行输出:

PASS  private: true
PASS  type: module
PASS  main: lib/index.js
PASS  types: lib/types/index.d.ts
PASS  exports["."].types
PASS  exports["."].default
PASS  files contains lib/index.js
PASS  files contains lib/types d.ts
PASS  every peer mirrored in devDependencies
PASS  @deepseek-ai/cordis is peer + dev
PASS  every dsh peer mirrored in devDependencies
PASS  name is scoped lowercase
PASS  no src in files
MANIFEST_CONSTRAINTS_LITE=ALL_PASS

3. 真实 Loader 组合

load-composition.ts 使用与官方 loader-composition 测试完全相同的装配方式(packages/llm/llm-deepseek/tests/loader-composition.spec.ts:84-107):

await ctx.plugin(Loader)
  ctx.loader.builtins.include = Include
  const modules = new Map<string, unknown>([
    ['@rex-local/dsh-hello-tool', HelloTool],
  ])
  ctx.loader.internal = {
    version: 'v2',
    async import(specifier: string) {
      if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
      return modules.get(specifier)
    },
  } as unknown as NonNullable<typeof ctx.loader.internal>

  const configPath = join(import.meta.dirname, 'cordis.yml')
  await ctx.loader.create({
    name: 'cordis:include',
    config: { path: pathToFileURL(configPath).href },
  })
  await ctx.loader.await()

运行输出:

=== loader composition ===
  loadedEntries=["@rex-local/dsh-hello-tool"]
  toolSchemas=[{"name":"hello","description":"Return \"Aloha, <name>!\".","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name to greet"}},"required":["name"]}}]
  helloRegistered=true
  execute={"isError":false,"value":"Aloha, Rex!","content":[{"type":"text","text":"Aloha, Rex!"}]}
LOADER_COMPOSITION=PASS

关键点:装载是按包名(@rex-local/dsh-hello-tool)而不是文件路径。demo 用模块映射表替代解析(与官方测试一致,保持确定性);生产环境由 vendored cordis-plugin-loadernode_modules 解析——这部分代码在 cordis 生态(vendor/),不在 47f9438 树内,文章不替它背书。

二、模块契约:loader 依赖什么

回顾 B1-B5:你挂载的都是 class 默认导出(ctx.plugin(AgentLoop))。可交付包不同——官方规范要求 named exports:

导出类型作用
namestring插件名(loader 诊断、去重)
injectstring[]声明服务依赖,由 loader 注入
Configschemastery schema配置校验与描述
apply(ctx, config) => void真正的装载逻辑

inject: ['tools'] 表示“装载前保证 ctx.tools 可用”——loader 在调用 apply 前按此解析依赖顺序(B3 提过 inject: ['tools'] 只是服务依赖,不是模型可见性)。你的插件缺 inject 时,ctx.tools 可能是 undefined,注册会静默失败;这也是 B1 里“先 ctx.plugin(ToolRuntime) 再注册”的原因。

三、manifest 不变式:官方清单逐条对照

cookbook(docs/cookbook/adding-a-package.zh.md §1)列出由 pnpm run constraints 强制的不变式。以官方 dsh-tool-todo 的真实 package.jsonpackages/todo/tool-todo/package.json)为模板,关键字段:

{
  "name": "@deepseek-ai/dsh-tool-todo",
  "type": "module",
  "main": "lib/index.js",
  "types": "lib/types/index.d.ts",
  "exports": {
    ".": {
      "types": "./lib/types/index.d.ts",
      "default": "./lib/index.js"
    }
  },
  "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"],
  "peerDependencies": {
    "@deepseek-ai/dsh-tools": "workspace:^",
    "@deepseek-ai/cordis": "workspace:^"
  },
  "devDependencies": {
    "@deepseek-ai/dsh-tools": "workspace:^",
    "@deepseek-ai/cordis": "workspace:^"
  }
}

四组必须遵守的规则:

  1. 入口三件套maintypesexports["."] 都指向 lib/ 输出树,exports 同时给 typesdefault(NodeNext/Node16 消费方解析 .d.ts);
  2. 依赖镜像@deepseek-ai/cordis 与每个 dsh peer 依赖必须同时出现在 devDependencies(类型与测试需要);@deepseek-ai/schemasterydependencies(运行时校验器);
  3. files 白名单:只发 lib 产物——不发 src、声明映射、JS map 或陈旧的根声明文件
  4. 仓库内 private: true(发布用 publishConfig.access 控制)。

作者本机包的差异只有 scope(@rex-local/ 而非 @deepseek-ai/)与版本(0.0.0 而非根版本)——因为它不在仓库 workspace 内,不受版本同步约束;其余字段逐一满足不变式(见第一节 PASS 输出)。

四、tsconfig 项目布局

官方包的 tsconfig.jsonpackages/todo/tool-todo/tsconfig.json)与作者本机版本同形:

{
  "extends": "../../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "lib/types"
  },
  "include": ["src"],
  "references": [
    { "path": "../../../vendor/cosmokit" },
    { "path": "../../../vendor/cordis" },
    { "path": "../../core/tools" }
  ]
}
  • extendstsconfig.base.jsonrootDir: src / outDir: lib/types 把声明文件送进 lib/types
  • references 声明项目引用:vendor 的 cosmokit / cordis + 每个 dsh 依赖包;
  • 新包还要在 tsconfig.host.json(Host 包) tsconfig.client.json(Client 包)的 references 中登记——普通包恰好属于一个 aggregate,绝不两个都加;
  • 源码内相对导入显式 .ts 后缀(编译器输出时重写为 .js,声明文件保留 .ts 后缀,NodeNext 消费方解析到同目录 .d.ts)。

五、Loader 组合:cordis.yml 按包名装载

第一节的 demo 已经展示了官方测试的装载方式。这里把它放进上下文:官方 bundle 的每一个入口都是一行 id + name + configpackages/bundle/base/cordis.patch.yml:15-25):

- insert:
    - id: llm
      name: '@deepseek-ai/dsh-llm'
    - id: sandbox
      name: '@deepseek-ai/dsh-sandbox-local'

name 可以是包名(从 node_modules 解析),也可以是文件路径(B3 的 --patch ./scratch-plugin/cordis.yml)。Loader 做的事就是:解析每行 → internal.import(name) 拿到模块 → 按模块契约(name/inject/Config/apply)装载。

六、bundle patch 的分层组装

cordis.patch.yml 是“按行 id 的补丁”,不是合并。头注释(packages/bundle/base/cordis.patch.yml:1-13)定义了全部规则:

A patch replaces the targeted row's whole `config` rather than merging into
it, so a row whose value differs by mode does NOT live here ...
Row order carries no load semantics (activation is service-availability
driven); the grouping is for readers.

四层来源,后写者胜:

内容覆盖能力
base patch空 profile 根上的一次 insert,所有 mode 共享插入
mode patchweb / cli / headless 各自不同的行按 id 重写
用户 profile$DSH_HOME/cordis.patch.yml按 id 重写 / disabled
--patch overlay命令行临时叠加(含 B3 的路径 patch)按 id 重写 / disabled

bundle patch 的分层组装

图:同一行 id 只出现在“base + 一个 mode + 用户”三层;行字段 = id + name + config? + disabled?,支持 !!js 表达式。

两个值得注意的细节:

  • disabled 支持 !!js 表达式disabled: !!js process.platform === 'win32' 让 pwsh-sandbox 只在 Windows 装载、bash-sandbox 只在非 Windows 装载(cordis.patch.yml:178-186)——平台分支在配置层完成;
  • config 也是 !!js 可求值的:approval 的 policy 按环境变量决定(cordis.patch.yml:188-191),配置不再是静态文本。

七、验证与发布:官方命令与诚实边界

官方清单(cookbook §5)要求维护者按序执行:

pnpm install        # registers the workspace
pnpm run doc-sync
pnpm run constraints && pnpm run typecheck && pnpm run lint
pnpm run build && pnpm run hygiene

以及行为专项测试与覆盖率门槛(仓库测试政策)。作者本机没有运行以上任何一条:本机 pnpm/Corepack shim 在进入 harness loader 前就失败(B3 已记录)。文章能实证的只有:

  • manifest 不变式的复刻检查(constraints-lite,13 项全 PASS);
  • 真实 Loader+Include 组合装载与执行(LOADER_COMPOSITION=PASS);
  • 模块契约与官方包逐字对齐(tool-todo 源码比对)。

npm 发布(publishConfig.access: public + npm publish)属于把包带给仓库外用户的那一步,文章仅按官方 manifest 记录字段,未执行真实发布。

八、常见错误写法

过强或错误的说法更准确的表述
“插件必须 default export。”官方包用 named exports(name/inject/Config/apply);default export 是另一条兼容路径。
“package.json 的 files 可以带上 src。”官方规范只发 lib 产物,src / map / 陈旧声明都不发。
“exports 只有 default 就够了。”每个入口同时给 types 与 default,否则 TS 消费方解析失败。
“patch 的 config 是合并的。”按行 id 整行替换;同一行最多三层来源,后写者胜。
“patch 行顺序决定装载顺序。”头注释明确:行顺序无装载语义,激活按服务可用性驱动。
“constraints-lite 等于 pnpm run constraints。”constraints-lite 是作者复刻的 13 项检查;官方脚本含 workspace 全局规则,本机未运行。
“demo 证明了 npm 发布。”没有执行发布;只验证了包骨架、不变式与 Loader 装载。

九、下一步

B6 的结论:可交付包 = 模块契约(named exports)+ manifest 不变式 + 项目布局 + 一条装载路径。你的插件只要满足这四件事,就能被任何 profile 按包名装载,也能进官方 bundle 的 patch 行。

设计自己的包时,按这个顺序问:

  1. 模块导出 name / inject / Config / apply 了吗?(loader 依赖它们)
  2. manifest 满足四组不变式吗?(入口 / 镜像 / files / private)
  3. tsconfig 把声明送进 lib/types 了吗?
  4. cordis.yml 一行验证装载了吗?(Loader+Include)

下一篇:B7:端到端真实实战案例——从零做一个有落地场景的 dsh 插件

(B7 需要你提供自研二开仓库地址;在拿到仓库前,先把 B6 的包骨架当作 B7 的起点。)


打包是把“能跑的代码”变成“能被任何人装载的包”的最后一步:契约、清单、布局、装载四条线都对齐,你的插件才真正可交付。