Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 16 additions & 2 deletions dsh-mneme/docs/FORGET.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 的合成轨迹测试。
4 changes: 4 additions & 0 deletions dsh-mneme/lib/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 25 additions & 3 deletions dsh-mneme/lib/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -4265,19 +4265,40 @@ 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(
`SELECT m.id AS id
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 兜底;
Expand Down Expand Up @@ -6010,6 +6031,7 @@ export function createStore(path) {
setConflictLedgerStatus,
supersedeChain,
supersededIds,
supersedeContext,
openConflictPairs,
listConflictLedger,
countConflictLedger,
Expand Down
35 changes: 33 additions & 2 deletions dsh-mneme/lib/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}) } }
: {})
};
}
}),

Expand Down
4 changes: 4 additions & 0 deletions dsh-mneme/src/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 25 additions & 3 deletions dsh-mneme/src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -4265,19 +4265,40 @@ 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(
`SELECT m.id AS id
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 兜底;
Expand Down Expand Up @@ -6010,6 +6031,7 @@ export function createStore(path) {
setConflictLedgerStatus,
supersedeChain,
supersededIds,
supersedeContext,
openConflictPairs,
listConflictLedger,
countConflictLedger,
Expand Down
Loading
Loading