@modusensus/dsh-mneme 0.7.32 → 0.8.0

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.
@@ -0,0 +1,165 @@
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
+ import { createTools } from "../src/tools.js";
6
+
7
+ // v0.8.0 A3(issue #17):strictScope 硬过滤 + agent 不对称可见性。
8
+ // 四象限公式:记忆可见 ⇔ (agent 维未标注或命中) AND (workspace 维未标注或命中);
9
+ // 当前会话维度解析不到时该维度带标注的记忆不可见(fail-closed)。
10
+
11
+ function setup(config) {
12
+ const store = createStore(":memory:");
13
+ const service = createService({ store, mirror: null, config });
14
+ return { store, service };
15
+ }
16
+
17
+ test("visibility predicate: four-quadrant semantics", () => {
18
+ const { service } = setup({ scopeEnabled: true, strictScope: true });
19
+ const vis = service.isVisibleInScope;
20
+ const me = { agent_scope: "coder", workspace_scope: "D:\\p" };
21
+
22
+ // 全局(未标注)对谁都可见。
23
+ assert.equal(vis({}, me), true);
24
+ // agent 专属:同 agent 任意 workspace 可见,他人不可见。
25
+ assert.equal(vis({ agent_scope: "coder" }, me), true);
26
+ assert.equal(vis({ agent_scope: "novelist" }, me), false);
27
+ // workspace 专属:同 workspace 任意 agent 可见,别的 workspace 不可见。
28
+ assert.equal(vis({ workspace_scope: "D:\\p" }, me), true);
29
+ assert.equal(vis({ workspace_scope: "D:\\q" }, me), false);
30
+ // 双标注:精确命中才可见。
31
+ assert.equal(vis({ agent_scope: "coder", workspace_scope: "D:\\p" }, me), true);
32
+ assert.equal(vis({ agent_scope: "coder", workspace_scope: "D:\\q" }, me), false);
33
+ assert.equal(vis({ agent_scope: "novelist", workspace_scope: "D:\\p" }, me), false);
34
+ });
35
+
36
+ test("visibility predicate: anonymous session sees only unscoped rows (fail-closed)", () => {
37
+ const { service } = setup({ scopeEnabled: true, strictScope: true });
38
+ const vis = service.isVisibleInScope;
39
+ const anon = { agent_scope: null, workspace_scope: null };
40
+ assert.equal(vis({}, anon), true);
41
+ assert.equal(vis({ agent_scope: "coder" }, anon), false);
42
+ assert.equal(vis({ workspace_scope: "D:\\p" }, anon), false);
43
+ });
44
+
45
+ test("searchMemories hard-filters foreign scopes when strictScope is on", async () => {
46
+ const { store, service } = setup({ scopeEnabled: true, strictScope: true });
47
+ store.save({ type: "project", title: "global needle", content: "x" });
48
+ store.save({ type: "project", title: "mine needle", content: "x", agent_scope: "me" });
49
+ store.save({ type: "project", title: "foreign needle", content: "x", agent_scope: "other" });
50
+
51
+ const rows = await service.searchMemories("needle", {
52
+ mode: "keyword",
53
+ scope: { agent_scope: "me", workspace_scope: null }
54
+ });
55
+ // 他 scope 直接出局(区别于 A2 的降权保留可见);未标注与命中行保留。
56
+ assert.deepEqual(rows.map((m) => m.title).sort(), ["global needle", "mine needle"]);
57
+ });
58
+
59
+ test("strictScope off keeps A2 behavior: foreign rows demoted but visible", async () => {
60
+ const { store, service } = setup({ scopeEnabled: true });
61
+ store.save({ type: "project", title: "foreign needle", content: "x", agent_scope: "other" });
62
+ const rows = await service.searchMemories("needle", {
63
+ mode: "keyword",
64
+ scope: { agent_scope: "me", workspace_scope: null }
65
+ });
66
+ assert.deepEqual(rows.map((m) => m.title), ["foreign needle"]);
67
+ });
68
+
69
+ test("store.list/count visibility option filters in SQL with consistent totals", () => {
70
+ const { store } = setup({ scopeEnabled: true, strictScope: true });
71
+ store.save({ type: "decision", title: "g", content: "x" });
72
+ store.save({ type: "decision", title: "mine", content: "x", agent_scope: "me", workspace_scope: "D:\\p" });
73
+ store.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other" });
74
+ store.save({ type: "decision", title: "other-ws", content: "x", workspace_scope: "D:\\q" });
75
+
76
+ const vis = { agentScope: "me", workspaceScope: "D:\\p" };
77
+ const titles = store.list({ visibility: vis }).map((m) => m.title).sort();
78
+ assert.deepEqual(titles, ["g", "mine"]);
79
+ assert.equal(store.count("decision", { visibility: vis }), 2);
80
+
81
+ // 匿名会话(维度解析不到):只放行未标注行。
82
+ const anonTitles = store.list({ visibility: { agentScope: null, workspaceScope: null } }).map((m) => m.title);
83
+ assert.deepEqual(anonTitles, ["g"]);
84
+ });
85
+
86
+ test("injectCandidates hard-filters scoped rows when strictScope is on", () => {
87
+ const { store, service } = setup({ scopeEnabled: true, strictScope: true });
88
+ store.save({ type: "decision", title: "global", content: "x", importance: 4 });
89
+ store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other" });
90
+
91
+ const injected = service.injectCandidates({
92
+ maxItems: 5,
93
+ threshold: 3,
94
+ scope: { agent_scope: "me", workspace_scope: null }
95
+ });
96
+ assert.deepEqual(injected.map((m) => m.title), ["global"]);
97
+
98
+ // flag 下(scope 不传)→ 他 scope 照常注入(A2 前行为)。
99
+ const unscoped = service.injectCandidates({ maxItems: 5, threshold: 3 });
100
+ assert.deepEqual(unscoped.map((m) => m.title).sort(), ["foreign", "global"]);
101
+ });
102
+
103
+ function setupTools(config) {
104
+ const store = createStore(":memory:");
105
+ const service = createService({ store, mirror: null, config });
106
+ const registered = [];
107
+ createTools(
108
+ { tools: { register(def) { registered.push(def); return () => {}; } } },
109
+ service,
110
+ config
111
+ );
112
+ const pick = (name) => registered.find((def) => def.name === name);
113
+ return { store, pick };
114
+ }
115
+
116
+ async function runHandler(def, args, exec) {
117
+ const handler = def.execute.bind(def);
118
+ return handler(args, exec);
119
+ }
120
+
121
+ const STRICT_EXEC = { agent: { session: { id: "s1", header: { agentPreset: "me", cwd: "D:\\p" } } } };
122
+ const OTHER_EXEC = { agent: { session: { id: "s2", header: { agentPreset: "other", cwd: "D:\\q" } } } };
123
+
124
+ test("memory_get hides foreign-scope rows under strictScope, resolvable for the owner", async () => {
125
+ const { store, pick } = setupTools({ scopeEnabled: true, strictScope: true });
126
+ const mine = store.save({ type: "pitfall", title: "mine", content: "c", agent_scope: "me" });
127
+ const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other" });
128
+ const get = pick("memory_get");
129
+
130
+ const out = await runHandler(get, { id: mine.id }, STRICT_EXEC);
131
+ assert.equal(out.memory.title, "mine");
132
+
133
+ // 他 scope → 按不存在处理(无存在性泄漏)。
134
+ await assert.rejects(() => runHandler(get, { id: theirs.id }, STRICT_EXEC), /memory not found/);
135
+ // 行主人自己照常可取。
136
+ const ownerOut = await runHandler(get, { id: theirs.id }, OTHER_EXEC);
137
+ assert.equal(ownerOut.memory.title, "theirs");
138
+ });
139
+
140
+ test("memory_get without strictScope returns any row regardless of scope annotation", async () => {
141
+ const { store, pick } = setupTools({ scopeEnabled: true });
142
+ const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other" });
143
+ const get = pick("memory_get");
144
+ const out = await runHandler(get, { id: theirs.id }, STRICT_EXEC);
145
+ assert.equal(out.memory.title, "theirs");
146
+ });
147
+
148
+ test("memory_list filters rows and keeps total consistent under strictScope", async () => {
149
+ const { store, pick } = setupTools({ scopeEnabled: true, strictScope: true });
150
+ store.save({ type: "decision", title: "g", content: "x" });
151
+ store.save({ type: "decision", title: "mine", content: "x", agent_scope: "me" });
152
+ store.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other" });
153
+ const list = pick("memory_list");
154
+
155
+ const out = await runHandler(list, { type: "decision" }, STRICT_EXEC);
156
+ assert.deepEqual(out.items.map((m) => m.title).sort(), ["g", "mine"]);
157
+ assert.equal(out.total, 2);
158
+
159
+ // flag 关(A2 默认):他 scope 的行照常出现在浏览视图。
160
+ const { store: store2, pick: pick2 } = setupTools({ scopeEnabled: true });
161
+ store2.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other" });
162
+ const out2 = await runHandler(pick2("memory_list"), { type: "decision" }, STRICT_EXEC);
163
+ assert.deepEqual(out2.items.map((m) => m.title), ["theirs"]);
164
+ assert.equal(out2.total, 1);
165
+ });
@@ -327,3 +327,79 @@ test("Bug7: injection ranking re-weights by quality_score (degraded memories ran
327
327
  assert.equal(candidates[0].title, "高质量", "100-quality preference leads the degraded one");
328
328
  assert.equal(candidates[1].title, "元记忆");
329
329
  });
