Imported from xia-sc/dsh-opencode-go (
AGENTS.md). Install upstream withnpx skills add xia-sc/dsh-opencode-go. Copyright stays with the author.
AGENTS.md
面向在本仓库里干活的 AI agent(以及人类协作者)。只写这个仓库特有的东西 —— 通用的
编程常识、以及 README.md 已经讲清楚的功能介绍,这里不重复。
- 包名
@dsh-plugins/dsh-opencode-go,是 DeepSeek Harness 的 LLM provider 插件, 注册zen-go路由(上游是 OpenCode Go 的 OpenAI 兼容端点)。 - 运行环境:Node
>=22(engines);peer 依赖@deepseek-ai/dsh-*为^0.1.6-alpha.1—— 预发布区间要写成同一个 tuple,^0.1.5-rc.1在 semver 规则下不接受0.1.6-alpha.1,装出来会去解析0.1.5-rc.x。0.1.6 的图片 offload 契约 (projectOffloadedImages/requiredImageOffload/IMAGE_OFFLOAD_REQUIRED) 是本插件的硬依赖,所以下限就是 0.1.6。 - 当前版本见
package.json(PLUGIN_VERSION由它派生,会进user-agent)。 - 功能说明看
README.md(中文主文档)/README.en.md。
目录与职责
| 路径 | 职责 | 注意 |
|---|---|---|
lib/index.js |
宿主接线:Config schema、resolveOptions、apply(ctx)、/zen-go-rpc 端点表 |
配置校验与 RPC 端点都在这里 |
lib/adapter.js |
LlmAdapter(providerInfo / listModels / resolveModel / stream)、三个 SSE pump、图片读盘、refreshModels / knownModels |
一切外部依赖经 thunk 注入(getOptions / resolveKey / services),保持可测 |
lib/models.js |
静态模型表、capabilitiesOf、各端面档位词表 |
能力解析的唯一真源 |
lib/protocol.js |
纯协议层:请求头、三端面消息转译、SSE 解析、usage 映射、错误映射、{ok}/fail 信封 |
无 Services、无 IO;顶部有内容块策略说明 |
lib/usage.js |
用量账本(JSONL、聚合) | |
lib/client.js |
生成物:src/client/*.js 拼接后的浏览器半 |
禁止手改,见下 |
src/client/*.js |
浏览器半源码,7 个分片按 scripts/build-client.cjs 的 ORDER 拼接 |
00-head(入口) / 01-dicts(i18n) / 02-ui(样式+helper) / 10-store(状态) / 20-card(主卡片) / 21-section(侧栏入口) / 99-tail(apply) |
test/smoke.mjs |
插件自洽测试(node:test,单文件);宿主面全部 stubCtx 打桩 |
默认门禁 |
test/host-compat.mjs |
真宿主回归:从 node_modules 里 import 真实的 @deepseek-ai/dsh-llm / dsh-invariants / cordis,走真 llm 服务与真流语法不变量 |
版本敏感;宿主换版后先跑这个 |
scripts/build-client.cjs |
拼接 + vm 语法门禁(先校验后写盘);导出 { ORDER, OUT, build } |
测试会调用 build() 做同步断言 |
scripts/setup-local-deps.cjs |
本地源码安装时把 node_modules/@deepseek-ai 桥到宿主安装树(Windows junction) |
只在 link: 安装下需要 |
cordis.patch.yml |
组合层 patch(id + 完整包名 + 默认 config) |
只放组合层字段;用户可调的都进 settings |
常用命令
node test/smoke.mjs # 推荐:本进程内跑完全部测试
npm test # = node --test test/smoke.mjs(在受限沙箱里会 spawn EPERM)
node test/host-compat.mjs # 真宿主回归(宿主换版后必跑)
npm run build:client # 改过 src/client/* 之后必须跑
node scripts/setup-local-deps.cjs [--host <dir>] [--dry-run] # 本地源码安装桥接
没有 lint、没有 typecheck、没有 CI(仓库里没有 .github/)。唯一的门禁是测试 + 构建脚本
里的语法检查,所以别指望自动化帮你兜底。
硬性约定
lib/client.js是生成物:改浏览器半永远改src/client/*.js,然后npm run build:client。test/smoke.mjs里有一条断言直接比较产物与build()的结果, 忘记重建会立刻测挂(这是故意的)。- 纯模块要保持纯:
models.js/protocol.js/usage.js不引入 Services、不做 IO。 需要外部能力时经adapter.js的 thunk 注入。 - 内容块策略(
protocol.js顶部注释是权威版):三个端面对 user/assistant 消息 一律「带得了的带、带不了的丢」,不抛错;只有image硬报错。 原因是踩过的坑:一条转译不了的内容块已经在持久历史里,抛错会让该会话之后每一轮 都失败且不自愈(后台子代理结算通知会把子会话最后的 assistant 内容整段展开进 user 消息, 所以reasoning/tool-call合法地出现在 user 消息里)。别把throw加回来。 - 能力解析只走
capabilitiesOf(model, override):优先级是 用户modelCaps声明 > 静态表 > 保守 unknown。reasoningFor/modelSupportsImage都是它的薄封装。不要在别处再写一套「这个模型支持什么」的判断。 - 声明必须与消费者一致:
resolveModel/listModels声明什么,stream()就按什么路由;models/refresh与models/known共用classifyModel(),避免卡上的展示与实际请求分叉。 声明image: true时,imagePart的准入判定也必须放行(runtime 会凭同一声明把图片放行), 所以覆盖要顺着参数一路传下去,不能只改resolveModel。surface与efforts是被一起校验的一个组合,不是两个独立字段:卡片改端面时必须把档位 收敛进同一次scope.set(走setModelCapFields),否则会持久化一个被拒绝的组合——宿主 把整段设置丢掉,路由静默退回默认值。收敛规则见20-card.js的levelVocabulary/reconcileEfforts;「跟随默认」时的可用档位来自每行的defaultEfforts(由classifyModel给出,不能让客户端猜:未归类模型跟随 chat 端面,但本身不提供任何档位)。 - 配置校验 fail-fast:
resolveOptions对非法值直接抛(含档位 id 与端面不匹配、messages 端面给档位等);apply捕获后保留上一份好配置并记日志。新增字段时同步更新Configschema、resolveOptions、测试与 README。 - schemastery 的坑:
z.array(...)在字段缺席时会 materialize 成[]。当「缺席」 与「空数组」语义不同时(例如modelCaps[].efforts:缺席=跟随内置表,[]=明确无档位), 必须写.default(undefined),否则任何只填了别的字段的条目都会被解读成「无档位」。 - 凭证只走 seam:
credentials.resolve(env → 托管 store →.env),settings 与组合文件里 不得出现明文 key;apiKeyEnv只是引用名。UI 侧只经remote.credentials。 - UI 文案一律进字典:
src/client/01-dicts.js的zh与en两份都要加。 用了不存在的 key 不会崩,只会把 key 原样显示出来,所以别指望手测发现。 - 路由名
zen-go是刻意的:opencode-go是用户自建 pi-ai profile 的常用名, 路由独占会顶掉别人;apply()启动时也会检查并报DUPLICATE_ADAPTER。别改。 Refs #N,不要Fixes/Closes:issue 由维护者确认报告人验证通过后再关。- 三段式改动(
modelCaps这类)要同时顾及:schema、校验、resolveModel/listModels、 路由、客户端 UI、i18n、测试、README、版本号。漏一段就会出现「卡上能填、请求不生效」 这类半成品。 - 图片 offload 是 adapter 的责任(0.1.5 起,0.1.6 才强制):0.1.6 的
dsh-llm提供projectOffloadedImages/requiredImageOffload/IMAGE_OFFLOAD_REQUIRED, 而dsh-compaction-image-offload(0.1.6 才有的包)只监听那个错误码。三条不能破: ① 已被 harness 标记offloaded的图片永远按占位文本发(stream()里在所有读盘之前 先投影),重新内联就是撤销别人的持久决定;② 请求超预算时抛错而不是自己丢图——丢图是 持久会话决定,只有 harness 能记录;③ 判定必须在 dispatch 之前,所以用 durable ref 自带的bytes记账,不读盘。三条都有test/host-compat.mjs的行为级断言兜着。 顺带记住图片体积的真实边界,别在文档里写「原图直塞」:单图大小由 attachment 的准入 归一化决定(本部署 ≤4 MiB / ≤2048×2048,imageHostPath给的就是归一化副本), 插件的MAX_INLINE_IMAGE_BYTES(20 MiB)只对不做归一化的 provider 生效,是兜底; 真正的请求级保护是maxRequestImageBytes,且它按张数才会撞线(单图 base64 后约 5.4 MiB,64 MiB 约合十余张同请求)。 - 回放降级、创作严格:同一条 tool-call 在两个方向上的策略是相反的,别搞混。
- 创作(
pumpMessages):provider 刚说完stop_reason: tool_use、参数却不是 JSON → 抛PROVIDER_PROTOCOL_ERROR。那是上游违反协议,一个无法执行的调用不该被保留 或派发(宿主的 max-tokens 与 interrupt 规则也是这个取向)。 - 回放(
toAnthropicMessages的toolInput):持久历史里的arguments非法或不是 对象 → 退成{}照发。这个调用早就执行过、也有tool_result了,抛错只会让该 会话在 messages 端面上之后每一轮都失败(#3 同一类)。对端要求input是对象, 所以"5"/"[1]"/"null"和非法 JSON 一样都得降级。 宿主自己也容忍这种畸形(dsh-agent-loop的parseArguments保留原文、dsh-llm-pi-ai退成{}),只有dsh-llm-deepseek的 messages 实现选择抛。
- 创作(
测试怎么写
- 单文件
test/smoke.mjs,用node:test+node:assert/strict;跑法见上。 - 宿主侧用
stubCtx({ credentials, providers })拿到被apply()注册的对象 (calls.adapter.adapter/calls.rpc.dispatch),再直接调用 adapter 方法或 RPC 端点。 test/host-compat.mjs补的是 smoke 打桩测不到的那一层:它用真@deepseek-ai/dsh-llm起一个真 cordis app(外加真dsh-invariants+dsh-llm/invariant的流语法校验), 只把connection/webServer/settings/credentials/attachments/fs打桩。 宿主换版本后先跑它:宿主收紧契约(例如 0.1.6 把图片 offload 从 runtime 挪进 adapter) 只有这一层能发现。已知未实现项的断言写成{ skip: '原因' },修好后删掉 skip 即验收。- 需要一个 key 的联网探针(
live /v1/models)在无 key 时自动 skip —— skip 不是失败, 当前基线是「N 项,N-1 pass + 1 skip」。 - 浏览器半用
stub React(createElement/ 有状态的useState/ 仅挂载运行的useEffect) 驱动lib/client.js:断言行渲染、展开/收起、以及改动落成的modelCaps载荷。 注意h()把数组子节点嵌套一层,遍历要用.flat(Infinity);条件子节点是null,要跳过。 - 写断言时别持有上一轮渲染的元素对象去触发事件:它的闭包捕获的是那一轮的状态 (真实 DOM 节点不会这样,React 每轮更新 handler)。每次触发前重新查一遍。
- 新增行为就加断言,别为了过而放宽既有断言。
发版流程
这个包不在 npm 上(registry 404),发行渠道是 GitHub:用户用
dsh plugin --profile web add github xia-sc/dsh-opencode-go 安装/升级。
package.json版本号跟随改动(预发布用-rc.N,同一目标版本的后续 RC 递增 N)。- 提交信息:conventional 前缀 + 英文主题 + 括号里带版本,正文写「为什么」与踩过的坑,
结尾一行
Tests: N, X pass + 1 skip与Refs #N。 - annotated tag
v<版本>,推送;gh release create v<版本> --prerelease --latest=false, 标题形如v0.8.9-rc.2 — <中文一句话> (preview);notes 用中英双语(中文在前,---, 英文),分节:新功能 / 修复 / 验证 / 已知未覆盖 / 升级。正式版去掉--prerelease并写详细 notes(参考v0.8.8)。
宿主 vs 客户端:生效时机不同
- 宿主侧(
lib/*.js除client.js):dsh web启动时加载,改完必须重启进程才生效。 - 客户端半:宿主按请求从磁盘读取 bundle,并用内容哈希当版本号(
&rev=), 所以刷新页面即可拿到新代码,不需要重启。 - 排查「改了没生效」时先分清是哪一半;
models/known这类新端点在新进程起来前会返回unknown-endpoint,那是正常的,不代表代码写错了。
这个环境(Windows 沙箱)的坑
git push/git ls-remote走 HTTPS 时可能报schannel: AcquireCredentialsHandle failed: SEC_E_NO_CREDENTIALS—— 是沙箱不给凭据句柄, 不是仓库或凭据坏了;gh与npm用自己的 TLS 栈,不受影响。推送需要提权重试。npm test(node --test)会 spawn 子进程,在受限沙箱里EPERM;用node test/smoke.mjs在同一进程内跑。- 本机 GUI 在
http://127.0.0.1:3080,没有 token 直接访问会 401,别拿 curl 当探针; 需要浏览器时用 playwright/chrome-devtools MCP,截图默认落在 MCP 服务进程的工作目录 (本机是C:\Users\sc),不在仓库里。 ~/.dsh/sessions/**/session*.jsonl.zstd是多帧 zstd(每次 append 一帧), Node 的zstdDecompressSync只解第一帧;要逐帧解(按魔数28 b5 2f fd切)。
已知文档债
(暂无。cordis.patch.yml 头部注释已在 v0.9.0-rc.1 一并修正。)
