# 开发与验收(dsh-experience-memory) 本文件是仓库内的开发向文档:构建、打包、启动验收、真实回合验收、测试清单与开发环境。 面向「用这个插件的人」的内容在 [`README.md`](README.md)。 > 本文写于 2026-09-23,随 README 的改版一起拆出来。内容是原 README 里第 39–199 行与第 776–829 行的原文搬运, > 只调了标题层级与交叉引用;数字与结论一字未改。 ## 目录 | 想干什么 | 去哪节 | |---|---| | 打一个包、装到某个 profile 里试 | [安装(开发期细节)](#安装开发期细节) | | 搞清楚为什么要先构建 | [为什么要构建](#为什么要构建) | | 分清 tarball 与 `link:` 两种安装形态 | [安装形态:发布用-tarball开发用-link](#安装形态发布用-tarball开发用-link) | | 为什么改了 `lib/*.js` 不生效 | [`link:` 换不来免重启热重载](#link-换不来免重启热重载我原先写错了) | | 确认这个进程加载的是哪一版代码 | [这个进程加载的是哪个构建](#这个进程加载的是哪个构建) | | 证明「装上了、真的导入了」 | [启动验收](#启动验收) | | 证明「模型真的看到了」 | [跑一次真实模型回合](#跑一次真实模型回合) | | 看测试套件都覆盖了什么 | [测试](#测试) | | 让测试能解析 `@deepseek-ai/*` | [开发环境](#开发环境) | ## 安装(开发期细节) ```sh pnpm pack # prepack 会自动构建 lib/ dsh --profile --dump-config # 应出现 "# == dsh-experience-memory" 层 ``` 装完后可以在该 profile 目录里跑一次消费者级校验(纯 `node`,不加任何 flag): ```sh cp tools/verify-install.mjs "$DSH_HOME/profiles//" node "$DSH_HOME/profiles//verify-install.mjs" ``` 它验的是**挂载**:命令注册成功、命令名不重复、上下文贡献不重复、工具表就是那 5 个、按名从 `node_modules` 解析。 ## 为什么要构建 发布产物是 `lib/` 下的普通 JavaScript,`main` 指向 `lib/index.js`。原因是 Node 拒绝对 `node_modules` 里的文件做类型擦除: ``` ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING ``` 源码树里 `main: "src/index.ts"` 能跑通(`link:` 安装、开发测试都行),但用户装的是 tarball, 所以随包发出的入口必须已经是 JavaScript。 构建用 `node:module` 的 `stripTypeScriptTypes`,因此**构建期也是零依赖**,不需要 TypeScript 或打包器: ```sh node tools/build.mjs # src/*.ts -> lib/*.js ``` `stripTypeScriptTypes` 不会重写模块说明符,所以构建脚本自己做这件事:每个相对 `./x.ts` 说明符改写为 `./x.js`。改写数量为 0 或 `lib/` 里残留任何 `.ts` 说明符时,构建直接失败拒绝发包。 **类型注解从不被检查。** `stripTypeScriptTypes` 只把注解擦掉,不做类型检查,而 toolchain 里没有 `tsc`—— 零构建依赖是刻意的,代价就是**类型不一致不会被任何一步发现**:错误的注解会被原样删掉,运行期行为不受影响, 所以它连测试都不会惊动。类型在这里是**给人读的文档**,不是被验证的契约;真正被验证的是行为(套件数**和断言数都由 `tests/run.ts` 自己报**——`PASS 13 suites · N assertions`,本文不写这个数字,因为手抄的数字必然过期:这里原先写的是"470+",实际跑出来是 661) 和导出面(`tests/built.mjs` 逐名比对 `src` 与 `lib`)。要加类型门禁就得引入 TypeScript 依赖, 那与"构建期零依赖"直接冲突,所以这是一个**明知的取舍**,而不是遗漏。 ## 关于依赖与警告 `@deepseek-ai/*` 全部声明为 `peerDependencies`,由 DSH 安装目录通过 `$DSH_HOME/profiles/node_modules` 的扁平回退符号链接解析,因此本包既不打包也不安装它们。 安装时 pnpm 会报 6 条 `missing peer @deepseek-ai/*` 警告,这是**预期行为**:这些包由 DSH 宿主提供, 第三方 bundle 不该自带副本。真正加载时它们都能解析到。 开发期测试用 DSH 自带 Node 直接跑 TypeScript(Node 24 类型擦除),不需要先构建: ```sh node --experimental-strip-types tests/run.ts # 源码语义 node tools/build.mjs && node tests/built.mjs # 构建产物 ``` ## 安装形态:发布用 tarball,开发用 `link:` 两种形态**契约不同**,别混用(一位调用方问过这个,值得写下来): | | tarball(发布形态) | `link:`(开发形态) | |---|---|---| | 加载的代码 | 打包那一刻的 `lib/`,**冻结** | 仓库的 `lib/`,**跟着工作区变** | | `files` 白名单 | 生效——`src/`、`tools/`、`tests/` 都不在包里 | **不生效**:整个仓库(含 `.git`,以及指向 `$DSH_HOME/profiles/node_modules` 的那个 `node_modules` 联接)都在 `node_modules/<包名>/` 下可见 | | "安装 == 产物"不变量 | 成立,可用 `audit/compare-install.mjs` 逐字节核 | **不成立**,比较无意义(同一份文件) | | 改代码后 | 必须 `build + pack + 重装`,**并且重启** | 跑 `node tools/build.mjs`,**同样要重启**(原因见下) | | 免重启热重载 | 不适用(包是冻结的) | ❌ **不会发生**,即使 HMR 配置完全正确 | **结论**:开发回路用 `link:`(这正是它存在的意义),**发布与验收一律用 tarball**。`link:` 下要注意两点: ① 回路是「改 `src` → **构建** → `lib` 变化 → 重载」,漏掉构建就会加载与源码不一致的 `lib/`(`node tools/build.mjs --check` 会当场报出来,exit 1); ② "整个仓库可见"是真的副作用,会影响任何遍历 `node_modules` 的扫描器(包清单、skill 扫描、client module 扫描)。只想跑代码而不想暴露仓库时,用 tarball。 ### `link:` 换不来免重启热重载(我原先写错了) 这张表原先在 `link:` 那一栏写着"**配 HMR 可免重启**"。**这句话是错的,已被实测证伪。** `dsh-bigfat` 会话做过一次完整排除法:junction 安装、`--dump-config` 确认合成结果里 `hmr: disabled: false` 且 root 指向 `/lib`、宿主确实带 `--expose-internals`、`node_modules\<包名>` 的 LinkType 确实是 Junction、宿主确实**没有** `--preserve-symlinks` —— 配置全对,然后**改一句渲染字符串、立刻调工具,输出没变**。 根因不是配置层级,是**「监视到了」≠「能定位到模块」**:HMR 用**联接路径**算出的 module URL,与 Node ESM 加载器按 **realpath** 登记的键对不上,于是它 emit 一个 `hmr/change`,**无人监听模块被换掉,既不生效也不报错**。 对 `dsh-experience-memory` 的直接含义:**它现在就是目录联接安装**,所以它自己的 `lib/*.js` 改动在本 harness 里**一律需要重启**。README 里所有"重启后 `memory_stats` 首行会变成…"的说法与此一致。 要么改真实目录安装(失去"改仓库即时生效"),要么 `NODE_OPTIONS=--preserve-symlinks`(全局影响)。两条都不是本仓库能单方面决定的。 ## 这个进程加载的是哪个构建 版本号永远是 `0.1.0`,而 tarball 会把所有文件的时间戳还原成 1985——**副本身份在磁盘上没有判别物**。而 `link:` 下"磁盘哈希"还回答不了真正的问题: 那份文件就是工作区,哈希相同并不能说明**进程**重载了它。 插件因此**在激活时自己算一遍**它加载的那批模块的内容哈希(`src/build-id.ts`),两个地方能看到: - `/memory-status` 首行:`插件构建 ( 个模块)`——**给人 / 运维看**; - `memory_stats` 文本首行:同一行——**给模型侧调用方看**(它没有日志访问权,这才是它能用的那一半)。 ⚠️ **它不在 `harness.log` 里。** 我最初把激活时的 `ctx.logger.info` 当成可 grep 的锚点,**实测是错的**:那个文件只捕获进程的 `stdout`/`stderr` 与桌面启动器自己的行(node 的 `ExperimentalWarning` 在里面,**Cordis logger 的输出不在**——530 行里没有任何 level 标签)。 要确认"重启加载的是哪一版",**读 `/memory-status` 或 `memory_stats`**,不要去 grep 日志。 与仓库里同一份构建的哈希一致,才说明"重启后生效的是这一版";两个会话的 id 相同,说明它们跑的是同一份代码。 **唯一锚点是构建标识,不是逐文件 sha256 表。** 这条是被一个校验方推着我改的:他按回执里的 10 行 sha256 表逐文件比对,得到 7/10 一致、3/10 不一致 —— 原因只是我在他验收之后又发了一版。他的论证比我原来的做法对,所以照办: > 以**构建标识为唯一锚点**,停止维护逐文件表。它是对**已加载的编译模块**算的哈希,比"磁盘上某些文件"更贴近"进程真正跑的是什么"。逐文件表是冗余的,而**冗余的快照就是过期源**。 因此:对外校验只给 `插件构建 ( 个模块)`。逐文件 sha256 仍然可以算,但**只在需要 diff 一个检出、找出哪几个文件不同时**才用,而且**必须连同它所属的构建标识一起给出** —— 一张没有标注版本的哈希表,读到的人第一件事是怀疑"是不是被换了",那是它自己制造的成本。 ## 只读工具会报出自己的 call id `memory_stats` 与 `memory_recall` 的返回末尾会带一行 `本调用 id call_xx…(把它填进 source_ref 即可判 verified-tool)`。 原因是实测出来的:`route: tool-call` 的判据是"被引用的那次工具调用成功",而模型**从来看不到 call id 的文本**——它唯一一次被印出来,是在一条**失败**记录的 `reason` 里。于是"把我刚看到的那次工具输出记下来"要付两次调用:第一次专门用来失败、以取得那个 id。让只读工具自报 id,就是把这一次省掉(`tests/plugin.test.ts` 里有一条一次调用直达 `verified-tool` 的端到端断言)。 只读观测者带,三个写工具不带:**一次写操作不是关于工作区的事实**,而 `source_ref` 是给"后来能重新核对"的主张用的。 `tools/verify-install.mjs` 还会顺带断言**命令恰好 7 个、上下文恰好 2 条**。**它验的是挂载期去重,不是重载期去重** —— 这个区分是必要的:重载后名字翻倍是 HMR 泄漏的症状,但在**联接安装下根本不会发生重载**(见上一节),所以"重载后再跑一遍"并不能证明重载安全,只能证明这次挂载没有重复注册。要验重载期去重,需要一种 HMR 真能重载模块的安装形态(真实目录 + `--preserve-symlinks`,或整包重载)。 它的断言条数由脚本自己打印(`PASS installed package (26 checks)`),不在文档里手抄。 ## 启动验收 `--dump-config` 只证明配置能合成,证明不了**加载器真的导入了这个 bundle**——而正是后者曾经失败 (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`)。验收办法是让启动留下一个可观测的事实:给 profile 打一个 只改 `dbPath` 的 overlay,指到一个尚不存在的文件。 ```yaml # boot-acceptance.overlay.yml - id: experience-memory config: enabled: true dbPath: F:/…/boot-acceptance.db ``` ```sh DSH_TELEMETRY_DISABLED=1 node /lib/bin.js --profile --patch boot-acceptance.overlay.yml ``` 库文件出现,就一次证明了四件事:加载器按 `name` 解析到了包、导入了它、`inject` 声明的 `tools` 与 `systemPrompt` 在**真实 base 合成树**里都解析到了(这一点 `--dump-config` 抓不到——`inject` 依赖缺失时 插件只是静默不激活),以及 `apply()` 跑完并建好了 schema。 该 profile 的 bundle 列表里没有 app,所以它只挂载、不提供服务;确认库文件出现后结束进程即可。 ## 跑一次真实模型回合 挂载层断言证明不了模型**实际看到**了什么。要跑真实回合、又不污染正式库、也不起服务器,用一次性的 `@deepseek-ai/dsh-headless` app 配一个临时 profile: 1. 临时 profile 的 `bundles` = `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-headless` + 本插件。 它必须同时带 `pnpm-workspace.yaml`(`nodeLinker: hoisted`、`autoInstallPeers: false`),否则 pnpm 会去 公共 registry 找 `@deepseek-ai/*`,而那些是 in-box 包、根本没发布,安装以 404 失败。 2. 再打一个只改 `dbPath` 的 overlay,指向一个**一次性库**——正式库由正在运行的应用持有。 3. 给模型布置一个**只可能来自记忆**的任务:先往一次性库写一条在任何文件、任何环境里都搜不到的断言 (例如一个自造的部署代号),再在**另一个新回合**里问它这个代号是什么。 判据不是「答对了」——模型可能瞎猜。判据是**答对、且工具调用数为 0**:那证明事实是随 systemPrompt 注进去的, 不是它 `memory_recall` 出来的。这两件事在工具日志里长得完全不同。 反过来同样有用:需要**逐字引文**才能定级的那条路径,也只有真实回合能验证。本仓库的 `src/session.ts` 就是被这一步抓出来的——12 个套件全绿,因为它们的 fixture 手写了一个真实 Session 上 并不存在的属性。 ## 文档与代码对齐(`docs` 套件钉住了哪些东西) `tests/docs.test.ts` 把 README 当成一份**可核对的契约**,而不是散文。它机械核对的是: | 钉住的东西 | 怎么核 | |---|---| | `## 配置` 表的每一格默认值 | 与 `resolveConfig({})` 双向比对:改代码不改表会红,改表不改代码也会红 | | 表里出现的键集合 | 恰好等于 `Config` 接受的键,多一个少一个都红 | | 工具表、命令表 | 注册到真实 `tools` / `commands` 服务上的名字,与表里列出的**逐名相等** | | 摘要条数上限 | `## 模型的体验(Model Experience)` 一节里必须写出 `coreMaxRecords+residentMaxRecords=7`,且不得出现"两段合计最多"这种错误形状 | | 那一行提示的体积 | `### 它挂了四个表面`、`#### Token effect`、`## Known Limitations and Deferred Work` 三处都要写出当前字节数与上限 | | 套件数、审计产出文件名 | README 里点名的数字与文件名,必须与磁盘上的实际情况一致 | **测试按标题逐字定位**(`sectionOf()` 找的是整行完全相等的标题)。所以改标题就是改测试锚点: 现在被钉住的五个是 `## 配置`、`## 模型的体验(Model Experience)`、`### 它挂了四个表面`、 `#### Token effect`、`## Known Limitations and Deferred Work`。 重命名它们必须同时改 `tests/docs.test.ts` 里的对应字符串,否则那几条断言会因为找不到锚点而**失败**(不是静默跳过)。 `README.en.md` 是同一份文档的英文版,共用**完全相同**的标题文本,因此同一套锚点也能查它; `docs` 套件对两个文件都跑一遍名单类断言(工具名、命令名、配置键、审计文件名、套件数、摘要上限)。 **为什么不检查散文**:改个措辞就误报、换个说法就漏掉。只钉能机械核对的那几类——本仓库出过两次同类事故 (README 说两段摘要合计最多 5 条;四处注释说模型工具表是 4 个),两次都不是有意说假话,而是**没有任何检查在看这些断言**。 ## 测试 16 个套件,全部用 DSH 自带 Node 运行,无测试框架: | 套件 | 覆盖 | |---|---| | `tokenize` | CJK 二元组、任意语种词元、标识符折叠键、**英文散文不产生标识符** | | `rank` | 证据等级单调性、失败惩罚、衰减、**标识符加成封顶**、**被查过算作"碰过"所以不再衰减**、**检索加分封顶且压不过一次成功复用** | | `db` | 单后端、FK 单一开关、原地更新不丢正文、**正文可搜**、列权重、**旧版本的库重开后补上新列且老数据不丢** | | `retrieve` | **可见性 fail-closed**、分层状态窗口、预算截断、排除计数、**核心层只收跨工作区印证过的领域级记录**、两段共享字节预算、**来源行只陈述一次证据等级且带出处** | | `domain` | 归一化、四级解析顺序、坏文件不抛异常 | | `evidence` | 四种等级、**疑问句内的同一句话不算断言**、路径逃逸拒绝、**最近存在目录按"目录在前、带 `/`"列出并承认截断** | | `lifecycle` | 候选/定案/晋升/合并/退役/维护游标、**身份不含标题**、**过期窗口两端都生效且过去窗口被拒绝**、**被复用过的记录不在复核期退役**、**退役理由按替代者的实际等级生成**(未定级的替代者不得声称"有可核实出处")、**purge 连印证一起带走,且不带走别的工作区的印证**、**维护回合修复旧版本留下的无主印证** | | `precall` | 走**真实的工具执行链路**(`prepare`→`dispatch`→`finalize`):只有参数里点名了记录里的文件/符号才递、无关调用不递、**递的是它正要动的那一次调用**、冷却期内不重复递、会话上限封顶、**库坏掉时不阻断工具本身** | | `import` | 字段映射、事件记录不导入、试运行不写、重复导入合并不重复、**副本库排除**、**同库重复写入合并**、**正文相同标题不同只写一行**、**工具失败事件按正文形状排除**、**选择清单只导指定记录且空清单导 0 条** | | `failure` | 形状归一化(**同一错误换文件是同一形状、不同错误不合并、只留首行、数字与引号内容折叠**)、**用真实会话里 `isError` 的那一层嵌套读失败**、**自家 edit 工具的失败必须被计数**(教训那条路跳过它、统计这条路不能跳)、按形状累计并记住会话数、**关掉就不再写**、**表有上限**、报告按次数过滤、**关键词重合度给分而不下"已覆盖"的结论** | | `audit` | **散文粘连的路径不算缺失**、真缺失路径带最长存在前缀、**标识符不当命令查**、精确/近重复、漏斗每步、**注入实测**、报告不含过期硬编码数字、空目录不崩 | | `census` | 状态/证据/作用域分组、**只审 confirmed 且恰好卡在常驻线上的那一条**、审计轨迹计数、退役原因与「无纠错记录」、渲染 | | `commands` | 参数解析、**命令都注册在真实的 command 服务上**、**用斜杠菜单读的同一个 `list()` 断言可发现性(描述、参数提示、排序)**、**`recordInput: false` 使运维输入不进会话**、预览与状态/维护/审计/导入、**导入默认不写入**、坏清单报错、**模型工具表没有变大** | | `plugin` | 挂载真实服务、五个工具闭环、**`memory_stats` 的计数与库实际状态一致且只读**、**同一条主张有无引文导致不同召回结果**、**查询推导读的是真实 Session 形状(`snapshotEvents()`)而不是不存在的 `events` 属性**、**候选默认不可见但可显式复核并带出待复核说明**、**驱动真实 `assemble` 断言注入**、**库空时摘要为空而记录提示仍然注入**、**跨工作区印证后无关的一轮仍出现**、**驱动真实 `agent/turn-stopping` 断言维护执行且失败不破坏回合**、**工具收到的天数落库为绝对到期时间且 0 天被拒**、**只读工具自报 call id,引用它的主张一次调用直达 `verified-tool`**、**维护回合把 WAL 折回主文件(只拷 `memory.db` 不再静默过期)**、**检索被记成"被查过",而自动注入不算被查** | | `harvest` | 五条判据各自**一正一负**(尤其"问句不算陈述")、**系统包装文本与技能目录不算用户陈述**、**自家编辑工具的用法失误不算项目教训**、**一轮只出一条且取最强的那条**、**宽判据默认不跑而显式开启才跑**、**采回来的即使引文是用户原话也仍是候选**、**候选会老化而"被查过"的不老化**、**候选池超上限时退役最旧的那条** | | `docs` | **README 配置表逐格等于 `resolveConfig({})`(两个方向都查)**、每个配置键都在 `cordis.patch.yml` 里被重述、**只有 `coreMaxRecords` 允许为 0**、注册的工具/命令集恰好是 README 列的那些、**摘要条数是每段各算(默认 2+5=7 行)而不是合计 5 行**、**审计写四份就报四份**、**常驻记录提示不超过 256 字节且点名了工具** | 外加**构建产物与打包契约**验收(`tests/built.mjs`,纯 `node` 不加 flag):每个 `lib/*.js` 都能导入、 导出名与 `src/*.ts` 一一对应、`lib/index.js` 是合法 Cordis 插件、挂载后行为与源码一致、**随包命令注册成功**, 并且**打包契约成立**——`files` 承诺的都在、入口在包内、`license` 与 `LICENSE` 齐备、 **没有随包模块反向 import `src/`**(这正是「发了跑不起来的东西」那类缺陷)。 `tools/verify-install.mjs` 再把同一套检查搬到真实 profile 里,验证按名从 `node_modules` 解析。 一条命令跑完全部:`pnpm verify`。 ## 开发环境 `@deepseek-ai/*` 是 peer 依赖,由宿主提供,所以仓库不 vendored 它们。测试要能解析这些包, `node_modules` 才指向 DSH 安装里那份扁平符号链接: ```powershell # 在仓库根目录执行一次;Node 只会解析 node_modules,不认 dmn 或别名 New-Item -ItemType Junction -Path node_modules ` -Target "$env:APPDATA\dsh-desktop\harness\profiles\node_modules" ``` 这个 junction 已被 `.gitignore` 忽略。没有它,`pnpm verify` 会因为解析不到 `@deepseek-ai/cordis` 而失败 ——插件本身不受影响(它的 peer 由宿主提供),受影响的只是开发期测试。 `tools/` 下的脚本**不在发布包里**:它们只是 `lib/` 之上的一层薄壳(解析参数 + 打印), 供仓库内使用和脚本化。插件安装后,同样的能力走斜杠命令。