@modusensus/dsh-mneme 0.9.1 → 0.9.2
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 +7 -6
- package/bin/cli.mjs +61 -7
- package/docs/CONFIGURATION.md +23 -0
- package/lib/api.js +57 -2
- package/lib/client.js +72 -9
- package/lib/config.js +17 -0
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +52 -22
- package/lib/scope-state.js +277 -0
- package/lib/service.js +80 -3
- package/lib/settings.js +7 -0
- package/lib/store.js +139 -0
- package/lib/tools.js +71 -0
- package/package.json +1 -1
- package/src/api.js +57 -2
- package/src/config.js +17 -0
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +52 -22
- package/src/scope-state.js +277 -0
- package/src/service.js +80 -3
- package/src/settings.js +7 -0
- package/src/store.js +139 -0
- package/src/tools.js +71 -0
- package/test/api.test.js +113 -1
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +64 -3
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -0
- package/test/readme-claims.test.js +68 -0
- package/test/scope-state.test.js +481 -0
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
|
@@ -0,0 +1,481 @@
|
|
|
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 { runSleep } from "../src/dream/sleep.js";
|
|
7
|
+
import { createDreamScheduler } from "../src/dream.js";
|
|
8
|
+
|
|
9
|
+
// issue #471:作用域级可逆闸门(政策轴)。
|
|
10
|
+
//
|
|
11
|
+
// 与 #17 / #170 那条事实轴(scope 标注与可见性)是两条不同的轴:
|
|
12
|
+
// - 事实轴回答「这条记忆归谁」,答案写在记忆行上,改了它就是在改事实;
|
|
13
|
+
// - 政策轴回答「这一路现在通不通」,答案写在 scope_state 表里,可逆、
|
|
14
|
+
// 不改任何记忆行、不改作用域标注、不动向量与归档位。
|
|
15
|
+
//
|
|
16
|
+
// 三道验收线(对应 issue 正文的三处落点):
|
|
17
|
+
// 1. 默认关:gateByScope 的输出与改动前**逐字段一致**,且是**同一个数组引用**
|
|
18
|
+
// (引用相等才钉得住「零行为变化」,内容相等会让「悄悄复制一份」蒙过去);
|
|
19
|
+
// 2. 按下与松开:同一作用域的候选进不来,松开后原样回来——两个方向都要断言,
|
|
20
|
+
// 只锁「进不来」的话,一个「永远返回空」的实现也能过;
|
|
21
|
+
// 3. 观察档:enabled 开、enforce 关 → 留一条 llm_audit_logs 但注入照常,
|
|
22
|
+
// 这条是两级开关的意义所在(先在真实流量里看「如果按下会少掉多少」)。
|
|
23
|
+
|
|
24
|
+
function setup(config) {
|
|
25
|
+
const store = createStore(":memory:");
|
|
26
|
+
const service = createService({ store, mirror: null, config });
|
|
27
|
+
return { store, service };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PAUSED_CONFIG = { scopeState: { enabled: true, enforce: true } };
|
|
31
|
+
const OBSERVE_CONFIG = { scopeState: { enabled: true, enforce: false } };
|
|
32
|
+
|
|
33
|
+
/** 造一批带 workspace 标注的记忆,一半属于 `proj-a`,一半无标注。 */
|
|
34
|
+
function seed(store) {
|
|
35
|
+
store.save({ type: "decision", title: "a-1", content: "alpha beta gamma", importance: 4, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
36
|
+
store.save({ type: "decision", title: "a-2", content: "alpha beta gamma", importance: 4, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
37
|
+
store.save({ type: "decision", title: "b-1", content: "alpha beta gamma", importance: 4, workspace_scope: "proj-b", workspace_scope_source: "explicit" });
|
|
38
|
+
store.save({ type: "decision", title: "g-1", content: "alpha beta gamma", importance: 4 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const titles = (rows) => rows.map((m) => m.title).sort();
|
|
42
|
+
|
|
43
|
+
// --- 验收线 1:默认关时逐字段一致,且引用不变 -----------------------------
|
|
44
|
+
|
|
45
|
+
test("默认关:gateByScope 原样返回同一个数组(引用相等,不是等值副本)", () => {
|
|
46
|
+
const { store, service } = setup({});
|
|
47
|
+
seed(store);
|
|
48
|
+
const rows = store.list({});
|
|
49
|
+
const out = service.filterPaused(rows);
|
|
50
|
+
// 引用相等:内容相等的副本会让「复制一份再返回」蒙过去,那样后续每次
|
|
51
|
+
// 检索都白复制一遍,而「零行为变化」这条验收就退化成「看起来一样」。
|
|
52
|
+
assert.equal(out, rows);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("默认关:按下过状态也不生效——开关不打开时状态表根本不被读", () => {
|
|
56
|
+
const { store, service } = setup({});
|
|
57
|
+
seed(store);
|
|
58
|
+
// 即便有人在开关关着的时候写进了状态(比如另一台机器同步过来),也不生效。
|
|
59
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
60
|
+
const rows = store.list({});
|
|
61
|
+
assert.equal(service.filterPaused(rows), rows);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("默认关:注入候选集与无闸门时逐条相同", () => {
|
|
65
|
+
const off = setup({});
|
|
66
|
+
seed(off.store);
|
|
67
|
+
const baseline = titles(off.service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
68
|
+
|
|
69
|
+
const pressed = setup({});
|
|
70
|
+
seed(pressed.store);
|
|
71
|
+
pressed.service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
72
|
+
const after = titles(pressed.service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
73
|
+
|
|
74
|
+
assert.deepEqual(after, baseline);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// --- 验收线 2:按下与松开(两个方向都锁) ---------------------------------
|
|
78
|
+
|
|
79
|
+
test("按下后:被按下作用域的候选进不来注入池,别的照常进", () => {
|
|
80
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
81
|
+
seed(store);
|
|
82
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
83
|
+
|
|
84
|
+
const got = titles(service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
85
|
+
assert.deepEqual(got, ["b-1", "g-1"]);
|
|
86
|
+
// 反向断言:不是「全都进不来」。
|
|
87
|
+
assert.ok(got.includes("b-1") && got.includes("g-1"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("松开后:原样回来(同一个 service 实例,不重开库)", () => {
|
|
91
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
92
|
+
seed(store);
|
|
93
|
+
const before = titles(service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
94
|
+
|
|
95
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
96
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), ["b-1", "g-1"]);
|
|
97
|
+
|
|
98
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "active" });
|
|
99
|
+
// 逐条相同(不只「数量相同」):松开要真的恢复原样,不是恢复个大概。
|
|
100
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), before);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("按下不动事实:记忆行、作用域标注、归档位一字段都不变", () => {
|
|
104
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
105
|
+
seed(store);
|
|
106
|
+
const snapshot = () => store.list({}).map((m) => ({
|
|
107
|
+
id: m.id, title: m.title, workspace_scope: m.workspace_scope,
|
|
108
|
+
workspace_scope_source: m.workspace_scope_source, archived: m.archived, forgotten: m.forgotten
|
|
109
|
+
})).sort((x, y) => x.title.localeCompare(y.title));
|
|
110
|
+
|
|
111
|
+
const before = snapshot();
|
|
112
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
113
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "active" });
|
|
114
|
+
assert.deepEqual(snapshot(), before);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("检索侧同款:被按下的作用域在 searchMemories 里也出局", async () => {
|
|
118
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
119
|
+
seed(store);
|
|
120
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
121
|
+
|
|
122
|
+
const hits = await service.searchMemories("alpha", { mode: "keyword", limit: 10 });
|
|
123
|
+
const got = titles(hits);
|
|
124
|
+
assert.ok(!got.includes("a-1") && !got.includes("a-2"), `unexpected: ${got.join(",")}`);
|
|
125
|
+
assert.ok(got.includes("b-1"));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("全局按下('*')拦下该维度所有有标注的行,但不波及未标注行", () => {
|
|
129
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
130
|
+
seed(store);
|
|
131
|
+
service.setScopeState({ dimension: "workspace", scope_value: "*", state: "paused" });
|
|
132
|
+
|
|
133
|
+
// g-1 无 workspace 标注 ⇒ 在 workspace 这一维上不参与比较,不受影响。
|
|
134
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), ["g-1"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("维度独立:按下 agent 维不影响 workspace 维的行", () => {
|
|
138
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
139
|
+
seed(store);
|
|
140
|
+
service.setScopeState({ dimension: "agent", scope_value: "proj-a", state: "paused" });
|
|
141
|
+
|
|
142
|
+
// 这批行没有 agent 标注,agent 维的按下一条都命中不了。
|
|
143
|
+
const got = titles(service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
144
|
+
assert.deepEqual(got, ["a-1", "a-2", "b-1", "g-1"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// --- 验收线 3:观察档留审计但不拦 -----------------------------------------
|
|
148
|
+
|
|
149
|
+
test("观察档:注入照常,但留一条 scopeState 审计行", () => {
|
|
150
|
+
const { store, service } = setup(OBSERVE_CONFIG);
|
|
151
|
+
seed(store);
|
|
152
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
153
|
+
|
|
154
|
+
const got = titles(service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
155
|
+
// 观察档不拦:四条全在。
|
|
156
|
+
assert.deepEqual(got, ["a-1", "a-2", "b-1", "g-1"]);
|
|
157
|
+
|
|
158
|
+
const audits = store.listLlmAudits({ source: "scopeState" });
|
|
159
|
+
assert.equal(audits.length, 1);
|
|
160
|
+
assert.equal(audits[0].operation_type, "scope_state_gate");
|
|
161
|
+
assert.equal(audits[0].model_id, "-");
|
|
162
|
+
assert.equal(audits[0].status, "skipped");
|
|
163
|
+
assert.equal(audits[0].metadata.enforced, false);
|
|
164
|
+
// gated 记的是「本应拦下几条」,不是「拦了几条」——观察档下后者恒 0,
|
|
165
|
+
// 记它就等于没记。
|
|
166
|
+
assert.equal(audits[0].metadata.gated, 2);
|
|
167
|
+
assert.deepEqual(audits[0].metadata.paused, [{ dimension: "workspace", scope_value: "proj-a" }]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("拦截档:同一条审计行但 enforced=true,且真拦下了", () => {
|
|
171
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
172
|
+
seed(store);
|
|
173
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
174
|
+
|
|
175
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), ["b-1", "g-1"]);
|
|
176
|
+
|
|
177
|
+
const audits = store.listLlmAudits({ source: "scopeState" });
|
|
178
|
+
assert.equal(audits.length, 1);
|
|
179
|
+
assert.equal(audits[0].metadata.enforced, true);
|
|
180
|
+
assert.equal(audits[0].metadata.gated, 2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("没有候选被拦时不写审计行(门没经过人,不该留痕)", () => {
|
|
184
|
+
const { store, service } = setup(OBSERVE_CONFIG);
|
|
185
|
+
seed(store);
|
|
186
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-zzz", state: "paused" });
|
|
187
|
+
service.injectCandidates({ maxItems: 10, threshold: 1 });
|
|
188
|
+
assert.equal(store.listLlmAudits({ source: "scopeState" }).length, 0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("enforce 开、enabled 关:无效果(enforce 不单独生效,同 preInjectGate)", () => {
|
|
192
|
+
const { store, service } = setup({ scopeState: { enabled: false, enforce: true } });
|
|
193
|
+
seed(store);
|
|
194
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
195
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), ["a-1", "a-2", "b-1", "g-1"]);
|
|
196
|
+
assert.equal(store.listLlmAudits({ source: "scopeState" }).length, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// --- 状态表本身的语义 -----------------------------------------------------
|
|
200
|
+
|
|
201
|
+
test("同一状态重复按下不刷新 decided_at(按两次不等于换了决定)", () => {
|
|
202
|
+
const { service } = setup(PAUSED_CONFIG);
|
|
203
|
+
const first = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused", decided_at: "2026-01-01T00:00:00.000Z" });
|
|
204
|
+
const again = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused", decided_at: "2026-01-02T00:00:00.000Z" });
|
|
205
|
+
assert.equal(first.decided_at, "2026-01-01T00:00:00.000Z");
|
|
206
|
+
assert.equal(again.decided_at, "2026-01-01T00:00:00.000Z");
|
|
207
|
+
|
|
208
|
+
const flipped = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "active", decided_at: "2026-01-03T00:00:00.000Z" });
|
|
209
|
+
assert.equal(flipped.decided_at, "2026-01-03T00:00:00.000Z");
|
|
210
|
+
assert.equal(flipped.state, "active");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("不带理由的重按不抹掉已有理由(理由被一次手滑抹掉是净损失)", () => {
|
|
214
|
+
const { service } = setup(PAUSED_CONFIG);
|
|
215
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused", note: "等这轮重构完再开" });
|
|
216
|
+
const again = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "active" });
|
|
217
|
+
assert.equal(again.note, "等这轮重构完再开");
|
|
218
|
+
|
|
219
|
+
const replaced = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused", note: "换了个理由" });
|
|
220
|
+
assert.equal(replaced.note, "换了个理由");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("非法维度/值/状态返回 undefined,不静默落一行永不生效的状态", () => {
|
|
224
|
+
const { service, store } = setup(PAUSED_CONFIG);
|
|
225
|
+
assert.equal(service.setScopeState({ dimension: "agent", scope_value: "ok", state: "maybe" }), undefined);
|
|
226
|
+
assert.equal(service.setScopeState({ dimension: "nope", scope_value: "ok", state: "paused" }), undefined);
|
|
227
|
+
assert.equal(service.setScopeState({ dimension: "agent", scope_value: " ", state: "paused" }), undefined);
|
|
228
|
+
assert.equal(store.listScopeStates().length, 0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("一次现读:另一个实例改了状态,本实例下一次判定立刻看得见(没有缓存要失效)", () => {
|
|
232
|
+
const store = createStore(":memory:");
|
|
233
|
+
const a = createService({ store, mirror: null, config: PAUSED_CONFIG });
|
|
234
|
+
const b = createService({ store, mirror: null, config: PAUSED_CONFIG });
|
|
235
|
+
store.save({ type: "decision", title: "a-1", content: "alpha beta gamma", importance: 4, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
236
|
+
|
|
237
|
+
assert.deepEqual(titles(a.injectCandidates({ maxItems: 10, threshold: 1 })), ["a-1"]);
|
|
238
|
+
b.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
239
|
+
assert.deepEqual(titles(a.injectCandidates({ maxItems: 10, threshold: 1 })), []);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// --- 工具面 ---------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
function makeTools(config) {
|
|
245
|
+
const store = createStore(":memory:");
|
|
246
|
+
const service = createService({ store, mirror: null, config });
|
|
247
|
+
const ctx = {
|
|
248
|
+
tools: { register() {} },
|
|
249
|
+
logger: { warn() {}, info() {}, error() {} }
|
|
250
|
+
};
|
|
251
|
+
const tools = createTools(ctx, service, config, null);
|
|
252
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
253
|
+
return { store, service, tool: byName.get("set_scope_state") };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
test("set_scope_state 回执回显库内当前值,不是回显入参", async () => {
|
|
257
|
+
const { tool } = makeTools(PAUSED_CONFIG);
|
|
258
|
+
const first = await tool.execute({ dimension: "workspace", scope_value: "proj-a", state: "paused", note: "先停一下" });
|
|
259
|
+
assert.deepEqual(first.scope, {
|
|
260
|
+
dimension: "workspace",
|
|
261
|
+
scope_value: "proj-a",
|
|
262
|
+
state: "paused",
|
|
263
|
+
note: "先停一下",
|
|
264
|
+
decided_at: first.scope.decided_at
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// 同一状态重按:入参没带 note,但回执里 note 还在,decided_at 也没变。
|
|
268
|
+
const again = await tool.execute({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
269
|
+
assert.equal(again.scope.decided_at, first.scope.decided_at);
|
|
270
|
+
assert.equal(again.scope.note, "先停一下");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("set_scope_state 对非法参数抛可行动的错,不是静默返回空", async () => {
|
|
274
|
+
const { tool } = makeTools(PAUSED_CONFIG);
|
|
275
|
+
await assert.rejects(() => tool.execute({ dimension: "nope", scope_value: "x", state: "paused" }), /dimension/);
|
|
276
|
+
await assert.rejects(() => tool.execute({ dimension: "agent", scope_value: "x", state: "maybe" }), /state/);
|
|
277
|
+
await assert.rejects(() => tool.execute({ dimension: "agent", scope_value: "", state: "paused" }), /scope_value/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("检视面刻意不过闸:按下之后 memory_list / memory_get 仍看得见那一行", async () => {
|
|
281
|
+
const config = { ...PAUSED_CONFIG, strictScope: false };
|
|
282
|
+
const store = createStore(":memory:");
|
|
283
|
+
const service = createService({ store, mirror: null, config });
|
|
284
|
+
const ctx = { tools: { register() {} }, logger: { warn() {}, info() {}, error() {} } };
|
|
285
|
+
const tools = new Map(createTools(ctx, service, config, null).map((t) => [t.name, t]));
|
|
286
|
+
|
|
287
|
+
const memory = store.save({ type: "project", title: "hidden", content: "body", importance: 3, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
288
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
289
|
+
|
|
290
|
+
// 供料面:被按下 → 不进注入池。
|
|
291
|
+
const injected = service.injectCandidates({ maxItems: 5, threshold: 0, scope: { agent_scope: null, workspace_scope: "proj-a" } });
|
|
292
|
+
assert.ok(!injected.some((m) => m.id === memory.id), "供料面必须已挡住");
|
|
293
|
+
|
|
294
|
+
// 检视面:仍看得见。这是刻意的——按下的是政策不是事实,人还要能查
|
|
295
|
+
// 「我按下了什么、里面有什么」,否则就没有入口确认该不该松开。
|
|
296
|
+
// 若将来有人「顺手」把闸门也接到 list/get 上,这条会红。
|
|
297
|
+
const listed = await tools.get("memory_list").execute({});
|
|
298
|
+
assert.ok(listed.items.some((m) => m.id === memory.id), "memory_list 不该被政策轴关掉(检视面)");
|
|
299
|
+
const got = await tools.get("memory_get").execute({ id: memory.id });
|
|
300
|
+
assert.equal(got.memory.id, memory.id, "memory_get 不该被政策轴关掉(检视面)");
|
|
301
|
+
store.close();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// --- 睡眠侧:政策轴不只管「看得见」,还要管「改得到」 -----------------------
|
|
305
|
+
//
|
|
306
|
+
// 上面所有用例都停在读侧(检索出局、注入不进)。但 sleep 的四个相位里有三个会
|
|
307
|
+
// **写**:demotion 会 setArchived / demoteToSummary(真改归档面),patterns 会
|
|
308
|
+
// mint 出 type=pattern 的新行。只挡读侧的话,闸门开着而每 6 小时一次的睡眠照样
|
|
309
|
+
// 在被按下的作用域上动手——「按下」就只等于让它在白天看不见,夜里照旧。
|
|
310
|
+
//
|
|
311
|
+
// 每条都配一个同轮的反向断言(未被按下的同类记忆**确实**被处理了):只断言
|
|
312
|
+
// 「暂停的没动」的话,一个「整个相位都没跑」的实现也能全绿。
|
|
313
|
+
|
|
314
|
+
/** demotion 要用时间窗 + 热联合判定,故把 project 的 λ 调快到冷态可降级。 */
|
|
315
|
+
function sleepConfig(overrides = {}) {
|
|
316
|
+
return {
|
|
317
|
+
...PAUSED_CONFIG,
|
|
318
|
+
sleepModeEnabled: true,
|
|
319
|
+
heatTypeDecay: { project: 0.02 },
|
|
320
|
+
sleepArchiveDays: 30,
|
|
321
|
+
sleepCompressDays: 90,
|
|
322
|
+
sleepPatternMinMemories: 100,
|
|
323
|
+
sleepMaxPatternPerRun: 3,
|
|
324
|
+
...overrides
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function sleepCtx() {
|
|
329
|
+
return {
|
|
330
|
+
logger: { warn() {}, info() {} },
|
|
331
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
|
|
332
|
+
llm: {
|
|
333
|
+
async *stream() {
|
|
334
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
335
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 标注走 store.save 的入参——那是存储层唯一的落标注路径,saveWithDedupe 不收
|
|
342
|
+
// 这两个字段(标注是 scope.js 在工具层盖的)。这一点本身也是被测的性质:
|
|
343
|
+
// 政策轴按**行上已有的标注**匹配,与标注从哪条路写进来无关。
|
|
344
|
+
|
|
345
|
+
test("睡眠侧:被按下作用域的冷记忆不被 demotion 降级,同轮的未按下记忆照常降级", async () => {
|
|
346
|
+
const { store, service } = setup(sleepConfig());
|
|
347
|
+
const paused = store.save({
|
|
348
|
+
type: "project", title: "paused-cold", content: "paused-cold 的正文".repeat(20), importance: 3,
|
|
349
|
+
workspace_scope: "proj-a", workspace_scope_source: "explicit"
|
|
350
|
+
});
|
|
351
|
+
const open = store.save({
|
|
352
|
+
type: "project", title: "open-cold", content: "open-cold 的正文".repeat(20), importance: 3
|
|
353
|
+
});
|
|
354
|
+
for (const m of [paused, open]) {
|
|
355
|
+
service.touchLastAccess(m.id, new Date(Date.now() - 40 * 86400000).toISOString());
|
|
356
|
+
}
|
|
357
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
358
|
+
|
|
359
|
+
const ctx = sleepCtx();
|
|
360
|
+
await runSleep(ctx, service, sleepConfig(), ctx.logger, null, null);
|
|
361
|
+
|
|
362
|
+
const afterPaused = service.getById(paused.id);
|
|
363
|
+
const afterOpen = service.getById(open.id);
|
|
364
|
+
assert.equal(afterPaused._full_content ?? null, null, "被按下的那条一个字都不该被改写");
|
|
365
|
+
assert.ok(afterOpen._full_content, "同轮里未按下的冷记忆必须真被降级——否则本用例只是证明了相位没跑");
|
|
366
|
+
store.close();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("睡眠侧:被按下作用域的冷记忆不被 demotion 归档,同轮的未按下记忆照常归档", async () => {
|
|
370
|
+
const { store, service } = setup(sleepConfig());
|
|
371
|
+
const paused = store.save({
|
|
372
|
+
type: "project", title: "paused-ancient", content: "paused-ancient 的正文", importance: 3,
|
|
373
|
+
agent_scope: "agent-x", agent_scope_source: "explicit"
|
|
374
|
+
});
|
|
375
|
+
const open = store.save({
|
|
376
|
+
type: "project", title: "open-ancient", content: "open-ancient 的正文", importance: 3
|
|
377
|
+
});
|
|
378
|
+
// 推到 compressCut 之外(>= 90 天)→ 直接归档而不是压缩成摘要。
|
|
379
|
+
const ancient = new Date(Date.now() - 100 * 86400000).toISOString();
|
|
380
|
+
for (const m of [paused, open]) service.touchLastAccess(m.id, ancient);
|
|
381
|
+
service.setScopeState({ dimension: "agent", scope_value: "agent-x", state: "paused" });
|
|
382
|
+
|
|
383
|
+
const ctx = sleepCtx();
|
|
384
|
+
await runSleep(ctx, service, sleepConfig(), ctx.logger, null, null);
|
|
385
|
+
|
|
386
|
+
assert.equal(service.getById(paused.id).archived, false, "被按下的作用域不该被睡眠归档");
|
|
387
|
+
assert.equal(service.getById(open.id).archived, true, "未按下的同类必须真被归档");
|
|
388
|
+
store.close();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("睡眠侧:被按下作用域的行不进 pattern 扫描池,也就不该占掉名额", async () => {
|
|
392
|
+
const { store, service } = setup(sleepConfig());
|
|
393
|
+
// 顺序是这条用例的关键:池子按 updated_at 降序取前 sleepPatternMinMemories 条,
|
|
394
|
+
// 所以**被按下的必须最后创建**(= 最新),才能在「政策轴排在截断之后」的实现里
|
|
395
|
+
// 抢到名额。第一版把被按下的写在前头,未修复代码取到的恰好是两条 open,
|
|
396
|
+
// 用例照样全绿——那是「没测到东西」,不是「测过了」。
|
|
397
|
+
store.save({ type: "project", title: "open-1", content: "open-1 正文", importance: 3 });
|
|
398
|
+
store.save({ type: "project", title: "open-2", content: "open-2 正文", importance: 3 });
|
|
399
|
+
for (const t of ["p1", "p2"]) {
|
|
400
|
+
store.save({ type: "project", title: t, content: `${t} 正文`, importance: 3, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
401
|
+
}
|
|
402
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
403
|
+
|
|
404
|
+
const seen = [];
|
|
405
|
+
const ctx = {
|
|
406
|
+
...sleepCtx(),
|
|
407
|
+
llm: {
|
|
408
|
+
async *stream(options) {
|
|
409
|
+
const text = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
410
|
+
seen.push(text);
|
|
411
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
412
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
await runSleep(ctx, service, sleepConfig({ sleepPatternMinMemories: 2 }), ctx.logger, null, null);
|
|
417
|
+
|
|
418
|
+
const scanned = seen.join("\n");
|
|
419
|
+
assert.ok(scanned.includes("open-1"), "未按下的记忆必须进扫描池——被按下的不该把它们挤出名额");
|
|
420
|
+
assert.ok(!scanned.includes("p1"), "被按下的记忆不该出现在模式扫描的提示里");
|
|
421
|
+
store.close();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("睡眠侧:被按下作用域的行不进关系补全的候选池", async () => {
|
|
425
|
+
const { store, service } = setup(sleepConfig());
|
|
426
|
+
const alpha = service.createEntity({ name: "Alpha", type: "project" });
|
|
427
|
+
service.createEntity({ name: "Beta", type: "project" });
|
|
428
|
+
store.save({
|
|
429
|
+
type: "project", title: "t", content: "Alpha 与 Beta 一起出现", importance: 3,
|
|
430
|
+
workspace_scope: "proj-a", workspace_scope_source: "explicit"
|
|
431
|
+
});
|
|
432
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
433
|
+
|
|
434
|
+
const ctx = sleepCtx();
|
|
435
|
+
await runSleep(ctx, service, sleepConfig(), ctx.logger, null, null);
|
|
436
|
+
assert.equal((service.getRelations(alpha.id) ?? []).length, 0, "被按下的记忆不该被拿去连关系");
|
|
437
|
+
store.close();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------- dream 侧
|
|
441
|
+
|
|
442
|
+
test("巩固侧:被按下作用域的行不进总览输入——否则会被洗成无作用域行", async () => {
|
|
443
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
444
|
+
service.saveWithDedupe({ type: "project", title: "open-title", content: "open 正文", importance: 3 });
|
|
445
|
+
// 标注走 store.save 的入参——saveWithDedupe 不收这两个字段(标注是 scope.js 在工具层盖的)。
|
|
446
|
+
store.save({ type: "project", title: "paused-title", content: "paused 正文", importance: 3, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
447
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
448
|
+
|
|
449
|
+
const summaryPrompts = [];
|
|
450
|
+
const ctx = {
|
|
451
|
+
logger: { warn() {} },
|
|
452
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
453
|
+
llm: {
|
|
454
|
+
async *stream(options) {
|
|
455
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
456
|
+
if (userText.startsWith("id=")) {
|
|
457
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
458
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
summaryPrompts.push(userText);
|
|
462
|
+
yield { type: "text-delta", index: 0, text: "总览正文" };
|
|
463
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true }), thresholdCount: 1 });
|
|
468
|
+
await dream.runDream(ctx, service, {});
|
|
469
|
+
|
|
470
|
+
const fed = summaryPrompts.join("\n");
|
|
471
|
+
assert.ok(fed.includes("open-title"), "未按下的记忆必须进总览输入——否则本用例只是证明了总览没跑");
|
|
472
|
+
assert.ok(!fed.includes("paused-title"), "被按下的记忆不该进总览输入");
|
|
473
|
+
|
|
474
|
+
// 这是这条用例真正的要点:总览落库时是**无作用域**的 summary 行,
|
|
475
|
+
// 所以一旦被按下的内容混进去,注入侧按作用域过滤就再也拦不住它——
|
|
476
|
+
// 内容已经被洗成全局行了。别的入口漏了只是「漏一条」,这里漏了是「一直漏」。
|
|
477
|
+
const overview = service.all().find((m) => m.type === "summary" && m.source === "dream");
|
|
478
|
+
assert.ok(overview, "总览仍应正常落库(闸门不该把总览整个关掉)");
|
|
479
|
+
assert.equal(overview.workspace_scope ?? null, null, "总览本身没有作用域标注——正因如此,输入必须在源头过滤");
|
|
480
|
+
store.close();
|
|
481
|
+
});
|
|
@@ -38,7 +38,8 @@ const TOOL_NAMES = [
|
|
|
38
38
|
"memory_forget",
|
|
39
39
|
"memory_archive",
|
|
40
40
|
"memory_register_document",
|
|
41
|
-
"memory_runtime"
|
|
41
|
+
"memory_runtime",
|
|
42
|
+
"set_scope_state"
|
|
42
43
|
];
|
|
43
44
|
|
|
44
45
|
function setup(config = {}) {
|
|
@@ -121,7 +122,7 @@ test("工具矩阵与 TOOL_NAMES 清单一致(新增工具必须补进矩阵
|
|
|
121
122
|
assert.deepEqual(Object.keys(byName).sort(), [...TOOL_NAMES].sort());
|
|
122
123
|
});
|
|
123
124
|
|
|
124
|
-
test("A 实跑:
|
|
125
|
+
test("A 实跑:11 个工具每个可安全触达的分支,真实返回 ⊆ 声明的 output schema", async () => {
|
|
125
126
|
const { store, service, byName } = setup({ documentMemoryEnabled: true });
|
|
126
127
|
const shapes = seedShapes(store);
|
|
127
128
|
const annotated = shapes[shapes.length - 1];
|
|
@@ -197,6 +198,14 @@ test("A 实跑:10 个工具每个可安全触达的分支,真实返回 ⊆
|
|
|
197
198
|
assert.equal((await drive(byName, "memory_archive", { id: archiveTarget.id, archived: false })).memory.archived, false);
|
|
198
199
|
note("memory_archive");
|
|
199
200
|
|
|
201
|
+
// --- set_scope_state(#471):两个方向的分支——按下与松开各驱动一次 ---
|
|
202
|
+
const pressed = await drive(byName, "set_scope_state", { dimension: "workspace", scope_value: "proj-alpha", state: "paused" });
|
|
203
|
+
assert.equal(pressed.scope.state, "paused");
|
|
204
|
+
assert.equal(pressed.scope.scope_value, "proj-alpha");
|
|
205
|
+
const released = await drive(byName, "set_scope_state", { dimension: "workspace", scope_value: "proj-alpha", state: "active" });
|
|
206
|
+
assert.equal(released.scope.state, "active");
|
|
207
|
+
note("set_scope_state");
|
|
208
|
+
|
|
200
209
|
// --- memory_runtime:只驱动只读与「无载荷早退」两条分支 ---
|
|
201
210
|
// provision 会联网下载数十至上百 MB(且可能 adopt 本机载荷、硬链接数以百 MB 计),
|
|
202
211
|
// verify 命中载荷时会真实加载模型——两者都不属于单元测试该做的事。status 与其
|
|
@@ -285,7 +294,7 @@ test("B' search / list / get 三个消费方共用同一份 item schema(#184
|
|
|
285
294
|
}
|
|
286
295
|
});
|
|
287
296
|
|
|
288
|
-
test("C 结构:全部
|
|
297
|
+
test("C 结构:全部 11 个工具的 output schema 闭合、required ⊆ properties、每项带 type", () => {
|
|
289
298
|
const { byName } = setup();
|
|
290
299
|
for (const name of TOOL_NAMES) {
|
|
291
300
|
const schema = byName[name].output.schema;
|
package/test/tools.test.js
CHANGED
|
@@ -49,11 +49,11 @@ function walkSchema(node, path, problems) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
test("registers
|
|
52
|
+
test("registers eleven tools with correct names", () => {
|
|
53
53
|
const { registered } = setup();
|
|
54
54
|
const names = registered.map((t) => t.name).sort();
|
|
55
55
|
// #230 新增 memory_register_document(agent 产长文档的指针行铸造口)。
|
|
56
|
-
assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update"]);
|
|
56
|
+
assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update", "set_scope_state"]);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
// v0.8.5 tool-exposure gate: disabling a tool hides it from the model entirely
|
|
@@ -62,19 +62,19 @@ test("registers ten tools with correct names", () => {
|
|
|
62
62
|
test("disableMemorySearch hides only memory_search", () => {
|
|
63
63
|
const { registered } = setup({}, { disableMemorySearch: true });
|
|
64
64
|
const names = registered.map((t) => t.name).sort();
|
|
65
|
-
assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update"]);
|
|
65
|
+
assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update", "set_scope_state"]);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
test("disableMemoryArchive hides only memory_archive", () => {
|
|
69
69
|
const { registered } = setup({}, { disableMemoryArchive: true });
|
|
70
70
|
const names = registered.map((t) => t.name).sort();
|
|
71
|
-
assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update"]);
|
|
71
|
+
assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update", "set_scope_state"]);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
test("both disable flags hide both tools", () => {
|
|
75
75
|
const { registered } = setup({}, { disableMemorySearch: true, disableMemoryArchive: true });
|
|
76
76
|
const names = registered.map((t) => t.name).sort();
|
|
77
|
-
assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update"]);
|
|
77
|
+
assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update", "set_scope_state"]);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
// Description discipline: with all tools exposed, the descriptions must steer a
|
|
@@ -90,7 +90,7 @@ test("memory_search/archive descriptions carry tool-use discipline", () => {
|
|
|
90
90
|
|
|
91
91
|
test("compiled schemas pass the enforced DSH subset (defineTool projection)", () => {
|
|
92
92
|
const { registered } = setup();
|
|
93
|
-
assert.equal(registered.length,
|
|
93
|
+
assert.equal(registered.length, 11);
|
|
94
94
|
for (const tool of registered) {
|
|
95
95
|
assertSupportedJsonSchema(tool.parameters);
|
|
96
96
|
assertSupportedJsonSchema(tool.output.schema);
|