@modusensus/dsh-mneme 0.9.2 → 0.9.4

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.
Files changed (59) hide show
  1. package/README.md +11 -7
  2. package/docs/CONFIGURATION.md +9 -2
  3. package/docs/WORKFLOWS.md +70 -3
  4. package/lib/api.js +178 -3
  5. package/lib/capabilities.js +1 -1
  6. package/lib/client.js +315 -27
  7. package/lib/config.js +70 -3
  8. package/lib/dream.js +1 -1
  9. package/lib/index.js +21 -1
  10. package/lib/inject.js +10 -25
  11. package/lib/lang.js +25 -0
  12. package/lib/organize.js +4 -2
  13. package/lib/pipeline-nudge-auto.js +126 -0
  14. package/lib/pipeline-nudge.js +337 -28
  15. package/lib/pipeline-runs.js +1 -1
  16. package/lib/pipeline.js +4 -4
  17. package/lib/profile-extract.js +161 -0
  18. package/lib/scope.js +30 -1
  19. package/lib/service.js +3 -2
  20. package/lib/settings.js +22 -14
  21. package/lib/summarize.js +64 -3
  22. package/lib/tools.js +240 -1
  23. package/lib/user-settings.js +78 -0
  24. package/package.json +1 -1
  25. package/src/api.js +178 -3
  26. package/src/capabilities.js +1 -1
  27. package/src/config.js +70 -3
  28. package/src/dream.js +1 -1
  29. package/src/index.js +21 -1
  30. package/src/inject.js +10 -25
  31. package/src/lang.js +25 -0
  32. package/src/organize.js +4 -2
  33. package/src/pipeline-nudge-auto.js +126 -0
  34. package/src/pipeline-nudge.js +337 -28
  35. package/src/pipeline-runs.js +1 -1
  36. package/src/pipeline.js +4 -4
  37. package/src/profile-extract.js +161 -0
  38. package/src/scope.js +30 -1
  39. package/src/service.js +3 -2
  40. package/src/settings.js +22 -14
  41. package/src/summarize.js +64 -3
  42. package/src/tools.js +240 -1
  43. package/src/user-settings.js +78 -0
  44. package/test/api-nudge-route.test.js +259 -0
  45. package/test/api.test.js +19 -1
  46. package/test/client.test.js +115 -7
  47. package/test/lang.test.js +11 -0
  48. package/test/organize-tool.test.js +232 -0
  49. package/test/persona-addressing.test.js +83 -0
  50. package/test/persona-settings.test.js +30 -8
  51. package/test/pipeline-nudge-auto.test.js +224 -0
  52. package/test/pipeline-nudge.test.js +161 -6
  53. package/test/profile-extract.test.js +269 -0
  54. package/test/scope-optional-service.test.js +110 -0
  55. package/test/session-usage.test.js +11 -2
  56. package/test/summarize.test.js +225 -0
  57. package/test/tools-dto-schema-matrix.test.js +48 -5
  58. package/test/tools.test.js +6 -1
  59. 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,110 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createScopeResolver } from "../src/scope.js";
