@modusensus/dsh-mneme 0.9.3 → 0.9.5
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 +56 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +10 -2
- package/docs/DAEMON.md +9 -2
- package/docs/DISTILL.md +66 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +1 -1
- package/docs/STORAGE.md +1 -1
- package/lib/api-standalone.js +286 -8
- package/lib/api.js +254 -3
- package/lib/client.js +1000 -50
- package/lib/config.js +50 -1
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +88 -3
- package/lib/dream/sleep.js +63 -13
- package/lib/dream.js +66 -10
- package/lib/index.js +6 -1
- package/lib/inject.js +10 -25
- package/lib/lang.js +48 -6
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +9 -3
- package/lib/pipeline.js +62 -1
- package/lib/profile-extract.js +161 -0
- package/lib/provenance-http.js +126 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +70 -6
- package/lib/settings.js +17 -13
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +272 -11
- package/lib/summarize.js +118 -8
- package/lib/tools.js +240 -1
- package/lib/user-settings.js +78 -0
- package/package.json +1 -1
- package/src/api-standalone.js +286 -8
- package/src/api.js +254 -3
- package/src/config.js +50 -1
- package/src/document.js +15 -2
- package/src/dream/decisions.js +88 -3
- package/src/dream/sleep.js +63 -13
- package/src/dream.js +66 -10
- package/src/index.js +6 -1
- package/src/inject.js +10 -25
- package/src/lang.js +48 -6
- package/src/memory-source.js +9 -0
- package/src/organize.js +9 -3
- package/src/pipeline.js +62 -1
- package/src/profile-extract.js +161 -0
- package/src/provenance-http.js +126 -0
- package/src/serve.js +13 -0
- package/src/service.js +70 -6
- package/src/settings.js +17 -13
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +272 -11
- package/src/summarize.js +118 -8
- package/src/tools.js +240 -1
- package/src/user-settings.js +78 -0
- package/test/api.test.js +384 -3
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +215 -6
- package/test/dedupe-candidate-window.test.js +180 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-fence.test.js +164 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/lang.test.js +11 -0
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +1 -1
- package/test/organize-tool.test.js +232 -0
- package/test/persona-addressing.test.js +83 -0
- package/test/persona-settings.test.js +30 -8
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/profile-extract.test.js +269 -0
- package/test/provenance-http.test.js +184 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/serve.test.js +62 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -0
- package/test/standalone-api.test.js +524 -5
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +240 -2
- package/test/tools-dto-schema-matrix.test.js +48 -5
- package/test/tools.test.js +6 -1
- package/test/user-settings-preview.test.js +127 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// issue #497 的第三段:从记忆提取「用户画像」**草稿**。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要单独立一份用例文件(而不是塞进 dream/summarize 的套件):这条链的产品承诺很窄
|
|
4
|
+
// 也很硬——① 只出草稿,绝不写 user_settings(画像正文是用户手写的原文,让模型合并等于
|
|
5
|
+
// 给它改写权);② 没有可用输入就不调模型;③ 模型吐的不是 JSON 就报错,绝不回一张空草稿
|
|
6
|
+
// 冒充「这次没提炼出东西」;④ 记忆正文是模型的可影响输入,围栏 + 角色重申必须在位,
|
|
7
|
+
// 且草稿里不许出现助手身份声明(画像与规则不受 personaEnabled 管,写进一句「你是某某」
|
|
8
|
+
// 就会重演 #497 那个身份撞车)。这四条都是「做错了界面看不出来」的那类,所以逐条锁。
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
12
|
+
import { createStore } from "../src/store.js";
|
|
13
|
+
import { createService } from "../src/service.js";
|
|
14
|
+
import { createSettings } from "../src/settings.js";
|
|
15
|
+
import { createApi } from "../src/api.js";
|
|
16
|
+
import { createProfileExtractor, ProfileExtractError } from "../src/profile-extract.js";
|
|
17
|
+
|
|
18
|
+
// 假的 ctx.llm:按真实协议发 text-delta / usage / finish 三类 chunk(用量嵌在
|
|
19
|
+
// chunk.usage,与 dream.js 的 streamText 注释一致——把整个 chunk 当用量会在
|
|
20
|
+
// 审计行里落 0,v0.8.4 实测踩过)。messages 抓下来供围栏断言用。
|
|
21
|
+
function fakeLlm(chunks) {
|
|
22
|
+
const calls = [];
|
|
23
|
+
return {
|
|
24
|
+
calls,
|
|
25
|
+
llm: {
|
|
26
|
+
async *stream(options) {
|
|
27
|
+
calls.push(options);
|
|
28
|
+
for (const c of chunks) yield c;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const okJson = (profile) => [
|
|
35
|
+
{ type: "text-delta", text: "```json\n" },
|
|
36
|
+
{ type: "text-delta", text: JSON.stringify({ profile }) },
|
|
37
|
+
{ type: "text-delta", text: "\n```" },
|
|
38
|
+
{ type: "usage", usage: { inputTokens: 120, outputTokens: 30 } },
|
|
39
|
+
{ type: "finish", reason: { kind: "stop" } }
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function setup(chunks, opts = {}) {
|
|
43
|
+
const { config = {}, dropRoute = false } = opts;
|
|
44
|
+
const store = createStore(":memory:");
|
|
45
|
+
const service = createService({ store, mirror: null, config });
|
|
46
|
+
const settings = createSettings(store.db);
|
|
47
|
+
const fake = fakeLlm(chunks);
|
|
48
|
+
const logger = { info() {}, warn() {}, error() {} };
|
|
49
|
+
const extractor = createProfileExtractor({
|
|
50
|
+
ctx: { ...fake, agentDefaultModel: { currentSelection: () => null } },
|
|
51
|
+
service,
|
|
52
|
+
settings,
|
|
53
|
+
config: dropRoute ? { ...config } : { dreamProvider: "fake", dreamModel: "m1", ...config },
|
|
54
|
+
logger
|
|
55
|
+
});
|
|
56
|
+
return { store, service, settings, extractor, fake, logger };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function seedRows(store, n, over = {}) {
|
|
60
|
+
for (let i = 0; i < n; i++) {
|
|
61
|
+
store.save({ type: "project", title: `行 ${i}`, content: `第 ${i} 行的正文`, source: "test", ...over });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("没有可用的模型路由:抛 no-route,且一次调用都不发(不是 TypeError→500)", async () => {
|
|
66
|
+
// resolveRoute 在「无 dreamProvider/dreamModel 且宿主解析不到默认模型」时回
|
|
67
|
+
// undefined。不判它就会去读 route.provider —— TypeError 落到路由上是一个语义为
|
|
68
|
+
// 「内部错误」的 500,而该说的是「先去选模型」。
|
|
69
|
+
const { store, extractor, fake } = setup(okJson("不该发生"), { dropRoute: true });
|
|
70
|
+
seedRows(store, 2);
|
|
71
|
+
await assert.rejects(() => extractor(), (err) => err instanceof ProfileExtractError && err.code === "no-route");
|
|
72
|
+
assert.equal(fake.calls.length, 0, "没有路由就不该把请求发出去");
|
|
73
|
+
store.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("成功路径:出草稿、留一条审计行,而库里的画像逐字节没被动过", async () => {
|
|
77
|
+
const { store, service, settings, extractor } = setup(okJson("我知道她跨考公共管理。"));
|
|
78
|
+
seedRows(store, 3);
|
|
79
|
+
settings.setProfile("原文不许被改写");
|
|
80
|
+
const profileBefore = settings.getProfile();
|
|
81
|
+
|
|
82
|
+
const out = await extractor();
|
|
83
|
+
assert.equal(out.draft, "我知道她跨考公共管理。");
|
|
84
|
+
assert.equal(out.rowsUsed, 3);
|
|
85
|
+
assert.equal(out.truncated, false);
|
|
86
|
+
assert.equal(out.model, "m1");
|
|
87
|
+
// 「只出草稿」是这条链的产品承诺:user_settings 里必须还是那句原文。
|
|
88
|
+
assert.equal(settings.getProfile(), profileBefore);
|
|
89
|
+
|
|
90
|
+
const audits = service.listLlmAudits({ limit: 10 });
|
|
91
|
+
assert.equal(audits.length, 1, "一次提取必须留一行审计");
|
|
92
|
+
assert.equal(audits[0].trigger_source, "profileExtract");
|
|
93
|
+
assert.equal(audits[0].status, "success");
|
|
94
|
+
assert.equal(audits[0].input_tokens, 120, "用量要从 chunk.usage 里取,落在 chunk 顶层会恒为 0");
|
|
95
|
+
store.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("模型吐的不是 JSON:抛错,绝不回一张空草稿冒充「没提炼出来」", async () => {
|
|
99
|
+
const { store, service, settings, extractor } = setup([
|
|
100
|
+
{ type: "text-delta", text: "我觉得她是个很好的用户,加油。" },
|
|
101
|
+
{ type: "finish", reason: { kind: "stop" } }
|
|
102
|
+
]);
|
|
103
|
+
seedRows(store, 2);
|
|
104
|
+
await assert.rejects(() => extractor(), ProfileExtractError, "解析失败必须是可识别的错误类型");
|
|
105
|
+
assert.equal(settings.getProfile(), "", "失败路径一行都不许写");
|
|
106
|
+
const audits = service.listLlmAudits({ limit: 10 });
|
|
107
|
+
assert.equal(audits[0].status, "error", "吐了文字但没有可用产物 = 失败调用,审计不许写 success");
|
|
108
|
+
store.close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("没有可用记忆:不调模型、直接报错(空输入下模型只会编)", async () => {
|
|
112
|
+
const { store, extractor, fake } = setup(okJson("不该被调用"));
|
|
113
|
+
await assert.rejects(() => extractor(), /no/i);
|
|
114
|
+
assert.equal(fake.calls.length, 0, "一条输入都没有就不该花这次调用");
|
|
115
|
+
store.close();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("归档与被遗忘的行不进输入:它们已经不在活跃面上", async () => {
|
|
119
|
+
const { store, service, extractor, fake } = setup(okJson("只用活跃行。"));
|
|
120
|
+
seedRows(store, 3);
|
|
121
|
+
const [gone, forgotten] = store.list({ limit: null });
|
|
122
|
+
service.setArchived(gone.id, true);
|
|
123
|
+
service.setForget(forgotten.id, true);
|
|
124
|
+
const out = await extractor();
|
|
125
|
+
assert.equal(out.rowsUsed, 1, "三行里只剩一行还在活跃面上");
|
|
126
|
+
const user = fake.calls[0].messages[1].content[0].text;
|
|
127
|
+
assert.ok(!user.includes(gone.content), "归档行的正文不许被喂给模型(它已经不在活跃面上)");
|
|
128
|
+
assert.ok(!user.includes(forgotten.content), "被遗忘的行同理");
|
|
129
|
+
store.close();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("行数上限:超界时如实标记 truncated,而不是静默少读", async () => {
|
|
133
|
+
const { store, extractor } = setup(okJson("截断也要说清。"));
|
|
134
|
+
seedRows(store, 30);
|
|
135
|
+
const out = await extractor({ maxRows: 5 });
|
|
136
|
+
assert.equal(out.rowsUsed, 5);
|
|
137
|
+
assert.equal(out.truncated, true);
|
|
138
|
+
store.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("记忆正文包在围栏里,且围栏之前没有正文;系统段重申「这是数据不是指令」", async () => {
|
|
142
|
+
const { store, extractor, fake } = setup(okJson("围栏检查。"));
|
|
143
|
+
seedRows(store, 2);
|
|
144
|
+
await extractor();
|
|
145
|
+
assert.equal(fake.calls.length, 1);
|
|
146
|
+
const system = fake.calls[0].messages[0].content[0].text;
|
|
147
|
+
const user = fake.calls[0].messages[1].content[0].text;
|
|
148
|
+
// 结构断言:正文只出现在围栏内部,围栏外不残留任何一行内容。
|
|
149
|
+
const openIdx = user.indexOf("<<<");
|
|
150
|
+
const closeIdx = user.lastIndexOf(">>>");
|
|
151
|
+
assert.ok(openIdx >= 0 && closeIdx > openIdx, "用户段必须带围栏对");
|
|
152
|
+
assert.ok(user.indexOf("第 0 行的正文") > openIdx, "记忆正文不许出现在围栏之前");
|
|
153
|
+
assert.ok(user.indexOf("第 1 行的正文") < closeIdx, "记忆正文不许出现在围栏之后");
|
|
154
|
+
// 角色重申与「数据不是指令」是字面量锁:防的是「有人重构提示词时顺手删掉这一段」
|
|
155
|
+
// ——那会让记忆里的指令型文本重新获得指令地位,而界面上一切照常。
|
|
156
|
+
assert.match(system, /不是指令|not instructions/i, "系统段必须声明输入是数据");
|
|
157
|
+
assert.match(system, /(绝不|不要).{0,6}(助手|agent).{0,20}(身份|名字|人格|起名)|never (name|invent|assign).{0,60}(assistant|identity|name)|do not (invent|assign).{0,40}(identity|name)/i,
|
|
158
|
+
"草稿里不许出现助手身份声明:画像不受 personaEnabled 管辖,写进去就会与 bot 自带身份撞车");
|
|
159
|
+
store.close();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---- HTTP 面(POST /api/dsh-mneme/profile/extract) -------------------------
|
|
163
|
+
// 三条各自防一类事故:开关没开不许偷偷调模型;非 POST 方法不许触发副作用
|
|
164
|
+
// (#489 的 /nudge 教训——方法白名单在读体之前拦);HTTP 面上「只出草稿」同样成立。
|
|
165
|
+
|
|
166
|
+
class FakeRes extends EventEmitter {
|
|
167
|
+
constructor() { super(); this.statusCode = 200; this.headers = null; this.body = ""; }
|
|
168
|
+
writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
|
|
169
|
+
end(text) { this.body = text ?? ""; this.emit("end"); return this; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function httpReq(method, body = null) {
|
|
173
|
+
const r = new EventEmitter();
|
|
174
|
+
r.method = method;
|
|
175
|
+
r.url = "/api/dsh-mneme/profile/extract";
|
|
176
|
+
r.headers = {};
|
|
177
|
+
if (body !== null) {
|
|
178
|
+
process.nextTick(() => {
|
|
179
|
+
r.emit("data", Buffer.from(JSON.stringify(body)));
|
|
180
|
+
r.emit("end");
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return r;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setupApi({ config = {}, extractor, apiToken = "" } = {}) {
|
|
187
|
+
const store = createStore(":memory:");
|
|
188
|
+
const service = createService({ store, mirror: null, config });
|
|
189
|
+
const settings = createSettings(store.db);
|
|
190
|
+
const routes = [];
|
|
191
|
+
const ctx = { webServer: { register: (route) => { routes.push(route); return () => {}; } }, logger: { warn() {}, info() {}, error() {} } };
|
|
192
|
+
createApi(ctx, service, settings, { add: () => {}, remove: () => {}, list: () => [] },
|
|
193
|
+
undefined, undefined, apiToken, config, null, extractor ?? null);
|
|
194
|
+
return { store, service, settings, route: routes.find((r) => r.path === "/api/dsh-mneme/profile/extract") };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test("policy 是 token:配了 token 又不带 token 的 POST 被挡在提取器之前", async () => {
|
|
198
|
+
let called = 0;
|
|
199
|
+
const { store, route } = setupApi({
|
|
200
|
+
apiToken: "sekret",
|
|
201
|
+
config: { profileExtractEnabled: true },
|
|
202
|
+
extractor: async () => { called++; return { draft: "x" }; }
|
|
203
|
+
});
|
|
204
|
+
const res = new FakeRes();
|
|
205
|
+
await route.handler(httpReq("POST", {}), res);
|
|
206
|
+
assert.equal(res.statusCode, 401);
|
|
207
|
+
assert.equal(called, 0, "鉴权必须在这次模型调用之前");
|
|
208
|
+
store.close();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("开关关着:409 disabled,一次模型调用都不发生", async () => {
|
|
212
|
+
let called = 0;
|
|
213
|
+
const { store, route } = setupApi({
|
|
214
|
+
config: { profileExtractEnabled: false },
|
|
215
|
+
extractor: async () => { called++; return { draft: "不该出现" }; }
|
|
216
|
+
});
|
|
217
|
+
const res = new FakeRes();
|
|
218
|
+
await route.handler(httpReq("POST", {}), res);
|
|
219
|
+
assert.equal(res.statusCode, 409);
|
|
220
|
+
assert.equal(JSON.parse(res.body).error, "disabled");
|
|
221
|
+
assert.equal(called, 0);
|
|
222
|
+
store.close();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("非 POST 方法:405 + Allow,不触发任何副作用", async () => {
|
|
226
|
+
let called = 0;
|
|
227
|
+
const { store, route } = setupApi({
|
|
228
|
+
config: { profileExtractEnabled: true },
|
|
229
|
+
extractor: async () => { called++; return { draft: "x" }; }
|
|
230
|
+
});
|
|
231
|
+
for (const method of ["GET", "HEAD", "DELETE", "PATCH"]) {
|
|
232
|
+
const res = new FakeRes();
|
|
233
|
+
await route.handler(httpReq(method), res);
|
|
234
|
+
assert.equal(res.statusCode, 405, `${method} 不该落到提取器`);
|
|
235
|
+
assert.equal(res.headers.Allow, "POST");
|
|
236
|
+
}
|
|
237
|
+
assert.equal(called, 0, "#489 那类洞:方法分流只拦 POST/PUT 时,其余方法会直落副作用分支");
|
|
238
|
+
store.close();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("开关开着:200 回草稿,且库里的画像一个字节都没变", async () => {
|
|
242
|
+
const { store, settings, route } = setupApi({
|
|
243
|
+
config: { profileExtractEnabled: true },
|
|
244
|
+
extractor: async () => ({ draft: "我知道她跨考公共管理。", rowsUsed: 7, truncated: false, provider: "p", model: "m" })
|
|
245
|
+
});
|
|
246
|
+
settings.setProfile("手写原文");
|
|
247
|
+
const res = new FakeRes();
|
|
248
|
+
await route.handler(httpReq("POST", {}), res);
|
|
249
|
+
assert.equal(res.statusCode, 200);
|
|
250
|
+
const body = JSON.parse(res.body);
|
|
251
|
+
assert.equal(body.draft, "我知道她跨考公共管理。");
|
|
252
|
+
assert.equal(body.rowsUsed, 7);
|
|
253
|
+
assert.equal(settings.getProfile(), "手写原文", "采用草稿必须是用户在面板上的显式一次动作");
|
|
254
|
+
store.close();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("提取器报的 code 映射成可区分的状态码(「没东西可提」≠「模型没配合」)", async () => {
|
|
258
|
+
for (const [code, status] of [["no-input", 409], ["no-route", 503], ["unparseable", 502], ["stream-failed", 502]]) {
|
|
259
|
+
const { store, route } = setupApi({
|
|
260
|
+
config: { profileExtractEnabled: true },
|
|
261
|
+
extractor: async () => { throw new ProfileExtractError(code, code); }
|
|
262
|
+
});
|
|
263
|
+
const res = new FakeRes();
|
|
264
|
+
await route.handler(httpReq("POST", {}), res);
|
|
265
|
+
assert.equal(res.statusCode, status, `${code} 的状态码`);
|
|
266
|
+
assert.equal(JSON.parse(res.body).error, code);
|
|
267
|
+
store.close();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// 记忆级溯源读出口的 HTTP 口径 —— 共用模块自己的锁。
|
|
2
|
+
//
|
|
3
|
+
// 这个文件盯三类回归:
|
|
4
|
+
// a) 口径本身(id 解析、两种「空」的区分、窗口形状、原文按序);
|
|
5
|
+
// b) distillRecords.enabled 门(#509 评审整改):判据 fail-closed、409 赢 503、
|
|
6
|
+
// 关着时一次都不碰 service;
|
|
7
|
+
// c) **不许再长出第二份实现**。#509 第一版曾把整段复製到 api.js 与
|
|
8
|
+
// api-standalone.js 各一份,维护者在 review 里点名(本仓 #411 之后不收这个
|
|
9
|
+
// 形状)。静态锁把三件事写成断言:口径字面量与开关判据只许出现在本模块、两个
|
|
10
|
+
// 壳都真的把 config 转交进来、读侧判据与写侧 summarize.js 逐字同源。谁要是再
|
|
11
|
+
// 复制一段出去,这里直接转红。
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import {
|
|
18
|
+
DISTILL_RECORDS_DISABLED,
|
|
19
|
+
PROVENANCE_UNAVAILABLE,
|
|
20
|
+
isDistillRecordsEnabled,
|
|
21
|
+
parseProvenanceQuery,
|
|
22
|
+
provenanceReply
|
|
23
|
+
} from "../src/provenance-http.js";
|
|
24
|
+
|
|
25
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
|
+
|
|
27
|
+
// 开着开关的 cfg:这个端点返回蒸馏窗口的逐字原文,所以测试里的「正常路径」必须
|
|
28
|
+
// 显式把开关打开——不传就是关(fail-closed),没有「默认开」这回事。
|
|
29
|
+
const RECORDS_ON = { distillRecords: { enabled: true } };
|
|
30
|
+
|
|
31
|
+
function fakeService(over = {}) {
|
|
32
|
+
return {
|
|
33
|
+
provenanceFor: (id) => over.links ?? [],
|
|
34
|
+
distillRecordsFor: (sessionId, from, to) => over.records ?? [],
|
|
35
|
+
...over.service
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("parseProvenanceQuery:空/缺失 id 报 400,非空原样通过", () => {
|
|
40
|
+
for (const raw of [null, undefined, "", " "]) {
|
|
41
|
+
const r = parseProvenanceQuery(raw);
|
|
42
|
+
assert.equal(r.ok, false, `${JSON.stringify(raw)} 不该通过`);
|
|
43
|
+
assert.equal(r.status, 400);
|
|
44
|
+
assert.equal(r.error, "missing-id");
|
|
45
|
+
}
|
|
46
|
+
assert.deepEqual(parseProvenanceQuery(" m1 "), { ok: true, id: "m1" }, "id 两端空白要裁掉");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越界", () => {
|
|
50
|
+
const seen = [];
|
|
51
|
+
const service = {
|
|
52
|
+
provenanceFor: () => [{ session_id: "s1", seq_start: 10, seq_end: 12, created_at: "2026-10-23T00:00:00Z" }],
|
|
53
|
+
distillRecordsFor: (sessionId, from, to) => {
|
|
54
|
+
seen.push([sessionId, from, to]);
|
|
55
|
+
return [{ seq: 12 }, { seq: 10 }, { seq: 11 }];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const r = provenanceReply({ service, id: "m1", config: RECORDS_ON });
|
|
59
|
+
assert.equal(r.status, 200);
|
|
60
|
+
assert.equal(r.body.memoryId, "m1");
|
|
61
|
+
assert.equal(r.body.windows.length, 1);
|
|
62
|
+
const w = r.body.windows[0];
|
|
63
|
+
assert.equal(w.session_id, "s1");
|
|
64
|
+
assert.equal(w.seq_start, 10);
|
|
65
|
+
assert.equal(w.seq_end, 12);
|
|
66
|
+
assert.equal(w.created_at, "2026-10-23T00:00:00Z");
|
|
67
|
+
assert.deepEqual(w.records.map((x) => x.seq), [12, 10, 11], "次序由 store 的 ORDER BY seq 保证,答复层不改序");
|
|
68
|
+
assert.deepEqual(seen, [["s1", 10, 12]], "区间原样透传给 store");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("provenanceReply:没有链接是有效答案——空 windows,200 不是 404", () => {
|
|
72
|
+
const r = provenanceReply({ service: fakeService(), id: "m1", config: RECORDS_ON });
|
|
73
|
+
assert.equal(r.status, 200);
|
|
74
|
+
assert.deepEqual(r.body, { memoryId: "m1", windows: [] });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("provenanceReply:方法没接线是装配坏了——503,不与「没有来源」混为一个读数", () => {
|
|
78
|
+
// headless / 旧装配:service 上没有这两个方法。
|
|
79
|
+
const r = provenanceReply({ service: {}, id: "m1", config: RECORDS_ON });
|
|
80
|
+
assert.equal(r.status, 503);
|
|
81
|
+
assert.equal(r.body.error, PROVENANCE_UNAVAILABLE);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("provenanceReply:读抛错只 warn 并回 500,不把异常漏给调用方", () => {
|
|
85
|
+
const warnings = [];
|
|
86
|
+
const service = {
|
|
87
|
+
provenanceFor: () => { throw new Error("db locked"); },
|
|
88
|
+
distillRecordsFor: () => []
|
|
89
|
+
};
|
|
90
|
+
const r = provenanceReply({ service, id: "m1", config: RECORDS_ON, warn: (m) => warnings.push(m) });
|
|
91
|
+
assert.equal(r.status, 500);
|
|
92
|
+
assert.equal(r.body.error, "internal");
|
|
93
|
+
assert.equal(warnings.length, 1, "失败要留痕");
|
|
94
|
+
assert.match(warnings[0], /memory provenance read failed/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- distillRecords.enabled 门(#509 评审整改)--------------------------------
|
|
98
|
+
// 写侧(src/summarize.js:988)早就吃这个开关,读出口此前一处都不读:关着时历史
|
|
99
|
+
// 原文照旧留在库里、还多一个能读出去的口。门加在共用模块里,两个壳都吃它。
|
|
100
|
+
|
|
101
|
+
test("isDistillRecordsEnabled:只认严格的 true,其余一律算关(fail-closed)", () => {
|
|
102
|
+
assert.equal(isDistillRecordsEnabled({ distillRecords: { enabled: true } }), true);
|
|
103
|
+
for (const cfg of [
|
|
104
|
+
null, undefined, {}, { distillRecords: null }, { distillRecords: {} },
|
|
105
|
+
{ distillRecords: { enabled: false } },
|
|
106
|
+
// 非布尔值:字符串 / 数字都不该被当成开——那是配置写错了,不是用户开了
|
|
107
|
+
{ distillRecords: { enabled: "true" } },
|
|
108
|
+
{ distillRecords: { enabled: 1 } }
|
|
109
|
+
]) {
|
|
110
|
+
assert.equal(isDistillRecordsEnabled(cfg), false, `${JSON.stringify(cfg)} 必须算关`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("provenanceReply:开关没开 → 409 distill-records-disabled,一个键都不多带", () => {
|
|
115
|
+
// config 整个缺失(旧调用方直连、daemon 最小 cfg)也走这一格:默认方向必须是关。
|
|
116
|
+
for (const cfg of [null, undefined, {}, { distillRecords: { enabled: false } }]) {
|
|
117
|
+
const r = provenanceReply({ service: fakeService(), id: "m1", config: cfg });
|
|
118
|
+
assert.equal(r.status, 409, `${JSON.stringify(cfg)} → 409`);
|
|
119
|
+
assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED },
|
|
120
|
+
"body 只准有 error 一个键——不带 memoryId / windows,调用方不会把「功能关了」读成「这条记忆没有来源」");
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("provenanceReply:409 赢 503——「用户关了」比「装配坏了」更具体", () => {
|
|
125
|
+
// 关着 + service 上连方法都没有:报 503 会让人去查一个没坏的部署。
|
|
126
|
+
const r = provenanceReply({ service: {}, id: "m1", config: { distillRecords: { enabled: false } } });
|
|
127
|
+
assert.equal(r.status, 409);
|
|
128
|
+
assert.equal(r.body.error, DISTILL_RECORDS_DISABLED);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("provenanceReply:开关关着时一次 SQL 都不发(不碰 service)", () => {
|
|
132
|
+
// 门要在读之前:关着还被调用方当成「能读」是次要的,白跑一趟库才是真浪费。
|
|
133
|
+
let touched = 0;
|
|
134
|
+
const service = fakeService({ service: {
|
|
135
|
+
provenanceFor: () => { touched++; return []; },
|
|
136
|
+
distillRecordsFor: () => { touched++; return []; }
|
|
137
|
+
} });
|
|
138
|
+
provenanceReply({ service, id: "m1", config: null });
|
|
139
|
+
assert.equal(touched, 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("provenanceReply:开关开着才读得到——200 与 409 是同一把门的两个方向", () => {
|
|
143
|
+
const service = fakeService({ links: [{ session_id: "s1", seq_start: 1, seq_end: 1, created_at: "t" }], records: [{ seq: 1 }] });
|
|
144
|
+
const on = provenanceReply({ service, id: "m1", config: RECORDS_ON });
|
|
145
|
+
assert.equal(on.status, 200);
|
|
146
|
+
assert.equal(on.body.windows.length, 1);
|
|
147
|
+
const off = provenanceReply({ service, id: "m1", config: { distillRecords: { enabled: false } } });
|
|
148
|
+
assert.equal(off.status, 409);
|
|
149
|
+
assert.deepEqual(off.body, { error: DISTILL_RECORDS_DISABLED });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,两个入口都要真的走它", () => {
|
|
153
|
+
// #509 第一版把整段复制到了两张皮各一份——本仓 #411 之后不收这个形状。
|
|
154
|
+
for (const name of ["api.js", "api-standalone.js"]) {
|
|
155
|
+
const src = readFileSync(join(ROOT, "src", name), "utf8");
|
|
156
|
+
assert.ok(src.includes("provenance-http.js"), `src/${name} 必须从共用模块取口径`);
|
|
157
|
+
assert.ok(src.includes("provenanceReply("), `src/${name} 要真的走共用答复`);
|
|
158
|
+
assert.ok(!src.includes("provenance-unavailable"), `src/${name} 不准自己写错误码字面量`);
|
|
159
|
+
assert.ok(!src.includes("distill-records-disabled"), `src/${name} 不准自己写「功能关了」的错误码字面量`);
|
|
160
|
+
// 判据也不许重写:两个壳各自算一遍 config?.distillRecords?.enabled === true,
|
|
161
|
+
// 就是读写两道门漂移的形状(本文件上面的注释写过这笔账)。
|
|
162
|
+
assert.ok(!src.includes("distillRecords?.enabled === true"),
|
|
163
|
+
`src/${name} 不准自己写开关判据——只许把 config 转交给共用模块`);
|
|
164
|
+
// 而且要真的转交了:壳里调 provenanceReply 时必须带上 config。
|
|
165
|
+
assert.match(src, /provenanceReply\(\{ service, id: parsed\.id, config,/,
|
|
166
|
+
`src/${name} 要把本入口的 config 传给共用模块,否则门永远读不到用户的开关`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("🔴 静态锁:读侧判据与写侧 src/summarize.js 逐字同源", () => {
|
|
171
|
+
// 「读写两道门永不分歧」不能只活在注释里:写侧那句话在 summarize.js,读侧在本
|
|
172
|
+
// 模块,两边必须是同一个字符串。谁改了其中一边,这里转红。
|
|
173
|
+
const shared = readFileSync(join(ROOT, "src", "provenance-http.js"), "utf8");
|
|
174
|
+
const writeSide = readFileSync(join(ROOT, "src", "summarize.js"), "utf8");
|
|
175
|
+
const readSide = readFileSync(join(ROOT, "src", "index.js"), "utf8");
|
|
176
|
+
const criterion = "config?.distillRecords?.enabled === true";
|
|
177
|
+
assert.ok(shared.includes(criterion), "共用模块要写判据(读侧唯一一份)");
|
|
178
|
+
assert.ok(writeSide.includes(criterion), "写侧 summarize.js 要还在用同一个判据");
|
|
179
|
+
// 读侧只准有一份:两个壳各写一遍的漂移形状,静态锁上一节已经禁了壳,这里禁
|
|
180
|
+
// 共用模块自己长出第二份。
|
|
181
|
+
assert.equal(shared.split(criterion).length - 1, 1, "共用模块里判据只许出现一次");
|
|
182
|
+
// 写侧那一处(启动期裁剪)与它同源,所以三处各一份、彼此逐字相同。
|
|
183
|
+
assert.ok(readSide.includes("cfg.distillRecords?.enabled === true"), "index.js 的启动裁剪判据应保持同族");
|
|
184
|
+
});
|
|
@@ -66,3 +66,48 @@ test("隐私节必须写出「巩固与摘要走宿主模型」这个出站事
|
|
|
66
66
|
"English Privacy section should disclose that consolidation uses the host model",
|
|
67
67
|
);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
// 「模型工具」表是代码的投影,不是愿望清单(AGENTS.md 给 SECURITY.md 定的那条规矩,
|
|
71
|
+
// 这张表一直没有)。它已经跑偏两次:#471 加 set_scope_state、#231 加 memory_organize,
|
|
72
|
+
// 两次都没人回头改表或改那个数字——因为没人核过。所以这里全部**从代码反推**:
|
|
73
|
+
// 工具集从 tools.js 的 name: "…" 取,opt-in 从 hiddenTools 的 `!== true` 判据取
|
|
74
|
+
// (`=== true` 那种是「默认在、用户可关」,不能算进 opt-in,否则默认注册数会算少)。
|
|
75
|
+
test("包 README 的「模型工具」表与 tools.js 注册表一致", () => {
|
|
76
|
+
const toolsSrc = readFileSync(path.join(pkgRoot, "src/tools.js"), "utf8");
|
|
77
|
+
const declared = [...toolsSrc.matchAll(/^ {6}name: "((?:memory_|set_scope)[a-z_]*)",/gm)].map((m) => m[1]);
|
|
78
|
+
assert.ok(declared.length >= 10, `tools.js 的工具名解析失败(只看到 ${declared.length} 个)——先修这条守卫,别改表`);
|
|
79
|
+
const optIn = [...toolsSrc.matchAll(/if \(config\?\.(\w+) !== true\) hiddenTools\.add\("([a-z_]+)"\)/g)]
|
|
80
|
+
.map((m) => ({ key: m[1], tool: m[2] }));
|
|
81
|
+
const defaultCount = declared.length - optIn.length;
|
|
82
|
+
|
|
83
|
+
const heading = pkgReadme.match(/^### 模型工具(([^)]*))$/m);
|
|
84
|
+
assert.ok(heading, "包 README 应有「### 模型工具(N 个)」这一节标题(标题格式变了就同步改这条守卫)");
|
|
85
|
+
const bodyStart = pkgReadme.indexOf(heading[0]);
|
|
86
|
+
const bodyRest = pkgReadme.slice(bodyStart + heading[0].length);
|
|
87
|
+
const nextSection = bodyRest.search(/\r?\n#{2,3} /);
|
|
88
|
+
const body = nextSection === -1 ? bodyRest : bodyRest.slice(0, nextSection);
|
|
89
|
+
const rows = [...body.matchAll(/^\| `([a-z_]+)` \|/gm)].map((m) => m[1]);
|
|
90
|
+
|
|
91
|
+
assert.deepEqual(
|
|
92
|
+
[...new Set(declared)].sort(),
|
|
93
|
+
[...new Set(rows)].sort(),
|
|
94
|
+
"表与注册表必须一一对应:少了是漏文档,多了是文档写了不存在的东西"
|
|
95
|
+
);
|
|
96
|
+
assert.equal(rows.length, new Set(rows).size, "表里不许有重复行(重复行会让数量核对失效)");
|
|
97
|
+
// 按**顺序**比数字,不是各自 includes:把「默认注册 11 个;开后 12 个」写成
|
|
98
|
+
// 「默认 12、开后 11」时,两个独立的 includes 都会绿——而那正是这条守卫要防的
|
|
99
|
+
// 第二种错法(第一种是数字与表不符,已被上面的集合比对盖住)。
|
|
100
|
+
const nums = (heading[1].match(/\d+/g) ?? []).map(Number);
|
|
101
|
+
const expected = optIn.length ? [defaultCount, declared.length] : [defaultCount];
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
nums,
|
|
104
|
+
expected,
|
|
105
|
+
`标题里的数字要按序是「默认注册数、全数」= ${expected.join(" 与 ")}(实跑 ${nums.join(" 与 ") || "没有数字"})`
|
|
106
|
+
);
|
|
107
|
+
// opt-in 工具那一行必须点名它的开关:读者看到表里有它,得知道默认是不出现的。
|
|
108
|
+
for (const { key, tool } of optIn) {
|
|
109
|
+
const row = body.match(new RegExp(`^\\| \`${tool}\` \\|(.*)$`, "m"));
|
|
110
|
+
assert.ok(row, `${tool} 必须在表里`);
|
|
111
|
+
assert.ok(row[1].includes(key), `${tool} 那行要写明它需要 ${key} 才注册`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
@@ -72,8 +72,8 @@ function dreamCtx({ onConsolidation, summaryText = "记忆库总览:用户偏
|
|
|
72
72
|
async *stream(options) {
|
|
73
73
|
captured.push(options);
|
|
74
74
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
75
|
-
if (userText.
|
|
76
|
-
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
|
|
75
|
+
if (userText.includes("| type=")) {
|
|
76
|
+
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText, options.purpose) : "[]" };
|
|
77
77
|
} else {
|
|
78
78
|
yield { type: "text-delta", index: 0, text: summaryText };
|
|
79
79
|
}
|
|
@@ -282,7 +282,7 @@ test("issue#9: rejected reasoningEffort retries once without it and still consol
|
|
|
282
282
|
async *stream(options) {
|
|
283
283
|
calls.push(options);
|
|
284
284
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
285
|
-
if (userText.
|
|
285
|
+
if (userText.includes("| type=")) {
|
|
286
286
|
// First attempt forwards reasoningEffort: the provider rejects it.
|
|
287
287
|
if (options.reasoningEffort) {
|
|
288
288
|
throw new Error("UNSUPPORTED_REASONING_EFFORT: DeepSeek does not support reasoning effort \"low\"");
|
|
@@ -340,7 +340,7 @@ function sleepCtx(onConsolidation, selection = { provider: "mock", model: "sleep
|
|
|
340
340
|
async *stream(options) {
|
|
341
341
|
captured.push(options);
|
|
342
342
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
343
|
-
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
|
|
343
|
+
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText, options.purpose) : "[]" };
|
|
344
344
|
yield { type: "finish", reason: { kind: "stop" } };
|
|
345
345
|
}
|
|
346
346
|
}
|
|
@@ -355,7 +355,7 @@ test("issue#9: sleep forwards sleepReasoningEffort on its LLM passes", async ()
|
|
|
355
355
|
vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
|
|
356
356
|
const captured = [];
|
|
357
357
|
const ctx = sleepCtx(
|
|
358
|
-
(userText) =>
|
|
358
|
+
(userText, purpose) => purpose === "sleep-conflict"
|
|
359
359
|
? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
360
360
|
: "[]",
|
|
361
361
|
{ provider: "mock", model: "sleep-model" },
|
|
@@ -378,7 +378,7 @@ test("issue#135: sleep unset effort also resolves to the lowest supported", asyn
|
|
|
378
378
|
vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
|
|
379
379
|
const captured = [];
|
|
380
380
|
const ctx = sleepCtx(
|
|
381
|
-
(userText) =>
|
|
381
|
+
(userText, purpose) => purpose === "sleep-conflict"
|
|
382
382
|
? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
383
383
|
: "[]",
|
|
384
384
|
{ provider: "mock", model: "sleep-model" },
|
|
@@ -433,7 +433,7 @@ test("rc.1 stream-level effort rejection (error finish chunk) also triggers the
|
|
|
433
433
|
return;
|
|
434
434
|
}
|
|
435
435
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
436
|
-
if (userText.
|
|
436
|
+
if (userText.includes("| type=")) {
|
|
437
437
|
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
438
438
|
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
|
|
439
439
|
]) };
|
|
@@ -500,7 +500,7 @@ test("sleep passes the stream failure accessor so a stream-level effort rejectio
|
|
|
500
500
|
return;
|
|
501
501
|
}
|
|
502
502
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
503
|
-
yield { type: "text-delta", index: 0, text:
|
|
503
|
+
yield { type: "text-delta", index: 0, text: options.purpose === "sleep-conflict"
|
|
504
504
|
? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
505
505
|
: "[]" };
|
|
506
506
|
yield { type: "finish", reason: { kind: "stop" } };
|
|
@@ -625,7 +625,7 @@ test("defaultEffort trap: capability query failure falls back to configured effo
|
|
|
625
625
|
throw new Error("UNSUPPORTED_REASONING_EFFORT: mock does not support reasoning effort \"high\"");
|
|
626
626
|
}
|
|
627
627
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
628
|
-
if (userText.
|
|
628
|
+
if (userText.includes("| type=")) {
|
|
629
629
|
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
630
630
|
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
|
|
631
631
|
]) };
|
|
@@ -655,7 +655,7 @@ test("defaultEffort trap: sleep conflict pass remaps a poison effort too", async
|
|
|
655
655
|
vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
|
|
656
656
|
const captured = [];
|
|
657
657
|
const ctx = sleepCtx(
|
|
658
|
-
(userText) =>
|
|
658
|
+
(userText, purpose) => purpose === "sleep-conflict"
|
|
659
659
|
? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
660
660
|
: "[]",
|
|
661
661
|
{ provider: "mock", model: "sleep-model" },
|
|
@@ -53,7 +53,7 @@ function llmCtx(captured = [], selection = { provider: "mock", model: "sleep-mod
|
|
|
53
53
|
async *stream(options) {
|
|
54
54
|
captured.push(options);
|
|
55
55
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
56
|
-
yield { type: "text-delta", index: 0, text:
|
|
56
|
+
yield { type: "text-delta", index: 0, text: options.purpose === "sleep-conflict" ? "[]" : "[]" };
|
|
57
57
|
yield { type: "finish", reason: { kind: "stop" } };
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -210,7 +210,7 @@ test("non-freeze with an LLM: cross-scope pair bypasses adjudication; same-scope
|
|
|
210
210
|
ctx.llm.stream = async function* (options) {
|
|
211
211
|
captured.push(options);
|
|
212
212
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
213
|
-
const isConflictPass =
|
|
213
|
+
const isConflictPass = options.purpose === "sleep-conflict";
|
|
214
214
|
const text = isConflictPass
|
|
215
215
|
? JSON.stringify([{ action: "conflict", winner: yNew.id, loser: yOld.id, reason: "同归属重复" }])
|
|
216
216
|
: "[]";
|
|
@@ -221,9 +221,12 @@ test("non-freeze with an LLM: cross-scope pair bypasses adjudication; same-scope
|
|
|
221
221
|
const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
|
|
222
222
|
assert.equal(result.status, "ok");
|
|
223
223
|
|
|
224
|
+
// 认「哪一次是冲突裁决」改按 purpose,不再看 user 段首行:#493 追加的围栏让 user 段以
|
|
225
|
+
// 角色声明开头,而败者行的溯源注释里也会出现「候选冲突」这个词——用 includes 会把模式
|
|
226
|
+
// 阶段误判成冲突阶段。purpose 与 prompt 形态彻底解耦。
|
|
224
227
|
const conflictPassTexts = captured
|
|
225
|
-
.
|
|
226
|
-
.
|
|
228
|
+
.filter((o) => o.purpose === "sleep-conflict")
|
|
229
|
+
.map((o) => o.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "");
|
|
227
230
|
assert.ok(conflictPassTexts.length >= 1, "conflict pass ran for the same-scope pair");
|
|
228
231
|
for (const t of conflictPassTexts) {
|
|
229
232
|
assert.ok(!t.includes(xNew.id) && !t.includes(xOld.id), "cross-scope pair must never enter the LLM prompt");
|