@modusensus/dsh-mneme 0.8.12 → 0.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.js CHANGED
@@ -359,6 +359,22 @@ export function createService({ store, mirror, config, onWrite, logger, document
359
359
  }
360
360
  }
361
361
 
362
+ /**
363
+ * v0.8.0 A3(issue #17)strictScope 硬过滤的**唯一实现**。三处共用:融合池、
364
+ * `entity:` 与 `attr:` 两条前缀路。
365
+ *
366
+ * 抽成单一实现是为了让「过滤点写在哪」不再漂移——那两条前缀路在 searchMemories
367
+ * 的入口就 return(见那里的路由块),早于融合池那道过滤,曾经整条绕过硬墙;三处
368
+ * 各写一份判断,只会让下一个新增通路再漏一次(同 #349 把阈值口径收进
369
+ * activeStoreSize 的理由:两处各写一份过滤条件,漂移只在某条路径上显形)。
370
+ *
371
+ * strictScope 关(默认)或 scope 解析不到时原样返回:行为与改动前逐字节一致。
372
+ */
373
+ function gateByScope(rows, scope) {
374
+ if (config?.strictScope !== true || !scope) return rows;
375
+ return rows.filter((m) => isVisibleInScope(m, scope));
376
+ }
377
+
362
378
  /**
363
379
  * Search for memories attached to a named entity (v0.3.0 Phase 3).
364
380
  * 合并优先级:entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7;
@@ -366,23 +382,34 @@ export function createService({ store, mirror, config, onWrite, logger, document
366
382
  * @param {string} entityName
367
383
  * @param {object} [options]
368
384
  * @param {number} [options.topK=20]
385
+ * @param {object|null} [options.scope] 当前会话 scope,供 strictScope 闸门使用
369
386
  * @returns {any[]}
370
387
  */
