dsh-mem0 插件开发记:从配置卡片到自动化发布
2026年8月16日 · 1814 字
2026-08-16 一整天的实录:把「读写自托管 mem0 记忆」做成 DeepSeek Harness(dsh)的热插拔插件,从本地原型到公开仓库,最后配上 GitHub Actions 自动打包发版。整个过程踩了不少平台特有的坑,写下来既是复盘,也给想给 dsh 写插件的人留个参考。
背景:为什么会有这个插件
dsh(DeepSeek Harness)是一个以 Cordis 为骨架的 Agent 宿主框架,所有能力都是 cordis.yml 里的一行插件。我自托管了 mem0 的新版 OSS 构建,带 dashboard,用 X-API-Key 认证,端点没有 /v1 前缀。想让宿主 Agent 能直接读写它:mem0_add / mem0_search / mem0_get / mem0_update / mem0_delete / mem0_history / mem0_reset / mem0_status 八个工具,外加一个设置面板里的配置卡片。
前提是不改 dsh 源码,一切能力都从插件包内部实现。这决定了后面几乎所有绕弯。
架构:两个半边 + 一条路由
- 宿主半边(
src/,TypeScript):注册工具、系统提示段、设置命名空间。工具每次请求都实时读配置,改配置不用重启。 - 浏览器半边(
client/client.cjs,手写纯 JS bundle,不是构建产物):只做一件事:在设置面板注册settings.plugin.item卡片。 - 配置通道:卡片 →
GET/POST /api/dsh-mem0/config(宿主路由)→ settings 服务。
包通过 cordis.patch.yml 把自己插入 web profile 清单。pnpm build 产出 lib/ 并提交进 git,因为 dsh plugin add link: 直接加载它,克隆仓库就能用。
坑一:设置面板的配置卡片为什么出不来
README 承诺了配置界面,但装上后设置面板里什么都没有。查了 harness 源码才发现:dsh 的 settings.* 线上通道(dsh-host-apiproxy)有硬编码白名单,只暴露 WEB_SETTINGS_NAMESPACES、模型提供方和产品命名空间,插件无法把自己的命名空间加进去,源码注释里明说是 deferred work。
所以配置卡片不能走官方的 settings.describe RPC,得走插件自有的路由。于是实现了 /api/dsh-mem0/config:GET 返回脱敏配置,apiKey 只返回一个已配置或未配置的标记,密钥字面量不下发浏览器,schema 上标记 role('secret');POST 做字段白名单校验后写入。客户端在 bundle 里实现了一个 RouteScope,表面与官方 settingsScope 完全同构(getSnapshot/subscribe/set/unset),只是传输层换成了 fetch 自有路由。
配套还有个细节:插件的 inject 只有 ['tools','systemPrompt'],激活得早,早于 webServer 和 settings 提供者就绪。此时 ctx.get(name) 严格模式会漏,要用 ctx.get(name, false) 的 loose 模式;没声明进 inject 的服务,用属性访问会直接抛错。路由注册和路由里读 settings 都得走 loose get。
坑二:Windows 上报错:Cannot read properties of undefined (reading 'prepare')
换到 Windows 机器一装,工具一调用就报 Cannot read properties of undefined (reading 'prepare'),在本地 headless 也复现了。
根源很隐蔽:@deepseek-ai/dsh-tools 既是 dsh harness 的 tools bundle 行,也是普通 npm 包。把它写成依赖后,profile 的 pnpm 会 hoist 一份副本,遮蔽 harness 自带的那份,两份模块各自加载,产生两个不同的 TOOL_RUNTIME_SCHEDULER Symbol,工具调度器完全对不上,于是每次调用都在读一个 undefined 的 prepare。
修复是把工具定义全部改成手写 JSON Schema 的 ToolDefinition 对象,零运行时导入,只 import type。这条经验直接写进了项目规则:凡是 harness bundle 行的包,绝不能做运行时依赖。
坑三:Windows 上的第二个问题:配置卡片 404
工具好了,但 Windows 上配置卡片还是不出来,控制台 Failed to load resource: 404,路由根本没注册。原因:路由注册用的 ctx.get('webServer', false) 在这个 dsh 版本上 loose 参数被忽略。改为 scoped inject:ctx.inject(['webServer'], (sctx) => { const webServer = sctx.get('webServer'); ... }),版本无关,headless 也不阻塞。
输出校验之战:invalid output
用户自己在 Windows 上诊断出一个更深的 bug:mem0_get / mem0_search 的返回被 harness 的输出校验拦死(ToolOutputError: invalid output)。原因是输出 schema 写得太严格,而 mem0 OSS 的序列化行(_serialize_memory)形状很野:
- 恒有
hash/attributed_to,新行有role expiration_date常以null存在metadata/run_id/agent_id/created_at/updated_at在旧行上可能是null
而 dsh-tools 的 JSON Schema 子集不支持 type 数组,['string','null'] 直接挂,可空字段必须写成 oneOf: [{type:'x'},{type:'null'}],对象还要 additionalProperties: true 容忍额外字段。修完加了一个回归冒烟:用真实服务器形状的载荷打 schema,确保读路径不再被拦。
安全收尾:把 apiKey 从 git 历史里挖出来
真机脚本里曾硬编码过 apiKey 和内网地址,全部改成环境变量(MEM0_API_KEY / MEM0_BASE_URL),破坏性迁移脚本额外要求 MIGRATE_CONFIRM=yes 才执行。
但密钥已经进了 git 历史,于是用 git filter-repo --replace-text 重写全部历史(key 与内网 IP → ***REMOVED***),reflog + gc 深度清理。教训:旧 key 即使清完历史也视为泄露,要在 dashboard 轮换。.env 进 .gitignore,任何密钥不进 git。
自动化发布:GitHub Actions
今天收尾的大活:让「打 tag → 自动构建 → 发布 GitHub Release」全自动。.github/workflows/release.yml 的流水线:
pnpm install --frozen-lockfile,lockfile 入库,pnpm 版本读packageManager字段pnpm build(tsc → lib/)- 四项离线冒烟:client、routes、apply、tools 各一项,全部无网络依赖,CI 上直接跑
- lib/ 新鲜度守卫:构建后
git status必须干净,防止 src 改了但 lib/ 没提交就放出版本 - 标签版本校验:
v0.1.0必须等于 package.json 的version npm pack→dsh-mem0-0.1.0.tgz挂到 Release,自动生成 changelog- 可选 npm 发布:设置
NPM_TOKENsecret 才跑,没设置就跳过
这里有个关键调研结论:dsh plugin add github:... 是直接 clone 仓库安装的,不吃 GitHub Release 资产。dsh 把 pnpm 薄转发,git 源构建靠 prepare 脚本。所以真正保证可安装的是已入库的 lib/,Release 用于版本锚定和产物留档。也因此我故意没加 prepare 脚本,加了就得在用户机器上现场跑 tsc,要装 devDeps,有失败风险;而 lib/ 入库已经零成本可用,用 CI 守卫代替。
尾声与花絮
- 插件提交到了 awesome-dsh-plugin(走 PR,等仓库年龄门槛)。
- 补签提交时发现签名用的 Keyguard SSH agent 升级(2.14.2 → 3.0.2)后 socket 路径变了:旧路径
~/.bitwarden-ssh-agent.sock失效,新默认在~/Library/Group Containers/com.artemchep.keyguard/ssh-agent.sock,而且 agent 要 vault 解锁后才启动监听。一顿排查猛如虎,最后一步是解锁。 - 顺手补了 MIT LICENSE,npm 包会自动带上。
收获
给 dsh 写插件的几条硬经验:
- settings 卡片别指望白名单 RPC,自备路由 + loopback 围栏 +
role('secret')脱敏。 - harness bundle 行的包只能
import type,否则 Symbol 分裂、工具全挂。 - 工具输出 schema 要按真实服务形状写,可空字段用 oneOf,别写
type数组。 - lib/ 提交进 git,是克隆下来就能用的根基,CI 用新鲜度守卫兜底。
- 自动化发布的关键不是打包本身,而是搞清楚安装源到底吃什么。GitHub 源吃的是仓库树,不是 Release 资产。
仓库:github.com/orangeshinee/dsh-mem0(MIT)