4
+
5
+ // issue #487:workspaceRegistry 不在 inject 声明里(src/index.js:45),而 cordis 4
6
+ // 的 ctx 是 Proxy——裸读未注入属性会抛 "cannot get property ... without inject",
7
+ // 不返回 undefined。于是 scope.js 曾经那行 `ctx?.workspaceRegistry?.list?.()` 从
8
+ // v0.8.0 起必然抛错走降级:canonical path 归一与成员资格校验静默失效,只剩
9
+ // header.cwd。
10
+ //
11
+ // 为什么既有测试没拦住:scope-storage.test.js 里三处 registry 测试都用普通对象
12
+ // 伪造 ctx({ workspaceRegistry: {...} }),普通对象裸读不抛错。本文件改用
13
+ // 「读未声明属性即抛」的 Proxy 复刻真实宿主形状,把两侧行为都钉死。
14
+
15
+ /**
16
+ * cordis 4 形状的 ctx:只有显式提供的属性可读,其余一律抛错(不是 undefined)。
17
+ * @param {object} provided 已提供的属性(reflect / logger …)
18
+ */
19
+ function proxyCtx(provided) {
20
+ return new Proxy(provided, {
21
+ get(target, prop) {
22
+ if (typeof prop === "symbol" || prop === "then" || String(prop).startsWith("_")) {
23
+ return target[prop];
24
+ }
25
+ if (prop in target) return target[prop];
26
+ throw new Error(`cannot get property "${String(prop)}" without inject`);
27
+ }
28
+ });
29
+ }
30
+
31
+ /** 带 workspaceRegistry 服务的 reflect(免 inject 读取出口)。 */
32
+ function reflectWith(services, seen = []) {
33
+ return {
34
+ get(name) {
35
+ seen.push(name);
36
+ return services[name];
37
+ }
38
+ };
39
+ }
40
+
41
+ const EXEC = { agent: { session: { id: "s1", header: { agentPreset: "coder", cwd: "D:\\raw" } } } };
42
+
43
+ function loggerSpy() {
44
+ const warnings = [];
45
+ return { warnings, logger: { warn: (m) => warnings.push(String(m)) } };
46
+ }
47
+
48
+ test("#487: unregistered ctx property never throws out of the resolver (Proxy shape)", () => {
49
+ // 没有任何服务:裸读 workspaceRegistry 在真宿主里会抛。解析器必须吞掉并降级。
50
+ const { warnings, logger } = loggerSpy();
51
+ const ctx = proxyCtx({ reflect: reflectWith({}), logger });
52
+ const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true }, logger });
53
+ // 不会抛;agentPreset 照常解析,workspace 落到 header.cwd。
54
+ assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
55
+ // 服务缺失是合法状态(headless / 旧宿主),不是故障——不许刷 warn。
56
+ assert.deepEqual(warnings, [], "missing optional service must stay silent");
57
+ });
58
+
59
+ test("#487: registry reached through reflect.get restores the canonical path", () => {
60
+ // 修复的收益不只是消 warning:宿主确实提供 workspaceRegistry(@deepseek-ai/
61
+ // dsh-workspace),走免 inject 读取能取回 realpath 归一后的 canonical path,
62
+ // 而不是退化成原始 cwd 字符串。
63
+ const registry = { list: () => [{ path: "C:\\canon", sessionIds: ["s1"] }] };
64
+ const seen = [];
65
+ const ctx = proxyCtx({ reflect: reflectWith({ workspaceRegistry: registry }, seen) });
66
+ const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true } });
67
+
68
+ assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "C:\\canon" });
69
+ assert.ok(seen.includes("workspaceRegistry"), "registry must be read via reflect.get");
70
+ });
71
+
72
+ test("#487: membership miss still falls back to header cwd (registry hit is id-scoped)", () => {
73
+ const registry = { list: () => [{ path: "C:\\canon", sessionIds: ["other"] }] };
74
+ const ctx = proxyCtx({ reflect: reflectWith({ workspaceRegistry: registry }) });
75
+ const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true } });
76
+
77
+ assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
78
+ });
79
+
80
+ test("#487: hosts without ctx.reflect keep working through the plain-object shape", () => {
81
+ // 旧宿主:ctx 是普通对象、没有 reflect.get。普通对象裸读安全,行为必须和修复前一致。
82
+ const ctx = { workspaceRegistry: { list: () => [{ path: "C:\\canon", sessionIds: ["s1"] }] } };
83
+ const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true } });
84
+ assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "C:\\canon" });
85
+ });
86
+
87
+ test("#487: registry explosion still degrades to header cwd and warns exactly once", () => {
88
+ // 既有语义(scope-storage.test.js 已有同款普通对象版):服务确实炸了要出声,
89
+ // 但每次写一个解析器实例只警告一次、绝不影响写入。
90
+ const { warnings, logger } = loggerSpy();
91
+ const registry = { list: () => { throw new Error("boom"); } };
92
+ const ctx = proxyCtx({ reflect: reflectWith({ workspaceRegistry: registry }), logger });
93
+ const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true }, logger });
94
+
95
+ assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
96
+ assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
97
+ assert.equal(warnings.length, 1, "warn once per resolver instance, never on every write");
98
+ });
99
+
100
+ test("#487: resolver never throws even when reflect.get itself explodes", () => {
101
+ const { logger } = loggerSpy();
102
+ const ctx = proxyCtx({
103
+ reflect: { get() { throw new Error("reflect boom"); } },
104
+ logger
105
+ });
106
+ const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true }, logger });
107
+ assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
108
+ // 无会话时照旧全 NULL(fail-closed 读取侧依赖这个契约)。
109
+ assert.deepEqual(resolve(undefined), { agent_scope: null, workspace_scope: null });
110
+ });
@@ -64,8 +64,17 @@ test("多帧 zstd:**每一帧**都要解出来(只解第一帧是踩过的
64
64
  assert.deepEqual(seen, ["第一帧的内容", "第二帧的内容", "第三帧的内容"], "三帧都要被回调到");
65
65
  assert.equal(stat.frames, 3);
66
66
  assert.equal(stat.failed, 0);
67
- // 反证:证明这个夹具真能暴露「只解第一帧」——直接 zstdDecompressSync 只能拿到第一段
68
- assert.equal(zlib.zstdDecompressSync(buf).toString("utf8"), "第一帧的内容");
67
+ // 反证:证明这个夹具真能暴露「只解第一帧」——数帧头,不依赖 Node 的 zstd 语义。
68
+ // 原来这行是 `zstdDecompressSync(buf)` 只解出第一段;Node 24.22.0 起整块解会吐出全部帧
69
+ // (nodejs/node#65865 让解码跨 chunk 边界),反证随之过期,所以改成数 magic。
70
+ // 注意这行锁的是「切帧判据仍是 magic」,**不是碰撞免疫**:payload 里撞上 magic 时
71
+ // 计数器与解码器会一起错(半片也解得出来),实测 frames 同样变成 4,这条测不出来。
72
+ let frameHeaders = 0;
73
+ for (let at = 0; (at = buf.indexOf(ZSTD_MAGIC, at)) !== -1; at += ZSTD_MAGIC.length) frameHeaders += 1;
74
+ assert.equal(frameHeaders, 3, "夹具必须是三帧,否则上面测不到「只解第一帧」");
75
+ // 帧头数 = 成功数 + 失败数,是实现的契约:`forEachFrame` 用 magic 切帧,每段要么解开要么记失败。
76
+ // 写成 `stat.frames` 只在 failed 恒为 0 时成立,那是巧合不是规矩。
77
+ assert.equal(frameHeaders, stat.frames + stat.failed, "帧头数必须交代得清:每一帧要么解开要么记失败");
69
78
  });