371
- function searchByEntity(entityName, { topK = 20 } = {}) {
388
+ function searchByEntity(entityName, { topK = 20, scope = null } = {}) {
372
389
  const entity = store.findEntityByName(entityName);
373
390
  if (!entity) return [];
374
391
  const attrs = store.getCurrentAttrs(entity.id);
375
392
  const memoryIds = [...new Set(attrs.map((a) => a.memory_id).filter(Boolean))];
376
393
  const attrHits = memoryIds.map((id) => store.getById(id)).filter(Boolean);
377
- const keywordHits = store.search(entityName, { limit: topK });
394
+ // 关键词这一路的窗口取两倍:闸门在截断之前生效,出局的候选会让位,窗口按最终条数
395
+ // 取就会不够(与融合路给向量检索取 lim * 2 同一个理由)。没有候选被闸掉时结果与
396
+ // 取 topK 逐字节一致——多出来的是排在后面的低分候选,进不了 topK。
397
+ const keywordHits = store.search(entityName, { limit: topK * 2 });
378
398
  const merged = new Map();
379
399
  for (const mem of attrHits) merged.set(mem.id, { ...mem, _source: "entity_attr", _score: 1.0 });
380
400
  for (const mem of keywordHits) {
381
401
  if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
382
402
  }
383
- const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
384
- touchRecalled(hits);
385
- return hits;
403
+ // 先过闸、再截 topK:反过来做的话,出局的候选会占掉名额、可见的补不进来——topK=1
404
+ // 而首位出局时直接返回空数组。排序本身不动,只是把闸门插在截断之前,与融合池那条
405
+ // 路同序(那边也是先 filter 后 slice)。
406
+ const ranked = Array.from(merged.values()).sort((a, b) => b._score - a._score);
407
+ const visible = gateByScope(ranked, scope).slice(0, topK);
408
+ // 闸门还必须在 touchRecalled **之前**:放在后面的话,一次越权检索照样会给出局的
409
+ // 行刷回温时钟,并在被动确认开启时 bump 它们的关联边——命中反馈落到了调用方
410
+ // 本不该看见的行上。
411
+ touchRecalled(visible);
412
+ return visible;
386
413
  }
387
414
 
388
415
  /**
@@ -392,17 +419,19 @@ export function createService({ store, mirror, config, onWrite, logger, document
392
419
  * @param {string | undefined} value
393
420
  * @param {object} [options]
394
421
  * @param {number} [options.topK=20]
422
+ * @param {object|null} [options.scope] 当前会话 scope,供 strictScope 闸门使用
395
423
  * @returns {any[]}
396
424
  */
397
- function searchByAttr(key, value, { topK = 20 } = {}) {
425
+ function searchByAttr(key, value, { topK = 20, scope = null } = {}) {
398
426
  if (!key) return [];
399
427
  // value 可能为 undefined(attr:key 无 = 值):归一为空串后交给
400
428
  // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
401
429
  // 当前有效记忆(v0.3.0,store.js 已实现)。
402
430
  const rows = store.findMemoriesByAttr(key, value ?? "");
403
- const hits = rows.slice(0, topK);
404
- touchRecalled(hits);
405
- return hits;
431
+ // 同 searchByEntity:先过闸再截 topK,且闸门在 touchRecalled 之前。
432
+ const visible = gateByScope(rows, scope).slice(0, topK);
433
+ touchRecalled(visible);
434
+ return visible;
406
435
  }
407
436
 
408
437
  /**
@@ -896,9 +925,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
896
925
  // (区别于 A2 的降权保留可见);未标注行与命中行保留。strict 与 A2 加权
897
926
  // 叠加:过滤后剩下的命中行仍吃加成。scope 未传(flag 关)或完全解析不到
898
927
  // 时跳过——identity 为空的对象({null,null})按 fail-closed 过滤。
899
- if (config.strictScope === true && scope) {
900
- merged = merged.filter((m) => isVisibleInScope(m, scope));
901
- }
928
+ merged = gateByScope(merged, scope);
902
929
  // v0.8.0 A2:occurred_at 时间过滤——在融合池上先滤再 dedup/slice,rerank
903
930
  // 只看窗内候选,topK 槽位不被窗外行占用。
904
931
  const occurredBounds = updatedAtBounds(occurredFrom, occurredTo);
@@ -1503,7 +1530,11 @@ export function createService({ store, mirror, config, onWrite, logger, document
1503
1530
  // 合序都不动)。heat 关闭时不建表、不算 heat,权重恒 1,排序与改动前一致。
1504
1531
  // selectiveInject 相似度重排(下方)开启且向量可用时相似度优先——heat 只
1505
1532
  // 生效于规则路排序与语义路回填序。
1506
- const heatMap = config?.heatEnabled === true
1533
+ // issue #218 / E5 效用考卷:heat 乘进注入排序在真实年龄混合下饿死老约束
1534
+ // (现行量级 ≡ 拟合参数,importance-only 遵从 +12.7pp),故注入侧 heat 拆出
1535
+ // 独立开关 injectHeatEnabled(默认关):不开时权重恒 1,排序与 heat 关闭
1536
+ // 逐字节一致;召回侧时钟(touchLastAccess)与 sleep 降级联判不受影响。
1537
+ const heatMap = config?.heatEnabled === true && config?.injectHeatEnabled === true
1507
1538
  ? new Map(filtered.map((m) => [m.id, computeHeat(m, Date.now(), config)]))
1508
1539
  : null;
1509
1540
  const heatOf = (m) => (heatMap ? heatMap.get(m.id) ?? 1 : 1);
@@ -1631,11 +1662,6 @@ export function createService({ store, mirror, config, onWrite, logger, document
1631
1662
  }
1632
1663
  } catch { /* topic re-rank unavailable: keep rule-based order */ }
1633
1664
  }
1634
- // v0.8.0 A3(issue #17):strictScope 硬过滤同样作用于自动注入——scoped 记忆
1635
- // 泄进无关注入上下文是最典型的越权通道,检索侧过滤挡不住这里。
1636
- if (config?.strictScope === true && scope) {
1637
- candidates = candidates.filter((m) => isVisibleInScope(m, scope));
1638
- }
1639
1665
  // #24 块3:图召回候选打标。entityRecall 只在 entityRecallEnabled 开启时
1640
1666
  // 有产出,这里为命中者贴 graphHint 标签——注入侧据此(a)graphInjectHint
1641
1667
  // 开=标成 [检索线索] 前缀的线索行(独立预算),(b)关=线索行只参与排序
@@ -1656,6 +1682,16 @@ export function createService({ store, mirror, config, onWrite, logger, document
1656
1682
  }
1657
1683
  } catch { /* graph hint is best-effort */ }
1658
1684
  }
1685
+ // v0.8.0 A3(issue #17):strictScope 硬过滤同样作用于自动注入——scoped 记忆
1686
+ // 泄进无关注入上下文是最典型的越权通道,检索侧过滤挡不住这里。
1687
+ // 位置纪律:必须在**图召回合并之后**、pin 池之前——entityRecall 不做 scope
1688
+ // 门控,过滤放在它前面时,出局行会被图召回重新打上 graphHint 带回候选池
1689
+ // (graphInjectHint 开时作为线索行进注入块;CodeRabbit on #371 的可达性分析,
1690
+ // /context 与宿主注入同受影响)。pin 池 eligible 取自本过滤之后的候选,
1691
+ // 顺带保证越权行进不了 pin。
1692
+ if (config?.strictScope === true && scope) {
1693
+ candidates = candidates.filter((m) => isVisibleInScope(m, scope));
1694
+ }
1659
1695
  // #249 第一批:B1 pin 池。取在相关性排序之后、轮换之前——取谁按此刻的候选
