@modusensus/dsh-mneme 0.9.3 → 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.
@@ -0,0 +1,232 @@
1
+ // issue #231 的第二批:把已造好的整理引擎接到 agent 的调用面上(organizeEnabled
2
+ // + memory_organize 工具)。src/organize.js 的本体语义由 test/organize.test.js 守,
3
+ // 本文件只守**出口**这一层:闸门、报告/落地的两步协议、以及 strictScope 下
4
+ // 「看不见的行按不存在处理」这条可信上下文。
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { createStore } from "../src/store.js";
8
+ import { createService } from "../src/service.js";
9
+ import { createTools } from "../src/tools.js";
10
+ import { applyLightModePreset } from "../src/config.js";
11
+
12
+ function setup(config = {}) {
13
+ const store = createStore(":memory:");
14
+ const service = createService({ store, mirror: null, config });
15
+ const registered = [];
16
+ const ctx = {
17
+ tools: {
18
+ register(def) {
19
+ registered.push(def);
20
+ return () => {};
21
+ }
22
+ }
23
+ };
24
+ createTools(ctx, service, config, undefined);
25
+ const byName = Object.fromEntries(registered.map((def) => [def.name, def]));
26
+ return { store, service, byName, registered };
27
+ }
28
+
29
+ const CANDIDATES = [
30
+ { type: "project", title: "发布只在包目录内", content: "根目录发过坏包,发布必须在 dsh-mneme/ 内执行。" },
31
+ { type: "project", title: "另一条", content: "第二条候选的正文。" }
32
+ ];
33
+
34
+ test("memory_organize registers when organizeEnabled is on", async () => {
35
+ const { byName } = setup({ organizeEnabled: true });
36
+ const tool = byName.memory_organize;
37
+ assert.ok(tool, "organizeEnabled=true 应当注册 memory_organize");
38
+ const report = await tool.execute({ mode: "dryRun", candidates: CANDIDATES });
39
+ assert.equal(report.status, "ok");
40
+ assert.equal(report.counts.total, 2, "两条候选都应进报告");
41
+ assert.equal(report.run_id ? typeof report.run_id : "none", "string", "dryRun 必须回一个 run_id 供 apply 引用");
42
+ });
43
+
44
+ // 默认关=零行为变化是仓库红线(AGENTS.md 自查清单第 1 条)。这条现在就会绿——
45
+ // 它防的是「以后有人把默认翻成 true」,而不是今天的缺失实现。
46
+ test("memory_organize stays unregistered by default", () => {
47
+ const { registered } = setup();
48
+ assert.equal(registered.some((t) => t.name === "memory_organize"), false, "organizeEnabled 缺省时不得注册");
49
+ });
50
+
51
+ test("lightMode preset turns organize off, but an explicit user switch wins", () => {
52
+ // 轻量档那一格是**预设**,不是强制(合并顺序「用户开关 > 轻量预设 > bundle」在
53
+ // index.js),所以这里断言的是预设值本身——同 continuity.test.js 的
54
+ // continuityRescueEnabled 那条。工具侧的注册与否由上一条默认关用例守着。
55
+ assert.equal(applyLightModePreset({ lightMode: true }).organizeEnabled, false);
56
+ assert.equal(applyLightModePreset({ lightMode: true, organizeEnabled: true }).organizeEnabled, false, "预设写死 false,赢的是合并时的用户开关,不是这里");
57
+ });
58
+
59
+ test("dryRun writes no memory rows but leaves one organize audit row", () => {
60
+ const { store, byName } = setup({ organizeEnabled: true });
61
+ return byName.memory_organize.execute({ mode: "dryRun", candidates: CANDIDATES }).then((report) => {
62
+ assert.deepEqual(store.list({ limit: null }), [], "比对是报告,不是动作:记忆表必须一行不写");
63
+ const runs = store.listDreamRuns({ limit: 10 });
64
+ assert.equal(runs.length, 1, "dryRun 只留 dream_runs 一行回执");
65
+ assert.equal(runs[0].run_type, "organize");
66
+ assert.equal(runs[0].id, report.run_id);
67
+ });
68
+ });
69
+
70
+ test("apply lands the decisions it references and reports counters", async () => {
71
+ const { store, byName } = setup({ organizeEnabled: true });
72
+ const report = await byName.memory_organize.execute({ mode: "dryRun", candidates: CANDIDATES });
73
+ const applied = await byName.memory_organize.execute({
74
+ mode: "apply",
75
+ run_id: report.run_id,
76
+ decisions: [
77
+ { action: "save", index: 0 },
78
+ { action: "discard", index: 1 }
79
+ ]
80
+ });
81
+ assert.equal(applied.saved, 1, "只数新建的行");
82
+ assert.equal(applied.discarded, 1);
83
+ assert.equal(applied.status, "ok");
84
+ assert.equal(store.list({ limit: null }).length, 1, "一条 save、一条 discard → 库里只有一行");
85
+ const types = store.listDreamRuns({ limit: 10 }).map((r) => r.run_type);
86
+ assert.deepEqual(types, ["organize", "organize"], "报告与落地各留一行审计");
87
+ });
88
+
89
+ test("apply refuses a run_id that was never reported", async () => {
90
+ const { byName } = setup({ organizeEnabled: true });
91
+ await assert.rejects(
92
+ () => byName.memory_organize.execute({ mode: "apply", run_id: "no-such-run", decisions: [{ action: "discard", index: 0 }] }),
93
+ /unknown organize run/,
94
+ "没有比对过的候选一律不落库(organize 硬规则 2)"
95
+ );
96
+ });
97
+
98
+ test("apply refuses a missing run_id", async () => {
99
+ const { byName } = setup({ organizeEnabled: true });
100
+ await assert.rejects(
101
+ () => byName.memory_organize.execute({ mode: "apply", decisions: [{ action: "discard", index: 0 }] }),
102
+ /run_id is required/,
103
+ "apply 必须引用一次真实 dryRun"
104
+ );
105
+ });
106
+
107
+ // 整理接口不能成为第二条「写进库却不带 scope」的通道:memory_save 在 scopeEnabled
108
+ // 下按会话身份盖 auto 章,organize 落地的候选必须同源。显式参数改盖 explicit,
109
+ // 与 memory_save / memory_register_document 同一规则。
110
+ test("save lands candidates stamped with the session scope (auto), like memory_save", async () => {
111
+ const { store, byName } = setup({ organizeEnabled: true, scopeEnabled: true });
112
+ const exec = { agent: { session: { id: "s1", header: { agentPreset: "me", cwd: "D:\\p" } } } };
113
+ const report = await byName.memory_organize.execute({ mode: "dryRun", candidates: CANDIDATES }, exec);
114
+ await byName.memory_organize.execute({ mode: "apply", run_id: report.run_id, decisions: [{ action: "save", index: 0 }] }, exec);
115
+ const row = store.list({ limit: null })[0];
116
+ assert.equal(row.agent_scope, "me", "缺省吃会话身份");
117
+ assert.equal(row.agent_scope_source, "auto");
118
+ assert.equal(row.workspace_scope, "D:\\p");
119
+ });
120
+
121
+ test("explicit scope arguments stamp as explicit and win over the session label", async () => {
122
+ const { store, byName } = setup({ organizeEnabled: true, scopeEnabled: true });
123
+ const exec = { agent: { session: { id: "s1", header: { agentPreset: "me", cwd: "D:\\p" } } } };
124
+ const report = await byName.memory_organize.execute(
125
+ { mode: "dryRun", candidates: CANDIDATES, agent_scope: "other-agent" },
126
+ exec
127
+ );
128
+ await byName.memory_organize.execute({ mode: "apply", run_id: report.run_id, decisions: [{ action: "save", index: 0 }] }, exec);
129
+ const row = store.list({ limit: null })[0];
130
+ assert.equal(row.agent_scope, "other-agent", "显式参数盖在会话身份之上");
131
+ assert.equal(row.agent_scope_source, "explicit");
132
+ });
133
+
134
+ test("unknown mode is rejected", async () => {
135
+ const { byName } = setup({ organizeEnabled: true });
136
+ await assert.rejects(() => byName.memory_organize.execute({ mode: "force" }), /unknown mode/);
137
+ });
138
+
139
+ // #170 复核项 4 的口径延伸到整理接口的**两侧**:报告侧不得泄漏存在性,落地侧不得
140
+ // 绕过 scope 治理改库。用例形状是刻意挑的:比对键只看三列**值**(sensitivity/agent/
141
+ // workspace),可见性还多认一个 `explicit` 来源,所以要造出「按值该命中、但会话看不见」
142
+ // 那一格——匿名会话(解析不到 agentPreset)对 explicit 行 fail-closed。
143
+ // 报告侧少了过滤就是探测口:`agent_scope` 是模型自己给的参数,A 会话可以拿 B 的标题问
144
+ // 「那一档有没有这行」,拿回 id/title 就等于确认存在——memory_get 在同一条件下回「不存在」。
145
+ test("dryRun reports an invisible match as new, with no id or title", async () => {
146
+ const config = { organizeEnabled: true, scopeEnabled: true, strictScope: true };
147
+ const { store, byName } = setup(config);
148
+ store.save({
149
+ type: "project",
150
+ title: "发布只在包目录内",
151
+ content: "team-a 名下的旧行。",
152
+ agent_scope: "team-a",
153
+ agent_scope_source: "explicit",
154
+ workspace_scope: "D:\\p",
155
+ workspace_scope_source: "auto"
156
+ });
157
+ const exec = { agent: { session: { id: "s-anon", header: { cwd: "D:\\p" } } } };
158
+ const report = await byName.memory_organize.execute(
159
+ {
160
+ mode: "dryRun",
161
+ candidates: [{ type: "project", title: "发布只在包目录内", content: "换一种说法的同一件事。" }],
162
+ agent_scope: "team-a",
163
+ workspace_scope: "D:\\p"
164
+ },
165
+ exec
166
+ );
167
+ assert.equal(report.items[0].verdict, "new", "看不见的行按不存在处理 → 报告只能说「新条目」");
168
+ assert.equal(report.items[0].match, undefined, "不得带回落点 id/title/sim");
169
+ assert.equal(report.counts.new, 1);
170
+ });
171
+
172
+ // 反面对照:strictScope 是**可选**硬过滤,关掉它时那行本来就可读(A2 只降权)。少了这条,
173
+ // 过滤就变成「闸门关着也少召回」的第二重惩罚——修安全问题不许顺手改默认行为。
174
+ test("the same row is still reported as exact when strictScope is off", async () => {
175
+ const config = { organizeEnabled: true, scopeEnabled: true };
176
+ const { store, byName } = setup(config);
177
+ const row = store.save({
178
+ type: "project",
179
+ title: "发布只在包目录内",
180
+ content: "team-a 名下的旧行。",
181
+ agent_scope: "team-a",
182
+ agent_scope_source: "explicit",
183
+ workspace_scope: "D:\\p",
184
+ workspace_scope_source: "auto"
185
+ });
186
+ const exec = { agent: { session: { id: "s-anon", header: { cwd: "D:\\p" } } } };
187
+ const report = await byName.memory_organize.execute(
188
+ {
189
+ mode: "dryRun",
190
+ candidates: [{ type: "project", title: "发布只在包目录内", content: "换一种说法的同一件事。" }],
191
+ agent_scope: "team-a",
192
+ workspace_scope: "D:\\p"
193
+ },
194
+ exec
195
+ );
196
+ assert.equal(report.items[0].verdict, "exact");
197
+ assert.equal(report.items[0].match.id, row.id, "闸门关着时照常给出落点,否则比对报告失去意义");
198
+ });
199
+
200
+ // 落地侧:报告已按「不存在」说了,archive 也不许真的动那一行(`opts.hiddenIds` 由可信
201
+ // 调用层算好传入,绝不取 payload 字段——那是调用方可自选的内容,不是授权)。
202
+ test("archive of a row the caller cannot see is skipped as out of scope", async () => {
203
+ const config = { organizeEnabled: true, scopeEnabled: true, strictScope: true };
204
+ const { store, byName } = setup(config);
205
+ const teamRow = store.save({
206
+ type: "project",
207
+ title: "发布只在包目录内",
208
+ content: "team-a 名下的旧行。",
209
+ agent_scope: "team-a",
210
+ agent_scope_source: "explicit",
211
+ workspace_scope: "D:\\p",
212
+ workspace_scope_source: "auto"
213
+ });
214
+ const exec = { agent: { session: { id: "s-anon", header: { cwd: "D:\\p" } } } };
215
+ const report = await byName.memory_organize.execute(
216
+ {
217
+ mode: "dryRun",
218
+ candidates: [{ type: "project", title: "发布只在包目录内", content: "换一种说法的同一件事。" }],
219
+ agent_scope: "team-a",
220
+ workspace_scope: "D:\\p"
221
+ },
222
+ exec
223
+ );
224
+ const applied = await byName.memory_organize.execute(
225
+ { mode: "apply", run_id: report.run_id, decisions: [{ action: "archive", id: teamRow.id }] },
226
+ exec
227
+ );
228
+ assert.equal(applied.archived, 0, "看不见的行按不存在处理,不得归档");
229
+ assert.equal(applied.skipped.length, 1);
230
+ assert.match(applied.skipped[0].error, /out of scope/, "文案维持「unknown, archived or out of scope」:不泄漏存在性");
231
+ assert.equal(store.getById(teamRow.id).archived, false, "他人的行原样未动");
232
+ });
@@ -0,0 +1,83 @@
1
+ // 修复一:人格寻址的兜底口径。default 卡的存在理由是「这一帧解析不到会话身份」,
2
+ // 而不是「所有没建卡的 agent 都该拿到它」——后者会把某个人格无条件塞进外部 bot 的
3
+ // 会话(DS Bot 那类:它自己带身份与设定,两处身份声明撞在同一帧里)。
4
+ // 现状(personaStrictAddressing 缺省 = false)已由 test/persona-inject.test.js:113
5
+ // 与 :124 钉住,本文件第一条照样锁它——这是 opt-in,不是把别人的行为改了。
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { createStore } from "../src/store.js";
9
+ import { createService } from "../src/service.js";
10
+ import { createInjector } from "../src/inject.js";
11
+ import { createSettings } from "../src/settings.js";
12
+
13
+ function setup(over = {}) {
14
+ const store = createStore(":memory:");
15
+ const service = createService({ store, mirror: null, config: {} });
16
+ const settings = createSettings(store.db);
17
+ const contexts = [];
18
+ const ctx = {
19
+ systemPrompt: {
20
+ context(def) {
21
+ contexts.push(def);
22
+ return () => {};
23
+ }
24
+ }
25
+ };
26
+ createInjector(ctx, service, settings, { maxInjectedItems: 3, importanceThreshold: 3, ...over });
27
+ return { store, settings, settingsCtx: contexts.find((c) => c.name === "user-settings") };
28
+ }
29
+
30
+ const withPreset = (agentPreset) => ({ agent: { session: { id: "s1", header: { agentPreset } } } });
31
+
32
+ // 种子:default 卡(Amarre)开着 + 一张 zephyr 卡。
33
+ function seed(settings, zephyrEnabled = true) {
34
+ settings.setPersona({ name: "Amarre", personality: "轻松幽默", enabled: true });
35
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", personality: "冷危", enabled: zephyrEnabled });
36
+ }
37
+
38
+ test("缺省(开关关):没建卡的身份仍落 default——本 PR 不改默认行为", () => {
39
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true });
40
+ seed(settings);
41
+ const text = settingsCtx.text(withPreset("unknown-bot"));
42
+ assert.ok(text.includes("Amarre"), "personaStrictAddressing 缺省时行为逐字节不变");
43
+ store.close();
44
+ });
45
+
46
+ test("strict on:没建卡的身份不注入任何人格行", () => {
47
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true, personaStrictAddressing: true });
48
+ seed(settings);
49
+ settings.setProfile("用户是规划背景");
50
+ const text = settingsCtx.text(withPreset("unknown-bot"));
51
+ assert.ok(!text.includes("Amarre"), "外部身份不该收到别人的人格");
52
+ assert.ok(!text.includes("你的人格名字"), "无人格时整段人格行都不出现,不是留个空标签");
53
+ store.close();
54
+ });
55
+
56
+ test("strict on:显式关掉的卡不再回落 default——这让「关掉这张卡」第一次有意义", () => {
57
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true, personaStrictAddressing: true });
58
+ seed(settings, false);
59
+ const text = settingsCtx.text(withPreset("zephyr"));
60
+ assert.ok(!text.includes("泽菲尔"), "关掉的卡本身不注入");
61
+ assert.ok(!text.includes("Amarre"), "关掉之后落回 default 等于没关");
62
+ store.close();
63
+ });
64
+
65
+ test("strict on:解析不到身份(无 session.header)照旧用 default 卡", () => {
66
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true, personaStrictAddressing: true });
67
+ seed(settings);
68
+ const text = settingsCtx.text({});
69
+ assert.ok(text.includes("你的人格名字:Amarre"), "无名会话正是 default 卡的受众,strict 不许把它一起关掉");
70
+ store.close();
71
+ });
72
+
73
+ test("strict on:命中的卡照常注入,画像与规则不受影响", () => {
74
+ const { store, settings, settingsCtx } = setup({ personaEnabled: true, personaStrictAddressing: true });
75
+ seed(settings);
76
+ settings.setProfile("用户是规划背景");
77
+ settings.setRules(["不提及用户名"]);
78
+ const hit = settingsCtx.text(withPreset("zephyr"));
79
+ assert.ok(hit.includes("你的人格名字:泽菲尔"), "身份命中时行为与现状一致");
80
+ assert.ok(hit.includes("- 用户画像:用户是规划背景"), "画像恒注入:本开关只管人格行");
81
+ assert.ok(hit.includes("- 规则:不提及用户名"), "规则恒注入:同上");
82
+ store.close();
83
+ });
@@ -7,6 +7,7 @@ import test from "node:test";
7
7
  import assert from "node:assert/strict";
