@modusensus/dsh-mneme 0.8.11 → 0.8.13
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/README.md +5 -5
- package/assets/icon.svg +3 -3
- package/lib/api.js +2 -0
- package/lib/client.js +12 -1
- package/lib/config.js +66 -1
- package/lib/dream/decisions.js +16 -0
- package/lib/dream/sleep.js +3 -0
- package/lib/dream.js +57 -9
- package/lib/entities/extractor.js +4 -1
- package/lib/graph/anchoring.js +180 -0
- package/lib/guide.js +13 -1
- package/lib/index.js +18 -5
- package/lib/inject.js +15 -3
- package/lib/lang.js +10 -0
- package/lib/sensitive-scan.js +209 -0
- package/lib/service.js +203 -35
- package/lib/settings.js +29 -3
- package/lib/store.js +155 -11
- package/lib/summarize.js +104 -7
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +114 -113
- package/scripts/sync-test-badge.mjs +9 -7
- package/scripts/test-count-sync.mjs +44 -0
- package/src/api.js +2 -0
- package/src/config.js +66 -1
- package/src/dream/decisions.js +16 -0
- package/src/dream/sleep.js +3 -0
- package/src/dream.js +57 -9
- package/src/entities/extractor.js +4 -1
- package/src/graph/anchoring.js +180 -0
- package/src/guide.js +13 -1
- package/src/index.js +18 -5
- package/src/inject.js +15 -3
- package/src/lang.js +10 -0
- package/src/sensitive-scan.js +209 -0
- package/src/service.js +203 -35
- package/src/settings.js +29 -3
- package/src/store.js +155 -11
- package/src/summarize.js +104 -7
- package/test/api.test.js +15 -2
- package/test/baseline-persist.test.js +248 -0
- package/test/dream.test.js +75 -0
- package/test/graph-anchoring.test.js +185 -0
- package/test/graph-inject-budget.test.js +104 -0
- package/test/graph-passive-confirm.test.js +92 -0
- package/test/graph-weight-evolution.test.js +105 -0
- package/test/inject-pools.test.js +19 -0
- package/test/scope-inject-soft.test.js +106 -0
- package/test/scope-strict.test.js +112 -0
- package/test/sensitive-scan.test.js +207 -0
- package/test/summarize.test.js +177 -0
- package/test/test-count-sync.test.js +60 -0
- package/test/write-admission.test.js +115 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// issue #24 块4 被动确认(graphPassiveConfirm)测试。
|
|
2
|
+
// 覆盖:默认关=零行为变化 / 开启=命中记忆的关联边 bump / heat 关但 passive
|
|
3
|
+
// 开仍 bump(独立闸门)/ bump 只加不减封顶 / 无关联边的记忆触达安全降级。
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { createStore } from "../src/store.js";
|
|
7
|
+
import { createService } from "../src/service.js";
|
|
8
|
+
|
|
9
|
+
function setup(config = {}) {
|
|
10
|
+
const store = createStore(":memory:");
|
|
11
|
+
const service = createService({ store, mirror: null, config });
|
|
12
|
+
return { store, service };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function putMem(store, service, { title, content, importance = 3 }) {
|
|
16
|
+
return service.saveWithDedupe({ type: "project", title, content, importance }).memory;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function linkRelToMem(store, { fromName, toName, memoryId, source = "llm" }) {
|
|
20
|
+
const a = store.findEntityByName(fromName) ?? store.createEntity({ name: fromName, type: "technology" });
|
|
21
|
+
const b = store.findEntityByName(toName) ?? store.createEntity({ name: toName, type: "technology" });
|
|
22
|
+
return store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", memory_id: memoryId, source });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 跑一次检索(内部 touchRecalled 触发触达),返回 rows。 */
|
|
26
|
+
async function searchOnce(service, query) {
|
|
27
|
+
return service.searchMemories(query, { mode: "auto" });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("graphPassiveConfirm=false (default): touched memory does NOT bump relation weight", async () => {
|
|
31
|
+
const { store, service } = setup();
|
|
32
|
+
const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
|
|
33
|
+
const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
|
|
34
|
+
assert.equal(rel.weight, 0.4);
|
|
35
|
+
await searchOnce(service, "数据库迁移");
|
|
36
|
+
// 默认关:检索触达不 bump(weight 保持 0.4)。
|
|
37
|
+
assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "weight unchanged by default");
|
|
38
|
+
store.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("graphPassiveConfirm=true: touched memory bumps its linked relation weight", async () => {
|
|
42
|
+
const { store, service } = setup({ graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 });
|
|
43
|
+
const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
|
|
44
|
+
const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
|
|
45
|
+
assert.equal(rel.weight, 0.4);
|
|
46
|
+
const rows = await searchOnce(service, "数据库迁移");
|
|
47
|
+
assert.ok(rows.some((r) => r.id === mem.id), "memory surfaced");
|
|
48
|
+
assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "weight bumped after touch");
|
|
49
|
+
store.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("heatEnabled=false does NOT gate passive confirm (independent gates)", async () => {
|
|
53
|
+
const { store, service } = setup({ heatEnabled: false, graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 });
|
|
54
|
+
const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
|
|
55
|
+
linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
|
|
56
|
+
await searchOnce(service, "数据库迁移");
|
|
57
|
+
assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "bumped even with heat off");
|
|
58
|
+
// 但 touchLastAccess 不写(heat 关的既有语义保留:last_accessed_at 仍为空)。
|
|
59
|
+
assert.equal(store.getById(mem.id).last_accessed_at, undefined);
|
|
60
|
+
store.close();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("repeated touches cap at 1.0 (never exceeds)", () => {
|
|
64
|
+
const { store } = setup();
|
|
65
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
66
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
67
|
+
const rel = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" }); // 0.4
|
|
68
|
+
for (let i = 0; i < 5; i++) {
|
|
69
|
+
store.bumpRelationWeight(rel.id, 0.3); // 0.4→0.7→1.0→1.0…
|
|
70
|
+
}
|
|
71
|
+
assert.equal(store.getRelations(a.id)[0].weight, 1.0, "capped at 1.0");
|
|
72
|
+
store.close();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 总闸(块2 的 graphWeightEnabled)关着时被动确认不许生效——单开通道键就能演化
|
|
76
|
+
// 边权的话,块2 立的「演化默认关」总闸形同虚设。
|
|
77
|
+
test("graphWeightEnabled=false gates passive confirm (master switch)", async () => {
|
|
78
|
+
const { store, service } = setup({ graphPassiveConfirm: true, graphWeightDelta: 0.1 });
|
|
79
|
+
const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
|
|
80
|
+
linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
|
|
81
|
+
await searchOnce(service, "数据库迁移");
|
|
82
|
+
assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "master switch off → weight untouched");
|
|
83
|
+
store.close();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("memory without relations survives touch (no crash)", async () => {
|
|
87
|
+
const { store, service } = setup({ graphPassiveConfirm: true });
|
|
88
|
+
const mem = putMem(store, service, { title: "孤立条", content: "无关联边" });
|
|
89
|
+
await searchOnce(service, "孤立条");
|
|
90
|
+
assert.ok(store.getById(mem.id), "memory still present");
|
|
91
|
+
store.close();
|
|
92
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// issue #24 块2 权重演化(entity_relations weight + source + bump)测试。
|
|
2
|
+
// 覆盖:来源→初值映射(manual/tag/llm)/ weight 显式优先 / toRelation 回读 /
|
|
3
|
+
// bumpRelationWeight 只加不减封顶 / extractor 建 llm 边 / 四来源全映射落表。
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { createStore, RELATION_SOURCE_DEFAULTS } from "../src/store.js";
|
|
7
|
+
|
|
8
|
+
function makeStore() {
|
|
9
|
+
return createStore(":memory:");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("RELATION_SOURCE_DEFAULTS matches issue #24 mapping", () => {
|
|
13
|
+
assert.equal(RELATION_SOURCE_DEFAULTS.manual, 1.0);
|
|
14
|
+
assert.equal(RELATION_SOURCE_DEFAULTS.confirmed, 1.0);
|
|
15
|
+
assert.equal(RELATION_SOURCE_DEFAULTS.tag, 0.3);
|
|
16
|
+
assert.equal(RELATION_SOURCE_DEFAULTS.llm, 0.4);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("saveRelation maps source to initial weight", () => {
|
|
20
|
+
const store = makeStore();
|
|
21
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
22
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
23
|
+
const manual = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "manual" });
|
|
24
|
+
const tag = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "y", source: "tag" });
|
|
25
|
+
const llm = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "z", source: "llm" });
|
|
26
|
+
assert.equal(manual.weight, 1.0);
|
|
27
|
+
assert.equal(manual.source, "manual");
|
|
28
|
+
assert.equal(tag.weight, 0.3);
|
|
29
|
+
assert.equal(tag.source, "tag");
|
|
30
|
+
assert.equal(llm.weight, 0.4);
|
|
31
|
+
assert.equal(llm.source, "llm");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("explicit weight overrides source mapping", () => {
|
|
35
|
+
const store = makeStore();
|
|
36
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
37
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
38
|
+
const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm", weight: 0.7 });
|
|
39
|
+
assert.equal(r.weight, 0.7);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("toRelation round-trips weight and source via getRelations", () => {
|
|
43
|
+
const store = makeStore();
|
|
44
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
45
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
46
|
+
store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "tag" });
|
|
47
|
+
const rels = store.getRelations(a.id);
|
|
48
|
+
assert.equal(rels.length, 1);
|
|
49
|
+
assert.equal(rels[0].weight, 0.3);
|
|
50
|
+
assert.equal(rels[0].source, "tag");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("bumpRelationWeight raises weight, caps at 1.0, never lowers", () => {
|
|
54
|
+
const store = makeStore();
|
|
55
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
56
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
57
|
+
const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" });
|
|
58
|
+
assert.equal(store.bumpRelationWeight(r.id, 0.2), true);
|
|
59
|
+
assert.equal(store.bumpRelationWeight(r.id, 0.2), true);
|
|
60
|
+
assert.equal(store.bumpRelationWeight(r.id, 0.2), true); // 0.4→0.6→0.8→1.0
|
|
61
|
+
assert.equal(store.bumpRelationWeight(r.id, 0.2), true);
|
|
62
|
+
assert.equal(store.getRelations(a.id)[0].weight, 1.0, "capped");
|
|
63
|
+
// 不存在的关系:幂等 false
|
|
64
|
+
assert.equal(store.bumpRelationWeight("no-such-id"), false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("legacy relation rows migrate with manual weight default (no crash)", () => {
|
|
68
|
+
const store = makeStore();
|
|
69
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
70
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
71
|
+
// 直接走底层 SQL 模拟旧库行(不带 weight/source 列 —— 但新 schema 已含列,
|
|
72
|
+
// 所以这条主要验证 migration 幂等路径不崩 + 默认映射成立)。
|
|
73
|
+
const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x" });
|
|
74
|
+
assert.equal(r.weight, 1.0, "default source manual → weight 1.0");
|
|
75
|
+
assert.equal(r.source, "manual");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---- #24 复审回归(review findings 逐条落锁)--------------------------------
|
|
79
|
+
|
|
80
|
+
// 「只加不减」是存储接口自己的契约:负/非法增量必须在存储边界被拒(false +
|
|
81
|
+
// 权值不动),不能把下界托付给调用方的配置范围——旧写法 bumpRelationWeight(id,
|
|
82
|
+
// -0.2) 能把 0.4 敲到 0.2。
|
|
83
|
+
test("bumpRelationWeight rejects negative and non-finite increments (只加不减)", () => {
|
|
84
|
+
const store = makeStore();
|
|
85
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
86
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
87
|
+
const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" });
|
|
88
|
+
for (const bad of [-0.2, -1, Number.NaN, Number.POSITIVE_INFINITY, "0.2"]) {
|
|
89
|
+
assert.equal(store.bumpRelationWeight(r.id, bad), false, `increment ${String(bad)} must be refused`);
|
|
90
|
+
assert.equal(store.getRelations(a.id)[0].weight, 0.4, `weight must not move on increment ${String(bad)}`);
|
|
91
|
+
}
|
|
92
|
+
assert.equal(store.bumpRelationWeight(r.id, 0.2), true, "a valid increment still lands");
|
|
93
|
+
assert.ok(Math.abs(store.getRelations(a.id)[0].weight - 0.6) < 1e-9, "0.4 + 0.2 lands at 0.6 (float-safe)");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 来源查询只认映射表的自有键:src="constructor"/"toString" 会命中原型链上的函数,
|
|
97
|
+
// 旧写法把它当权重绑进 SQL(函数不可绑定 → 整条写入抛错)而不是回退默认 1.0。
|
|
98
|
+
test("an inherited source name falls back to the default weight", () => {
|
|
99
|
+
const store = makeStore();
|
|
100
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
101
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
102
|
+
const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "constructor" });
|
|
103
|
+
assert.equal(r.weight, 1.0, "prototype-chain names must fall back to 1.0, not a function");
|
|
104
|
+
assert.equal(r.source, "constructor", "source is recorded verbatim");
|
|
105
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
|
|
6
|
+
// issue #339 / E7 考卷补齐:A2 软加权(foreign ×0.5 / 命中 ×1.25)此前只作用于
|
|
7
|
+
// searchMemories——E7 实测「explicit 标注 + 软档」的注入集与无标注逐条相同
|
|
8
|
+
// (80/80),主泄露面上软档形同虚设。本文件锁注入通道的软加权语义:
|
|
9
|
+
// - softScope 激活(scopeEnabled + scope 至少一维可解析)→ foreign 行在层内
|
|
10
|
+
// 数值积上 ×0.5,压不过同档自己行;未标注行同列吃 BOOST 不被压制;
|
|
11
|
+
// - flag 关 / scope 两维全空 → 注入序与改动前逐字节一致(锁平价);
|
|
12
|
+
// - strictScope 硬过滤先行,被滤行不会被二次降权;
|
|
13
|
+
// - 加权后的序流入 pin 池选取(pin 拿到的是加权后次序)。
|
|
14
|
+
|
|
15
|
+
function setup(config) {
|
|
16
|
+
const store = createStore(":memory:");
|
|
17
|
+
const service = createService({ store, mirror: null, config });
|
|
18
|
+
return { store, service };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SCOPE_ME = { agent_scope: "me", workspace_scope: null };
|
|
22
|
+
|
|
23
|
+
test("injectCandidates soft weight demotes explicit foreign rows below same-importance own rows", () => {
|
|
24
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: false });
|
|
25
|
+
// 同 importance 档(E7 的同档设计):无软加权时全靠插入序,foreign 在前。
|
|
26
|
+
store.save({ type: "decision", title: "foreign-a", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
27
|
+
store.save({ type: "decision", title: "mine-a", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
|
|
28
|
+
store.save({ type: "decision", title: "foreign-b", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
29
|
+
|
|
30
|
+
const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME });
|
|
31
|
+
const titles = injected.map((m) => m.title);
|
|
32
|
+
// foreign ×0.5 = 2.0 < 自己 ×1.25 = 5.0:自己行第一;两条 foreign 同分,
|
|
33
|
+
// 相对序随 store.list 的同分序(updated_at),不锁。
|
|
34
|
+
assert.equal(titles[0], "mine-a");
|
|
35
|
+
assert.deepEqual(titles.slice(1).sort(), ["foreign-a", "foreign-b"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("injectCandidates soft tier keeps unlabeled rows ranked with matches, not suppressed", () => {
|
|
39
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: false });
|
|
40
|
+
store.save({ type: "decision", title: "global", content: "x", importance: 4 });
|
|
41
|
+
store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
42
|
+
|
|
43
|
+
const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME });
|
|
44
|
+
// 未标注 = 全局可见,与命中行同列(×1.25)而非被压到 foreign 之后。
|
|
45
|
+
assert.deepEqual(injected.map((m) => m.title), ["global", "foreign"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("injectCandidates order is byte-identical when scopeEnabled is off or scope is anonymous", () => {
|
|
49
|
+
const seed = (store) => {
|
|
50
|
+
store.save({ type: "decision", title: "foreign-a", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
51
|
+
store.save({ type: "decision", title: "mine-a", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
|
|
52
|
+
store.save({ type: "decision", title: "global", content: "x", importance: 3 });
|
|
53
|
+
};
|
|
54
|
+
// flag 关:不传 scope(改动前后必须同序——锁平价)。
|
|
55
|
+
const off = setup({ scopeEnabled: false, strictScope: false });
|
|
56
|
+
seed(off.store);
|
|
57
|
+
// flag 开但身份两维全空:软加权门不激活,也不得改序。
|
|
58
|
+
const anon = setup({ scopeEnabled: true, strictScope: false });
|
|
59
|
+
seed(anon.store);
|
|
60
|
+
|
|
61
|
+
// 平价锁:两种情况的注入序必须一致(比较器与改动前逐字节相同)。
|
|
62
|
+
const baseline = off.service.injectCandidates({ maxItems: 5, threshold: 3 }).map((m) => m.title);
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
anon.service.injectCandidates({ maxItems: 5, threshold: 3, scope: { agent_scope: null, workspace_scope: null } }).map((m) => m.title),
|
|
65
|
+
baseline
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("strictScope hard filter runs before soft weighting: filtered rows are not double-demoted", () => {
|
|
70
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: true });
|
|
71
|
+
store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
72
|
+
store.save({ type: "decision", title: "global", content: "x", importance: 3 });
|
|
73
|
+
|
|
74
|
+
const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME });
|
|
75
|
+
// 硬墙先删 foreign;剩下的 global(未标注)吃 BOOST 保留——若软加权先跑会把
|
|
76
|
+
// foreign 压到后面再被删,成员虽同但语义不同;此处锁成员与顺序。
|
|
77
|
+
assert.deepEqual(injected.map((m) => m.title), ["global"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("soft-weighted order feeds the pin pool (pin picks the weighted-first candidates)", () => {
|
|
81
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: false, pinnedInjectBudget: 1 });
|
|
82
|
+
// pin 池只收 constraint/preference——两条都用 preference,同档靠软加权分序。
|
|
83
|
+
store.save({ type: "preference", title: "foreign-pin", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
84
|
+
store.save({ type: "preference", title: "mine", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
|
|
85
|
+
|
|
86
|
+
const pinnedStats = {};
|
|
87
|
+
const injected = service.injectCandidates({
|
|
88
|
+
maxItems: 5, threshold: 3, scope: SCOPE_ME, pinnedStats,
|
|
89
|
+
});
|
|
90
|
+
// 加权后 mine 在前:pin 池取的是加权后次序的第一条。
|
|
91
|
+
assert.equal(pinnedStats.shown, 1);
|
|
92
|
+
assert.equal(injected[0].title, "mine");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("first-round BM25 fallback applies the soft weight (no query vector yet)", () => {
|
|
96
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: false });
|
|
97
|
+
// 同内容同长度(标题各 1 token 且不在查询词表)→ BM25 原始分相同;
|
|
98
|
+
// 未加权时同分序随 store.list(updated desc,foreign 后存先出)。
|
|
99
|
+
store.save({ type: "decision", title: "alpha", content: "plan the dinner party menu with care", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
|
|
100
|
+
store.save({ type: "decision", title: "bravo", content: "plan the dinner party menu with care", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
101
|
+
|
|
102
|
+
const injected = service.injectCandidates({
|
|
103
|
+
query: "dinner party menu", maxItems: 5, threshold: 3, scope: SCOPE_ME,
|
|
104
|
+
});
|
|
105
|
+
assert.equal(injected[0].title, "alpha", "BM25 hits are soft-weighted before the semantic-first merge");
|
|
106
|
+
});
|
|
@@ -229,3 +229,115 @@ 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("scope 闸先于 topK 截断:出局的候选不占名额(topK=1 仍有可见结果)", async () => {
|
|
320
|
+
// 回归锁(评审发现):先 slice 再 filter 的话,排在前面那条被闸掉的候选会占住唯一的
|
|
321
|
+
// 槽位,明明还有可见匹配却返回空数组。用 topK=1 把次序钉死——排序不动,只是闸门要插
|
|
322
|
+
// 在截断之前。
|
|
323
|
+
const me = { agent_scope: "me", workspace_scope: null };
|
|
324
|
+
|
|
325
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true });
|
|
326
|
+
const theirsE = store.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
|
|
327
|
+
const mineE = store.save({ type: "project", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" });
|
|
328
|
+
// 先挂 theirs、后挂 mine:前者排在候选前面,正好充当那个「占位」的候选。
|
|
329
|
+
linkToEntity(store, "阿尔托", [theirsE, mineE]);
|
|
330
|
+
const viaEntity = await service.searchMemories("entity:阿尔托", { topK: 1, scope: me });
|
|
331
|
+
assert.deepEqual(viaEntity.map((m) => m.title), ["mine"], "entity: 在 topK=1 下不该被出局候选挤空");
|
|
332
|
+
|
|
333
|
+
// attr: 同理——两条挂同一个属性键、各挂自己的实体,theirs 先写入。
|
|
334
|
+
const { store: s2, service: svc2 } = setup({ scopeEnabled: true, strictScope: true, entitySearchEnabled: true });
|
|
335
|
+
const theirsA = s2.save({ type: "project", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
|
|
336
|
+
const mineA = s2.save({ type: "project", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" });
|
|
337
|
+
for (const [i, mem] of [theirsA, mineA].entries()) {
|
|
338
|
+
const entity = s2.createEntity({ name: `e${i}`, type: "person" });
|
|
339
|
+
s2.saveAttr({ entity_id: entity.id, attr_key: "国籍", attr_value: "芬兰", memory_id: mem.id });
|
|
340
|
+
}
|
|
341
|
+
const viaAttr = await svc2.searchMemories("attr:国籍=芬兰", { topK: 1, scope: me });
|
|
342
|
+
assert.deepEqual(viaAttr.map((m) => m.title), ["mine"], "attr: 在 topK=1 下不该被出局候选挤空");
|
|
343
|
+
});
|