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 @@
-
+
@@ -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 @@
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
[](LICENSE)
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
-[](https://github.com/slow-stack/mneme)
+[](https://github.com/slow-stack/mneme)
[](https://github.com/slow-stack/mneme/actions)
[](https://nodejs.org)
[](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 @@
-
+
@@ -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 @@
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
[](LICENSE)
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
-[](https://github.com/slow-stack/mneme)
+[](https://github.com/slow-stack/mneme)
[](https://github.com/slow-stack/mneme/actions)
[](https://nodejs.org)
[](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 下不该被出局候选挤空");
+});