70
79
 
71
80
  test("多帧 zstd:每帧都解;截断产生的**半行**由 jsonl 层丢掉(宁可少一条,不要错一条)", () => {
@@ -1813,3 +1813,228 @@ test("v0.9.0: scopeEnabled 关时蒸馏写入一个 scope 字段都不加(老
1813
1813
  assert.equal(row.workspace_scope_source, undefined);
1814
1814
  }
1815
1815
  });
1816
+
1817
+ // --- Issue #493:转录围栏 / 失败护栏 / 失败行可诊断性 ------------------------------
1818
+ // 现场:转录裸放 user 位 → 模型当成「该我回话了」接着往下写 → 输出无 `[` →
1819
+ // 解析器括号早退判失败 → 不推进游标 → 下一个 turn/end 同文同温重试 → 同一窗口
1820
+ // 确定性失败、窗口越攒越大(报告者 20 条失败 input 8194→11118 单调递增)。
1821
+
1822
+ test("#493 提示构造:转录包进围栏,user 侧重申「第三方提炼员」,system 不动", async () => {
1823
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1824
+ await eventHandler(events)(
1825
+ {
1826
+ id: "s493-prompt",
1827
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1828
+ events: [userMessage("把这段对话蒸馏一下", 1), { seq: 2, type: "turn/end" }]
1829
+ },
1830
+ { seq: 2, type: "turn/end" }
1831
+ );
1832
+
1833
+ const [system, user] = calls[0].messages;
1834
+ assert.equal(system.role, "system");
1835
+ assert.ok(system.content[0].text.includes("原子记忆"), "system 提示词保持原样(既有回归断言依赖它)");
1836
+ assert.equal(user.role, "user");
1837
+ const body = user.content[0].text;
1838
+ // 角色句与围栏都必须落在 user 侧:模型对「这段文本是什么」的判断由它所在的
1839
+ // 那条消息决定,只写进 system 救不回来(实测同段转录只加 system 约束仍 0/2 出 JSON)。
1840
+ assert.ok(body.includes("第三方提炼员"), "user 侧必须重申角色,否则模型接着对话往下写");
1841
+ assert.ok(body.includes("不是下面这段会话的参与者"), "角色句要明说不参与,不能只说「请输出 JSON」");
1842
+ const open = body.indexOf("<<<会话转录开始>>>");
1843
+ const close = body.indexOf("<<<会话转录结束>>>");
1844
+ assert.ok(open > 0 && close > open, "转录必须被围栏包住");
1845
+ assert.ok(body.slice(open, close).includes("把这段对话蒸馏一下"), "原文仍在围栏内");
1846
+ assert.ok(body.indexOf("第三方提炼员") < open, "角色句在围栏之前");
1847
+ });
1848
+
1849
+ test("#493 提示构造:围栏是确定性的,重试收到的转录与首次逐字节相同", async () => {
1850
+ let attempt = 0;
1851
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
1852
+ stream() {
1853
+ const output = attempt++ === 0 ? "不按格式的一整段中文点评" : "[]";
1854
+ return (async function* () {
1855
+ yield { type: "block-start", block: { type: "text" } };
1856
+ yield { type: "text-delta", delta: output };
1857
+ yield { type: "finish", kind: "ok" };
1858
+ })();
1859
+ }
1860
+ });
1861
+ const handler = eventHandler(events);
1862
+ const session = {
1863
+ id: "s493-deterministic",
1864
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1865
+ events: [userMessage("同一窗口重试", 1), { seq: 2, type: "turn/end" }]
1866
+ };
1867
+ await handler(session, { seq: 2, type: "turn/end" });
1868
+ await handler(session, { seq: 2, type: "turn/end" });
1869
+ assert.equal(calls.length, 2);
1870
+ const first = calls[0].messages.find((m) => m.role === "user").content[0].text;
1871
+ const second = calls[1].messages.find((m) => m.role === "user").content[0].text;
1872
+ // 围栏里不能有时间戳/随机数:否则「同文同温」这条前提就不成立,护栏阈值
1873
+ // 会变成对着一堆各不相同的输入计数。
1874
+ assert.equal(first, second, "重试必须收到逐字节相同的转录");
1875
+ });
1876
+
1877
+ test("#493 计量口径:distillChars 只数转录正文,不被围栏与角色句撑大", async () => {
1878
+ // 这条是护栏的邻居而非护栏本身:#493 给 user 消息加了约 100 字的固定包裹,
1879
+ // 若 distillChars 仍按「消息总字符数」算,4 字的窗口会凭空越过任何阈值,
1880
+ // summarizeMinWindowChars 等于被自己的提示词旁路掉。
1881
+ const { events, service } = setup({ summarizeMinWindowChars: 60, distillRateLimitIntervalMs: 0 });
1882
+ await eventHandler(events)(
1883
+ {
1884
+ id: "s493-metric",
1885
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1886
+ events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
1887
+ },
1888
+ { seq: 2, type: "turn/end" }
1889
+ );
1890
+ const row = service.listLlmAudits()[0];
1891
+ assert.equal(row.error_message, "window-too-small", "4 字正文不得被包裹撑过 60 字阈值");
1892
+ assert.equal(row.metadata.distillChars, 4, "计的是转录正文(「用户:短」= 4 字)");
1893
+ });
1894
+
1895
+ test("#493 失败行可诊断:metadata 带 raw_head / raw_has_bracket / streak", async () => {
1896
+ const { events, service } = setup({ distillRateLimitIntervalMs: 0 }, {
1897
+ stream() {
1898
+ return (async function* () {
1899
+ yield { type: "block-start", block: { type: "text" } };
1900
+ yield { type: "text-delta", delta: "已按你的要求将值班轮单日上限改为 5,下面是具体改动…" };
1901
+ yield { type: "finish", kind: "ok" };
1902
+ })();
1903
+ }
1904
+ });
1905
+ await eventHandler(events)(
1906
+ {
1907
+ id: "s493-diag",
1908
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1909
+ events: [userMessage("帮我改一下值班轮上限", 1), { seq: 2, type: "turn/end" }]
1910
+ },
1911
+ { seq: 2, type: "turn/end" }
1912
+ );
1913
+
1914
+ const row = service.listLlmAudits()[0];
1915
+ assert.equal(row.status, "error");
1916
+ assert.equal(row.error_message, "invalid summary JSON");
1917
+ // 原先 metadata 恒为 null,90 条历史失败一条原始输出都没留 —— 事后无法回答
1918
+ // 「模型到底吐了什么形状」。raw_has_bracket 是区分「没按格式写」与「格式对但
1919
+ // 内容救不回来」的唯一低成本判据,也正是 #493 现场的关键特征。
1920
+ assert.equal(row.metadata?.parse_failure, true);
1921
+ assert.equal(row.metadata?.raw_has_bracket, false, "这段点评里没有方括号");
1922
+ assert.ok(row.metadata?.raw_len > 0);
1923
+ assert.ok(row.metadata?.raw_head.startsWith("已按你的要求"), "原始输出前缀要留档");
1924
+ assert.equal(row.metadata?.streak, 1);
1925
+ assert.ok(Number.isInteger(row.metadata?.distillChars), "失败行同样带窗口字符数");
1926
+ });
1927
+
1928
+ test("#493 失败护栏默认关:连续失败也不放弃窗口(历史行为逐字节不变)", async () => {
1929
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
1930
+ stream() {
1931
+ return (async function* () {
1932
+ yield { type: "block-start", block: { type: "text" } };
1933
+ yield { type: "text-delta", delta: "永远不是 JSON" };
1934
+ yield { type: "finish", kind: "ok" };
1935
+ })();
1936
+ }
1937
+ });
1938
+ const handler = eventHandler(events);
1939
+ const session = {
1940
+ id: "s493-guard-off",
1941
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1942
+ events: [userMessage("窗口", 1), { seq: 2, type: "turn/end" }]
1943
+ };
1944
+ for (let i = 0; i < 5; i++) await handler(session, { seq: 2, type: "turn/end" });
1945
+ // 默认 0 = 关:这是本仓新行为的既定纪律(AGENTS.md),也是既有 4 个
1946
+ // 「解析失败不消费窗口」用例语义不变的前提。
1947
+ assert.equal(calls.length, 5, "护栏关着时每个 turn/end 仍重试同一窗口");
1948
+ });
1949
+
1950
+ test("#493 失败护栏:连续 N 次失败后强制消费游标跳过该窗口", async () => {
1951
+ let attempt = 0;
1952
+ const { events, calls, service, store, summarizer } = setup(
1953
+ { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
1954
+ {
1955
+ stream() {
1956
+ attempt++;
1957
+ const output = attempt <= 2 ? "确定性失败:一段中文点评" : "[]";
1958
+ return (async function* () {
1959
+ yield { type: "block-start", block: { type: "text" } };
1960
+ yield { type: "text-delta", delta: output };
1961
+ yield { type: "finish", kind: "ok" };
1962
+ })();
1963
+ }
1964
+ }
1965
+ );
1966
+ const handler = eventHandler(events);
1967
+ const session = {
1968
+ id: "s493-guard-on",
1969
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1970
+ events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }]
1971
+ };
1972
+
1973
+ await handler(session, { seq: 2, type: "turn/end" });
1974
+ assert.equal(calls.length, 1);
1975
+ assert.equal(service.getDistillCursor("s493-guard-on"), undefined, "第一次失败仍不推进游标");
1976
+
1977
+ await handler(session, { seq: 2, type: "turn/end" });
1978
+ assert.equal(calls.length, 2, "阈值内仍重试");
1979
+ // 达到阈值(2):窗口被放弃,游标推进到本窗末尾,下一轮从全新窗口起算。
1980
+ const cursor = service.getDistillCursor("s493-guard-on");
1981
+ assert.equal(cursor?.last_seq, 2, "达到阈值后强制消费游标");
1982
+ const errorRows = service.listLlmAudits().filter((r) => r.status === "error");
1983
+ assert.equal(errorRows.length, 2);
1984
+ // 按 streak 定位而不是按下标:审计表的返回顺序是存储层的事,不该被这条断言锁住。
1985
+ const abandoned = errorRows.find((r) => r.metadata?.streak === 2);
1986
+ assert.equal(abandoned?.metadata?.skipped, "parse-failure-streak", "放弃必须可观测");
1987
+ assert.equal(abandoned?.metadata?.maxRetries, 2);
1988
+ assert.equal(errorRows.find((r) => r.metadata?.streak === 1)?.metadata?.skipped, undefined,
1989
+ "阈值前的那次失败仍按「可重试」记账,不得提前宣告放弃");
1990
+
1991
+ // 游标已消费 → 同一 turn/end 不再重试;新事件开新窗口并正常落库。
1992
+ await handler(session, { seq: 2, type: "turn/end" });
1993
+ assert.equal(calls.length, 2, "被放弃的窗口不再重试");
1994
+ session.events.push(userMessage("新的窗口", 3), { seq: 4, type: "turn/end" });
1995
+ await handler(session, { seq: 4, type: "turn/end" });
1996
+ assert.equal(calls.length, 3, "新窗口正常蒸馏");
1997
+ summarizer.dispose();
1998
+ store.close();
1999
+ });
2000
+
2001
+ test("#493 失败护栏:一次成功即清零计数,不会隔窗累计", async () => {
2002
+ let attempt = 0;
2003
+ const { events, calls, service, store, summarizer } = setup(
2004
+ { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
2005
+ {
2006
+ stream() {
2007
+ // 失败 → 成功 → 失败:第 3 次是「新一轮的第一次失败」,不该被判为第 2 次。
2008
+ const outputs = ["不是 JSON", "[]", "又不是 JSON"];
2009
+ const output = outputs[attempt++] ?? "[]";
2010
+ return (async function* () {
2011
+ yield { type: "block-start", block: { type: "text" } };
2012
+ yield { type: "text-delta", delta: output };
2013
+ yield { type: "finish", kind: "ok" };
2014
+ })();
2015
+ }
2016
+ }
2017
+ );
2018
+ const handler = eventHandler(events);
2019
+ const session = {
2020
+ id: "s493-streak-reset",
2021
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2022
+ events: [userMessage("第一窗", 1), { seq: 2, type: "turn/end" }]
2023
+ };
2024
+ await handler(session, { seq: 2, type: "turn/end" }); // 失败 #1
2025
+ await handler(session, { seq: 2, type: "turn/end" }); // 成功 → 清零
2026
+ session.events.push(userMessage("第二窗", 3), { seq: 4, type: "turn/end" });
2027
+ await handler(session, { seq: 4, type: "turn/end" }); // 失败 #1(不是 #2)
2028
+
2029
+ // 两条失败行都必须是「新一轮的第一次」:只查最后一条会让「第一条其实被
2030
+ // 记成 streak=2」这类回归漏网。
2031
+ const errorRows = service.listLlmAudits().filter((r) => r.status === "error");
2032
+ assert.equal(errorRows.length, 2);
2033
+ for (const row of errorRows) {
2034
+ assert.equal(row.metadata?.streak, 1, "成功必须清零,否则阈值会在跨窗处误触发");
2035
+ assert.equal(row.metadata?.skipped, undefined, "计数未达阈值,不得放弃窗口");
2036
+ }
2037
+ assert.equal(calls.length, 3);
2038
+ summarizer.dispose();
2039
+ store.close();
2040
+ });