# AGENTS.md — dsh-mem0 开发须知 > 给 AI agent(和人类协作者)的项目内指南。README 讲「怎么用」,本文件讲「怎么改」: > 代码地图、构建/验证闭环、以及几条违反就会白费功夫的平台陷阱。改动前先读完「平台陷阱」。 ## 这是什么 dsh 的热插拔插件:宿主 Agent 通过 `mem0_*` 工具读写自托管 mem0 REST 服务,并在 web 设置面板提供配置卡片。**硬约束:不修改 dsh/harness 源码**,一切能力都从本包内实现。 ## 代码地图 | 路径 | 职责 | 半边 | |---|---|---| | `src/index.ts` | 插件入口:注册工具 + 系统提示段 + 设置命名空间 + 配置路由 | 宿主 | | `src/config.ts` | 配置 schema(schemastery)、默认值、`MEM0_SETTINGS_NAMESPACE` | 宿主 | | `src/mem0-client.ts` | mem0 REST 客户端(每次请求读实时配置) | 宿主 | | `src/tools.ts` | 8 个 `mem0_*` 工具定义 | 宿主 | | `src/settings-routes.ts` | `/api/dsh-mem0/config` 路由(配置卡片的读写通道) | 宿主 | | `client/client.cjs` | 浏览器半边:设置面板配置卡片(**手写纯 JS bundle,非构建产物**) | 浏览器 | | `cordis.patch.yml` | 把插件行插入 web profile 清单 | 装配 | | `scripts/smoke-client.mjs` | 浏览器半边冒烟(VM + 假路由) | 测试 | | `scripts/smoke-routes.mjs` | 宿主路由冒烟(假 settings 服务) | 测试 | | `scripts/smoke.mjs` / `load-test.mjs` / `migrate-user-id.mjs` | 真机 E2E / apply 冒烟 / 数据迁移(凭据走环境变量,见安全红线) | 测试 | | `lib/` | `pnpm build` 的 tsc 产物,**纳入 git**(link 挂载直接加载它) | 产物 | ## 构建与验证(每次改动必跑) ```sh pnpm build # tsc 输出 lib/ + 类型检查(必须通过) pnpm smoke:client # 浏览器半边:bundle 加载 + 表单暂存/保存/放弃 pnpm smoke:routes # 宿主路由:脱敏 / 字段白名单 / loopback 围栏 / set+unset pnpm smoke:apply # 宿主 apply 冒烟:8 工具 + 提示段注册(scripts/load-test.mjs) pnpm smoke:tools # 工具输出 schema 冒烟:真实服务器形状载荷必须通过(scripts/smoke-tools.mjs) ``` - 改了 `src/**` → `pnpm build`,且 **dsh web 进程要重启**才生效(宿主代码启动时加载)。 - 只改了 `client/client.cjs` → 刷新页面即生效(该文件按请求现读,rev 仅作缓存破坏)。 - 改了 `package.json` 的 `dsh.client` 声明或新增路由 → 同样需要重启(启动时编排清单)。 - 冒烟测试用假对象/VM,不碰真实 mem0 数据;`scripts/smoke.mjs` 才打真实实例(用独立 `dsh-mem0-test` 用户并自清理)。 ## 发布(Release) - 打 `v*` 标签触发 `.github/workflows/release.yml`:`pnpm build` → 四项离线冒烟 → lib/ 新鲜度守卫(构建后工作树必须干净)→ 标签版本必须等于 `package.json` 的 `version` → `npm pack` 产物挂 GitHub Release;设置了 `NPM_TOKEN` secret 时同步 `npm publish`(未设置则跳过)。 - 流程:先 `git push` main(确保 lib/ 是最新),再 `git tag v0.1.0 && git push origin v0.1.0`。标签版本与 package.json 不一致会在 CI 失败。 - 使用者可装指定版本:`dsh plugin add github:orangeshinee/dsh-mem0#v0.1.0`(dsh 对 git 源是 clone 整个仓库 + 跑 prepare 脚本;本插件无 prepare、lib/ 已入库,clone 即用)。 ## 架构:两个半边 + 一条路由 - **宿主半边**(`src/`)注册设置段(`ctx.settings.configure` + 导出 `Config`,dsh 0.2 起)、 8 个工具、系统提示段。工具经 `resolve()` 每次请求读实时配置 → 设置改动即时生效,无需重启。 - **浏览器半边**(`client/client.cjs`)只做一件事:注册 `plugins.row.config` 卡片, 入口是 **设置 → 插件 → dsh-mem0 行 → 配置**(keyed `dsh-mem0#dsh-mem0`)。 卡片不直接依赖宿主工具,只读写配置。 > 槽位历史:0.1.x 用 `settings.plugin.item`("插件配置" tab 里的卡片);0.2.0-rc.2 换成 > `plugins.row.config`("插件"页给单个行加「配置」控件)。两者都 keyed,但 key 语义不同 > ——旧的是 settings 命名空间,新的是 `<包名>#<行 id>`。 - **配置通道**:卡片 → `GET/POST /api/dsh-mem0/config`(宿主路由)→ `ctx.settings` 服务。 卡片内的 `RouteScope` 实现与官方 `settingsScope` 相同的 `getSnapshot/subscribe/set/unset` 表面,所以表单模型与官方卡片同构,只是传输层换成了自有路由。 ## 平台陷阱(最重要,违反必踩坑) 1. **settings 线上通道有硬编码白名单**。dsh 的 `settings.*` RPC(`dsh-host-apiproxy`)只 暴露 `WEB_SETTINGS_NAMESPACES` + 模型提供方 + 产品命名空间;插件**无法**把自己命名空间 加进去(harness 源码注释明说这是 deferred work)。所以配置卡片不能走 `settings.describe`, 必须走插件自有的 `/api/dsh-mem0/config` 路由。**不要**试图通过注册命名空间让卡片可见—— 没用。宿主侧 `installSettingsSection` 照常注册(工具读得到),只是浏览器 RPC 读不到。(dsh 0.2 起注册方式见下方「dsh 0.2 迁移」,`installSettingsSection` 已不存在。) 2. **`ctx.get` 严格模式会漏**。本插件 `inject` 只有 `['tools','systemPrompt']`,激活很早, 早于 `webServer`/`settings` 提供者纤维达到 ACTIVE。此时 `ctx.get(name)`(严格)返回 `undefined`,`ctx.get(name, false)`(loose)才拿得到。路由注册和路由内读 settings 都必须 用 loose get。**未声明在 `inject` 里的服务用属性访问(`ctx.webServer`)会直接抛错** ("cannot get property without inject")——要么声明进 inject,要么用 `ctx.get(name, false)`。 3. **客户端 bundle 格式**。`client/client.cjs` 是 `window.__ModuleLoader__.load({ id: 'dsh-mem0', factory })` 格式的手写纯 JS(无 TS/JSX/import),factory 返回 `{ apply, inject }`。只能 `require` 平台模块:`react`、 `@deepseek-ai/dsh-client-store`(`createSnapshotStore`)。浏览器半边 `inject = ['slots', 'locale', 'remote']`。改它时保持该格式,别引入打包器依赖。 > 注:dsh 0.1.2-rc.1 把浏览器端运行时从 `dsh-client-runtime` 重构为 > `dsh-client-store`(seed word 已变);旧版 `dsh-client-runtime/client` 在 > 0.1.2-rc.1 已不在前端 seed 词表,require 会报 "missed the module table"。 4. **`role('secret')` 的脱敏边界**。`apiKey` 在 schema 上标记 secret;`describe({ redactSecrets: true })` 会从 value/base/user 三层剥离它,只给 `secrets:[{path,set}]`。脱敏 walker 只沿 object/dict/array 容器走——secret 放在 union/transform 里会被原样下发。新增敏感字段时保持 plain 字段 + `role('secret')`。 5. **`settings.yaml` 里 apiKey 是明文**(dsh settings 提供者的设计)。卡片只显示「已配置/ 未配置」,密钥字面量不下发浏览器;但文件本身含密钥,注意权限与备份。 ## mem0 服务端怪癖(写客户端时别踩) - OSS 构建端点**无 `/v1` 前缀**(那是托管平台才有);路径直接挂在 `baseUrl` 下。 - 服务端**默认把中文事实翻译成英文存储** → 中文语义搜索命中率低;提示词与测试里都建议 英文关键词复搜。 - **搜索必须按标识符范围**(`user_id` 等),无过滤全局搜索被拒(400)。 - `GET /memories/{id}/history` 返回**裸数组**(不是 `{results:[]}`)。 - 批量删除 / reset 需要 admin 角色 + 明确 confirm 词。 - `mem0_get all=true` 跨标识符列举需要 admin key。 - **`POST /memories` 返回 `{"results":[]}` 不是失败**。服务端在事实抽取没产出、或判定该 内容与既有记忆重复时,返回 **HTTP 200 + 空 results**。工具据此报 `created: 0`(正常 分支,不是 `error`)——agent 看到 `added 0 memory/memories` 应理解为「没提取出新事实/ 判定重复」,别当写入失败重试。实测:同一正文连发两次,第二次即空;换全新正文即入库 (与是否携带 `agent_id` 无关)。 - **`POST /search` 的 `limit` 被忽略**,要限条数得用 `top_k`(客户端发的就是 `top_k`, 别改成 `limit`)。 - **首调用冷启动很慢**:mem0 依赖链(DeepSeek LLM / SiliconFlow embedder)冷启动时首次 写入实测 76s 后 502、首次搜索 31s,热态分别 ~3s / ~0.4s。默认 `timeoutMs: 15000` 会 在这种冷启动下误报超时——重试即可;要避免噪音可调大 `timeoutMs`。 - **序列化行(`_serialize_memory`)形状**:恒有 `hash` / `attributed_to`,新行有 `role`, `expiration_date` 常以 null 存在,且 `metadata` / `run_id` / `agent_id` / `created_at` / `updated_at` 在旧行上可能是 null。**工具输出 schema 必须容忍这些**(`MEMORY_ROW_SCHEMA`: `additionalProperties:true` + 可空字段用 `oneOf:[{type:'x'},{type:'null'}]`)——严格 schema 会让整个读路径被 harness 的输出校验拦死(ToolOutputError「invalid output」)。回归保障: `pnpm smoke:tools`。注意 dsh-tools 的 JSON Schema 子集**不支持 `type` 数组**(如 `['string','null']`),可空必须用 oneOf。 - **`score` 的形状在两条读路径上不同**:`GET /memories/{id}` **恒带 `score: null`**, 而 `GET /memories`(列表)**完全没有该键**。所以「`score !== undefined`」不足以判可 渲染——必须 `typeof score === 'number'`,否则按 id 读取会崩在 render(`Cannot read properties of null (reading 'toFixed')`,表现为 `ToolOutputError: output.render failed`)。schema 侧对应 `score: oneOf:[number, null]`。回归保障:`pnpm smoke:tools`(该用例既验 schema **也真正调用 render**,因为 schema 通过而 render 崩也曾是一类漏网 bug)。 ## dsh 0.2 迁移(0.1.x → 0.2.0-rc.2 → 0.2.1-rc.3) 本插件已适配 dsh **0.2.1-rc.3**(同时保留 0.2.0-rc.2 的迁移结论)。升级时踩到的断裂,改动前必读: 1. **版本闸门会直接拒绝加载**。peerDependencies 与运行时大版本不符时,dsh 在 profile 启动 时拒掉插件(`dsh plugin --profile web list` 会打出 "incompatible with dsh …",并提示 `allow-version` 豁免)。**不要用豁免绕过**——官方原文警告可能崩溃或数据丢失,且依赖包 也得一个个豁免。正确做法是把 `cordis` 升到 `~4.0.4`、`dsh-llm`/`dsh-system-prompt`/ `dsh-tools`/`dsh-settings` 升到 `^0.2.0-rc.2`,并且 **profile 里的包也要一起升** (`dsh plugin --profile web add '@deepseek-ai/dsh-settings@^0.2.0-rc.2'`)。 2. **`installSettingsSection` 已被删除**。0.2 的 `@deepseek-ai/dsh-settings` 只剩 `SettingsForms` / `SettingsConflictError` / `redactSecrets`(`SettingsProvider` 改名为 `SettingsForms`,`settingsNamespace` / `deepEqualJson` 一并移除)。现在的机制是: - 命名空间 = 插件行的 `id`(`entry.options.id`),所以 `MEM0_SETTINGS_NAMESPACE` 是 **普通字符串** `'dsh-mem0'`,不再用 branded 构造; - schema 由 `SettingsForms` 直接读 `fiber.runtime.Config` —— 所以**插件必须导出 `Config`**; - 表单字段靠 **`z.string().volatile()`** 标记(`volatileForm()` 只投影 volatile 字段), 没标 `.volatile()` 的字段**根本不会出现在设置面板**; - 宿主侧的等价注册动作只剩 `ctx.settings.configure({ auto: false }, ctx.fiber)` + 订阅 `settings/document-updated` 事件来 re-sync(对应旧 `onChange` / `setSource`)。**自带页面 的插件必须传 `auto: false`,并显式传插件自己的 `ctx.fiber`**:`configure` 的 owner 默认是 settings 服务自己的 fiber,省略会把页面策略挂到错误的 fiber 上(`describe()` 按 `entry.fiber` 查策略)。 - `role('secret')` 与 `.volatile()` 可叠加,脱敏行为不变。 - ⚠️ **`.volatile()` 字段不是裸值,是 `Volatile` 引用对象**,取值必须 `.get()`: ```ts interface Volatile { get(): VolatileSnapshot } ``` 所以 `apply(ctx, config)` 拿到的 `config.baseUrl` **不是字符串**。直接当字符串用 (`(config.baseUrl ?? '').replace(...)`)会抛 `(this.config(...).baseUrl ?? "").replace is not a function` —— 表现为**每次工具调用 都失败**,但插件加载、设置面板、冒烟测试全部正常,极难定位。 `resolveConfig`(`src/config.ts`)负责解包:按 `typeof field.get === 'function'` 判断,裸值原样透传(手搭的测试上下文传的就是裸值);引用解析为 `undefined` 时回落默认值。 类型上也分开:`Mem0Config` 是**已解析的裸值**,`RawMem0Config` 才描述 `apply` 实收形状 (字段可能是引用)。别把两者合并,否则 `tsc` 抓不到这类错误。 > **回归保障**:`smoke:apply` 必须包含用**真实引用形状**(`{ get: () => v }`)的用例。 > 传裸值的用例发现不了这个 bug —— 这正是它曾经溜过全部四项冒烟的原因。 3. **配置卡片的槽位在 0.2 换了名字与语义**,且**不同 profile 装的版本不一样**,必须按版本判断: - `web` profile 里装的是 `@deepseek-ai/dsh-client-ui-settings-plugins@0.1.5-rc.2`, 声明旧的 `settings.plugin.item`(keyed,`key` = 命名空间); - **desktop profile(Electron GUI 实际使用的那个)用的是 asar 内置的 0.2.0-rc.2**, 该版本**没有** `settings.plugin.item`。插件配置改由「插件」页声明的三个槽位承担: `plugins.item`(list,官方插件卡片)、`plugins.bundle.config`(keyed by 包名)、 **`plugins.row.config`(keyed by `<包名>#<行 id>`,本插件用的就是这个)**。 注册后该行会出现「配置」控件,打开行自己的页面;`view: 'page'` 用于自带保存控件的表单, `view: 'summary'` 用于行详情页缺包描述时的摘要视图。 - 本插件现注册 `key: 'dsh-mem0#dsh-mem0'`,入口是 **设置 → 插件 → dsh-mem0 → 配置**。 - 三处标识符仍须一致:`cordis.patch.yml` 行 `id`、`client/client.cjs` 的 `NS`、 `src/config.ts` 的 `MEM0_SETTINGS_NAMESPACE`(现均为 `dsh-mem0`); `plugins.row.config` 的 key 是 `${包名}#${行id}`,两半都取自这些常量。 - 改 `client.cjs` 槽位时同步改 `scripts/smoke-client.mjs` 的断言,否则冒烟会红。 > 排查教训(两条血泪): > 1. 判断某个槽位/包是否存在,**asar 和 profile 的 node_modules 都要查**。 > 桌面版 UI 在 asar 里,web 的部分 UI 包单独装在 `~/.dsh/profiles/*/node_modules`。 > 只搜 asar 会误判「已移除」,只搜 node_modules 会误判「还在」——我两次各错一半。 > 2. **务必先确认 GUI 跑的是哪个 profile**(看进程命令行里的 profiles 路径)。 > 装错 profile(插件进了 web、GUI 却跑 desktop)会让插件完全不加载,且毫无报错。 4. **附带**:`JsonValue` 从 `@deepseek-ai/dsh-session` 移到了 `@deepseek-ai/dsh-util-values`, 后者属 harness bundle 行、**不能做依赖**(见「约定」)。本插件在 `src/config.ts` 里本地 声明该类型(`import type` 零运行时成本)。另外 `.volatile()` 需要 schemastery **~3.18.5** (3.18.1 没有这个方法),升级时注意同步。 5. **未变、无需改**:`ctx.tools.register`(`ToolDefinition.output.{schema,render}` 已是强制项, 本插件早已具备)、`systemPrompt.section({name,order,text})`、webServer 路由契约 `{kind,path,handler}`、`settings.describe/update/mutate` 三个方法签名。 ## dsh 0.2.1-rc.3 更新(2026-10) Electron 桌面版实际加载的 harness 从 0.2.0-rc.2 升到 **0.2.1-rc.3**(`dsh` CLI 自身的 `--version` 可能仍是旧版,以 `app.asar/dsh/package.json` 为准)。这次只踩到两处: 1. **prerelease 的 peer 范围不跨补丁位**。`^0.2.0-rc.2` **不接受** `0.2.1-rc.3`(semver 规定 prerelease 只有在同一 `major.minor.patch` 且范围本身含 prerelease 时才匹配), 于是兼容性闸门 `evaluatePluginCompatibility` 直接把插件判为 incompatible。peerDependencies 要写成 `^0.2.1-rc.3`(会自动接受 0.2.1-rc.4/rc.5 与 0.2.1 正式版)。 2. **桌面 profile 被应用升级“修复”后不会自动恢复插件**。修复会重写 `~/.dsh/profiles/desktop/package.json` 与 `cordis.patch.yml`,把 `dsh-mem0` 从 `dsh.profile.bundles` 和 patch 行里删掉(`dependencies` 里的 `link:` 会保留),结果是 **插件完全不加载、没有 `mem0_*` 工具、也没有配置卡片,且没有报错**。用 `install_bundle`(或 GUI 插件页)重新装回当前 profile 即可;旧值可从 `~/.dsh/settings.yaml.imported` 的 `dsh-mem0:` 段恢复(导入后该文件已重命名,不会二次导入)。 其余宿主/客户端 API 在 0.2.1-rc.3 未变:`settings.describe/update/mutate`、转发到客户端的 `settings/document-updated` 事件、`tools.register`、`systemPrompt.section`、`webServer.register`、 `plugins.row.config` 槽位、`ctx.remote.$on`、`createSnapshotStore` 都仍在。 ## 安全红线 - **凭据一律从环境变量读取,禁止硬编码**。真机脚本 `scripts/smoke.mjs` 和 `migrate-user-id.mjs` 需要 `MEM0_API_KEY` + `MEM0_BASE_URL`(缺失即退出并提示); `migrate-user-id.mjs` 是**重建+删除**的破坏性脚本,还额外要求 `MIGRATE_CONFIRM=yes` 才执行。本地可用 `.env` 存放这些值(已在 `.gitignore`),**任何密钥都不进 git**。 - **历史密钥已清除**:2026-08 用 `git filter-repo` 重写过全部历史(旧 apiKey 与内网地址 已替换为 `***REMOVED***`,reflog/gc 已深度清理)。旧密钥仍视为可能泄露——不要复用到别处, 尽快在 mem0 dashboard 轮换。 - 配置路由是 **loopback-only**(`isLoopbackRequest` 围栏,同源校验)——执行写入的端点不能 对 LAN 开放。新增路由必须带同样的围栏。 - 破坏性工具(删除/清空)先展示目标再执行,工具描述里已写明 confirm 要求,别弱化。 - 工具输出不含 apiKey(`mem0_status` 只报认证状态)。 ## 约定 - **`lib/` 提交进 git**:link 挂载直接跑它,克隆仓库即可用;改 `src/` 后必须 `pnpm build` 并连同 `lib/` 一起提交,否则线上跑的是旧码。 - **可安装性规则(`dsh plugin add`)**:profile 默认 `autoInstallPeers:false`,peerDependencies 不会被装——**运行时导入的包必须是 `dependencies`**(当前只有 `@deepseek-ai/schemastery`, 用 scoped 版而非裸 `schemastery`,后者不在任何全局解析路径上;`@deepseek-ai/dsh-settings` 只做类型引用,放 `devDependencies`); 但**凡是 harness bundle 行的包(如 `@deepseek-ai/dsh-tools` 的 `tools` 行)绝不能做依赖**:会被 hoist 进 profile、遮蔽 harness 副本,两份模块产生两个 `TOOL_RUNTIME_SCHEDULER` Symbol,导致每次工具调用报 "Cannot read properties of undefined (reading 'prepare')"。这类包只能 `import type`(tools 定义已改为手写 JSON Schema,零运行时 导入)。新增运行时导入时保持这条规则,并确认版本在 npm registry 可获取。 - 工具/路由的注册与释放都挂在 `ctx.effect` 上(可逆副作用),新增面同样处理。 - 路由文件保持「每路径一个 handler + 方法内分派」的结构(对齐 `dsh-ssh` 的 route 家族)。 - 提示词文案(`MEM0_GUIDANCE`)是中文,面向模型;改配置默认值时同步改 README 表格。 - 宿主 `src/` 用 TS(`strict`);浏览器 `client/client.cjs` 是纯 JS 且不参与类型检查——它的 回归保障是 `pnpm smoke:client`,别绕过。