github csyangwen/dsh-memory-evolve v26092801

latest release: v26092802
26 days ago

上一个版本 v26091501 以来的全部变更(修复 / 新增 / 变更 / 测试)如下;更新说明与仓库 docs/CHANGELOG.md 同源。

2026-09-28

修复

  • 会话格式 v4 下插件注入消息会让接收方那一轮整轮失败(issue #68,阻塞级):DSH 0.1.7-rc.2 起会话持久化走 format v4,每条消息的 source.kind 必须由生产者自报(canonical 形态 plugin:<包名>,校验在 @deepseek-ai/dsh-session-format-v3-to-v4 的 message-sources 模块,持久化侧同规则);v3 时代的「裸 kind: 'plugin' + 并列 plugin 字段」被显式拒绝:format v4 message requires a producer-owned source kind。插件有 4 处注入点仍在用旧写法——会话广播 / COI 通知(lib/coi/index.js 的 deliver())、COI 任务状态通知(lib/coi/scheduler.js 的 #deliver())、写冲突警告(lib/coi/ws-coord.js 的 userMessage())与工作区活动公告板(同文件)。报错发生在接收方会话认领该事件时,所以症状是"注入所在的那一轮整轮失败",而不是注入调用本身报错;又因为公告板需要活跃会话 ≥2、写冲突与 COI 通知靠事件触发,表现为"重启就好、过一阵复发"——重启只是清空了公告板基线 / 节流状态,并非修复。修复:新增 lib/coi/source.js 导出唯一常量 PLUGIN_SOURCE_KIND = 'plugin:dsh-memory-evolve'(与宿主 producerKind() 对未知生产者的映射一致),4 处全部改用它并删掉 plugin 字段。读取侧无需改动:advisor 用自己的 kind: 'advisor',review.js 的 lastTurnWasMessage() 判 undefined || 'user',新 kind 的语义不变。
  • 带 [summary:…] 的条目在记忆 Tab 无法删除 / 编辑 / 归档,归档页也无法移回主记忆 / 删除(issue #59):根因是展示层剥了程序元数据、匹配层没跟上——buildMemoryFiles() 下发前会剥掉 [summary:…](渐进式披露,2026-08-17 引入)与 [id:…](2026-08-11 引入),前端 MemoryTabView 把这份展示文本当 raw 回传;而 findExactIndex() 只对 [id:…] 免疫,peekExact() 用 includes() 严格相等,归档侧 ArchiveStore.removeExact() 更是 entries.indexOf() 严格相等(连 [id:…] 都不免疫——启用 Git 同步后写入身份证的归档条目一律删不掉),promoteArchived() 的 includes 子串又被 [summary:…] 从中间截断。同一类问题第二次发生(第一次是 [id:…]),结论写进代码注释:展示层每剥一种新元数据,匹配层必须同步扩展。修复:lib/store.js 新增并导出 normalizeForMatch(entry) = stripEntrySummary(stripEntryId(entry));findExactIndex() 改用它,并新增 { first } 选项(主轨多条命中仍报歧义、保守拒绝;归档允许重复条目,取首条);peekExact() 改走 findExactIndex 并返回磁盘原文(归档「先写后删」因此保留 [id:…] / [summary:…],转正回主轨不丢元数据);ArchiveStore.remove() 与 removeExact() 两侧统一归一化;promoteArchived() 的 hits 与随后的 archive.remove() 必须同一套基准——只修 hits 不修 remove 会出现「转正成功但归档那条删不掉」→ 同一内容同时留在主轨与归档,且全程没有报错提示。
  • runGit() 的 30s 网络超时实际不生效——超时只杀直接子进程,Promise 可能永不 settle(issue #69,阻塞级):git ls-remote / git fetch 会再派生 git-remote-https,它继承并持有 stdout/stderr 管道写端;Node 的 'close' 事件要等所有 stdio 句柄关闭才触发,而 resolve() 只挂在 'close' 上——超时回调虽然把 git 杀了,'close' 却永不触发,于是 Promise 挂死。实测(本地 TCP 黑洞,比不可路由地址更确定):修复前 120 秒内 Promise 从未 settle;修复后 30022 ms 返回 {ok:false, code:null} 并在 stderr 末尾标注超时。影响不止"卡一次":同步在 systemd user timer 下周期运行,一次挂死就让调用方永远拿不到失败原因,node --test 跑 tests/sync-*.test.js 也会整片挂住。修复三层一起做:① settle() 单次落定——超时 / error / close 谁先到谁生效、后到的丢弃,否则会出现"超时返回失败、随后 close 又翻案成成功";② 超时主动 resolve 一个失败结果,不再等 close;③ 落定后 killGitTree() 尽力清理——detached 进程组 + kill(-pid) 连带孙进程(Windows 无进程组信号,退回 child.kill()),并 destroy 两路管道让 'close' 得以触发。顺序上必须先落定、再清理:反过来的话 destroy 管道触发的 'close' 会抢在超时结果之前 resolve 成一个假的成功。NETWORK_TIMEOUT_MS / LOCAL_TIMEOUT_MS 一并导出,供回归测试按真实阈值推进假定时器,避免测试另抄一份阈值后与实现漂移。
  • 内置技能同步:skillDir 哨兵显式落地 + 成功/失败都不再静默(issue #67):issue 报告的"第二层"(config.skillDir 默认 null 未解析就传给 syncBuiltinSkills() 并抛 TypeError)经复测不成立——resolveConfig() 自首个提交起就有 config.skillDir = resolve(config.skillDir ?? join(homedir(), '.agents', 'skills')),而 apply() 是先 resolveConfig() 再 installCoi(),调用点拿到的从来是字符串(实测 resolveConfig({}).skillDir → C:\Users\…\.agents\skills;本机 ~/.agents/skills 里五个内置技能俱全,memory-consolidate 的落盘时间正是插件启动那一刻)。真正成立的是 #58 的第一层:调用点在 installCoi() 内、而 installCoi 由 coiEnabled(默认 false)门控——默认配置下内置技能不会同步,这一层由同批的 e73a28e 一并解决(见下条)。本次修的是 #67 里仍然值得修的三点:① 调用点显式兜底 config.skillDir ?? DEFAULT_SKILL_DIR(新增导出常量,与 resolveConfig() 的解析结果由测试钉成一致),syncBuiltinSkills() 则对非字符串入参抛看得懂的错(点名参数 + 指出该用 resolveConfig() 还是 DEFAULT_SKILL_DIR),不再把 Node 的 The "path" argument must be of type string 抛给上层;② 每次同步都落一行可见日志(目标目录 + 总数 + 更新数),action: 'missing'(插件包内缺该技能 = 打包事故)单独 warn,catch 打完整堆栈——此前成功路径只在有变更时 log、失败路径只有一行 warn,"技能从来没装上"只能靠反查技能目录的 mtime 才能发现;③ 顺带修掉同一路径上一个 Windows 专属缺陷:skillVersion() 的 ^---\n 只认 LF,而 Windows 上 core.autocrlf=true 检出的 SKILL.md 是 CRLF——源与目标都解析出 0,版本门控退化成"永远相等",x-version 升级在 Windows 上静默失效(首次安装因目标不存在仍会复制,所以只有"升级不生效"这一半可见;本机 ~/.agents/skills 里那五个技能正是这么来的)。改为 ^---\r?\n… 后 CRLF 与 LF 解析一致。同源的 normalizeSkillText() 在 CRLF 下也把已有 frontmatter 误判成"没有"并叠加第二份头,一并改为按 trim() 比较边界行(不重写用户文本的换行)。
  • 内置技能同步被 COI 开关连带关闭:默认配置下内置技能永远装不上(issue #58):syncBuiltinSkills() 的调用点原本在 installCoi() 内,而 installCoi 由 coiEnabled 门控(默认 false——本插件本职是记忆/待办/技能,调度是按需增强),于是 memory-consolidate 与 kimi/codex/grok/hermes 四个 CLI 使用指南在默认配置下永远不会进技能库;调用点又包着 try/catch 只打一行 warn,连"从来没跑过"都留不下痕迹(与 §7.5 broadcast 当初"独立子模块挂在 COI 下拆不开"是同款事故)。修复:同步提到插件主装配(lib/index.js 的 apply() 步骤 6.5),实现抽成 lib/coi/skills-sync.js 的 syncBuiltinSkillsIfEnabled(config, pluginSkillsDir?)——判定只看 coiSyncSkills,代码里不再出现 coiEnabled;PLUGIN_SKILLS_DIR 一并移过去并导出(供测试断言源头确实在包内),installCoi() 内的调用与模块级常量删除,该模块只剩适配器指南的写入需要 normalizeSkillText。coiSyncSkills 的语义随之明确为"启动时是否把内置技能同步到技能库",与调度开关无关。
  • 收尾的 key 建议没写明用哪个工具(issue #58 第二项):snap.keyDuty / snap.subagentKeyTail 只说"另向 target=key 提交 1 条建议",而 memory_suggest 的 target 白名单是 ['memory','user','todo-life','todo-work','todo-project','todo-daily'](lib/review.js),不含 key——照字面选工具即报"不支持 target=key",会话收尾会反复踩。走待确认队列的正规通道是 memory 工具的 add(与 memory_suggest 共用 enqueueSuggestion 队列)。两处文案改为"另用 memory 工具 action=add 向 target=key 提交 1 条建议(走待确认队列,用户确认后写入并注入)",中英同步。

变更

  • 内置技能 memory-consolidate 升 x-version: 2(外部 PR #58 第三项,按原文摘取):补四类实战边界——① 同一轨的写操作串行执行(同一个 .md 是读-改-写,交错执行会互相覆盖、也会撞上 drift guard;一个簇处理完再动下一个);② replace 是整条替换:content 必须给完整新全文,只传增量或新句子会静默吞掉其余段落(2026-09-16 实证:更正一条多段记忆时只写新句子,其余段落整条丢失,靠 memories 仓库的 git 备份才找回;改写前先用 list、key 轨可用 expand 把原文读全);③ key 建议用 memory 工具 add(target=key)提交,不要用 memory_suggest(后者 target 白名单不含 key,传入即报「不支持 target=key」);④ 新增「四、故障处理(异常兜底)」——drift guard 报「无法解析往返」拒写时的逃生路径(先备份 → 用插件自带 lib/store.js 做 serializeEntries(parseEntries(text)) 规范化并校验往返条数不变 → 回到工具流程),以及「判断条目是否粘连只认 parseEntries(text).length」(用正则数 \n§\n 会因正文里逐字引用的内容性 § 假阳性)。技能正文取自 PR #58 的 d78043d,与该提交逐字节一致。注意:这一项只有在本版之后才真正装得上——此前 CRLF 检出下 skillVersion() 解析出 0、版本门控退化成"永远相等",Windows 上 x-version 升级是静默失效的(见上)。

测试

  • 新增 tests/injection-source-kind.test.js(4 例):① 复刻宿主 v4 准入规则并做前提自检——该规则必须拒绝退役写法、且通过 canonical 写法,否则后面的断言都是假绿;② PLUGIN_SOURCE_KIND 的形状与准入;③ 扫描 lib/ 不得残留退役写法;④ 扫描四处注入点必须引用同一常量(防未来新增注入点时又手写一份 kind)。行为侧证据落在既有用例上:tests/coi.test.js(会话广播 / 房间动态 / COI 完成通知)与 tests/ws-coord.test.js(写冲突 additionalContexts / 公告板)直接断言真实构造出来的消息对象满足 source.kind === PLUGIN_SOURCE_KIND 且 source.plugin === undefined;tests/plugin.test.js 与两个 advisor 用例里按旧写法构造的桩数据同步更新。
  • 新增 tests/store-display-match.test.js(12 例)。关键设计:展示文本取自真实的 buildMemoryFiles(),测试里没有复刻一遍剥离逻辑——展示层将来再剥一种新元数据时,这些用例会跟着一起红,而不是"测试全绿、线上仍坏"。覆盖:normalizeForMatch 只剥头部(正文里的 [summary:…] 字面量不动,且两条只差该字面量的条目仍分得清);主轨删除 / 编辑(时间戳与摘要原样保留)/ 分支范围 / [dsh-only] 四个精确操作;peekExact 返回磁盘原文;归档页删除(带 summary 与只有 [id:…] 两种,后者单独钉一条);归档重复条目取首条;promoteArchived 转正后断言归档文件已清空(防"只修 hits 不修 remove"的半吊子修复);以及"归一化后多条命中 → 保守拒绝、一条都不动"。已验证:把 normalizeForMatch 退回旧的"只 stripEntryId"行为后,12 例中 5 例必失败。
  • 新增 tests/sync-git-timeout.test.js(7 例,其中 1 例 POSIX 专属):注入永不 emit 'close' 的假子进程(等价于"孙进程占着管道"),用 mock.timers 按真实阈值推进,断言「未到点不得 settle」「到点必须 settle 且带超时说明与命令行」「迟到的 'close' 不得把结果翻案成成功」「两路管道被 destroy」「正常路径与 spawn 报错不受影响」。为避免回归退化成挂死——旧实现下 Promise 永不 settle,直接 await 会让测试进程一直挂着而不是报错——等待改用 setImmediate + Date.now() 轮询的真实时间预算,超预算直接抛出明确断言。另加一条 POSIX 专属用例:真实 detached 子进程 + 真实孙进程(正是 git → git-remote-https 的形态),验证 kill(-pid) 把整棵树清掉;Windows 没有进程组信号(也没有 SIGKILL 语义),跳过——Windows 上的保证是"调用方不再被拖住",孙进程可能活到它自己超时。已验证:把超时回调退回旧的"只 kill 不落定",7 例中 4 例必失败,且是明确的断言失败而非挂起。
  • 新增 tests/skills-sync-default-dir.test.js(6 例):① DEFAULT_SKILL_DIR 与 resolveConfig({}).skillDir、resolveConfig({ skillDir: null }).skillDir 必须一致(哨兵解析只有一处权威,防两处定义漂移),显式配置不被覆盖;② syncBuiltinSkills(ps, null | undefined | '') 抛的错必须点名 userSkillsDir、指出正确做法,且不含 Node 的 The "path" argument must be of type string;③ 打包守卫——BUILTIN_SKILLS 每一项都必须真的在插件包 skills/ 里且有含 name/description 的 frontmatter(把运行期静默的 action: 'missing' 变成红测试);④ 用插件包真实内容同步:五项全部落盘、零 missing、二次全 unchanged;⑤ CRLF 下 x-version 升级必须生效(把新版正则退回 LF-only 后该例必失败——已验证);⑥ normalizeSkillText 在 CRLF 下不叠加第二份 frontmatter,缺字段与未闭合仍要报错。
  • 新增 tests/skills-sync-decoupled.test.js(8 例):源头在包内(PLUGIN_SKILLS_DIR 指向仓库 skills/ 且五项齐全);coiEnabled:false 不影响同步(issue #58 的要害);coiSyncSkills:false 是唯一关断开关(coiEnabled:true 也照样不同步,且连技能库目录都不创建);skillDir 哨兵落到 DEFAULT_SKILL_DIR(用空的内置源头避免写入真实技能库,目标目录改由日志行断言);同步抛错收口成一条带原因的 warn 并返回 null;每次启动都汇报结果(含最常见的"共 5 个,更新 0 个");源头缺失时逐个记 missing 并 warn;目标路径被同名文件占位时同样收口不抛。
  • tests/plugin.test.js 新增两条装配级用例(跑真实 apply()):coiEnabled:false 时五个内置技能必须落盘、coiSyncSkills:false 时连技能库目录都不创建。已验证「摘掉主装配调用」后第一条必失败。同文件的快照用例补上第二项的断言:key 建议必须点名 memory 工具 action=add,且不得出现 memory_suggest target=key(防止文案退化回"没写工具名")。

本次发布包含的提交(v26091501..v26092801)

  • 2a74c58a167b chore: 移除误提交的 .dsh/ 测试残留 + 加入 .gitignore
  • ba02abddde6d docs(changelog): 修正 #67 条目里「#58 第一层本次未动」的过时表述
  • ab1b641cbcf7 feat(skills): 摘取 PR #58 的 memory-consolidate v2(x-version 1 → 2)
  • e73a28e5a772 fix(skills): issue #58 内置技能同步与 COI 解耦 + 收尾 key 建议写明工具名
  • 1e931fb718bd fix(skills): issue #67 skillDir 哨兵显式落地 + 同步不再静默 + CRLF 版本门控
  • ed7529b0e1a8 fix(sync): issue #69 runGit 超时主动 settle + 杀进程组,不再挂死
  • dd206f6eb105 fix(memory): issue #59 匹配层对展示层剥离的 [summary:…] 也免疫
  • f48ab4d0ff5a fix(coi): issue #68 注入消息改用 v4 producer-owned source kind

验证

  • 本次新增回归 39 例:injection-source-kind 4、store-display-match 12、sync-git-timeout 7(含 POSIX 专属 1 例,Windows 跳过)、skills-sync-default-dir 6、skills-sync-decoupled 8、tests/plugin.test.js 装配级 2。
  • 三处关键回归都做过反向验证(还原修复后必失败):normalizeForMatch 退回旧行为 → 12 例中 5 例失败;runGit 超时退回旧实现 → 7 例中 4 例失败、且是明确断言而非挂起;摘掉主装配的同步调用 → 装配级用例失败。
  • 全量测试 868 例(上一版基线 829 + 新增 39)。在 Windows 开发机上失败 54 例,失败集与未修改的 v26091501 基线逐条一致——均为 Windows 环境相关用例(lib/update.js 的 git 夹具、darwin 分支、沙箱敏感路径、假 git 的 PATH shim)与 3 个 advisor 系 flaky 用例,非本次引入。

提示:宿主需重启才会加载本次修复(lib/*.js 在进程启动时加载)。

Don't miss a new dsh-memory-evolve release

NewReleases is sending notifications on new releases.