1660
1696
  // 次序(即相关性次序),取到后从候选中摘除,于是下面的轮换重排碰不到它们
1661
1697
  // (验收:pin 不参与跨轮轮换)。独立预算的两层意义:pin 既不占 maxItems
package/src/settings.js CHANGED
@@ -83,6 +83,8 @@ const FEATURE_FLAG_BOOLEANS = [
83
83
  "autoDreamFailureBackoff",
84
84
  "sleepModeEnabled",
85
85
  "heatEnabled",
86
+ // issue #218 / E5:注入侧 heat 独立开关(默认关,老用户 injectHeatEnabled=true 回滚)。
87
+ "injectHeatEnabled",
86
88
  "hybridInject",
87
89
  "selectiveInjectEnabled",
88
90
  "searchSemanticDedup",
@@ -123,7 +125,11 @@ const FEATURE_FLAG_BOOLEANS = [
123
125
  // enforce 真拦;都默认关。同上,点号键平铺存、合并时展开回
124
126
  // writeAdmission 对象。
125
127
  "writeAdmission.enabled",
126
- "writeAdmission.enforce"
128
+ "writeAdmission.enforce",
129
+ // Issue #164 A2:写入边界的密钥 / PII 判据(src/sensitive-scan.js)。与上面两个
130
+ // 键分层——本键决定「这类判据参不参与」(默认关),命中之后是仅告警还是真拦仍由
131
+ // writeAdmission.enforce 决定(#164 口径:默认仅告警、拦截 opt-in)。
132
+ "sensitiveScanEnabled"
127
133
  ];
128
134
  // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
129
135
  const FEATURE_FLAG_INT_RANGES = {
package/test/api.test.js CHANGED
@@ -673,11 +673,14 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
673
673
  // issue #24 块1 新增 graphAnchoringEnabled/graphSeedCap/graphCascadeDepth、
674
674
  // 块2 新增 graphWeightEnabled/graphWeightDelta、块3 新增 graphInjectHint/graphInjectBudget、
675
675
  // 块4 新增 graphPassiveConfirm、
676
- // issue #339 新增 dreamMergeGuard)
677
- assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1);
676
+ // issue #339 新增 dreamMergeGuard、
677
+ // issue #218/E5 新增 injectHeatEnabled、
678
+ // issue #164 A2 新增 sensitiveScanEnabled(顶层扁平键,走 configFlagValue 默认分支))
679
+ assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1);
678
680
  assert.equal(data.effective.dreamSkipInvalid, true);
679
681
  assert.equal(data.effective.allowCrossTypeMerge, false);
680
682
  assert.equal(data.effective.dreamMergeGuard, false);
683
+ assert.equal(data.effective.injectHeatEnabled, false);
681
684
  assert.equal(data.effective.dreamMinIntervalMinutes, 0);
682
685
  assert.equal(data.effective.autoDreamFailureBackoff, false);
683
686
  assert.equal(data.effective.dreamMaxTokens, 131072);
@@ -697,6 +700,8 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
697
700
  // #254:写入准入两个键都默认关(默认路径零行为变化),面板可逐项启停。
698
701
  assert.equal(data.effective["writeAdmission.enabled"], false);
699
702
  assert.equal(data.effective["writeAdmission.enforce"], false);
703
+ // #164 A2:密钥/PII 判据自己的闸,同样默认关(#332 的行为逐字节保留)。
704
+ assert.equal(data.effective.sensitiveScanEnabled, false);
700
705
  // 新增字符串 / URL / 枚举键
701
706
  assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
702
707
  assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
@@ -751,6 +756,9 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
751
756
  // 这里往返一次就是钉这件事的(计数锁只钉数量,钉不住值)。
752
757
  "writeAdmission.enabled": true,
753
758
  "writeAdmission.enforce": true,
759
+ // #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返;
760
+ // 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。
761
+ sensitiveScanEnabled: true,
754
762
  embedProvider: "local",
755
763
  ollamaBaseUrl: "http://127.0.0.1:11434",
756
764
  dreamProvider: " siliconflow ",
@@ -765,6 +773,7 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
765
773
  "llmAudit.enabled": false,
766
774
  "writeAdmission.enabled": true,
767
775
  "writeAdmission.enforce": true,
776
+ sensitiveScanEnabled: true,
768
777
  embedProvider: "local",
769
778
  ollamaBaseUrl: "http://127.0.0.1:11434",
770
779
  dreamProvider: "siliconflow",
