From 0a35d0e6c91417e7a248751552ab4b3d9487f683 Mon Sep 17 00:00:00 2001 From: modusensus Date: Sat, 3 Oct 2026 20:31:04 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(scope):=20strictScope=20=E7=A1=AC?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E8=A1=A5=E5=88=B0=20entity:=20/=20attr:=20?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E5=89=8D=E7=BC=80=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searchMemories 里这两条前缀路在**函数入口**就 return,而 strictScope 硬过滤写在函数 **后半段**的融合池上——早返回的路根本走不到,于是显式标注为他者 scope 的记忆换这两个 前缀就能原样读出,而且没有 A2 的 ×0.5 降权(满分返回)。暴露面是 scopeEnabled + strictScope + entitySearchEnabled 三者同开(默认全关),而 entity: 正是 #24 图谱线在推 的语法,这条路的使用面只会越来越大。 修法:scope 闸抽成 gateByScope() 单一实现,融合池与两条前缀路三处共用,让「过滤点写在 哪」不再漂移(同 #349 把阈值口径收进 activeStoreSize() 的理由)。searchByEntity / searchByAttr 从 options 里取 scope——调用方原本就把 options 整个传了进来,只是这两个 函数只解构了 topK,scope 被丢掉。 闸门必须在 touchRecalled **之前**:只加在「返回前」的话,一次越权检索照样会给出局的行 刷回温时钟,并在被动确认开启时 bump 它们的关联边——命中反馈落到了调用方本不该看见的 行上。 strictScope 关(默认)时 gateByScope 原样返回,行为逐字节不变。他 scope 行的 A2 软加权 要不要一并补到这两条路,按 #339 的口径另行决定,本批不碰排序语义。 测试三条:entity: 路与 attr: 路各一条(显式他者出局、auto / 存量他者按 A2 保留、原主人 仍看得见显式那条——最后一条是防止「谁都搜不到」的假绿),加一条次序锁(出局行不被回温、 不 bump 关联边,并带可见行的正向对照)。变异检验:去掉任一条路的闸门、或把闸门挪到 touch 之后,对应用例各自变红。 --- dsh-mneme/lib/service.js | 40 +++++++++++--- dsh-mneme/src/service.js | 40 +++++++++++--- dsh-mneme/test/scope-strict.test.js | 86 +++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 18 deletions(-) diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 144d106c..8ad86ba7 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -359,6 +359,22 @@ export function createService({ store, mirror, config, onWrite, logger, document } } + /** + * v0.8.0 A3(issue #17)strictScope 硬过滤的**唯一实现**。三处共用:融合池、 + * `entity:` 与 `attr:` 两条前缀路。 + * + * 抽成单一实现是为了让「过滤点写在哪」不再漂移——那两条前缀路在 searchMemories + * 的入口就 return(见那里的路由块),早于融合池那道过滤,曾经整条绕过硬墙;三处 + * 各写一份判断,只会让下一个新增通路再漏一次(同 #349 把阈值口径收进 + * activeStoreSize 的理由:两处各写一份过滤条件,漂移只在某条路径上显形)。 + * + * strictScope 关(默认)或 scope 解析不到时原样返回:行为与改动前逐字节一致。 + */ + function gateByScope(rows, scope) { + if (config?.strictScope !== true || !scope) return rows; + return rows.filter((m) => isVisibleInScope(m, scope)); + } + /** * Search for memories attached to a named entity (v0.3.0 Phase 3). * 合并优先级:entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7; @@ -366,9 +382,10 @@ export function createService({ store, mirror, config, onWrite, logger, document * @param {string} entityName * @param {object} [options] * @param {number} [options.topK=20] + * @param {object|null} [options.scope] 当前会话 scope,供 strictScope 闸门使用 * @returns {any[]} */ - function searchByEntity(entityName, { topK = 20 } = {}) { + function searchByEntity(entityName, { topK = 20, scope = null } = {}) { const entity = store.findEntityByName(entityName); if (!entity) return []; const attrs = store.getCurrentAttrs(entity.id); @@ -381,8 +398,12 @@ export function createService({ store, mirror, config, onWrite, logger, document if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 }); } const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK); - touchRecalled(hits); - return hits; + // 闸门必须在 touchRecalled **之前**:放在后面的话,一次越权检索照样会给出局的 + // 行刷回温时钟,并在被动确认开启时 bump 它们的关联边——命中反馈落到了调用方 + // 本不该看见的行上。 + const visible = gateByScope(hits, scope); + touchRecalled(visible); + return visible; } /** @@ -392,17 +413,20 @@ export function createService({ store, mirror, config, onWrite, logger, document * @param {string | undefined} value * @param {object} [options] * @param {number} [options.topK=20] + * @param {object|null} [options.scope] 当前会话 scope,供 strictScope 闸门使用 * @returns {any[]} */ - function searchByAttr(key, value, { topK = 20 } = {}) { + function searchByAttr(key, value, { topK = 20, scope = null } = {}) { if (!key) return []; // value 可能为 undefined(attr:key 无 = 值):归一为空串后交给 // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部 // 当前有效记忆(v0.3.0,store.js 已实现)。 const rows = store.findMemoriesByAttr(key, value ?? ""); const hits = rows.slice(0, topK); - touchRecalled(hits); - return hits; + // 同 searchByEntity:闸门在 touchRecalled 之前(理由见那里)。 + const visible = gateByScope(hits, scope); + touchRecalled(visible); + return visible; } /** @@ -896,9 +920,7 @@ export function createService({ store, mirror, config, onWrite, logger, document // (区别于 A2 的降权保留可见);未标注行与命中行保留。strict 与 A2 加权 // 叠加:过滤后剩下的命中行仍吃加成。scope 未传(flag 关)或完全解析不到 // 时跳过——identity 为空的对象({null,null})按 fail-closed 过滤。 - if (config.strictScope === true && scope) { - merged = merged.filter((m) => isVisibleInScope(m, scope)); - } + merged = gateByScope(merged, scope); // v0.8.0 A2:occurred_at 时间过滤——在融合池上先滤再 dedup/slice,rerank // 只看窗内候选,topK 槽位不被窗外行占用。 const occurredBounds = updatedAtBounds(occurredFrom, occurredTo); diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 144d106c..8ad86ba7 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -359,6 +359,22 @@ export function createService({ store, mirror, config, onWrite, logger, document } } + /** + * v0.8.0 A3(issue #17)strictScope 硬过滤的**唯一实现**。三处共用:融合池、 + * `entity:` 与 `attr:` 两条前缀路。 + * + * 抽成单一实现是为了让「过滤点写在哪」不再漂移——那两条前缀路在 searchMemories + * 的入口就 return(见那里的路由块),早于融合池那道过滤,曾经整条绕过硬墙;三处 + * 各写一份判断,只会让下一个新增通路再漏一次(同 #349 把阈值口径收进 + * activeStoreSize 的理由:两处各写一份过滤条件,漂移只在某条路径上显形)。 + * + * strictScope 关(默认)或 scope 解析不到时原样返回:行为与改动前逐字节一致。 + */ + function gateByScope(rows, scope) { + if (config?.strictScope !== true || !scope) return rows; + return rows.filter((m) => isVisibleInScope(m, scope)); + } + /** * Search for memories attached to a named entity (v0.3.0 Phase 3). * 合并优先级:entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7; @@ -366,9 +382,10 @@ export function createService({ store, mirror, config, onWrite, logger, document * @param {string} entityName * @param {object} [options] * @param {number} [options.topK=20] + * @param {object|null} [options.scope] 当前会话 scope,供 strictScope 闸门使用 * @returns {any[]} */ - function searchByEntity(entityName, { topK = 20 } = {}) { + function searchByEntity(entityName, { topK = 20, scope = null } = {}) { const entity = store.findEntityByName(entityName); if (!entity) return []; const attrs = store.getCurrentAttrs(entity.id); @@ -381,8 +398,12 @@ export function createService({ store, mirror, config, onWrite, logger, document if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 }); } const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK); - touchRecalled(hits); - return hits; + // 闸门必须在 touchRecalled **之前**:放在后面的话,一次越权检索照样会给出局的 + // 行刷回温时钟,并在被动确认开启时 bump 它们的关联边——命中反馈落到了调用方 + // 本不该看见的行上。 + const visible = gateByScope(hits, scope); + touchRecalled(visible); + return visible; } /** @@ -392,17 +413,20 @@ export function createService({ store, mirror, config, onWrite, logger, document * @param {string | undefined} value * @param {object} [options] * @param {number} [options.topK=20] + * @param {object|null} [options.scope] 当前会话 scope,供 strictScope 闸门使用 * @returns {any[]} */ - function searchByAttr(key, value, { topK = 20 } = {}) { + function searchByAttr(key, value, { topK = 20, scope = null } = {}) { if (!key) return []; // value 可能为 undefined(attr:key 无 = 值):归一为空串后交给 // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部 // 当前有效记忆(v0.3.0,store.js 已实现)。 const rows = store.findMemoriesByAttr(key, value ?? ""); const hits = rows.slice(0, topK); - touchRecalled(hits); - return hits; + // 同 searchByEntity:闸门在 touchRecalled 之前(理由见那里)。 + const visible = gateByScope(hits, scope); + touchRecalled(visible); + return visible; } /** @@ -896,9 +920,7 @@ export function createService({ store, mirror, config, onWrite, logger, document // (区别于 A2 的降权保留可见);未标注行与命中行保留。strict 与 A2 加权 // 叠加:过滤后剩下的命中行仍吃加成。scope 未传(flag 关)或完全解析不到 // 时跳过——identity 为空的对象({null,null})按 fail-closed 过滤。 - if (config.strictScope === true && scope) { - merged = merged.filter((m) => isVisibleInScope(m, scope)); - } + merged = gateByScope(merged, scope); // v0.8.0 A2:occurred_at 时间过滤——在融合池上先滤再 dedup/slice,rerank // 只看窗内候选,topK 槽位不被窗外行占用。 const occurredBounds = updatedAtBounds(occurredFrom, occurredTo); diff --git a/dsh-mneme/test/scope-strict.test.js b/dsh-mneme/test/scope-strict.test.js index f7ec7f84..f123f9ec 100644 --- a/dsh-mneme/test/scope-strict.test.js +++ b/dsh-mneme/test/scope-strict.test.js @@ -229,3 +229,89 @@ test("memory_list filters explicit rows and keeps total consistent under strictS assert.deepEqual(out2.items.map((m) => m.title), ["theirs"]); assert.equal(out2.total, 1); }); + +// --- entity: / attr: 前缀路的 scope 闸 ---------------------------------------- +// 回归锁:这两条前缀路在 searchMemories 的入口就 return,早于融合池那道 strictScope +// 过滤,曾经整条绕过硬墙——显式他者 scope 的记忆用 `entity:` / `attr:` 就能原样读出, +// 而且是满分返回(连 A2 的 ×0.5 都没有)。所以除了「出局」,还要钉住「auto / 存量 +// 他者保留」这条 A2 语义没有跟着塌掉。 + +/** 把若干记忆挂到同一个实体上。 + * 属性键必须各不相同:同一 (实体, 键) 有时间轴语义,后写的一条会把前一条作废, + * 那样只有最后一条会被链接上(写这个测试时踩过,别改成同一个键)。 */ +function linkToEntity(store, name, memories) { + const entity = store.createEntity({ name, type: "person" }); + memories.forEach((mem, i) => { + store.saveAttr({ entity_id: entity.id, attr_key: `attr${i}`, attr_value: `value${i}`, memory_id: mem.id }); + }); + return entity; +} + +/** 五条覆盖各种 scope 来源的记忆,供下面两条用。 */ +function saveScopeBattery(store) { + return { + global: store.save({ type: "project", title: "global", content: "x" }), + mine: store.save({ type: "project", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" }), + theirs: store.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" }), + autoForeign: store.save({ type: "project", title: "auto", content: "x", agent_scope: "other", agent_scope_source: "auto" }), + legacyForeign: store.save({ type: "project", title: "legacy", content: "x", agent_scope: "other" }) + }; +} + +test("searchMemories entity: prefix honours the strictScope hard filter", async () => { + const { store, service } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true }); + const rows = saveScopeBattery(store); + linkToEntity(store, "阿尔托", Object.values(rows)); + + const asMe = await service.searchMemories("entity:阿尔托", { scope: { agent_scope: "me", workspace_scope: null } }); + assert.deepEqual(asMe.map((m) => m.title).sort(), ["auto", "global", "legacy", "mine"], "显式他者出局,auto / 存量他者按 A2 保留"); + + // 原主人自己查:显式那条必须还在(否则上面是「谁都搜不到」的假绿)。 + const asOther = await service.searchMemories("entity:阿尔托", { scope: { agent_scope: "other", workspace_scope: null } }); + assert.ok(asOther.some((m) => m.title === "theirs"), "命中当前 agent 的显式行必须可见"); +}); + +test("searchMemories attr: prefix honours the strictScope hard filter", async () => { + const { store, service } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true }); + const rows = saveScopeBattery(store); + // 每条挂到各自实体上、共用一个属性键:这样 attr:key=value 能一次覆盖全部五条 + // (同实体同键会被时间轴作废,见 linkToEntity 的注释)。 + Object.values(rows).forEach((mem, i) => { + const entity = store.createEntity({ name: `entity-${i}`, type: "person" }); + store.saveAttr({ entity_id: entity.id, attr_key: "国籍", attr_value: "芬兰", memory_id: mem.id }); + }); + + const asMe = await service.searchMemories("attr:国籍=芬兰", { scope: { agent_scope: "me", workspace_scope: null } }); + assert.deepEqual(asMe.map((m) => m.title).sort(), ["auto", "global", "legacy", "mine"], "显式他者出局,auto / 存量他者按 A2 保留"); + + const asOther = await service.searchMemories("attr:国籍=芬兰", { scope: { agent_scope: "other", workspace_scope: null } }); + assert.ok(asOther.some((m) => m.title === "theirs"), "命中当前 agent 的显式行必须可见"); +}); + +test("entity: / attr: 的 scope 闸在触达之前:出局的行不被回温,也不 bump 关联边", async () => { + // 闸门若只加在「返回前」,出局的行仍会先被 touchRecalled 摸一遍——一次越权检索 + // 照样给它刷回温时钟、并在被动确认开启时 bump 它的关联边。所以这条钉的是次序。 + const { store, service } = setup({ + scopeEnabled: true, strictScope: true, entitySearchEnabled: true, + heatEnabled: true, graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 + }); + const theirs = store.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" }); + const global = store.save({ type: "project", title: "global", content: "x" }); + linkToEntity(store, "阿尔托", [theirs, global]); + + const a = store.createEntity({ name: "A", type: "technology" }); + const b = store.createEntity({ name: "B", type: "technology" }); + for (const mem of [theirs, global]) { + store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", memory_id: mem.id, source: "llm" }); + } + + const rows = await service.searchMemories("entity:阿尔托", { scope: { agent_scope: "me", workspace_scope: null } }); + assert.deepEqual(rows.map((m) => m.title), ["global"], "只剩可见那条"); + + // 正向对照:可见那条确实被触达了——否则下面两个「没被触达」可能是假绿。 + assert.ok(store.getRelationsByMemory(global.id)[0].weight > 0.4, "可见行照常 bump 关联边"); + assert.ok(store.getById(global.id).last_accessed_at, "可见行照常回温"); + + assert.equal(store.getRelationsByMemory(theirs.id)[0].weight, 0.4, "出局行不该 bump 关联边"); + assert.equal(store.getById(theirs.id).last_accessed_at ?? null, null, "出局行不该被回温"); +}); From dc58454986ad32d16613d8c34d0e36a83db5aee6 Mon Sep 17 00:00:00 2001 From: modusensus Date: Sat, 3 Oct 2026 20:31:05 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(changelog):=20=E8=AE=B0=E5=BD=95=20str?= =?UTF-8?q?ictScope=20=E5=89=8D=E7=BC=80=E8=B7=AF=E7=BB=95=E8=BF=87?= =?UTF-8?q?=E7=9A=84=E4=BF=AE=E5=A4=8D=EF=BC=9Bbadge:sync=20=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=8F=8C=20README=20=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 双 README 的测试数此前停在 1497(#354 / #355 合并时遗留的漂移),#356 合入后是 1519, 本批再加 3 条用例,统一用仓库自己的 npm run badge:sync 刷到 1522(6 处),不手改。 CHANGELOG 与 #356 在同一处([Unreleased] 的 🐛 修复 段)撞车,按上次 #354/#355 的处置 把两条条目并入同一个段,不新开一节。 --- README.md | 6 +++--- dsh-mneme/CHANGELOG.md | 1 + dsh-mneme/README.md | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7d7da2f3..21dff7b5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -183,7 +183,7 @@ dsh web ```bash cd dsh-mneme && npm install -npm test # 1519 个测试 +npm test # 1522 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -366,7 +366,7 @@ The plugin ships a zero-dependency stdio MCP server (standalone npm package **`m ```bash cd dsh-mneme && npm install -npm test # 1519 tests +npm test # 1522 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 56f97aaf..94db2cc6 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -5,6 +5,7 @@ ## 🐛 修复 - **密钥 / PII 判据三处加固(`src/sensitive-scan.js`,均为合并后独立复审发现)**:① **回溯有上界**——`email` 的 local part 与 `connection_string` 的 scheme 都作用在含 `.` 的字符类上,缺上界时每个起点都要一路重扫到结尾才失败(O(n²)),而判据在写入路径上同步跑:实测 34KB 点分链 0.6 秒、120KB 对抗串 23.5 秒(email 18.6s + 连接串 4.1s),等于把写入卡死;加上界后同输入约 60ms,边界取 RFC 5321 给 local part 的 64。② **赋值型规则左边界不再排除 `_`**——环境变量名正是拿 `_` 当分隔符,原写法让 `DB_PASSWORD=` / `MY_API_KEY=` / `MYSQL_PASSWORD=` 这类「前缀_关键词」整类漏放(只有恰好落在行首的 `API_KEY=` 能中);放宽后 #332 的 26 条语料仍全绿,挡误杀的仍是那道占位符守卫。③ **身份证档补校验位**(GB 11643 / ISO 7064 MOD 11-2)——原先只有银行卡档有 Luhn,18 位纯数字(订单号 / 内部编号)先被身份证规则命中,等不到银行卡那条的校验。三处都配了回归测试,并做过变异检验(改回原写法各自变红)。判据仍在 `sensitiveScanEnabled` 默认关之后,线上行为不变。 +- **strictScope 硬过滤被 `entity:` / `attr:` 前缀检索绕过(#17 A3 的漏网分支)**:`searchMemories` 里这两条前缀路在**函数入口**就 return,而硬过滤写在**后半段**的融合池上——早返回的路根本走不到,于是显式标注为他者 scope 的记忆换这两个前缀就能原样读出,而且是**满分**返回(连 A2 的 ×0.5 降权都没有)。暴露面是 `scopeEnabled` + `strictScope` + `entitySearchEnabled` 三者同开(默认全关),而 `entity:` 正是 #24 图谱线在推的语法。修法:scope 闸抽成 `gateByScope()` 单一实现,融合池与两条前缀路三处共用,让「过滤点写在哪」不再漂移(同 #349 把阈值口径收进 `activeStoreSize()` 的理由);闸门放在 `touchRecalled` **之前**,否则出局的行仍会被刷回温时钟、并在被动确认开启时 bump 关联边——命中反馈落到了调用方本不该看见的行上。`strictScope` 关(默认)时逐字节不变;他 scope 行的 A2 软加权要不要一并补到这两条路,按 #339 的口径另行决定。 ## 🆕 新增 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index ff9e5eb9..fc647e05 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-1519%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1522%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -584,7 +584,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 1519 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 1522 个 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 运行时清单 ``` @@ -593,7 +593,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 1519 个测试 +npm test # 运行 1522 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` From 2e72f546fd9518c9ebb7bc9b91db49a1cf2312e5 Mon Sep 17 00:00:00 2001 From: modusensus Date: Sat, 3 Oct 2026 20:53:20 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(scope):=20scope=20=E9=97=B8=E5=85=88?= =?UTF-8?q?=E4=BA=8E=20topK=20=E6=88=AA=E6=96=AD=EF=BC=88=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E5=8F=91=E7=8E=B0=EF=BC=9A=E5=87=BA=E5=B1=80=E5=80=99?= =?UTF-8?q?=E9=80=89=E4=BC=9A=E5=8D=A0=E6=8E=89=E5=90=8D=E9=A2=9D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先 slice 再 filter 的话,排在前面那条被闸掉的候选会占住槽位——topK=1 且首位出局时直接 返回空数组,明明还有可见匹配。改成先过闸再截断,与融合池那条路同序(那边也是先 filter 后 slice)。 searchByEntity 的关键词路窗口同时取 topK 的两倍:闸门在截断之前生效,窗口按最终条数取就 会不够(与融合路给向量检索取 lim * 2 同一个理由)。没有候选被闸掉时结果逐字节不变—— 多出来的是排在后面的低分候选,进不了 topK。 新增一条用例用 topK=1 把次序钉死;变异检验:把两条路各自改回「先截断后过滤」,该用例 分别变红。README 计数随新增用例由 badge:sync 刷到 1523。 --- README.md | 6 +++--- dsh-mneme/CHANGELOG.md | 2 +- dsh-mneme/README.md | 6 +++--- dsh-mneme/lib/service.js | 19 ++++++++++++------- dsh-mneme/src/service.js | 19 ++++++++++++------- dsh-mneme/test/scope-strict.test.js | 26 ++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 21dff7b5..1028dcb9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -183,7 +183,7 @@ dsh web ```bash cd dsh-mneme && npm install -npm test # 1522 个测试 +npm test # 1523 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -366,7 +366,7 @@ The plugin ships a zero-dependency stdio MCP server (standalone npm package **`m ```bash cd dsh-mneme && npm install -npm test # 1522 tests +npm test # 1523 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 94db2cc6..517cc2d9 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -5,7 +5,7 @@ ## 🐛 修复 - **密钥 / PII 判据三处加固(`src/sensitive-scan.js`,均为合并后独立复审发现)**:① **回溯有上界**——`email` 的 local part 与 `connection_string` 的 scheme 都作用在含 `.` 的字符类上,缺上界时每个起点都要一路重扫到结尾才失败(O(n²)),而判据在写入路径上同步跑:实测 34KB 点分链 0.6 秒、120KB 对抗串 23.5 秒(email 18.6s + 连接串 4.1s),等于把写入卡死;加上界后同输入约 60ms,边界取 RFC 5321 给 local part 的 64。② **赋值型规则左边界不再排除 `_`**——环境变量名正是拿 `_` 当分隔符,原写法让 `DB_PASSWORD=` / `MY_API_KEY=` / `MYSQL_PASSWORD=` 这类「前缀_关键词」整类漏放(只有恰好落在行首的 `API_KEY=` 能中);放宽后 #332 的 26 条语料仍全绿,挡误杀的仍是那道占位符守卫。③ **身份证档补校验位**(GB 11643 / ISO 7064 MOD 11-2)——原先只有银行卡档有 Luhn,18 位纯数字(订单号 / 内部编号)先被身份证规则命中,等不到银行卡那条的校验。三处都配了回归测试,并做过变异检验(改回原写法各自变红)。判据仍在 `sensitiveScanEnabled` 默认关之后,线上行为不变。 -- **strictScope 硬过滤被 `entity:` / `attr:` 前缀检索绕过(#17 A3 的漏网分支)**:`searchMemories` 里这两条前缀路在**函数入口**就 return,而硬过滤写在**后半段**的融合池上——早返回的路根本走不到,于是显式标注为他者 scope 的记忆换这两个前缀就能原样读出,而且是**满分**返回(连 A2 的 ×0.5 降权都没有)。暴露面是 `scopeEnabled` + `strictScope` + `entitySearchEnabled` 三者同开(默认全关),而 `entity:` 正是 #24 图谱线在推的语法。修法:scope 闸抽成 `gateByScope()` 单一实现,融合池与两条前缀路三处共用,让「过滤点写在哪」不再漂移(同 #349 把阈值口径收进 `activeStoreSize()` 的理由);闸门放在 `touchRecalled` **之前**,否则出局的行仍会被刷回温时钟、并在被动确认开启时 bump 关联边——命中反馈落到了调用方本不该看见的行上。`strictScope` 关(默认)时逐字节不变;他 scope 行的 A2 软加权要不要一并补到这两条路,按 #339 的口径另行决定。 +- **strictScope 硬过滤被 `entity:` / `attr:` 前缀检索绕过(#17 A3 的漏网分支)**:`searchMemories` 里这两条前缀路在**函数入口**就 return,而硬过滤写在**后半段**的融合池上——早返回的路根本走不到,于是显式标注为他者 scope 的记忆换这两个前缀就能原样读出,而且是**满分**返回(连 A2 的 ×0.5 降权都没有)。暴露面是 `scopeEnabled` + `strictScope` + `entitySearchEnabled` 三者同开(默认全关),而 `entity:` 正是 #24 图谱线在推的语法。修法:scope 闸抽成 `gateByScope()` 单一实现,融合池与两条前缀路三处共用,让「过滤点写在哪」不再漂移(同 #349 把阈值口径收进 `activeStoreSize()` 的理由);闸门插在 `topK` 截断**之前**(先 filter 后 slice)——反过来会让出局的候选占掉名额,`topK=1` 且首位出局时直接返回空数组;同时闸门必须在 `touchRecalled` **之前**,否则出局的行仍会被刷回温时钟、并在被动确认开启时 bump 关联边,命中反馈落到了调用方本不该看见的行上。`strictScope` 关(默认)时逐字节不变;他 scope 行的 A2 软加权要不要一并补到这两条路,按 #339 的口径另行决定。 ## 🆕 新增 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index fc647e05..5d97de06 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-1522%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1523%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -584,7 +584,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 1522 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 1523 个 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 运行时清单 ``` @@ -593,7 +593,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 1522 个测试 +npm test # 运行 1523 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 8ad86ba7..f733d206 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -391,17 +391,23 @@ export function createService({ store, mirror, config, onWrite, logger, document const attrs = store.getCurrentAttrs(entity.id); const memoryIds = [...new Set(attrs.map((a) => a.memory_id).filter(Boolean))]; const attrHits = memoryIds.map((id) => store.getById(id)).filter(Boolean); - const keywordHits = store.search(entityName, { limit: topK }); + // 关键词这一路的窗口取两倍:闸门在截断之前生效,出局的候选会让位,窗口按最终条数 + // 取就会不够(与融合路给向量检索取 lim * 2 同一个理由)。没有候选被闸掉时结果与 + // 取 topK 逐字节一致——多出来的是排在后面的低分候选,进不了 topK。 + const keywordHits = store.search(entityName, { limit: topK * 2 }); const merged = new Map(); for (const mem of attrHits) merged.set(mem.id, { ...mem, _source: "entity_attr", _score: 1.0 }); for (const mem of keywordHits) { if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 }); } - const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK); - // 闸门必须在 touchRecalled **之前**:放在后面的话,一次越权检索照样会给出局的 + // 先过闸、再截 topK:反过来做的话,出局的候选会占掉名额、可见的补不进来——topK=1 + // 而首位出局时直接返回空数组。排序本身不动,只是把闸门插在截断之前,与融合池那条 + // 路同序(那边也是先 filter 后 slice)。 + const ranked = Array.from(merged.values()).sort((a, b) => b._score - a._score); + const visible = gateByScope(ranked, scope).slice(0, topK); + // 闸门还必须在 touchRecalled **之前**:放在后面的话,一次越权检索照样会给出局的 // 行刷回温时钟,并在被动确认开启时 bump 它们的关联边——命中反馈落到了调用方 // 本不该看见的行上。 - const visible = gateByScope(hits, scope); touchRecalled(visible); return visible; } @@ -422,9 +428,8 @@ export function createService({ store, mirror, config, onWrite, logger, document // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部 // 当前有效记忆(v0.3.0,store.js 已实现)。 const rows = store.findMemoriesByAttr(key, value ?? ""); - const hits = rows.slice(0, topK); - // 同 searchByEntity:闸门在 touchRecalled 之前(理由见那里)。 - const visible = gateByScope(hits, scope); + // 同 searchByEntity:先过闸再截 topK,且闸门在 touchRecalled 之前。 + const visible = gateByScope(rows, scope).slice(0, topK); touchRecalled(visible); return visible; } diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 8ad86ba7..f733d206 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -391,17 +391,23 @@ export function createService({ store, mirror, config, onWrite, logger, document const attrs = store.getCurrentAttrs(entity.id); const memoryIds = [...new Set(attrs.map((a) => a.memory_id).filter(Boolean))]; const attrHits = memoryIds.map((id) => store.getById(id)).filter(Boolean); - const keywordHits = store.search(entityName, { limit: topK }); + // 关键词这一路的窗口取两倍:闸门在截断之前生效,出局的候选会让位,窗口按最终条数 + // 取就会不够(与融合路给向量检索取 lim * 2 同一个理由)。没有候选被闸掉时结果与 + // 取 topK 逐字节一致——多出来的是排在后面的低分候选,进不了 topK。 + const keywordHits = store.search(entityName, { limit: topK * 2 }); const merged = new Map(); for (const mem of attrHits) merged.set(mem.id, { ...mem, _source: "entity_attr", _score: 1.0 }); for (const mem of keywordHits) { if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 }); } - const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK); - // 闸门必须在 touchRecalled **之前**:放在后面的话,一次越权检索照样会给出局的 + // 先过闸、再截 topK:反过来做的话,出局的候选会占掉名额、可见的补不进来——topK=1 + // 而首位出局时直接返回空数组。排序本身不动,只是把闸门插在截断之前,与融合池那条 + // 路同序(那边也是先 filter 后 slice)。 + const ranked = Array.from(merged.values()).sort((a, b) => b._score - a._score); + const visible = gateByScope(ranked, scope).slice(0, topK); + // 闸门还必须在 touchRecalled **之前**:放在后面的话,一次越权检索照样会给出局的 // 行刷回温时钟,并在被动确认开启时 bump 它们的关联边——命中反馈落到了调用方 // 本不该看见的行上。 - const visible = gateByScope(hits, scope); touchRecalled(visible); return visible; } @@ -422,9 +428,8 @@ export function createService({ store, mirror, config, onWrite, logger, document // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部 // 当前有效记忆(v0.3.0,store.js 已实现)。 const rows = store.findMemoriesByAttr(key, value ?? ""); - const hits = rows.slice(0, topK); - // 同 searchByEntity:闸门在 touchRecalled 之前(理由见那里)。 - const visible = gateByScope(hits, scope); + // 同 searchByEntity:先过闸再截 topK,且闸门在 touchRecalled 之前。 + const visible = gateByScope(rows, scope).slice(0, topK); touchRecalled(visible); return visible; } diff --git a/dsh-mneme/test/scope-strict.test.js b/dsh-mneme/test/scope-strict.test.js index f123f9ec..0ef645f0 100644 --- a/dsh-mneme/test/scope-strict.test.js +++ b/dsh-mneme/test/scope-strict.test.js @@ -315,3 +315,29 @@ test("entity: / attr: 的 scope 闸在触达之前:出局的行不被回温, assert.equal(store.getRelationsByMemory(theirs.id)[0].weight, 0.4, "出局行不该 bump 关联边"); assert.equal(store.getById(theirs.id).last_accessed_at ?? null, null, "出局行不该被回温"); }); + +test("scope 闸先于 topK 截断:出局的候选不占名额(topK=1 仍有可见结果)", async () => { + // 回归锁(评审发现):先 slice 再 filter 的话,排在前面那条被闸掉的候选会占住唯一的 + // 槽位,明明还有可见匹配却返回空数组。用 topK=1 把次序钉死——排序不动,只是闸门要插 + // 在截断之前。 + const me = { agent_scope: "me", workspace_scope: null }; + + const { store, service } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true }); + const theirsE = store.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" }); + const mineE = store.save({ type: "project", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" }); + // 先挂 theirs、后挂 mine:前者排在候选前面,正好充当那个「占位」的候选。 + linkToEntity(store, "阿尔托", [theirsE, mineE]); + const viaEntity = await service.searchMemories("entity:阿尔托", { topK: 1, scope: me }); + assert.deepEqual(viaEntity.map((m) => m.title), ["mine"], "entity: 在 topK=1 下不该被出局候选挤空"); + + // attr: 同理——两条挂同一个属性键、各挂自己的实体,theirs 先写入。 + const { store: s2, service: svc2 } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true }); + const theirsA = s2.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" }); + const mineA = s2.save({ type: "project", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" }); + for (const [i, mem] of [theirsA, mineA].entries()) { + const entity = s2.createEntity({ name: `e${i}`, type: "person" }); + s2.saveAttr({ entity_id: entity.id, attr_key: "国籍", attr_value: "芬兰", memory_id: mem.id }); + } + const viaAttr = await svc2.searchMemories("attr:国籍=芬兰", { topK: 1, scope: me }); + assert.deepEqual(viaAttr.map((m) => m.title), ["mine"], "attr: 在 topK=1 下不该被出局候选挤空"); +});