From fb1135dfdf3041935ba3a220d9e32ab4091f9908 Mon Sep 17 00:00:00 2001
From: modusensus
Date: Fri, 13 Nov 2026 23:49:59 +0800
Subject: [PATCH] =?UTF-8?q?chore:=20v0.15.1=20=E5=8F=91=E5=B8=83=E5=87=86?=
=?UTF-8?q?=E5=A4=87=EF=BC=88=E7=89=88=E6=9C=AC=E5=8F=B7=20+=20=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E6=95=B0=20+=20CHANGELOG=20=E5=BD=92=E4=BD=8D?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
0.15.0 打 tag 之后合入的两条修复(#663 系统写入准入、#648 验证基线)当时被写进了
已经发布的 [0.15.0] 小节里,GitHub Release 已经按旧正文生成过,读者看不到它们。
这次把它们连同 #707 的图例修复一起归到 [0.15.1],版本号与测试数同步。
---
README.md | 2 +-
dsh-mneme/CHANGELOG.md | 9 ++++++---
dsh-mneme/README.md | 6 +++---
dsh-mneme/package-lock.json | 4 ++--
dsh-mneme/package.json | 2 +-
5 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index 7e09a118..583193e8 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md
index 947980fc..64ebdb83 100644
--- a/dsh-mneme/CHANGELOG.md
+++ b/dsh-mneme/CHANGELOG.md
@@ -2,6 +2,12 @@
## [Unreleased]
+## [0.15.1] - 2026-11-13
+
+- **验证基线只记 HEAD:工作区里还没提交的改动,提交之后就永远判不到了(#648)**。`verifiedBaseline` 一直是 `git rev-parse HEAD`,而 claims 的行号取自**磁盘上的内容**——工作区脏的时候,这两者不是同一棵树。机理很具体:改了某文件第 30 行但没提交,这时跑 T1,claim 按新内容算出的行号落库、基线却记成当前 HEAD;等这个改动被提交,`diffSince(HEAD, 新HEAD)` 从改动之后起算,diff 里根本看不到这一段,claim 会被判成「没被动过」,而且**这个窗口不会自愈**——改动永远落在基线之前。修法是**把「工作区状态」单独记一份**,而不是给基线换一种表示:T1 富化时用 `git status --porcelain -z --untracked-files=all` 取脏文件清单(`src/wiki/t0.js` 新增 `gitDirtyFiles`,与 `gitHead` 并列),连同 `verified_baseline` 一起落库(`choro_pages.verified_dirty_files`,幂等迁移补列)与 evidence 旁证,页面 frontmatter 只写一个布尔 `verifiedDirty`(清单太长,不该淹掉给人读的页头)。判定侧在 `src/wiki/claims.js` 的 `summarizeClaims` 里分两层:清单里**被 claim 引用**的文件判新理由 `uncommitted`(行号来自任何提交都证明不了的树),其余文件照旧走精确 diff——**不做整页降级**,一个无关脏文件不该杀掉整页的漂移检测。刻意不写 `git write-tree` / stash 式 ref:那会留下悬空对象或额外 ref,默认 2 周后被 gc 掉,等于把可解释性押在对象存活上;文件清单是纯文本,随库走。`.chorography/**` 从清单里滤掉——那是工具自己的产地(这一轮写盘本身就会让它脏),而 claims 永远不会指向它。**老数据不猜**:`verified_dirty_files` 为 NULL 的页 = 「不知道当时工作区状态」,不判 `uncommitted`、不回填,面板照旧按提交基线比对;空数组才是明确的「当时干净」,两者严格分开。展示面同步:面板的复核理由多一档中文/英文文案,页头 provenance 对脏工作区验证过的页改成「对着 {sha} 验证(当时工作区有未提交改动)」,不再冒充干净提交。回归测试 +4(脏工作区富化记清单且 claim 判 `uncommitted`、提交之后仍不自愈;只有被引用的脏文件才牵连;老数据 NULL 与空数组语义分开;旧库幂等补列),`test/wiki-provenance.test.js` 10/10。变异自证:去掉 `uncommitted` 覆盖 → 前两条红;把脏清单当空数组处理 → 老数据用例红;去掉 `.chorography` 过滤 → 清单条数断言红;迁移不补列 → 迁移用例红。
+
+- **无会话身份的系统写入整片没进写入准入:三类出口在 `if (!sessionKey) return verdict` 处直接把判据跳过了(#663)**。`writeAdmission` 的判据挂在 `service.saveWithDedupe` 的汇流口上,而 `evaluate` 开头有一句「无会话身份 = 系统写入,不进预算」就 `return`——预算确实不该进,**判据却连跑都没跑**。于是 autoSummarize 的蒸馏产出、dream 的总览落盘、`bootstrapFromDirectory` 的整目录导入这三条路,密钥/PII 与空白噪声一次都没判过;`writeAdmission.enforce` 打开时它们照样无条件入库。更麻烦的是三条调用点各自把返回值丢了:`dream.js` 写总览的地方根本不看 `saveWithDedupe` 的返回,`summaryStored = true` 无条件置位(拒绝也被当成「已写入」,直接反映到运行结果与常驻条);`bootstrap.js` 解构出 `memory` 就往 `items.push({ id: memory.id })` 走,被拒时 `memory` 是 `null`,解引用抛错掉进外层逐条 catch,**拒绝理由与被拒条数一起丢**,只剩一条 warn。修法是把「会话时间线」和「判据」拆开:第 1 级判据(空白/噪声、密钥/PII)与会话身份无关,对所有写入照跑;命中且 `enforce` 时 `gate` 记 `null`(没有会话就没有预算维度)、拒绝照样生效;判据之后才 `if (!sessionKey) return judged;` 挡在预算/同话题冷却/内容哈希之前——这三样都要一条真实会话时间线做基准,硬凑一个假 `session_key` 会把真实会话的分布污染成两截,读审计的人再也分不开。`record` 的落行条件同步改成「放行档必须有会话且有 gate,拒绝档一律落行」:系统写入的放行不该留计量行(读不出分布,只撑大审计表),但**拒绝是一次真实的丢弃,必须留行**,`session_key` 记为 NULL。调用点各自补上拒绝处理:dream 只在非 `denied` 时置 `summaryStored = true`;bootstrap 解构出 `reason`、`denied` 计数与 `items.push({ action, id: null, title, reason })`,返回对象多一个 `denied` 字段——不再让 `null` 掉进 catch 把理由吃掉;standalone HTTP 那条路径的注释从「拦不到」改成「只过第 1 级」,显式 422 分支保留(落到 `toApiList([null])` 会变 500,把「被拒」伪装成「服务器出错」)。`sensitiveScanEnabled` 的文档口径一起校正:它覆盖所有写入的第 1 级,无会话身份的路径同样在内。**两处刻意没接**:`share-import.js` 走 `store.save` 而不走汇流口(`docs/SHARE.md` 把收包侧定成信任边界,要接得先改那条路,不在本件范围);`saveWithDedupe` 的 `existing` 合并分支在准入之前就 `return`,合并写入整条绕过闸门——这是另一条缺口,同样单独处理。**测试** 3,496 → **3,502**(+6):`test/write-admission.test.js` 覆盖三档系统写入(默认档不判不落行、观察档落行且 `session_key` 为 NULL / `metadata.gate === null`、拦截档 `denied` 且零行),以及 bootstrap 与 dream 两条真实出口的集成(被拒条数、`id: null`、无 summary 行、审计行带命中类型)。变异自证:把 `if (!sessionKey) return judged;` 挪回判据之前 → 4 红;`record` 恢复旧 guard → 拒绝档落行断言红;dream 改回无条件 `summaryStored = true` → 集成用例红;bootstrap 不接 `denied` 分支 → `report.denied` 与 `items[0].id` 断言红。
+
- **状态页「8 周沉淀」图看得懂了:图例补颜色色标,柱高的共同基准也写明白了(用户反馈)**。图上每周两根柱(琥珀=进来的、绿=稳住的),图例却只有四段纯文字「新增 / 巩固 / 归档 / 巩固·新增」,读者只能猜哪根柱对应哪个数字;「柱高按最近 8 周单周最高新增折算」这件事也一个字没提。现在图例每一项前面挂同色圆点(直接引用 `--mneme-sink-added` / `--mneme-sink-merged` 两个语义色,不另立颜色,避免与 donut 的组件色撞成两义),归档压根不给色块、只在数值后注明「未画入柱」——它本来就不进曲线、只报总量,给它一个色标(哪怕是空心点)就会长得像第三根柱的别名;图下补一行「柱高同基准:按最近 8 周单周最高新增折算」。`lib/client.js` 是独立手写的面板 bundle(`scripts/sync-lib.js` 只做 src→lib 拷贝、不碰它),所以改动只落在它自身与测试/CHANGELOG。回归锁加在 `test/client.test.js` 的沉淀图用例:两个色标必须引用语义色、归档项必须标明未画入柱。
## [0.15.0] - 2026-11-13
@@ -15,9 +21,6 @@
**测试** 3,496 → **3,499**(+3):`test/issue-692-default-on-reads.test.js` 三条——裸配置下 guarded 类型(`constraint`)的 merge 被 skip 且理由点名 `dreamMergeGuard`、显式 `dreamMergeGuard: false` 时同一条 merge 落库、装配判据三态(`{}` / `undefined` / `true` 为开,`false` 为关)。变异自证:两处各自改回 `=== true` → 2 红(默认开那两条),还原 → 3/3 绿;「改成恒真」会被那两条反向对照抓住。
- **知识库未启用时的空态文案指错了分组名(#668 尾巴)**。空态一直写「设置 → 记忆(mneme) → 功能 → 知识库」,但 v0.13 起 `group.ext` 是独立分组、标签是「扩展功能」(英文 `Extensions`),「知识库」只是组里的一项——照这句话去设置页找「知识库」分组会找不到。中英两版都改成「功能 → 扩展功能」并点名组内那一项,`test/client.test.js` 的文案锁同步跟上。
-- **无会话身份的系统写入整片没进写入准入:三类出口在 `if (!sessionKey) return verdict` 处直接把判据跳过了(#663)**。`writeAdmission` 的判据挂在 `service.saveWithDedupe` 的汇流口上,而 `evaluate` 开头有一句「无会话身份 = 系统写入,不进预算」就 `return`——预算确实不该进,**判据却连跑都没跑**。于是 autoSummarize 的蒸馏产出、dream 的总览落盘、`bootstrapFromDirectory` 的整目录导入这三条路,密钥/PII 与空白噪声一次都没判过;`writeAdmission.enforce` 打开时它们照样无条件入库。更麻烦的是三条调用点各自把返回值丢了:`dream.js` 写总览的地方根本不看 `saveWithDedupe` 的返回,`summaryStored = true` 无条件置位(拒绝也被当成「已写入」,直接反映到运行结果与常驻条);`bootstrap.js` 解构出 `memory` 就往 `items.push({ id: memory.id })` 走,被拒时 `memory` 是 `null`,解引用抛错掉进外层逐条 catch,**拒绝理由与被拒条数一起丢**,只剩一条 warn。修法是把「会话时间线」和「判据」拆开:第 1 级判据(空白/噪声、密钥/PII)与会话身份无关,对所有写入照跑;命中且 `enforce` 时 `gate` 记 `null`(没有会话就没有预算维度)、拒绝照样生效;判据之后才 `if (!sessionKey) return judged;` 挡在预算/同话题冷却/内容哈希之前——这三样都要一条真实会话时间线做基准,硬凑一个假 `session_key` 会把真实会话的分布污染成两截,读审计的人再也分不开。`record` 的落行条件同步改成「放行档必须有会话且有 gate,拒绝档一律落行」:系统写入的放行不该留计量行(读不出分布,只撑大审计表),但**拒绝是一次真实的丢弃,必须留行**,`session_key` 记为 NULL。调用点各自补上拒绝处理:dream 只在非 `denied` 时置 `summaryStored = true`;bootstrap 解构出 `reason`、`denied` 计数与 `items.push({ action, id: null, title, reason })`,返回对象多一个 `denied` 字段——不再让 `null` 掉进 catch 把理由吃掉;standalone HTTP 那条路径的注释从「拦不到」改成「只过第 1 级」,显式 422 分支保留(落到 `toApiList([null])` 会变 500,把「被拒」伪装成「服务器出错」)。`sensitiveScanEnabled` 的文档口径一起校正:它覆盖所有写入的第 1 级,无会话身份的路径同样在内。**两处刻意没接**:`share-import.js` 走 `store.save` 而不走汇流口(`docs/SHARE.md` 把收包侧定成信任边界,要接得先改那条路,不在本件范围);`saveWithDedupe` 的 `existing` 合并分支在准入之前就 `return`,合并写入整条绕过闸门——这是另一条缺口,同样单独处理。**测试** 3,496 → **3,502**(+6):`test/write-admission.test.js` 覆盖三档系统写入(默认档不判不落行、观察档落行且 `session_key` 为 NULL / `metadata.gate === null`、拦截档 `denied` 且零行),以及 bootstrap 与 dream 两条真实出口的集成(被拒条数、`id: null`、无 summary 行、审计行带命中类型)。变异自证:把 `if (!sessionKey) return judged;` 挪回判据之前 → 4 红;`record` 恢复旧 guard → 拒绝档落行断言红;dream 改回无条件 `summaryStored = true` → 集成用例红;bootstrap 不接 `denied` 分支 → `report.denied` 与 `items[0].id` 断言红。
-
-- **验证基线只记 HEAD:工作区里还没提交的改动,提交之后就永远判不到了(#648)**。`verifiedBaseline` 一直是 `git rev-parse HEAD`,而 claims 的行号取自**磁盘上的内容**——工作区脏的时候,这两者不是同一棵树。机理很具体:改了某文件第 30 行但没提交,这时跑 T1,claim 按新内容算出的行号落库、基线却记成当前 HEAD;等这个改动被提交,`diffSince(HEAD, 新HEAD)` 从改动之后起算,diff 里根本看不到这一段,claim 会被判成「没被动过」,而且**这个窗口不会自愈**——改动永远落在基线之前。修法是**把「工作区状态」单独记一份**,而不是给基线换一种表示:T1 富化时用 `git status --porcelain -z --untracked-files=all` 取脏文件清单(`src/wiki/t0.js` 新增 `gitDirtyFiles`,与 `gitHead` 并列),连同 `verified_baseline` 一起落库(`choro_pages.verified_dirty_files`,幂等迁移补列)与 evidence 旁证,页面 frontmatter 只写一个布尔 `verifiedDirty`(清单太长,不该淹掉给人读的页头)。判定侧在 `src/wiki/claims.js` 的 `summarizeClaims` 里分两层:清单里**被 claim 引用**的文件判新理由 `uncommitted`(行号来自任何提交都证明不了的树),其余文件照旧走精确 diff——**不做整页降级**,一个无关脏文件不该杀掉整页的漂移检测。刻意不写 `git write-tree` / stash 式 ref:那会留下悬空对象或额外 ref,默认 2 周后被 gc 掉,等于把可解释性押在对象存活上;文件清单是纯文本,随库走。`.chorography/**` 从清单里滤掉——那是工具自己的产地(这一轮写盘本身就会让它脏),而 claims 永远不会指向它。**老数据不猜**:`verified_dirty_files` 为 NULL 的页 = 「不知道当时工作区状态」,不判 `uncommitted`、不回填,面板照旧按提交基线比对;空数组才是明确的「当时干净」,两者严格分开。展示面同步:面板的复核理由多一档中文/英文文案,页头 provenance 对脏工作区验证过的页改成「对着 {sha} 验证(当时工作区有未提交改动)」,不再冒充干净提交。回归测试 +4(脏工作区富化记清单且 claim 判 `uncommitted`、提交之后仍不自愈;只有被引用的脏文件才牵连;老数据 NULL 与空数组语义分开;旧库幂等补列),`test/wiki-provenance.test.js` 10/10。变异自证:去掉 `uncommitted` 覆盖 → 前两条红;把脏清单当空数组处理 → 老数据用例红;去掉 `.chorography` 过滤 → 清单条数断言红;迁移不补列 → 迁移用例红。
- **跨类型同义双录:蒸馏提示词里「宁可拆成多条」把同一件事记了两遍,而三处去重各守一道类型闸(#687);实体表 75% 的实体只被提过一次、名字只按大小写敏感精确匹配(#688)**。两条 issue 报告的是同一个根病:**记忆的「类型」被当成了「对象身份」**。#687 上报者(xueyue33)给的分布很刺眼——他自己库里 `sim>=0.85` 的 81 对近重复中 **72 对跨类型**、`>=0.90` 的 7 对中 6 对跨类型;`sim>=0.88` 的 24 对里 21 对跨类型。四处夹缝逐个核过都成立:`src/lang.js` 的 `codingSummary` 明确让模型「宁可拆成多条」而 `pitfall`(现象+根因+规避)与 `constraint`(约束+来源)本就是同一结论的两种写法;`src/service.js:1480` 的 `findSessionDuplicate` 用 `store.list({ type: memory.type, source, limit: 200 })` 锁死同类型同会话;`src/dream/clustering.js:117` 的 `findPotentialConflicts` 首行 `if (memories[i].type !== memories[j].type) continue;`;dream 巩固提示词同样写着「仅合并同类型」。**我们不开常开类型闸**:本机活跃库 9,275 条带向量的记忆里跨类型 `cosine>=0.90` 共 45 对,逐对读正文后 **36 对(80%)确为同义,9 对合并会丢信息**(5 对时间序列/状态快照、2 对症状 vs 修复、1 对结论反转、1 对版本号不同)——也就是说「凡是跨类型高相似就合并」每 5 对要吞掉 1 对真互补的信息,而这类对只占活跃总量 0.97%,收益与风险不对称。落地的三层是**源头互斥 + 批内去重 + 存量一次性处置**:① `codingSummary` 提示词(中英双语)补一条互斥约束——同一结论只记一条,`pitfall` 与 `constraint` 不双写,时间序列/状态快照写进同一条;② 蒸馏落盘前做**批内**跨类型去重,只对 `pitfall↔constraint`、`history↔project` 两组生效(正是实测最集中的应用对:9 对 + 8 对),`planBatchCrossTypeDedupe(entries, { minSim })` 是纯函数——按相似度降序、每条最多被退休一次、不许链式藏行(A 取代 B 的同时 B 又取代过 C 会把 C 永久埋在链底);门槛 `BATCH_CROSS_TYPE_MIN_SIM = 0.85`(textSim,不是余弦,词面重合是这两组对的主特征)、`BATCH_CROSS_TYPE_MIN_CHARS = 24`、`BATCH_CROSS_TYPE_MIN_LEN_RATIO = 0.5`;③ 处置走 `store.supersedeChain` 只写 `superseded_by` / `superseded_at`,**保留正文更全的一条、被取代者正文零改写、不归档、不 merge**——判错时旧行仍在库里、显式搜索照旧读得到,且 `supersededIds()` 的 `JOIN ... AND w.archived=0` 让继任者一旦被归档旧行自动回到可见,等于自带回退。刻意**没有**复用 `src/dream/decisions.js` 的 `supersedeInfoFirstPair`:它走 `service.update` 会在正文尾部追加「已被取代」前缀,与「正文零改写」直接冲突。**默认开**(2026-11-13 拍板):这个开关存在的意义就是让新写入不再双录,默认关等于修了不生效,所以 `src/config.js` 给 `true`、设 `false` 是 kill switch,且关时审计行的 `batchDeduped` / `batchPairs` 两个键根本不出现——加开关之前的行为逐字节不变。两个键同时登记进 `settings.js` 的 `FEATURE_FLAG_BOOLEANS` 与 `FEATURE_FLAG_NUMBER_RANGES`,面板上能改。存量 17 对**不由代码处置**:只读脚本 `scripts/cross-type-dedupe-dryrun.mjs` 出清单(输出**禁止落在仓库内**,清单里含记忆原文),复核后由人决定、按纯 SQL 打链。
diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md
index 32b7725e..5d0572ba 100644
--- a/dsh-mneme/README.md
+++ b/dsh-mneme/README.md
@@ -11,7 +11,7 @@ $ cat ~/.dsh/memory/preferences.md
-
+
@@ -755,7 +755,7 @@ src/
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
└── index.js # 插件接线
lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
-test/ # 3503 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
+test/ # 3513 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
```
@@ -764,7 +764,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
```bash
cd dsh-mneme
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
-npm test # 运行 3503 个测试
+npm test # 运行 3513 个测试
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
```
diff --git a/dsh-mneme/package-lock.json b/dsh-mneme/package-lock.json
index 5c6931c1..5742901e 100644
--- a/dsh-mneme/package-lock.json
+++ b/dsh-mneme/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@modusensus/dsh-mneme",
- "version": "0.15.0",
+ "version": "0.15.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@modusensus/dsh-mneme",
- "version": "0.15.0",
+ "version": "0.15.1",
"license": "MIT",
"dependencies": {
"morphicons": "^1.7.1",
diff --git a/dsh-mneme/package.json b/dsh-mneme/package.json
index 5bc2a7f1..407e8dc8 100644
--- a/dsh-mneme/package.json
+++ b/dsh-mneme/package.json
@@ -1,7 +1,7 @@
{
"name": "@modusensus/dsh-mneme",
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 10 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
- "version": "0.15.0",
+ "version": "0.15.1",
"license": "MIT",
"keywords": [
"dsh",