@@ -775,6 +784,7 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
775
784
  assert.equal(data.effective["llmAudit.enabled"], false);
776
785
  assert.equal(data.effective["writeAdmission.enabled"], true);
777
786
  assert.equal(data.effective["writeAdmission.enforce"], true);
787
+ assert.equal(data.effective.sensitiveScanEnabled, true);
778
788
  assert.equal(data.effective.embedProvider, "local");
779
789
  assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
780
790
  assert.equal(data.effective.dreamProvider, "siliconflow");
@@ -0,0 +1,60 @@
1
+ // scripts/changelog-prep.mjs 的回归测试。
2
+ //
3
+ // 这一步的价值全在「别假成功」上:发布准备原先在 CRLF 检出上匹配不到 CHANGELOG 文件头,
4
+ // replace 退化成 no-op 却照样打印 ✓,git status 里看不出任何异常——CI 跑在 ubuntu(LF),
5
+ // 所以只有本机发版会中招(v0.8.13 那次就是这么踩到的,最后靠人工补的占位节)。
6
+ // 下面用 LF / CRLF / BOM 三种输入各钉一遍,并锁住「匹配不上要如实回报」这条契约:
7
+ // 调用方靠 reason 决定 exit 1,退回「返回原文」就等于退回假成功。
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+
11
+ import { detectEol, insertReleaseSection } from "../scripts/changelog-prep.mjs";
12
+
13
+ const LF_HEAD = "# Changelog\n\n## [0.8.12] - 2026-10-01\n\n## 🐛 修复\n\n- 旧条目\n";
14
+ const CRLF_HEAD = LF_HEAD.replace(/\n/g, "\r\n");
15
+ /** 有没有「裸 LF」(前面不是 CR 的 \n)——混排行尾的判据。 */
16
+ const hasBareLf = (text) => /(^|[^\r])\n/.test(text);
17
+
18
+ test("LF 输入:插入占位节,行尾保持 LF,旧内容原样保留", () => {
19
+ const r = insertReleaseSection(LF_HEAD, "0.8.13", "2026-10-03");
20
+ assert.equal(r.ok, true);
21
+ assert.ok(r.text.startsWith("# Changelog\n\n## [0.8.13] - 2026-10-03"), "插在标题之后");
22
+ assert.ok(r.text.includes("- (待填)"), "带 release.yml verify 要的占位节");
23
+ assert.ok(r.text.includes("## [0.8.12] - 2026-10-01"), "旧内容不动");
24
+ assert.equal(r.text.includes("\r\n"), false, "不该往 LF 文件里混 CRLF");
25
+ });
26
+
27
+ test("CRLF 输入:同样能插(原先这里静默 no-op —— 本模块存在的首要理由)", () => {
28
+ const r = insertReleaseSection(CRLF_HEAD, "0.8.13", "2026-10-03");
29
+ assert.equal(r.ok, true, "CRLF 检出上必须也能插入");
30
+ assert.notEqual(r.text, CRLF_HEAD, "绝不返回原文——那正是当初的假成功");
31
+ assert.ok(r.text.includes("## [0.8.13] - 2026-10-03"));
32
+ assert.equal(hasBareLf(r.text), false, "插入的内容也要用 CRLF,不混行尾");
33
+ });
34
+
35
+ test("带 BOM 的输入也能插(`^#` 匹配不到属同一类形状漂移)", () => {
36
+ const r = insertReleaseSection("\uFEFF" + LF_HEAD, "0.8.13", "2026-10-03");
37
+ assert.equal(r.ok, true);
38
+ assert.ok(r.text.includes("## [0.8.13] - 2026-10-03"));
39
+ });
40
+
41
+ test("已含该版本:幂等跳过,并如实回报 already-present", () => {
42
+ const once = insertReleaseSection(LF_HEAD, "0.8.13", "2026-10-03");
43
+ assert.equal(once.ok, true);
44
+ assert.deepEqual(insertReleaseSection(once.text, "0.8.13", "2026-10-03"), { ok: false, reason: "already-present" });
45
+ });
46
+
47
+ test("文件头不匹配:回报 header-not-found,而不是静默返回原文", () => {
48
+ // 这条锁的是契约本身。若有人把实现改回「返回原文 + 入口照旧打印 ✓」,它会红。
49
+ assert.deepEqual(insertReleaseSection("# ChangelogX\n\n## [0.8.12]\n", "0.8.13", "2026-10-03"), {
50
+ ok: false,
51
+ reason: "header-not-found"
52
+ });
53
+ assert.deepEqual(insertReleaseSection("", "0.8.13", "2026-10-03"), { ok: false, reason: "header-not-found" });
54
+ });
55
+
56
+ test("detectEol:认两种行尾,无换行时按 LF 处理", () => {
57
+ assert.equal(detectEol("a\nb"), "\n");
58
+ assert.equal(detectEol("a\r\nb"), "\r\n");
59
+ assert.equal(detectEol("a"), "\n");
60
+ });
@@ -6,8 +6,13 @@ import { createService } from "../src/service.js";
6
6
  // #218 v1:heat 作为注入排序的**同级内乘数**(issue #218 拍板口径)。
