diff --git a/README.md b/README.md index 7d7da2f..1028dcb 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 # 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 # 1519 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 56f97aa..517cc2d 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()` 的理由);闸门插在 `topK` 截断**之前**(先 filter 后 slice)——反过来会让出局的候选占掉名额,`topK=1` 且首位出局时直接返回空数组;同时闸门必须在 `touchRecalled` **之前**,否则出局的行仍会被刷回温时钟、并在被动确认开启时 bump 关联边,命中反馈落到了调用方本不该看见的行上。`strictScope` 关(默认)时逐字节不变;他 scope 行的 A2 软加权要不要一并补到这两条路,按 #339 的口径另行决定。 ## 🆕 新增 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index ff9e5eb..5d97de0 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-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/ # 1519 个 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 # 运行 1519 个测试 +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 144d106..f733d20 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,23 +382,34 @@ 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); 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(hits); - return hits; + // 先过闸、再截 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 它们的关联边——命中反馈落到了调用方 + // 本不该看见的行上。 + touchRecalled(visible); + return visible; } /** @@ -392,17 +419,19 @@ 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:先过闸再截 topK,且闸门在 touchRecalled 之前。 + const visible = gateByScope(rows, scope).slice(0, topK); + touchRecalled(visible); + return visible; } /** @@ -896,9 +925,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 144d106..f733d20 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,23 +382,34 @@ 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); 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(hits); - return hits; + // 先过闸、再截 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 它们的关联边——命中反馈落到了调用方 + // 本不该看见的行上。 + touchRecalled(visible); + return visible; } /** @@ -392,17 +419,19 @@ 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:先过闸再截 topK,且闸门在 touchRecalled 之前。 + const visible = gateByScope(rows, scope).slice(0, topK); + touchRecalled(visible); + return visible; } /** @@ -896,9 +925,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 f7ec7f8..0ef645f 100644 --- a/dsh-mneme/test/scope-strict.test.js +++ b/dsh-mneme/test/scope-strict.test.js @@ -229,3 +229,115 @@ 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, "出局行不该被回温"); +}); + +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 下不该被出局候选挤空"); +});