330
+
331
+ // v0.8.0 冲突集中处理:resolveConflictPending 盖章 + 处置落地(与 dream 非冻结
332
+ // conflict 同款:胜者正文追加已否决注记、败者归档),以及队列视图的联表形状。
333
+ test("resolveConflictPending applies the dream-style disposition on human confirmation", () => {
334
+ const { store, service } = setup();
335
+ const a = store.save({ type: "history", title: "界面暗色偏好", content: "用户当前使用暗色界面主题", importance: 3 });
336
+ const b = store.save({ type: "history", title: "界面亮色切换", content: "用户界面亮色切换计划", importance: 3 });
337
+ const pending = store.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "主题偏好前后矛盾" });
338
+ // store 会把 pair 按 id 字典序归一(防重复入队),A/B 只是排序位——
339
+ // 人工确认前先从队列行反查「界面亮色切换」(keeper,恒为 b)落在哪一侧,
340
+ // 再选那一侧保留;loser 恒为 a(「界面暗色偏好」),与侧位字母无关。
341
+ const queued = service.listConflictQueue().find((q) => q.id === pending.id);
342
+ const winnerSide = queued.memory_a.id === b.id ? "a" : "b";
343
+ const loserId = a.id;
344
+
345
+ const resolved = service.resolveConflictPending(pending.id, { winner: winnerSide, apply: true });
346
+ assert.ok(resolved, "returns the stamped row");
347
+ assert.ok(resolved.resolved_at, "stamps resolved_at");
348
+ assert.equal(resolved.resolved_winner, b.id, "resolved_winner records the chosen memory id");
349
+ assert.equal(resolved.disposition?.ok, true, "disposition landed");
350
+
351
+ // 败者归档 + 胜者保留——与 dream applyConflict 同款(胜者正文由
352
+ // applyDecisions 追加已否决注记)。
353
+ assert.equal(store.getById(loserId).archived, true, "loser archived");
354
+ assert.equal(store.getById(b.id).archived, false, "winner kept active");
355
+ assert.notEqual(store.getById(b.id).content, b.content, "winner content carries the veto note");
356
+ // 处置后队列不再返回该冲突。
357
+ assert.equal(service.listConflictQueue().length, 0);
358
+ });
359
+
360
+ test("resolveConflictPending with winner=null only stamps (mark reviewed)", () => {
361
+ const { store, service } = setup();
362
+ const a = store.save({ type: "project", title: "甲", content: "内容甲" });
363
+ const b = store.save({ type: "project", title: "乙", content: "内容乙" });
364
+ const pending = store.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "r" });
365
+ const resolved = service.resolveConflictPending(pending.id, { winner: null });
366
+ assert.ok(resolved.resolved_at);
367
+ assert.equal(resolved.disposition, null, "no disposition applied");
368
+ assert.equal(store.getById(a.id).archived, false);
369
+ assert.equal(store.getById(b.id).archived, false);
370
+ });
371
+
372
+ test("resolveConflictPending unknown id returns undefined", () => {
373
+ const { service } = setup();
374
+ assert.equal(service.resolveConflictPending("nope", { winner: "a" }), undefined);
375
+ });
376
+
377
+ // Review 守卫(#166 时序修复):盖章必须先于处置的**反面**契约——处置失败时
378
+ // 不得盖章。单线程下 casGuard 的 snapshot 与读取之间没有异步介入点,CAS 冲突
379
+ // 实际不可达(这是原 PR 没测它的原因),但它一旦因未来改动可达(异步 store、
380
+ // update/archive 抛错),先盖章会让行已 resolved、从队列消失却未处置。这条源码
381
+ // 断言把顺序固化下来(与 client.test.js 断言 ConflictsQueue 存在同一惯例),
382
+ // 防止未来有人再把盖章挪回处置之前。
383
+ test("resolveConflictPending stamps only after disposition succeeds (review ordering guard)", () => {
384
+ const source = readFileSync(new URL("../src/service.js", import.meta.url), "utf8");
385
+ const fn = source.slice(source.indexOf("function resolveConflictPending"));
386
+ const applyAt = fn.indexOf("applyDecisions(");
387
+ const stampAt = fn.indexOf("store.resolveConflictPending(");
388
+ assert.ok(applyAt !== -1, "resolveConflictPending must call applyDecisions");
389
+ assert.ok(stampAt !== -1, "resolveConflictPending must call store.resolveConflictPending");
390
+ assert.ok(stampAt > applyAt, "stamp must happen after applyDecisions — a failed disposition must not mark the conflict resolved");
391
+ assert.ok(fn.includes("if (failures.length)"), "apply failures must short-circuit before stamping");
392
+ });
393
+
394
+ test("listConflictQueue joins both sides and tolerates missing memories", () => {
395
+ const { store, service } = setup();
396
+ const a = store.save({ type: "decision", title: "现存方", content: "现存内容", importance: 4 });
397
+ const pending = store.saveConflictPending({ memory_a: a.id, memory_b: "ghost-id", reason: "对方已被删除" });
398
+ const queue = service.listConflictQueue();
399
+ assert.equal(queue.length, 1);
400
+ assert.equal(queue[0].id, pending.id);
401
+ assert.equal(queue[0].memory_a?.title, "现存方", "side A joined from the store");
402
+ assert.equal(queue[0].memory_a?.archived, false);
403
+ assert.equal(queue[0].memory_b?.missing, true, "ghost side marked missing");
404
+ assert.equal(queue[0].reason, "对方已被删除");
405
+ });