@modusensus/dsh-mneme 0.7.32 → 0.8.1
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 +44 -20
- package/lib/api.js +100 -12
- package/lib/client.js +276 -4
- package/lib/commands.js +30 -3
- package/lib/config.js +21 -0
- package/lib/dream/sleep.js +43 -12
- package/lib/dream.js +43 -2
- package/lib/inject.js +39 -5
- package/lib/lang.js +23 -0
- package/lib/scope.js +118 -0
- package/lib/service.js +374 -62
- package/lib/settings.js +7 -0
- package/lib/store.js +245 -7
- package/lib/tools.js +138 -17
- package/lib/version-check.js +122 -0
- package/package.json +1 -1
- package/src/api.js +100 -12
- package/src/commands.js +30 -3
- package/src/config.js +21 -0
- package/src/dream/sleep.js +43 -12
- package/src/dream.js +43 -2
- package/src/inject.js +39 -5
- package/src/lang.js +23 -0
- package/src/scope.js +118 -0
- package/src/service.js +374 -62
- package/src/settings.js +7 -0
- package/src/store.js +245 -7
- package/src/tools.js +138 -17
- package/src/version-check.js +122 -0
- package/test/api.test.js +90 -4
- package/test/client.test.js +58 -0
- package/test/commands.test.js +40 -3
- package/test/conflict-freeze.test.js +15 -5
- package/test/inject.test.js +93 -0
- package/test/scope-candidates.test.js +239 -0
- package/test/scope-explicit.test.js +452 -0
- package/test/scope-retrieval.test.js +197 -0
- package/test/scope-storage.test.js +255 -0
- package/test/scope-strict.test.js +231 -0
- package/test/service.test.js +76 -0
- package/test/version-check.test.js +152 -0
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
import { createScopeResolver } from "../src/scope.js";
|
|
7
|
+
|
|
8
|
+
// v0.8.0 A1(issue #17):scope 隔离存储层。
|
|
9
|
+
// 四列可空(agent_scope/workspace_scope/sensitivity/occurred_at)、迁移纯增量
|
|
10
|
+
// 不带 DEFAULT;写入标注走 scopeEnabled 门控;去重键扩展 (type, title, scope…)。
|
|
11
|
+
|
|
12
|
+
test("migration adds four nullable scope columns without defaults", () => {
|
|
13
|
+
const store = createStore(":memory:");
|
|
14
|
+
const cols = store.db.prepare("PRAGMA table_info(memories)").all();
|
|
15
|
+
for (const name of ["agent_scope", "workspace_scope", "sensitivity", "occurred_at"]) {
|
|
16
|
+
const col = cols.find((c) => c.name === name);
|
|
17
|
+
assert.ok(col, `column ${name} should exist`);
|
|
18
|
+
assert.equal(col.notnull, 0, `${name} must be nullable`);
|
|
19
|
+
assert.equal(col.dflt_value, null, `${name} must not carry a DEFAULT (存量零重写)`);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("rows written without scope fields read back unscoped (legacy semantics)", () => {
|
|
24
|
+
const store = createStore(":memory:");
|
|
25
|
+
const row = store.save({ type: "project", title: "legacy shape", content: "body" });
|
|
26
|
+
assert.equal(row.agent_scope, undefined);
|
|
27
|
+
assert.equal(row.workspace_scope, undefined);
|
|
28
|
+
assert.equal(row.sensitivity, undefined);
|
|
29
|
+
assert.equal(row.occurred_at, undefined);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("store.save persists and normalizes scope fields; dirty input lands as NULL", () => {
|
|
33
|
+
const store = createStore(":memory:");
|
|
34
|
+
const kept = store.save({
|
|
35
|
+
type: "project",
|
|
36
|
+
title: "scoped",
|
|
37
|
+
content: "c",
|
|
38
|
+
agent_scope: " coder-agent ",
|
|
39
|
+
workspace_scope: "D:\\proj",
|
|
40
|
+
sensitivity: "personal",
|
|
41
|
+
occurred_at: "2026-09-13T10:00:00+08:00"
|
|
42
|
+
});
|
|
43
|
+
assert.equal(kept.agent_scope, "coder-agent");
|
|
44
|
+
assert.equal(kept.workspace_scope, "D:\\proj");
|
|
45
|
+
assert.equal(kept.sensitivity, "personal");
|
|
46
|
+
// 时区归一:A2 的时间过滤按 ISO 字典序直接比较。
|
|
47
|
+
assert.equal(kept.occurred_at, "2026-09-13T02:00:00.000Z");
|
|
48
|
+
|
|
49
|
+
const dirty = store.save({
|
|
50
|
+
type: "project",
|
|
51
|
+
title: "dirty",
|
|
52
|
+
content: "c",
|
|
53
|
+
agent_scope: " ",
|
|
54
|
+
workspace_scope: 42,
|
|
55
|
+
sensitivity: "",
|
|
56
|
+
occurred_at: "not-a-date"
|
|
57
|
+
});
|
|
58
|
+
assert.equal(dirty.agent_scope, undefined);
|
|
59
|
+
assert.equal(dirty.workspace_scope, undefined);
|
|
60
|
+
assert.equal(dirty.sensitivity, undefined);
|
|
61
|
+
assert.equal(dirty.occurred_at, undefined);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("store.update keeps scope columns untouched (write-time annotation is immutable)", () => {
|
|
65
|
+
const store = createStore(":memory:");
|
|
66
|
+
const saved = store.save({ type: "decision", title: "t", content: "old", agent_scope: "a1", workspace_scope: "w1" });
|
|
67
|
+
store.update(saved.id, { content: "new", importance: 4 });
|
|
68
|
+
const row = store.getById(saved.id);
|
|
69
|
+
assert.equal(row.content, "new");
|
|
70
|
+
assert.equal(row.agent_scope, "a1");
|
|
71
|
+
assert.equal(row.workspace_scope, "w1");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("scopeEnabled=false: cross-scope same-title does NOT merge (issue #170 复核项 1)", () => {
|
|
75
|
+
const store = createStore(":memory:");
|
|
76
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
77
|
+
// A1 时代本测试断言「flag 关=跨 scope 合并」;显式声明落地后那是内容挂错
|
|
78
|
+
// 归属的 bug(strictScope 下原主人反而看不见)——去重键恒按 scope 比较。
|
|
79
|
+
store.save({ type: "project", title: "T", content: "scoped body", agent_scope: "agentA" });
|
|
80
|
+
const { action } = service.saveWithDedupe({ type: "project", title: "T", content: "new body" });
|
|
81
|
+
assert.equal(action, "created", "unscoped write never merges into a labeled row (flag off included)");
|
|
82
|
+
assert.equal(store.count("project"), 2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("scopeEnabled=true: dedupe key extends with agent_scope/workspace_scope/sensitivity", () => {
|
|
86
|
+
const store = createStore(":memory:");
|
|
87
|
+
const service = createService({ store, mirror: null, config: { scopeEnabled: true } });
|
|
88
|
+
const save = (fields) => service.saveWithDedupe({ type: "project", title: "T", content: "body", ...fields });
|
|
89
|
+
|
|
90
|
+
assert.equal(save({ agent_scope: "agentA" }).action, "created");
|
|
91
|
+
// 同标题、不同 agent_scope → 独立行,绝不互相合并。
|
|
92
|
+
assert.equal(save({ agent_scope: "agentB" }).action, "created");
|
|
93
|
+
// 不同 workspace_scope → 独立行。
|
|
94
|
+
assert.equal(save({ agent_scope: "agentA", workspace_scope: "D:\\proj-a" }).action, "created");
|
|
95
|
+
// 不同 sensitivity → 独立行。
|
|
96
|
+
assert.equal(save({ agent_scope: "agentA", sensitivity: "personal" }).action, "created");
|
|
97
|
+
// 三元组全同 → 合并。
|
|
98
|
+
assert.equal(save({ agent_scope: "agentA" }).action, "merged");
|
|
99
|
+
assert.equal(store.count("project"), 4);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("scopeEnabled=true: unscoped writes match legacy NULL rows but not stamped ones", () => {
|
|
103
|
+
const store = createStore(":memory:");
|
|
104
|
+
const service = createService({ store, mirror: null, config: { scopeEnabled: true } });
|
|
105
|
+
// 存量形状(NULL scope)。
|
|
106
|
+
store.save({ type: "preference", title: "P", content: "legacy" });
|
|
107
|
+
// 未标注新写入与存量 NULL 行互相匹配。
|
|
108
|
+
assert.equal(service.saveWithDedupe({ type: "preference", title: "P", content: "x" }).action, "merged");
|
|
109
|
+
// 已标注行与 NULL 行不匹配 → 独立新行。
|
|
110
|
+
assert.equal(service.saveWithDedupe({ type: "preference", title: "P", content: "x", agent_scope: "agentA" }).action, "created");
|
|
111
|
+
assert.equal(store.count("preference"), 2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("scopeEnabled=true: _mergeInto across scopes is rejected and falls back to a fresh row", () => {
|
|
115
|
+
const store = createStore(":memory:");
|
|
116
|
+
const service = createService({ store, mirror: null, config: { scopeEnabled: true } });
|
|
117
|
+
const scoped = store.save({ type: "project", title: "S", content: "scoped", agent_scope: "agentA" });
|
|
118
|
+
const cross = service.saveWithDedupe({
|
|
119
|
+
type: "project", title: "S", content: "summary text", _mergeInto: scoped.id
|
|
120
|
+
});
|
|
121
|
+
assert.equal(cross.action, "created", "cross-scope semantic merge must not leak into the scoped row");
|
|
122
|
+
const same = service.saveWithDedupe({
|
|
123
|
+
type: "project", title: "S2", content: "more text", agent_scope: "agentA", _mergeInto: scoped.id
|
|
124
|
+
});
|
|
125
|
+
assert.equal(same.action, "merged", "same-scope explicit target still merges");
|
|
126
|
+
assert.equal(same.memory.id, scoped.id);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function setupTools(config) {
|
|
130
|
+
const store = createStore(":memory:");
|
|
131
|
+
const service = createService({ store, mirror: null, config });
|
|
132
|
+
const registered = [];
|
|
133
|
+
createTools(
|
|
134
|
+
{ tools: { register(def) { registered.push(def); return () => {}; } } },
|
|
135
|
+
service,
|
|
136
|
+
config
|
|
137
|
+
);
|
|
138
|
+
const memorySave = registered.find((def) => def.name === "memory_save");
|
|
139
|
+
assert.ok(memorySave, "memory_save registered");
|
|
140
|
+
return { store, memorySave };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function runMemorySave(memorySave, args, exec) {
|
|
144
|
+
const handler = memorySave.execute.bind(memorySave);
|
|
145
|
+
return handler(args, exec);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
test("memory_save stamps resolved scope onto the row when the flag is on", async () => {
|
|
149
|
+
const { store, memorySave } = setupTools({ scopeEnabled: true });
|
|
150
|
+
const exec = { agent: { session: { id: "s1", header: { agentPreset: "coder", cwd: "D:\\proj" } } } };
|
|
151
|
+
const args = { type: "pitfall", title: "T", content: "c", sensitivity: "personal", occurred_at: "2026-09-12T00:00:00Z" };
|
|
152
|
+
const out = await runMemorySave(memorySave, args, exec);
|
|
153
|
+
const row = store.getById(out.id);
|
|
154
|
+
assert.equal(row.agent_scope, "coder");
|
|
155
|
+
assert.equal(row.workspace_scope, "D:\\proj");
|
|
156
|
+
assert.equal(row.sensitivity, "personal");
|
|
157
|
+
assert.equal(row.occurred_at, "2026-09-12T00:00:00.000Z");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("memory_save without exec context writes unscoped rows (flag on, nothing resolvable)", async () => {
|
|
161
|
+
const { store, memorySave } = setupTools({ scopeEnabled: true });
|
|
162
|
+
const out = await runMemorySave(memorySave, { type: "pitfall", title: "T", content: "c" }, undefined);
|
|
163
|
+
const row = store.getById(out.id);
|
|
164
|
+
assert.equal(row.agent_scope, undefined);
|
|
165
|
+
assert.equal(row.workspace_scope, undefined);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("memory_save with the flag off writes rows without any scope annotation", async () => {
|
|
169
|
+
const { store, memorySave } = setupTools({});
|
|
170
|
+
const exec = { agent: { session: { id: "s1", header: { agentPreset: "coder", cwd: "D:\\proj" } } } };
|
|
171
|
+
const out = await runMemorySave(memorySave, { type: "pitfall", title: "T", content: "c" }, exec);
|
|
172
|
+
const row = store.getById(out.id);
|
|
173
|
+
assert.equal(row.agent_scope, undefined);
|
|
174
|
+
assert.equal(row.workspace_scope, undefined);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("createScopeResolver returns identity even when scopeEnabled is off (gate lives at the write-annotation site, issue #170 复核项 2)", () => {
|
|
178
|
+
const resolve = createScopeResolver({ ctx: {}, config: {} });
|
|
179
|
+
// flag 关只关自动标注:读取侧(strictVisibility/get/注入)与显式声明仍需身份。
|
|
180
|
+
assert.deepEqual(
|
|
181
|
+
resolve({ agent: { session: { id: "s1", header: { agentPreset: "coder", cwd: "D:\\p" } } } }),
|
|
182
|
+
{ agent_scope: "coder", workspace_scope: "D:\\p" }
|
|
183
|
+
);
|
|
184
|
+
// 真解析不到 → 双 null 对象(fail-closed 读取侧据此只放行未标注/非显式行)。
|
|
185
|
+
assert.deepEqual(resolve({ agent: { session: { id: "s1" } } }), { agent_scope: null, workspace_scope: null });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("resolver stamps agentPreset and header cwd; registry hit wins over header cwd", () => {
|
|
189
|
+
const resolve = createScopeResolver({
|
|
190
|
+
config: { scopeEnabled: true },
|
|
191
|
+
ctx: { workspaceRegistry: { list: () => [{ path: "C:\\canon", sessionIds: ["s1"] }] } }
|
|
192
|
+
});
|
|
193
|
+
const exec = { agent: { session: { id: "s1", header: { agentPreset: "coder", cwd: "D:\\raw" } } } };
|
|
194
|
+
assert.deepEqual(resolve(exec), { agent_scope: "coder", workspace_scope: "C:\\canon" });
|
|
195
|
+
|
|
196
|
+
const resolveNoHit = createScopeResolver({
|
|
197
|
+
config: { scopeEnabled: true },
|
|
198
|
+
ctx: { workspaceRegistry: { list: () => [{ path: "C:\\canon", sessionIds: ["other"] }] } }
|
|
199
|
+
});
|
|
200
|
+
assert.deepEqual(resolveNoHit(exec), { agent_scope: "coder", workspace_scope: "D:\\raw" });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("resolver falls back to the static .header property on old hosts", () => {
|
|
204
|
+
const resolve = createScopeResolver({ config: { scopeEnabled: true }, ctx: {} });
|
|
205
|
+
const exec = { agent: { session: { id: "s1", header: { agentPreset: "novelist" } } } };
|
|
206
|
+
assert.deepEqual(resolve(exec), { agent_scope: "novelist", workspace_scope: null });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("session.header wins over requestHeader() (EpochHeader must not shadow the durable header)", () => {
|
|
210
|
+
const resolve = createScopeResolver({ config: { scopeEnabled: true }, ctx: {} });
|
|
211
|
+
// 本地 web 复验(2026-09-13)实测踩中的形状:requestHeader() 返回请求级
|
|
212
|
+
// EpochHeader(模型路由头,无 agentPreset/cwd),曾在首个请求后遮蔽
|
|
213
|
+
// session.header,导致标注全空。
|
|
214
|
+
const exec = {
|
|
215
|
+
agent: {
|
|
216
|
+
session: {
|
|
217
|
+
id: "s1",
|
|
218
|
+
header: { agentPreset: "coder", cwd: "D:\\proj" },
|
|
219
|
+
requestHeader: () => ({ config: { provider: "deepseek-official", model: "deepseek-v4-flash" } })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
assert.deepEqual(resolve(exec), { agent_scope: "coder", workspace_scope: "D:\\proj" });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("hosts without .header fall back to requestHeader().config shape", () => {
|
|
227
|
+
const resolve = createScopeResolver({ config: { scopeEnabled: true }, ctx: {} });
|
|
228
|
+
const exec = {
|
|
229
|
+
agent: {
|
|
230
|
+
session: {
|
|
231
|
+
id: "s2",
|
|
232
|
+
requestHeader: () => ({ config: { agentPreset: "legacy", cwd: "E:\\old" } })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
assert.deepEqual(resolve(exec), { agent_scope: "legacy", workspace_scope: "E:\\old" });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("resolver never throws: registry explosion degrades to header cwd and warns once", () => {
|
|
240
|
+
const warnings = [];
|
|
241
|
+
const resolve = createScopeResolver({
|
|
242
|
+
config: { scopeEnabled: true },
|
|
243
|
+
ctx: { workspaceRegistry: { list: () => { throw new Error("boom"); } } },
|
|
244
|
+
logger: { warn: (m) => warnings.push(String(m)) }
|
|
245
|
+
});
|
|
246
|
+
const exec = { agent: { session: { id: "s1", header: { agentPreset: "coder", cwd: "D:\\raw" } } } };
|
|
247
|
+
assert.deepEqual(resolve(exec), { agent_scope: "coder", workspace_scope: "D:\\raw" });
|
|
248
|
+
assert.equal(warnings.length, 1, "warn exactly once, never block the write path");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("resolver without a session or header lands all-NULL", () => {
|
|
252
|
+
const resolve = createScopeResolver({ config: { scopeEnabled: true }, ctx: {} });
|
|
253
|
+
assert.deepEqual(resolve(undefined), { agent_scope: null, workspace_scope: null });
|
|
254
|
+
assert.deepEqual(resolve({ agent: { session: { id: "s2" } } }), { agent_scope: null, workspace_scope: null });
|
|
255
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
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)→ v0.8.1 第 3 步(issue #170 4.3,作者已确认):
|
|
8
|
+
// strictScope 硬过滤只认显式声明。可见性公式收窄为:记忆可见 ⇔ 对每一维
|
|
9
|
+
// (该维标注存在且来源=explicit 时:命中当前会话)AND。
|
|
10
|
+
// - explicit 行为硬墙:他者维度不可见(含 memory_get 无存在性泄漏);
|
|
11
|
+
// - auto / 存量 NULL 来源(v0.8.0 自动标注)不进硬过滤,只吃 A2 软加权
|
|
12
|
+
// (foreign ×0.5 保留可见);
|
|
13
|
+
// - 当前会话维度解析不到 → 该维 explicit 行一律不可见(fail-closed 收窄)。
|
|
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
|
+
test("visibility predicate: explicit rows keep four-quadrant semantics", () => {
|
|
22
|
+
const { service } = setup({ scopeEnabled: true, strictScope: true });
|
|
23
|
+
const vis = service.isVisibleInScope;
|
|
24
|
+
const me = { agent_scope: "coder", workspace_scope: "D:\\p" };
|
|
25
|
+
const E = "explicit";
|
|
26
|
+
|
|
27
|
+
// 全局(未标注)对谁都可见。
|
|
28
|
+
assert.equal(vis({}, me), true);
|
|
29
|
+
// agent 专属:同 agent 任意 workspace 可见,他人不可见。
|
|
30
|
+
assert.equal(vis({ agent_scope: "coder", agent_scope_source: E }, me), true);
|
|
31
|
+
assert.equal(vis({ agent_scope: "novelist", agent_scope_source: E }, me), false);
|
|
32
|
+
// workspace 专属:同 workspace 任意 agent 可见,别的 workspace 不可见。
|
|
33
|
+
assert.equal(vis({ workspace_scope: "D:\\p", workspace_scope_source: E }, me), true);
|
|
34
|
+
assert.equal(vis({ workspace_scope: "D:\\q", workspace_scope_source: E }, me), false);
|
|
35
|
+
// 双标注:精确命中才可见。
|
|
36
|
+
assert.equal(vis({ agent_scope: "coder", agent_scope_source: E, workspace_scope: "D:\\p", workspace_scope_source: E }, me), true);
|
|
37
|
+
assert.equal(vis({ agent_scope: "coder", agent_scope_source: E, workspace_scope: "D:\\q", workspace_scope_source: E }, me), false);
|
|
38
|
+
assert.equal(vis({ agent_scope: "novelist", agent_scope_source: E, workspace_scope: "D:\\p", workspace_scope_source: E }, me), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("visibility predicate: auto and legacy rows are NOT hard walls (soft only)", () => {
|
|
42
|
+
const { service } = setup({ scopeEnabled: true, strictScope: true });
|
|
43
|
+
const vis = service.isVisibleInScope;
|
|
44
|
+
const me = { agent_scope: "coder", workspace_scope: "D:\\p" };
|
|
45
|
+
|
|
46
|
+
// auto(v0.8.1 载体自动标注)与存量 NULL 来源(v0.8.0 自动标注)他者维度
|
|
47
|
+
// 也可见——硬过滤只认 explicit。
|
|
48
|
+
assert.equal(vis({ agent_scope: "novelist" }, me), true, "legacy NULL source");
|
|
49
|
+
assert.equal(vis({ agent_scope: "novelist", agent_scope_source: "auto" }, me), true);
|
|
50
|
+
assert.equal(vis({ workspace_scope: "D:\\q", workspace_scope_source: "auto" }, me), true);
|
|
51
|
+
// 混合:agent 维 auto + workspace 维 explicit → 只 workspace 维构成硬墙。
|
|
52
|
+
assert.equal(vis({ agent_scope: "novelist", agent_scope_source: "auto", workspace_scope: "D:\\q", workspace_scope_source: "explicit" }, me), false);
|
|
53
|
+
assert.equal(vis({ agent_scope: "novelist", agent_scope_source: "explicit", workspace_scope: "D:\\q", workspace_scope_source: "auto" }, me), false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("visibility predicate: anonymous session fail-closes only explicit rows", () => {
|
|
57
|
+
const { service } = setup({ scopeEnabled: true, strictScope: true });
|
|
58
|
+
const vis = service.isVisibleInScope;
|
|
59
|
+
const anon = { agent_scope: null, workspace_scope: null };
|
|
60
|
+
assert.equal(vis({}, anon), true);
|
|
61
|
+
assert.equal(vis({ agent_scope: "coder", agent_scope_source: "explicit" }, anon), false);
|
|
62
|
+
assert.equal(vis({ workspace_scope: "D:\\p", workspace_scope_source: "explicit" }, anon), false);
|
|
63
|
+
// auto/存量行照常可见(不冒认,但也不因身份缺失误杀软标注)。
|
|
64
|
+
assert.equal(vis({ agent_scope: "coder" }, anon), true);
|
|
65
|
+
assert.equal(vis({ agent_scope: "coder", agent_scope_source: "auto" }, anon), true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("searchMemories: explicit foreign filtered, auto/legacy foreign stay visible under strictScope", async () => {
|
|
69
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: true });
|
|
70
|
+
store.save({ type: "project", title: "global needle", content: "x" });
|
|
71
|
+
store.save({ type: "project", title: "mine needle", content: "x", agent_scope: "me", agent_scope_source: "explicit" });
|
|
72
|
+
store.save({ type: "project", title: "explicit foreign needle", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
|
|
73
|
+
store.save({ type: "project", title: "auto foreign needle", content: "x", agent_scope: "other", agent_scope_source: "auto" });
|
|
74
|
+
store.save({ type: "project", title: "legacy foreign needle", content: "x", agent_scope: "other" });
|
|
75
|
+
|
|
76
|
+
const rows = await service.searchMemories("needle", {
|
|
77
|
+
mode: "keyword",
|
|
78
|
+
scope: { agent_scope: "me", workspace_scope: null }
|
|
79
|
+
});
|
|
80
|
+
// explicit 他者出局;auto/存量他者保留(A2 降权仍可见);未标注与命中行保留。
|
|
81
|
+
assert.deepEqual(rows.map((m) => m.title).sort(), [
|
|
82
|
+
"auto foreign needle", "global needle", "legacy foreign needle", "mine needle"
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("strictScope off keeps A2 behavior: foreign rows demoted but visible", async () => {
|
|
87
|
+
const { store, service } = setup({ scopeEnabled: true });
|
|
88
|
+
store.save({ type: "project", title: "foreign needle", content: "x", agent_scope: "other" });
|
|
89
|
+
const rows = await service.searchMemories("needle", {
|
|
90
|
+
mode: "keyword",
|
|
91
|
+
scope: { agent_scope: "me", workspace_scope: null }
|
|
92
|
+
});
|
|
93
|
+
assert.deepEqual(rows.map((m) => m.title), ["foreign needle"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("store.list/count visibility: explicit hard wall + auto/legacy pass, consistent totals", () => {
|
|
97
|
+
const { store } = setup({ scopeEnabled: true, strictScope: true });
|
|
98
|
+
store.save({ type: "decision", title: "g", content: "x" });
|
|
99
|
+
store.save({ type: "decision", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit", workspace_scope: "D:\\p", workspace_scope_source: "explicit" });
|
|
100
|
+
store.save({ type: "decision", title: "theirs-explicit", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
|
|
101
|
+
store.save({ type: "decision", title: "theirs-auto", content: "x", agent_scope: "other", agent_scope_source: "auto" });
|
|
102
|
+
store.save({ type: "decision", title: "theirs-legacy", content: "x", agent_scope: "other" });
|
|
103
|
+
store.save({ type: "decision", title: "other-ws-explicit", content: "x", workspace_scope: "D:\\q", workspace_scope_source: "explicit" });
|
|
104
|
+
|
|
105
|
+
const vis = { agentScope: "me", workspaceScope: "D:\\p" };
|
|
106
|
+
const titles = store.list({ visibility: vis }).map((m) => m.title).sort();
|
|
107
|
+
assert.deepEqual(titles, ["g", "mine", "theirs-auto", "theirs-legacy"]);
|
|
108
|
+
assert.equal(store.count("decision", { visibility: vis }), 4);
|
|
109
|
+
|
|
110
|
+
// 匿名会话:explicit 行全出局,auto/存量/未标注放行。
|
|
111
|
+
const anonTitles = store.list({ visibility: { agentScope: null, workspaceScope: null } }).map((m) => m.title).sort();
|
|
112
|
+
assert.deepEqual(anonTitles, ["g", "theirs-auto", "theirs-legacy"]);
|
|
113
|
+
assert.equal(store.count("decision", { visibility: { agentScope: null, workspaceScope: null } }), 3);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("injectCandidates hard-filters explicit foreign rows when strictScope is on", () => {
|
|
117
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: true });
|
|
118
|
+
store.save({ type: "decision", title: "global", content: "x", importance: 4 });
|
|
119
|
+
store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
120
|
+
store.save({ type: "decision", title: "auto-foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "auto" });
|
|
121
|
+
|
|
122
|
+
const injected = service.injectCandidates({
|
|
123
|
+
maxItems: 5,
|
|
124
|
+
threshold: 3,
|
|
125
|
+
scope: { agent_scope: "me", workspace_scope: null }
|
|
126
|
+
});
|
|
127
|
+
assert.deepEqual(injected.map((m) => m.title).sort(), ["auto-foreign", "global"]);
|
|
128
|
+
|
|
129
|
+
// flag 下(scope 不传)→ 他 scope 照常注入(A2 前行为)。
|
|
130
|
+
const unscoped = service.injectCandidates({ maxItems: 5, threshold: 3 });
|
|
131
|
+
assert.deepEqual(unscoped.map((m) => m.title).sort(), ["auto-foreign", "foreign", "global"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("memory_update/memory_delete hide explicit foreign-scope rows under strictScope (review item 4, aligned with memory_get)", async () => {
|
|
135
|
+
const { store, pick } = setupTools({ scopeEnabled: true, strictScope: true });
|
|
136
|
+
const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other", agent_scope_source: "explicit" });
|
|
137
|
+
const autoTheirs = store.save({ type: "pitfall", title: "auto-theirs", content: "c", agent_scope: "other", agent_scope_source: "auto" });
|
|
138
|
+
const update = pick("memory_update");
|
|
139
|
+
const del = pick("memory_delete");
|
|
140
|
+
|
|
141
|
+
// explicit 他 scope:update 按不存在拒绝、delete 视作不存在——不能凭 id 直改直删。
|
|
142
|
+
await assert.rejects(() => runHandler(update, { id: theirs.id, content: "tampered" }, STRICT_EXEC), /memory not found/);
|
|
143
|
+
assert.equal(store.getById(theirs.id).content, "c", "row untouched by the rejected update");
|
|
144
|
+
const delOut = await runHandler(del, { id: theirs.id }, STRICT_EXEC);
|
|
145
|
+
assert.equal(delOut.deleted, false, "delete treats invisible rows as absent (no existence leak)");
|
|
146
|
+
assert.ok(store.getById(theirs.id), "row NOT deleted");
|
|
147
|
+
|
|
148
|
+
// auto 他 scope:不构成硬墙,照常可改(与 get 的可见性口径一致)。
|
|
149
|
+
const updAuto = await runHandler(update, { id: autoTheirs.id, content: "updated" }, STRICT_EXEC);
|
|
150
|
+
assert.equal(updAuto.memory.id, autoTheirs.id);
|
|
151
|
+
|
|
152
|
+
// 行主人自己照常可改。
|
|
153
|
+
const ownerOut = await runHandler(update, { id: theirs.id, content: "owner edit" }, OTHER_EXEC);
|
|
154
|
+
assert.equal(ownerOut.memory.id, theirs.id);
|
|
155
|
+
|
|
156
|
+
// strictScope 关:update/delete 不做可见性校验(管理语义回归)。
|
|
157
|
+
const { store: store2, pick: pick2 } = setupTools({ scopeEnabled: true });
|
|
158
|
+
const t2 = store2.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other", agent_scope_source: "explicit" });
|
|
159
|
+
await runHandler(pick2("memory_update"), { id: t2.id, content: "edited" }, STRICT_EXEC);
|
|
160
|
+
assert.equal(store2.getById(t2.id).content, "edited");
|
|
161
|
+
const del2 = await runHandler(pick2("memory_delete"), { id: t2.id }, STRICT_EXEC);
|
|
162
|
+
assert.equal(del2.deleted, true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function setupTools(config) {
|
|
166
|
+
const store = createStore(":memory:");
|
|
167
|
+
const service = createService({ store, mirror: null, config });
|
|
168
|
+
const registered = [];
|
|
169
|
+
createTools(
|
|
170
|
+
{ tools: { register(def) { registered.push(def); return () => {}; } } },
|
|
171
|
+
service,
|
|
172
|
+
config
|
|
173
|
+
);
|
|
174
|
+
const pick = (name) => registered.find((def) => def.name === name);
|
|
175
|
+
return { store, pick };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runHandler(def, args, exec) {
|
|
179
|
+
const handler = def.execute.bind(def);
|
|
180
|
+
return handler(args, exec);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const STRICT_EXEC = { agent: { session: { id: "s1", header: { agentPreset: "me", cwd: "D:\\p" } } } };
|
|
184
|
+
const OTHER_EXEC = { agent: { session: { id: "s2", header: { agentPreset: "other", cwd: "D:\\q" } } } };
|
|
185
|
+
|
|
186
|
+
test("memory_get hides explicit foreign-scope rows under strictScope, resolvable for the owner", async () => {
|
|
187
|
+
const { store, pick } = setupTools({ scopeEnabled: true, strictScope: true });
|
|
188
|
+
const mine = store.save({ type: "pitfall", title: "mine", content: "c", agent_scope: "me", agent_scope_source: "explicit" });
|
|
189
|
+
const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other", agent_scope_source: "explicit" });
|
|
190
|
+
const autoTheirs = store.save({ type: "pitfall", title: "auto-theirs", content: "c", agent_scope: "other", agent_scope_source: "auto" });
|
|
191
|
+
const get = pick("memory_get");
|
|
192
|
+
|
|
193
|
+
const out = await runHandler(get, { id: mine.id }, STRICT_EXEC);
|
|
194
|
+
assert.equal(out.memory.title, "mine");
|
|
195
|
+
|
|
196
|
+
// explicit 他 scope → 按不存在处理(无存在性泄漏)。
|
|
197
|
+
await assert.rejects(() => runHandler(get, { id: theirs.id }, STRICT_EXEC), /memory not found/);
|
|
198
|
+
// auto 他 scope → 可见(软加权不进硬过滤)。
|
|
199
|
+
const autoOut = await runHandler(get, { id: autoTheirs.id }, STRICT_EXEC);
|
|
200
|
+
assert.equal(autoOut.memory.title, "auto-theirs");
|
|
201
|
+
// 行主人自己照常可取。
|
|
202
|
+
const ownerOut = await runHandler(get, { id: theirs.id }, OTHER_EXEC);
|
|
203
|
+
assert.equal(ownerOut.memory.title, "theirs");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("memory_get without strictScope returns any row regardless of scope annotation", async () => {
|
|
207
|
+
const { store, pick } = setupTools({ scopeEnabled: true });
|
|
208
|
+
const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other", agent_scope_source: "explicit" });
|
|
209
|
+
const get = pick("memory_get");
|
|
210
|
+
const out = await runHandler(get, { id: theirs.id }, STRICT_EXEC);
|
|
211
|
+
assert.equal(out.memory.title, "theirs");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("memory_list filters explicit rows and keeps total consistent under strictScope", async () => {
|
|
215
|
+
const { store, pick } = setupTools({ scopeEnabled: true, strictScope: true });
|
|
216
|
+
store.save({ type: "decision", title: "g", content: "x" });
|
|
217
|
+
store.save({ type: "decision", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" });
|
|
218
|
+
store.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
|
|
219
|
+
const list = pick("memory_list");
|
|
220
|
+
|
|
221
|
+
const out = await runHandler(list, { type: "decision" }, STRICT_EXEC);
|
|
222
|
+
assert.deepEqual(out.items.map((m) => m.title).sort(), ["g", "mine"]);
|
|
223
|
+
assert.equal(out.total, 2);
|
|
224
|
+
|
|
225
|
+
// flag 关(A2 默认):他 scope 的行照常出现在浏览视图。
|
|
226
|
+
const { store: store2, pick: pick2 } = setupTools({ scopeEnabled: true });
|
|
227
|
+
store2.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
|
|
228
|
+
const out2 = await runHandler(pick2("memory_list"), { type: "decision" }, STRICT_EXEC);
|
|
229
|
+
assert.deepEqual(out2.items.map((m) => m.title), ["theirs"]);
|
|
230
|
+
assert.equal(out2.total, 1);
|
|
231
|
+
});
|
package/test/service.test.js
CHANGED
|
@@ -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
|
+
});
|