8
8
  import { createStore } from "../src/store.js";
9
9
  import { createSettings } from "../src/settings.js";
10
+ import { resolvePersonaCard } from "../src/user-settings.js";
10
11
 
11
12
  function setup() {
12
13
  const store = createStore(":memory:");
@@ -81,10 +82,14 @@ test("v1 legacy single-object persona reads as default (zero migration)", () =>
81
82
  store.close();
82
83
  });
83
84
 
84
- test("byAgent card round-trips via setPersonaFor/getPersonaFor", () => {
85
+ // 寻址口径住在 src/user-settings.js(注入与面板预览共用一份),不在 settings 里——
86
+ // settings 只存卡。原来那两个 getPersonaFor 用例因此改指向 resolvePersonaCard:
87
+ // 断言的是同一件事,但落在唯一那份实现上。
88
+ test("byAgent card round-trips via setPersonaFor/resolvePersonaCard", () => {
85
89
  const { store, settings } = setup();
86
90
  settings.setPersonaFor("zephyr", { name: "泽菲尔", personality: "冷危占有", enabled: true });
87
- assert.deepEqual(settings.getPersonaFor("zephyr"), { name: "泽菲尔", personality: "冷危占有", enabled: true });
91
+ assert.deepEqual(resolvePersonaCard(settings, "zephyr").card, { name: "泽菲尔", personality: "冷危占有", enabled: true });
92
+ assert.equal(resolvePersonaCard(settings, "zephyr").source, "byAgent");
88
93
  // default 仍在且未被影响
89
94
  assert.deepEqual(settings.getPersona().default, {});
90
95
  // 卡列表含 zephyr
@@ -92,17 +97,34 @@ test("byAgent card round-trips via setPersonaFor/getPersonaFor", () => {
92
97
  store.close();
93
98
  });
94
99
 
95
- test("getPersonaFor falls back to default when id card missing or disabled", () => {
100
+ test("缺省寻址:身份没建卡、或卡被关掉,都落 default", () => {
96
101
  const { store, settings } = setup();
97
102
  settings.setPersona({ name: "Amarre", enabled: true }); // 单对象形写 default
98
103
  settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: false }); // 卡存在但关
99
- // 未注册身份 → default
100
- assert.equal(settings.getPersonaFor("unknown-bot").name, "Amarre");
104
+ assert.equal(resolvePersonaCard(settings, "unknown-bot").card.name, "Amarre");
101
105
  // 身份卡存在但 enabled=false → 落 default(兜底优先,不注入半张卡)
102
- assert.equal(settings.getPersonaFor("zephyr").name, "Amarre");
106
+ assert.equal(resolvePersonaCard(settings, "zephyr").card.name, "Amarre");
103
107
  // 身份缺失(null/空串)→ default
104
- assert.equal(settings.getPersonaFor(null).name, "Amarre");
105
- assert.equal(settings.getPersonaFor("").name, "Amarre");
108
+ assert.equal(resolvePersonaCard(settings, null).card.name, "Amarre");
109
+ assert.equal(resolvePersonaCard(settings, "").card.name, "Amarre");
110
+ store.close();
111
+ });
112
+
113
+ test("strict 寻址:身份没建卡与卡被关掉都不注入,无名会话仍用 default", () => {
114
+ const { store, settings } = setup();
115
+ settings.setPersona({ name: "Amarre", enabled: true });
116
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: false });
117
+ const strict = { strict: true };
118
+ for (const id of ["unknown-bot", "zephyr"]) {
119
+ const { card, source } = resolvePersonaCard(settings, id, strict);
120
+ assert.equal(source, "none", `${id} 在严格寻址下不该拿到任何卡`);
121
+ assert.deepEqual(card, {});
122
+ }
123
+ // 开着的身份卡照样命中(strict 不改变命中语义)
124
+ settings.setPersonaFor("mentor", { name: "导师", enabled: true });
125
+ assert.equal(resolvePersonaCard(settings, "mentor", strict).source, "byAgent");
126
+ // 解析不到身份 = default 卡的受众,strict 不动这一格
127
+ assert.equal(resolvePersonaCard(settings, null, strict).card.name, "Amarre");
106
128
  store.close();
107
129
  });
108
130
 
@@ -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
+ });