7
7
  // 规则路 comparator 的层内权重 importance×quality 乘 heat;priority 分层
8
8
  // (summary=0 / preference=1 / coding 同级 / 其余 2)与 store 的 order=chrono
9
- // 分页序不动。heatEnabled=false(默认)时权重恒 1,排序与改动前一致——
10
- // 本文件的关闭用例与 heat.test.js 的曲线断言共同锁住验收第 1 条。
9
+ // 分页序不动。
10
+ //
11
+ // issue #218 / E5 效用考卷:heat 乘进注入排序在真实年龄混合下饿死老约束
12
+ // (现行量级 ≡ 拟合参数,importance-only 遵从 +12.7pp),注入侧 heat 拆出
13
+ // 独立开关 injectHeatEnabled(默认关)——默认关档下 heatEnabled=true 的库
14
+ // 注入排序与 heat 关闭逐字节一致(平价锁);显式开启才恢复旧序。
15
+ // 召回侧时钟(touchLastAccess)与 sleep 降级联判不受本键影响。
11
16
 
12
17
  const HOUR = 3600000;
13
18
 
@@ -19,40 +24,51 @@ function setup(config = {}) {
19
24
 
20
25
  const titles = (candidates) => candidates.map((c) => c.title);
21
26
 
22
- test("heat off(默认):层内维持 importance 序,与改动前一致", () => {
23
- const { store, service } = setup();
27
+ // 三个用例共用的「冷高重要性 + 新低重要性」库。
28
+ function seedColdAndFresh(service) {
24
29
  const cold = service.saveWithDedupe({ type: "decision", title: "冷的高重要性决策", content: "旧", importance: 4 }).memory;
25
30
  service.saveWithDedupe({ type: "decision", title: "新的低重要性决策", content: "新", importance: 3 });
26
31
  service.touchLastAccess(cold.id, new Date(Date.now() - 3000 * HOUR).toISOString());
27
32
  // 新记忆不触达:ref 退 created_at(≈现在),heat≈1,不影响本用例
33
+ }
34
+
35
+ test("heat off(默认):层内维持 importance 序,与改动前一致", () => {
36
+ const { store, service } = setup();
37
+ seedColdAndFresh(service);
28
38
  const picked = service.injectCandidates({ maxItems: 5, threshold: 3 });
29
39
  assert.deepEqual(titles(picked), ["冷的高重要性决策", "新的低重要性决策"]);
30
40
  store.close();
31
41
  });
32
42
 
33
- test("heat on:同层内新鲜低重要性记忆越过冷的高重要性记忆", () => {
43
+ test("heat on 且 injectHeatEnabled 关(新默认):注入序与 heat 关闭逐字节一致(E5 平价锁)", () => {
34
44
  const { store, service } = setup({ heatEnabled: true });
35
- const cold = service.saveWithDedupe({ type: "decision", title: "冷的高重要性决策", content: "旧", importance: 4 }).memory;
36
- service.saveWithDedupe({ type: "decision", title: "新的低重要性决策", content: "新", importance: 3 });
37
- service.touchLastAccess(cold.id, new Date(Date.now() - 3000 * HOUR).toISOString());
45
+ seedColdAndFresh(service);
46
+ const picked = service.injectCandidates({ maxItems: 5, threshold: 3 });
47
+ // E5 效用考卷:heat 乘进注入排序饿死老约束——默认不再乘,冷的高重要性决策
48
+ // 回到它 importance 应在的位置(与 heat off 用例逐条相同)。
49
+ assert.deepEqual(titles(picked), ["冷的高重要性决策", "新的低重要性决策"]);
50
+ store.close();
51
+ });
52
+
53
+ test("heat on 且 injectHeatEnabled 开:恢复旧序(老用户回滚口)", () => {
54
+ const { store, service } = setup({ heatEnabled: true, injectHeatEnabled: true });
55
+ seedColdAndFresh(service);
38
56
  const picked = service.injectCandidates({ maxItems: 5, threshold: 3 });
39
57
  // λ=0.002、3000h → 冷记忆权重 4×e^-6≈0.01,新鲜记忆 3×≈1 → 反超
40
58
  assert.deepEqual(titles(picked), ["新的低重要性决策", "冷的高重要性决策"]);
41
59
  store.close();
42
60
  });
43
61
 
44
- test("heat on 且 λ=0(免疫类型):乘数恒 1,importance 序不受影响", () => {
45
- const { store, service } = setup({ heatEnabled: true, heatTypeDecay: { decision: 0 } });
46
- const cold = service.saveWithDedupe({ type: "decision", title: "冷的高重要性决策", content: "旧", importance: 4 }).memory;
47
- service.saveWithDedupe({ type: "decision", title: "新的低重要性决策", content: "新", importance: 3 });
48
- service.touchLastAccess(cold.id, new Date(Date.now() - 3000 * HOUR).toISOString());
62
+ test("heat on 且 injectHeatEnabled 开 且 λ=0(免疫类型):乘数恒 1,importance 序不受影响", () => {
63
+ const { store, service } = setup({ heatEnabled: true, injectHeatEnabled: true, heatTypeDecay: { decision: 0 } });
64
+ seedColdAndFresh(service);
49
65
  const picked = service.injectCandidates({ maxItems: 5, threshold: 3 });
50
66
  assert.deepEqual(titles(picked), ["冷的高重要性决策", "新的低重要性决策"]);
51
67
  store.close();
52
68
  });
53
69
 
54
- test("heat 不跨层:再热的 decision 也不越过 summary 层", () => {
55
- const { store, service } = setup({ heatEnabled: true });
70
+ test("heat 不跨层:再热的 decision 也不越过 summary 层(injectHeatEnabled 开)", () => {
71
+ const { store, service } = setup({ heatEnabled: true, injectHeatEnabled: true });
56
72
  service.saveWithDedupe({ type: "summary", title: "会话总览", content: "总览", importance: 2 });
57
73
  service.saveWithDedupe({ type: "decision", title: "滚烫决策", content: "新", importance: 3 });
58
74
  const picked = service.injectCandidates({ maxItems: 5, threshold: 3 });
@@ -173,3 +173,22 @@ test("#249: capability guide extends only the judgement-heavy tool descriptions"
173
173
  assert.ok(on.get(name).startsWith(off.get(name)), `${name}: guidance is appended, original text untouched`);
174
174
  }
175
175
  });
176
+
177
+ test("#249: memory_save guidance states the scope default in the safe direction", () => {
178
+ // #164 口径:scope 参数漏填的后果是单向的——未标注 = 处处可见(NULL 恒可见),
179
+ // 而显式 scope 会让记忆在离开他 scope 时被降权(A2 软隔离 ×0.5,保留可见)或直接
180
+ // 过滤(A3 strictScope,默认关)。所以这一句必须同时给出「什么时候标注」与「不确定
181
+ // 就都别填」,只写前半句会把模型推向「能填就填」,正好制造那个单向损失。
182
+ const store = createStore(":memory:");
183
+ const service = createService({ store, mirror: null, config: {} });
184
+ const registered = [];
185
+ createTools({ tools: { register(def) { registered.push(def); return () => {}; } } }, service, { injectGuidanceEnabled: true }, null);
186
+ const text = registered.find((t) => t.name === "memory_save").description;
187
+ assert.ok(text.includes("declare workspace_scope / agent_scope"), "scope declaration rule is present");
188
+ assert.ok(text.includes("otherwise leave both out"), "the no-scope default is stated, not just the declare case");
189
+ // 措辞回归锁:默认配置(strictScope 关)下他 scope 只是降权可见,不是消失。写成
190
+ // 「静默消失」既是错的,也会造出本来不存在的隐私预期。
191
+ assert.ok(!text.includes("silently disappears"), "must not claim a narrowed memory disappears silently");
192
+ // 回归锁:这句话不能只落在总则里(工具描述才是常驻、零注入成本的那个承载位)。
193
+ assert.ok(!MEMORY_GUIDE_SECTION.includes("declare workspace_scope / agent_scope"), "scope rule stays a per-tool rule, not a sixth general rule");
194
+ });
@@ -229,3 +229,139 @@ test("memory_list filters explicit rows and keeps total consistent under strictS
229
229
  assert.deepEqual(out2.items.map((m) => m.title), ["theirs"]);
230
230
  assert.equal(out2.total, 1);
231
231
  });
232
+
233
+ // --- entity: / attr: 前缀路的 scope 闸 ----------------------------------------
234
+ // 回归锁:这两条前缀路在 searchMemories 的入口就 return,早于融合池那道 strictScope
235
+ // 过滤,曾经整条绕过硬墙——显式他者 scope 的记忆用 `entity:` / `attr:` 就能原样读出,
236
+ // 而且是满分返回(连 A2 的 ×0.5 都没有)。所以除了「出局」,还要钉住「auto / 存量
237
+ // 他者保留」这条 A2 语义没有跟着塌掉。
238
+
239
+ /** 把若干记忆挂到同一个实体上。
240
+ * 属性键必须各不相同:同一 (实体, 键) 有时间轴语义,后写的一条会把前一条作废,
241
+ * 那样只有最后一条会被链接上(写这个测试时踩过,别改成同一个键)。 */
242
+ function linkToEntity(store, name, memories) {
243
+ const entity = store.createEntity({ name, type: "person" });
244
+ memories.forEach((mem, i) => {
245
+ store.saveAttr({ entity_id: entity.id, attr_key: `attr${i}`, attr_value: `value${i}`, memory_id: mem.id });
246
+ });
247
+ return entity;
248
+ }
249
+
250
+ /** 五条覆盖各种 scope 来源的记忆,供下面两条用。 */
251
+ function saveScopeBattery(store) {
252
+ return {
253
+ global: store.save({ type: "project", title: "global", content: "x" }),
254
+ mine: store.save({ type: "project", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" }),
255
+ theirs: store.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" }),
256
+ autoForeign: store.save({ type: "project", title: "auto", content: "x", agent_scope: "other", agent_scope_source: "auto" }),
257
+ legacyForeign: store.save({ type: "project", title: "legacy", content: "x", agent_scope: "other" })
258
+ };
259
+ }
260
+
261
+ test("searchMemories entity: prefix honours the strictScope hard filter", async () => {
262
+ const { store, service } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true });
263
+ const rows = saveScopeBattery(store);
264
+ linkToEntity(store, "阿尔托", Object.values(rows));
265
+
266
+ const asMe = await service.searchMemories("entity:阿尔托", { scope: { agent_scope: "me", workspace_scope: null } });
267
+ assert.deepEqual(asMe.map((m) => m.title).sort(), ["auto", "global", "legacy", "mine"], "显式他者出局,auto / 存量他者按 A2 保留");
268
+
269
+ // 原主人自己查:显式那条必须还在(否则上面是「谁都搜不到」的假绿)。
270
+ const asOther = await service.searchMemories("entity:阿尔托", { scope: { agent_scope: "other", workspace_scope: null } });
271
+ assert.ok(asOther.some((m) => m.title === "theirs"), "命中当前 agent 的显式行必须可见");
272
+ });
273
+
274
+ test("searchMemories attr: prefix honours the strictScope hard filter", async () => {
275
+ const { store, service } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true });
276
+ const rows = saveScopeBattery(store);
277
+ // 每条挂到各自实体上、共用一个属性键:这样 attr:key=value 能一次覆盖全部五条
278
+ // (同实体同键会被时间轴作废,见 linkToEntity 的注释)。
279
+ Object.values(rows).forEach((mem, i) => {
280
+ const entity = store.createEntity({ name: `entity-${i}`, type: "person" });
281
+ store.saveAttr({ entity_id: entity.id, attr_key: "国籍", attr_value: "芬兰", memory_id: mem.id });
282
+ });
283
+
284
+ const asMe = await service.searchMemories("attr:国籍=芬兰", { scope: { agent_scope: "me", workspace_scope: null } });
285
+ assert.deepEqual(asMe.map((m) => m.title).sort(), ["auto", "global", "legacy", "mine"], "显式他者出局,auto / 存量他者按 A2 保留");
286
+
287
+ const asOther = await service.searchMemories("attr:国籍=芬兰", { scope: { agent_scope: "other", workspace_scope: null } });
288
+ assert.ok(asOther.some((m) => m.title === "theirs"), "命中当前 agent 的显式行必须可见");
289
+ });
290
+
291
+ test("entity: / attr: 的 scope 闸在触达之前:出局的行不被回温,也不 bump 关联边", async () => {
292
+ // 闸门若只加在「返回前」,出局的行仍会先被 touchRecalled 摸一遍——一次越权检索
293
+ // 照样给它刷回温时钟、并在被动确认开启时 bump 它的关联边。所以这条钉的是次序。
294
+ const { store, service } = setup({
295
+ scopeEnabled: true, strictScope: true, entitySearchEnabled: true,
296
+ heatEnabled: true, graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1
297
+ });
298
+ const theirs = store.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
299
+ const global = store.save({ type: "project", title: "global", content: "x" });
300
+ linkToEntity(store, "阿尔托", [theirs, global]);
301
+
302
+ const a = store.createEntity({ name: "A", type: "technology" });
303
+ const b = store.createEntity({ name: "B", type: "technology" });
304
+ for (const mem of [theirs, global]) {
305
+ store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", memory_id: mem.id, source: "llm" });
306
+ }
307
+
308
+ const rows = await service.searchMemories("entity:阿尔托", { scope: { agent_scope: "me", workspace_scope: null } });
309
+ assert.deepEqual(rows.map((m) => m.title), ["global"], "只剩可见那条");
310
+
311
+ // 正向对照:可见那条确实被触达了——否则下面两个「没被触达」可能是假绿。
312
+ assert.ok(store.getRelationsByMemory(global.id)[0].weight > 0.4, "可见行照常 bump 关联边");
313
+ assert.ok(store.getById(global.id).last_accessed_at, "可见行照常回温");
314
+
315
+ assert.equal(store.getRelationsByMemory(theirs.id)[0].weight, 0.4, "出局行不该 bump 关联边");
316
+ assert.equal(store.getById(theirs.id).last_accessed_at ?? null, null, "出局行不该被回温");
317
+ });
318
+
319
+ test("injectCandidates:strictScope 硬过滤必须晚于图召回合并(graphHint 不能把出局行带回来)", async () => {
320
+ // 回归锁(CodeRabbit on #371 的可达性分析,#17 A3 的图召回分支):entityRecall
321
+ // 本身不做 scope 门控,硬过滤原本长在图合并**之前**——过滤出局的显式他域行会
322
+ // 被图召回重新打上 graphHint 带回候选池,graphInjectHint 开时即作为线索行进入
323
+ // 注入块(宿主注入与 /context 同受影响)。硬过滤移到图合并之后、pin 池之前封死。
324
+ // 夹具注意:只挂 theirs 一条——挂满全 battery 会让每行都成 graphHint 行、被
325
+ // graphInjectBudget=1 的线索预算挤到失真(写这条测试时踩过)。
326
+ const { store, service } = setup({
327
+ scopeEnabled: true, strictScope: true, entityRecallEnabled: true, graphInjectHint: true
328
+ });
329
+ const rows = saveScopeBattery(store);
330
+ linkToEntity(store, "阿尔托", [rows.theirs]);
331
+
332
+ const asMe = service.injectCandidates({ query: "阿尔托", maxItems: 5, scope: { agent_scope: "me", workspace_scope: null } });
333
+ const titles = asMe.map((m) => m.title);
334
+ assert.ok(!titles.includes("theirs"), "图召回不得把显式他域行带进注入块");
335
+ assert.ok(titles.includes("global"), "未挂联的可见行照常经规则路进块(排除真空假绿)");
336
+
337
+ // 正向对照:原主人自己取,显式行仍在(作为线索行或普通行均可——否则是
338
+ // 「谁都看不到」的假绿)。
339
+ const asOther = service.injectCandidates({ query: "阿尔托", maxItems: 5, scope: { agent_scope: "other", workspace_scope: null } });
340
+ assert.ok(asOther.some((m) => m.title === "theirs"), "命中当前 agent 的显式行必须可见");
341
+ });
342
+
343
+ test("scope 闸先于 topK 截断:出局的候选不占名额(topK=1 仍有可见结果)", async () => {
344
+ // 回归锁(评审发现):先 slice 再 filter 的话,排在前面那条被闸掉的候选会占住唯一的
345
+ // 槽位,明明还有可见匹配却返回空数组。用 topK=1 把次序钉死——排序不动,只是闸门要插
346
+ // 在截断之前。
347
+ const me = { agent_scope: "me", workspace_scope: null };
348
+
349
+ const { store, service } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true });
350
+ const theirsE = store.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
351
+ const mineE = store.save({ type: "project", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" });
352
+ // 先挂 theirs、后挂 mine:前者排在候选前面,正好充当那个「占位」的候选。
353
+ linkToEntity(store, "阿尔托", [theirsE, mineE]);
354
+ const viaEntity = await service.searchMemories("entity:阿尔托", { topK: 1, scope: me });
355
+ assert.deepEqual(viaEntity.map((m) => m.title), ["mine"], "entity: 在 topK=1 下不该被出局候选挤空");
356
+
357
+ // attr: 同理——两条挂同一个属性键、各挂自己的实体,theirs 先写入。
358
+ const { store: s2, service: svc2 } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true });
359
+ const theirsA = s2.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
360
+ const mineA = s2.save({ type: "project", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" });
361
+ for (const [i, mem] of [theirsA, mineA].entries()) {
362
+ const entity = s2.createEntity({ name: `e${i}`, type: "person" });
363
+ s2.saveAttr({ entity_id: entity.id, attr_key: "国籍", attr_value: "芬兰", memory_id: mem.id });
364
+ }
365
+ const viaAttr = await svc2.searchMemories("attr:国籍=芬兰", { topK: 1, scope: me });
366
+ assert.deepEqual(viaAttr.map((m) => m.title), ["mine"], "attr: 在 topK=1 下不该被出局候选挤空");
367
+ });