diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index a7a5a9ae..c147c3fa 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,8 +2,7 @@ ## [Unreleased] -- **品牌视觉换新:README 头图与插件列表图标**。三处 README(根、`dsh-mneme/`、`mcp/`)顶部改用 `images/mneme-banner-hero.webp`(1672×941,282 KiB)——原图 2.1 MiB 的 PNG 会让 `images/` 目录体积翻倍。根目录用相对路径,两个子目录用 raw 绝对地址:npm 详情页不解析相对图片路径,`../images/...` 在 npmjs.com 上必然裂图。 -- **插件列表图标从手绘 SVG 换成位图**:`package.json` 的 `icon` 改为 `assets/icon.png`(256×256 RGBA、圆角透明、120 KiB,仍在宿主 `iconOf()` 的 256 KiB 上限内);`assets/icon.svg` 删除。列表图标不再要求与客户端内联 mark(`MNEME_MARK_D`)同形,`test/client.test.js` 的图标用例改验 PNG 魔数、IHDR 边长、位深与 RGBA 通道——宿主对坏文件是静默回落成通用插画,不报错。 +- **取代链与遗忘的接缝修好了:继任者被 `memory_forget` 之后,被取代的旧值不再回潮成为「当前答案」(#532 存储层半边)**:#532 主楼的现象是「更正两个事实、只忘一个,保留的那个答回更正前的旧值」。`docs/FORGET.md` 的定案把根因拆成三段接缝(更正落新行且旧行活跃 / `memory_forget` 行粒度 / 旧值行成了唯一幸存载体),其中第二、三段的交互有一个**确定性**缺陷:`#650` 给 `supersededIds()` 加的守卫把「继任者归档、遗忘、删除」统一当成「继任者退场」,于是继任者一旦被遗忘,被它取代的旧行就重新回到注入面——而 `memory_forget` 的语义恰恰是「我不要再记得这个内容」,让用户明确抛弃的旧答案借继任者的退场回潮,正是本报的现象。现在两种情况分开:**遗忘继任者**不再恢复旧行可见性(旧行仍留在 suppressed 集合里、注入面不选它,但显式搜索照旧读得到——`fuseRecall` 只降权 0.5,不是沉默的不可逆失效);**归档继任者**维持 #650 原语义恢复可见(归档是整理面退场、可恢复、且常是合并落点,旧行可能是唯一载体),并加回归锁防止这条修正顺手改掉它。`memory_forget` 的回执同时报出连带面:新增只读 `store.supersedeContext(id)`(`{ supersededBy, predecessors }`)经 service 透传,抑制一行时若它取代过 N 条更早的记忆,回执明写「它们保持隐藏(显式搜索仍可达)、不应被当成当前值」——模型不必自己猜;恢复与无链行不渲染那句话。**明确没修的部分**(写进 `docs/FORGET.md` §5,别读成闭环):没有取代链的库(dream/sleep 未运行或 `conflictLedgerMode` 关着)里 `memory_forget` 依旧行粒度,§4 合成轨迹的「旧值幸存」断言依旧成立——写侧自动识别「同主题改值」需要 LLM 判断进热路径,仍按 R2/R3 等排期。回归测试 7 条(遗忘继任者旧行保持抑制且不入注入面 / 显式搜索仍可达 / 恢复继任者后仍抑制 / 归档继任者按 #650 恢复 / 回执报连带面且恢复时不报 / `supersedeContext` 空行返回 null 与计数只认指向本行的行 / **旧装配缺 `supersedeContext` 时回执降级、遗忘本身照常完成**)。最后一条补齐了审计发现的一处健壮性缺口:工具层原本裸调 `service.supersedeContext`,旧装配或手写测试替身没有该方法时会抛 `TypeError` 把整条遗忘操作打掉——回执增强不该有否决权,现按「无链信息」降级(与 `service.js` 的 store 层守卫对称)。变异自证 5 例(把 `forgotten = 0` 条件加回去 → 1 红;去掉 `archived = 0` 守卫 → 1 红;回执 `predecessors` 恒 0 → 2 红;恢复时也渲染连带面 → 1 红;删掉工具层 `typeof` 守卫 → 1 红),恢复后全绿。 ## [0.14.0] - 2026-11-12 diff --git a/dsh-mneme/docs/FORGET.md b/dsh-mneme/docs/FORGET.md index 0f65c87c..4e422ace 100644 --- a/dsh-mneme/docs/FORGET.md +++ b/dsh-mneme/docs/FORGET.md @@ -41,7 +41,7 @@ | **C. dream 触发前移**(更正类写入直接计 dream 优先级 / 降低阈值) | 让现成的裁决机制更早介入 | dream 有成本(LLM 调用);「更正类写入」的确定性判据同样不存在 | | **D. 遗忘回执带同侪提示**(`memory_forget` 答复列出同 type+scope 的高相似活跃行) | 让模型在遗忘当下看到「别处还有旧值」 | 相似度判据要么依赖嵌入(可能未配)、要么是弱启发(误报面要测);对已发生的遗忘只是事后提示 | -本 PR 只落地了零争议的一小块(工具描述的行粒度警示,见 §5);A–D 的取舍涉及 LLM 判断进入哪条路径、误杀面怎么量,按 R2/R3 纪律停下等人拍板。 +本 PR 只落地了零争议的一小块(工具描述的行粒度警示,见 §6);A–D 的取舍涉及 LLM 判断进入哪条路径、误杀面怎么量,按 R2/R3 纪律停下等人拍板。**A–D 之外还有一件确定性的接缝已在 §5 修掉**:取代链存在时,遗忘继任者不再让旧值回潮。 ## 4. 有界复测的最小合成轨迹(零 LLM) @@ -71,7 +71,21 @@ assert.match(store.getById(r1.memory.id).content, /每周三晚上八点/, "旧 完整可跑版本见 `test/forget-corrections.test.js`(本 PR 附),其中 ④⑤ 两条断言就是 #532 现象的存储层等价物——机制修复(§3 的 B/C)落地时,这条测试的 ⑤ 应当翻转:更正行写入后,旧行要么被记上失效关系、要么退出活跃面;遗忘新行不应让旧值重新成为「当前答案」。 -## 5. 本 PR 落地的零争议部分 +## 5. 取代链存在时的回潮缺口已修(2026-11-13) + +§3 的 B 项(写侧自动识别「同主题改值」并落失效关系)需要 LLM 判断,仍按原文口径不开工;但链**已经存在**的那些库(#641 的 enforce 档会落 T3 取代链),§2 的 Q2 有一个确定性的反面:**继任者被 `memory_forget` 之后,被取代的旧行会重新成为当前答案**。 + +那是 #650 评审时为「继任者归档/遗忘/删除」统一加的守卫:`supersededIds()` 直接 JOIN 继任者并要求 `archived = 0 AND forgotten = 0`。理由(「数据在库里却永远不再出现是一种沉默的不可逆失效」)对**归档**成立——归档是整理面退场、可恢复、且常是合并落点;对**遗忘**不成立:`memory_forget` 的语义就是「我不要再记得这个内容」,继任者退场的理由是用户主动要求,不是「这个版本不再成立」。把被取代的旧值摆回注入面,等于让用户明确抛弃的答案趁虚回潮,正是 #532 主楼报的那句话。 + +现在两件事分开: + +- **遗忘继任者**:旧行继续留在 `supersededIds()` 里 → 注入面不选它;但**显式搜索仍能读到**(`fuseRecall` 只降权 0.5,不摘除),所以不是沉默失效。 +- **归档继任者**:按 #650 原语义恢复旧行可见性(回归锁在 `test/forget-supersede-chain.test.js`)。 +- `memory_forget` 的回执现在报连带面:抑制一行时若它取代过 N 条更早的记忆,回执明写「它们保持隐藏、不应被当成当前值」——模型不必自己猜。 + +**仍然没修的**(如实记录,别读成闭环):没有取代链的库(dream / sleep 未运行、或 `conflictLedgerMode` 关着)里,`memory_forget` 依旧按行抑制,§4 合成轨迹的 ⑤ 依旧成立。那一段属于 §3 的 B/C,需要 LLM 判断进入写路径,按 R2/R3 纪律等排期。 + +## 6. 本 PR 落地的零争议部分 - **`memory_forget` 工具描述补行粒度警示**(`src/tools.js`):「抑制的是**一行**——多个事实合流在同一行里时,它们会一起退出可见面;请求只针对其中一个事实时,先看行内容、用 `memory_update` 拆分或在回复里说清连带了什么」。这是 #532 对话 5 里模型误用工具的直接对策:请求的语义粒度(一个事实)与工具的能力粒度(一行)之间的落差,模型无从得知,除非描述告诉它。 - **本文档**与 §4 的合成轨迹测试。 diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 6f0bc6b2..3b92f55f 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -3290,6 +3290,10 @@ export function createService({ store, mirror, config, onWrite, logger, document afterSync("write"); return updated; }, + // 取代链上下文(#532):memory_forget 的回执要说清「这次抑制连带谁」。 + // 只读,不走 afterSync——回执冒烟不该触发镜像重渲染。旧装配(测试替身) + // 没有这个方法时返回 null,工具侧按「无链信息」降级,不抛。 + supersedeContext: (id) => (typeof store.supersedeContext === "function" ? store.supersedeContext(id) : null), setArchived: archiveMemory, // sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate // content so they ride the normal write-hook path (mirror re-renders). diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 0c62cb5e..271bd810 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -4265,7 +4265,16 @@ export function createStore(path) { * 之后仍可能被归档/遗忘/删除。若只判 superseded_by IS NOT NULL,那种情况下 * 旧行会被当成「已有新版本」从注入里摘掉,可它自己明明是活的、且它指向的 * 那条已经退场——数据在库里却永远不再出现,是一种沉默的不可逆失效。JOIN 到 - * 继任者上要求 archived=0 且 forgotten=0,继任者退场即自动恢复旧行可见性。 + * 继任者上要求 archived=0,继任者被归档即自动恢复旧行可见性。 + * + * **遗忘是另一回事,不能一并恢复(#532)**:`memory_forget` 的语义是「我不要再 + * 记得这个内容」,继任者退场的理由是用户主动要求,不是「这个版本不再成立」—— + * 把被取代的旧值重新摆回注入面,等于让用户明确抛弃的旧答案趁虚回潮,正是 + * #532 报的那个现象(更正行被忘掉,新对话答回更正前的旧值)。归档则不同: + * 它是整理面的退场(可恢复,且常是合并/降级的落点,旧行可能是唯一载体), + * 那种情形按 #650 的理由恢复。所以这里只看 archived,不看 forgotten;旧行 + * 仍可通过显式搜索读到(fuseRecall 只降权 0.5,不摘除),不是沉默的不可逆 + * 失效。删除(物理删)沿用 JOIN 的自然结果——继任者不存在,链随之失效。 */ function supersededIds() { return db.prepare( @@ -4273,11 +4282,23 @@ export function createStore(path) { FROM memories m JOIN memories w ON w.id = m.superseded_by WHERE m.superseded_by IS NOT NULL - AND w.archived = 0 - AND w.forgotten = 0` + AND w.archived = 0` ).all().map((r) => r.id); } + /** + * 取代链上下文(#532):一行被遗忘时,回执要说清连带面。 + * 只读两个读数:这行被谁取代(superseded_by)、它取代了几条更早的行。 + * 后者是回执里「它们保持隐藏、不算当前答案」的那句话的依据——判据与 + * supersededIds 同源(链是否仍然生效由检索侧决定),这里不重复判断。 + */ + function supersedeContext(id) { + const row = db.prepare("SELECT id, superseded_by FROM memories WHERE id = ?").get(id); + if (!row) return null; + const preds = db.prepare("SELECT count(*) AS n FROM memories WHERE superseded_by = ?").get(id).n; + return { supersededBy: row.superseded_by ?? null, predecessors: preds }; + } + /** * 仍未消解的真矛盾对(issue #641,T4 注入侧感知用)。只取 tier=conflict 且 * status=open 的行——superseded/differentiated 已消解,zombied 已被 heat 兜底; @@ -6010,6 +6031,7 @@ export function createStore(path) { setConflictLedgerStatus, supersedeChain, supersededIds, + supersedeContext, openConflictPairs, listConflictLedger, countConflictLedger, diff --git a/dsh-mneme/lib/tools.js b/dsh-mneme/lib/tools.js index c3005c4e..cf405bbf 100644 --- a/dsh-mneme/lib/tools.js +++ b/dsh-mneme/lib/tools.js @@ -497,17 +497,48 @@ export function createTools(ctx, service, config, embedder, sessionGate = null, id: { type: "string", required: true }, forgotten: { type: "boolean", required: true } } + }, + // 取代链连带面(#532):predecessors = 这行取代了几条更早的记忆。 + // 抑制这行之后它们**仍然**不进注入面(supersededIds 只看继任者的 + // archived,不看 forgotten)——回执必须说出来,否则模型会以为 + // 「被取代的旧值会回来」,或者反过来把它当成仍在生效的当前答案。 + chain: { + type: "object", + additionalProperties: false, + properties: { + predecessors: { type: "integer", required: true }, + supersededBy: { type: "string" } + } } } }, - render: (_args, value) => TEXT_OUTPUT(`Memory ${value.memory.id} injection ${value.memory.forgotten ? "suppressed" : "restored"}.`) + render: (_args, value) => { + const base = `Memory ${value.memory.id} injection ${value.memory.forgotten ? "suppressed" : "restored"}.`; + const preds = value.chain?.predecessors ?? 0; + if (!value.memory.forgotten || preds === 0) return TEXT_OUTPUT(base); + return TEXT_OUTPUT( + `${base} It supersedes ${preds} older ${preds === 1 ? "entry" : "entries"}; ` + + "they stay hidden (explicit search can still reach them) and must not be treated as the current value." + ); + } }, async execute(args) { if (service.getById(args.id) === undefined) { throw new Error("memory not found"); } + // 先读链(setForget 不改 superseded_by,两侧等价;放在前面是为了让「找不到 + // 链」与「行不存在」共用一个前置检查,不产生第二次 getById)。 + // typeof 守卫与 service.js 的 store 层守卫对称:生产 createService 一定有这个 + // 方法,但旧装配/手写测试替身没有时,裸调用会抛 TypeError 把整条遗忘操作打掉 + // ——回执增强不该有否决权。缺方法按「无链信息」降级。 + const chain = typeof service.supersedeContext === "function" ? service.supersedeContext(args.id) : null; const memory = service.setForget(args.id, args.forgotten ?? true); - return { memory: { id: memory.id, forgotten: memory.forgotten } }; + return { + memory: { id: memory.id, forgotten: memory.forgotten }, + ...(chain + ? { chain: { predecessors: chain.predecessors, ...(chain.supersededBy ? { supersededBy: chain.supersededBy } : {}) } } + : {}) + }; } }), diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 6f0bc6b2..3b92f55f 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -3290,6 +3290,10 @@ export function createService({ store, mirror, config, onWrite, logger, document afterSync("write"); return updated; }, + // 取代链上下文(#532):memory_forget 的回执要说清「这次抑制连带谁」。 + // 只读,不走 afterSync——回执冒烟不该触发镜像重渲染。旧装配(测试替身) + // 没有这个方法时返回 null,工具侧按「无链信息」降级,不抛。 + supersedeContext: (id) => (typeof store.supersedeContext === "function" ? store.supersedeContext(id) : null), setArchived: archiveMemory, // sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate // content so they ride the normal write-hook path (mirror re-renders). diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 0c62cb5e..271bd810 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -4265,7 +4265,16 @@ export function createStore(path) { * 之后仍可能被归档/遗忘/删除。若只判 superseded_by IS NOT NULL,那种情况下 * 旧行会被当成「已有新版本」从注入里摘掉,可它自己明明是活的、且它指向的 * 那条已经退场——数据在库里却永远不再出现,是一种沉默的不可逆失效。JOIN 到 - * 继任者上要求 archived=0 且 forgotten=0,继任者退场即自动恢复旧行可见性。 + * 继任者上要求 archived=0,继任者被归档即自动恢复旧行可见性。 + * + * **遗忘是另一回事,不能一并恢复(#532)**:`memory_forget` 的语义是「我不要再 + * 记得这个内容」,继任者退场的理由是用户主动要求,不是「这个版本不再成立」—— + * 把被取代的旧值重新摆回注入面,等于让用户明确抛弃的旧答案趁虚回潮,正是 + * #532 报的那个现象(更正行被忘掉,新对话答回更正前的旧值)。归档则不同: + * 它是整理面的退场(可恢复,且常是合并/降级的落点,旧行可能是唯一载体), + * 那种情形按 #650 的理由恢复。所以这里只看 archived,不看 forgotten;旧行 + * 仍可通过显式搜索读到(fuseRecall 只降权 0.5,不摘除),不是沉默的不可逆 + * 失效。删除(物理删)沿用 JOIN 的自然结果——继任者不存在,链随之失效。 */ function supersededIds() { return db.prepare( @@ -4273,11 +4282,23 @@ export function createStore(path) { FROM memories m JOIN memories w ON w.id = m.superseded_by WHERE m.superseded_by IS NOT NULL - AND w.archived = 0 - AND w.forgotten = 0` + AND w.archived = 0` ).all().map((r) => r.id); } + /** + * 取代链上下文(#532):一行被遗忘时,回执要说清连带面。 + * 只读两个读数:这行被谁取代(superseded_by)、它取代了几条更早的行。 + * 后者是回执里「它们保持隐藏、不算当前答案」的那句话的依据——判据与 + * supersededIds 同源(链是否仍然生效由检索侧决定),这里不重复判断。 + */ + function supersedeContext(id) { + const row = db.prepare("SELECT id, superseded_by FROM memories WHERE id = ?").get(id); + if (!row) return null; + const preds = db.prepare("SELECT count(*) AS n FROM memories WHERE superseded_by = ?").get(id).n; + return { supersededBy: row.superseded_by ?? null, predecessors: preds }; + } + /** * 仍未消解的真矛盾对(issue #641,T4 注入侧感知用)。只取 tier=conflict 且 * status=open 的行——superseded/differentiated 已消解,zombied 已被 heat 兜底; @@ -6010,6 +6031,7 @@ export function createStore(path) { setConflictLedgerStatus, supersedeChain, supersededIds, + supersedeContext, openConflictPairs, listConflictLedger, countConflictLedger, diff --git a/dsh-mneme/src/tools.js b/dsh-mneme/src/tools.js index c3005c4e..cf405bbf 100644 --- a/dsh-mneme/src/tools.js +++ b/dsh-mneme/src/tools.js @@ -497,17 +497,48 @@ export function createTools(ctx, service, config, embedder, sessionGate = null, id: { type: "string", required: true }, forgotten: { type: "boolean", required: true } } + }, + // 取代链连带面(#532):predecessors = 这行取代了几条更早的记忆。 + // 抑制这行之后它们**仍然**不进注入面(supersededIds 只看继任者的 + // archived,不看 forgotten)——回执必须说出来,否则模型会以为 + // 「被取代的旧值会回来」,或者反过来把它当成仍在生效的当前答案。 + chain: { + type: "object", + additionalProperties: false, + properties: { + predecessors: { type: "integer", required: true }, + supersededBy: { type: "string" } + } } } }, - render: (_args, value) => TEXT_OUTPUT(`Memory ${value.memory.id} injection ${value.memory.forgotten ? "suppressed" : "restored"}.`) + render: (_args, value) => { + const base = `Memory ${value.memory.id} injection ${value.memory.forgotten ? "suppressed" : "restored"}.`; + const preds = value.chain?.predecessors ?? 0; + if (!value.memory.forgotten || preds === 0) return TEXT_OUTPUT(base); + return TEXT_OUTPUT( + `${base} It supersedes ${preds} older ${preds === 1 ? "entry" : "entries"}; ` + + "they stay hidden (explicit search can still reach them) and must not be treated as the current value." + ); + } }, async execute(args) { if (service.getById(args.id) === undefined) { throw new Error("memory not found"); } + // 先读链(setForget 不改 superseded_by,两侧等价;放在前面是为了让「找不到 + // 链」与「行不存在」共用一个前置检查,不产生第二次 getById)。 + // typeof 守卫与 service.js 的 store 层守卫对称:生产 createService 一定有这个 + // 方法,但旧装配/手写测试替身没有时,裸调用会抛 TypeError 把整条遗忘操作打掉 + // ——回执增强不该有否决权。缺方法按「无链信息」降级。 + const chain = typeof service.supersedeContext === "function" ? service.supersedeContext(args.id) : null; const memory = service.setForget(args.id, args.forgotten ?? true); - return { memory: { id: memory.id, forgotten: memory.forgotten } }; + return { + memory: { id: memory.id, forgotten: memory.forgotten }, + ...(chain + ? { chain: { predecessors: chain.predecessors, ...(chain.supersededBy ? { supersededBy: chain.supersededBy } : {}) } } + : {}) + }; } }), diff --git a/dsh-mneme/test/forget-supersede-chain.test.js b/dsh-mneme/test/forget-supersede-chain.test.js new file mode 100644 index 00000000..90ce447d --- /dev/null +++ b/dsh-mneme/test/forget-supersede-chain.test.js @@ -0,0 +1,153 @@ +// #532 × #650 取代链的接缝:继任者被**遗忘**时,被取代的旧行不许回潮。 +// +// #650 给 supersededIds 加过一个守卫:继任者归档/遗忘/删除时自动恢复旧行可见 +// 性,理由是「数据在库里却永远不再出现是一种沉默的不可逆失效」。那条理由对 +// **归档**成立(整理面退场、可恢复、常是合并落点),对**遗忘**不成立—— +// memory_forget 的语义是「我不要再记得这个内容」,继任者退场的理由是用户主动 +// 要求,把被取代的旧值摆回注入面等于让用户明确抛弃的答案趁虚回潮,正是 #532 +// 报的现象(更正行被忘掉,新对话答回更正前的旧值)。 +// +// 本文件锁四件事: +// ① 遗忘继任者后,被取代的旧行仍在 supersededIds 里 → 注入面继续排除它; +// ② 旧行没有消失:显式搜索仍能读到(降权 0.5 而非摘除)——不是沉默失效; +// ③ 归档继任者仍按 #650 恢复旧行可见性(不许顺手改掉那条语义); +// ④ memory_forget 回执报连带面(predecessors),且恢复时不报。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { createTools } from "../src/tools.js"; + +function chainFixture() { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + // 旧值行(两行都被新行取代,用来验回执的计数与「都保持隐藏」) + const oldColor = service.saveWithDedupe({ + type: "preference", title: "纸模工具的收纳", + content: "纸模工具放在雾蓝色收纳盒里", importance: 3 + }).memory; + const oldTime = service.saveWithDedupe({ + type: "preference", title: "纸模工具的整理时间", + content: "纸模工具每周三晚上八点整理一次", importance: 3 + }).memory; + // 更正后的新值行(#532 的对话 B 产物:颜色与时间合流一行) + const fresh = service.saveWithDedupe({ + type: "preference", title: "收纳盒颜色与整理时间的更正", + content: "收纳盒换成浅橙色,整理时间改为每周六上午九点", importance: 3 + }).memory; + // #641 enforce 档在真实库上落的就是这两条链(supersedeChain 是唯一写入口) + assert.equal(store.supersedeChain(oldColor.id, fresh.id), true); + assert.equal(store.supersedeChain(oldTime.id, fresh.id), true); + return { store, service, oldColor, oldTime, fresh }; +} + +test("遗忘继任者:被取代的旧行保持抑制(#532 的旧值回潮不许复活)", async () => { + const { store, service, oldColor, oldTime, fresh } = chainFixture(); + try { + // 前提:链生效时两行都在 superseded 集合里 + assert.deepEqual(store.supersededIds().sort(), [oldColor.id, oldTime.id].sort()); + + service.setForget(fresh.id, true); + + // ① 关键断言:继任者被遗忘 ≠ 继任者失效。旧值仍是过时值,不许重回注入面。 + assert.deepEqual(store.supersededIds().sort(), [oldColor.id, oldTime.id].sort(), + "forgotten 的继任者仍算有效的取代者(#532 修正,覆盖 #650 的 forgotten=0 条件)"); + const injected = service.injectCandidates({ query: "", maxItems: 20 }); + assert.ok(!injected.some((m) => m.id === oldColor.id || m.id === oldTime.id), + "旧的雾蓝色 / 周三 20:00 不许进注入面——否则就是 #532 报的那句话"); + + // ② 不是沉默失效:显式搜索仍读得到(降权不摘除,#650 的契约) + const found = await service.searchMemories("纸模工具", { mode: "auto", topK: 10, useRerank: false, recordRecall: false }); + assert.ok(found.some((r) => r.id === oldColor.id || r.id === oldTime.id), + "被取代的旧行必须仍可被显式搜索读到(可核查、可恢复)"); + + // ③ 继任者仍活着(只是被抑制)时,把遗忘撤掉,旧行照旧隐藏 + service.setForget(fresh.id, false); + assert.deepEqual(store.supersededIds().sort(), [oldColor.id, oldTime.id].sort()); + } finally { + store.close(); + } +}); + +test("归档继任者仍恢复旧行可见性(#650 的语义不许被这条修正顺手改掉)", () => { + const { store, service, oldColor, oldTime, fresh } = chainFixture(); + try { + service.setArchived(fresh.id, true); + assert.deepEqual(store.supersededIds(), [], + "归档 = 整理面退场(可恢复、常是合并落点)→ 旧行恢复可见,与遗忘分流"); + const injected = service.injectCandidates({ query: "", maxItems: 20 }); + assert.ok(injected.some((m) => m.id === oldColor.id || m.id === oldTime.id), + "归档继任者后旧行回到注入面(#650 原有行为)"); + } finally { + store.close(); + } +}); + +test("memory_forget 回执报取代链连带面:抑制时说明有几条旧行保持隐藏", async () => { + const { store, service, oldColor, oldTime, fresh } = chainFixture(); + try { + const registered = []; + const ctx = { tools: { register(def) { registered.push(def); return () => {}; } } }; + createTools(ctx, service, {}, undefined); + const forget = registered.find((d) => d.name === "memory_forget"); + assert.ok(forget, "memory_forget 必须注册"); + + const out = await forget.execute({ id: fresh.id, forgotten: true }); + assert.equal(out.chain.predecessors, 2, "两行更早的记忆被这行取代"); + assert.equal(out.chain.supersededBy, undefined, "这行不是别人的继任者,不编造指向"); + const text = forget.output.render({ id: fresh.id }, out); + const rendered = typeof text === "string" ? text : JSON.stringify(text); + assert.match(rendered, /supersedes 2 older entries/, + "回执必须说清连带面,否则模型会以为旧值会回来或把它当成当前答案"); + assert.match(rendered, /must not be treated as the current value/); + + // 恢复时不报连带面:那句话只在「抑制」语义下成立 + const restored = await forget.execute({ id: fresh.id, forgotten: false }); + const restoredText = forget.output.render({ id: fresh.id }, restored); + assert.doesNotMatch(typeof restoredText === "string" ? restoredText : JSON.stringify(restoredText), /supersedes/); + assert.equal(restored.chain.predecessors, 2, "恢复回执仍带链读数(事实不变,只是不渲染那句话)"); + + // 无链的普通行:没有 chain 键,渲染退化为原句 + const plain = service.saveWithDedupe({ type: "project", title: "无关条目", content: "与链无关" }).memory; + const plainOut = await forget.execute({ id: plain.id, forgotten: true }); + assert.equal(plainOut.chain.predecessors, 0); + const plainText = forget.output.render({ id: plain.id }, plainOut); + assert.doesNotMatch(typeof plainText === "string" ? plainText : JSON.stringify(plainText), /supersedes/); + } finally { + store.close(); + } +}); + +test("supersedeContext:不存在的行返回 null,计数只认指向本行的行", () => { + const { store, oldColor, fresh } = chainFixture(); + try { + assert.equal(store.supersedeContext("no-such-id"), null); + assert.deepEqual(store.supersedeContext(fresh.id), { supersededBy: null, predecessors: 2 }); + assert.deepEqual(store.supersedeContext(oldColor.id), { supersededBy: fresh.id, predecessors: 0 }); + } finally { + store.close(); + } +}); +// 回执增强不得有否决权(#532 审计观察):旧装配 / 手写测试替身没有 +// service.supersedeContext 时,裸调用会抛 TypeError,把真正的遗忘操作一起打掉。 +// 生产 createService 一定提供它,所以这条锁的是「替身/旧装配」这一侧。 +test("memory_forget:service 缺 supersedeContext 时降级为无链信息,遗忘本身照常完成", async () => { + const registered = []; + const ctx = { tools: { register(def) { registered.push(def); return () => {}; } } }; + const calls = []; + const stubService = { + getById: (id) => ({ id }), + setForget: (id, forgotten) => { calls.push([id, forgotten]); return { id, forgotten: forgotten !== false }; } + // 刻意不提供 supersedeContext(旧装配形状) + }; + createTools(ctx, stubService, {}, undefined); + const forget = registered.find((d) => d.name === "memory_forget"); + assert.ok(forget, "memory_forget 必须注册"); + + const out = await forget.execute({ id: "m1", forgotten: true }); + assert.deepEqual(calls, [["m1", true]], "遗忘操作必须真的执行"); + assert.equal(out.memory.forgotten, true); + assert.equal(out.chain, undefined, "缺链信息时不编造 chain 对象"); + const text = forget.output.render({ id: "m1" }, out); + assert.doesNotMatch(typeof text === "string" ? text : JSON.stringify(text), /supersedes/); +});