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'],激活得早,早于 webServersettings 提供者就绪。此时 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 的流水线:

  1. pnpm install --frozen-lockfile,lockfile 入库,pnpm 版本读 packageManager 字段
  2. pnpm build(tsc → lib/)
  3. 四项离线冒烟:client、routes、apply、tools 各一项,全部无网络依赖,CI 上直接跑
  4. lib/ 新鲜度守卫:构建后 git status 必须干净,防止 src 改了但 lib/ 没提交就放出版本
  5. 标签版本校验:v0.1.0 必须等于 package.json 的 version
  6. npm packdsh-mem0-0.1.0.tgz 挂到 Release,自动生成 changelog
  7. 可选 npm 发布:设置 NPM_TOKEN secret 才跑,没设置就跳过

这里有个关键调研结论: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 写插件的几条硬经验:

  1. settings 卡片别指望白名单 RPC,自备路由 + loopback 围栏 + role('secret') 脱敏。
  2. harness bundle 行的包只能 import type,否则 Symbol 分裂、工具全挂。
  3. 工具输出 schema 要按真实服务形状写,可空字段用 oneOf,别写 type 数组。
  4. lib/ 提交进 git,是克隆下来就能用的根基,CI 用新鲜度守卫兜底。
  5. 自动化发布的关键不是打包本身,而是搞清楚安装源到底吃什么。GitHub 源吃的是仓库树,不是 Release 资产。

仓库:github.com/orangeshinee/dsh-mem0(MIT)