@modusensus/dsh-mneme 0.10.1 → 0.12.0
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 +17 -6
- package/bin/dsh-mneme-choro.mjs +254 -0
- package/docs/CONFIGURATION.md +10 -1
- package/lib/api-standalone.js +17 -21
- package/lib/api.js +414 -12
- package/lib/bootstrap-http.js +105 -0
- package/lib/bootstrap.js +5 -1
- package/lib/choro-command.js +104 -0
- package/lib/client.js +1637 -110
- package/lib/config-keys.js +144 -0
- package/lib/config.js +60 -1
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/index.js +87 -5
- package/lib/inject.js +18 -4
- package/lib/lang.js +82 -0
- package/lib/profile-facts.js +356 -0
- package/lib/quality-filter.js +48 -12
- package/lib/service.js +112 -4
- package/lib/settings.js +13 -1
- package/lib/store.js +978 -27
- package/lib/tools.js +244 -1
- package/lib/user-settings.js +63 -7
- package/lib/wiki/inject.js +164 -0
- package/lib/wiki/partition.js +200 -0
- package/lib/wiki/plan.js +214 -0
- package/lib/wiki/queries.js +360 -0
- package/lib/wiki/render.js +187 -0
- package/lib/wiki/scan.js +371 -0
- package/lib/wiki/t0.js +463 -0
- package/lib/wiki/t1.js +496 -0
- package/lib/write-admission.js +5 -3
- package/package.json +4 -2
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +17 -21
- package/src/api.js +414 -12
- package/src/bootstrap-http.js +105 -0
- package/src/bootstrap.js +5 -1
- package/src/choro-command.js +104 -0
- package/src/config-keys.js +144 -0
- package/src/config.js +60 -1
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/index.js +87 -5
- package/src/inject.js +18 -4
- package/src/lang.js +82 -0
- package/src/profile-facts.js +356 -0
- package/src/quality-filter.js +48 -12
- package/src/service.js +112 -4
- package/src/settings.js +13 -1
- package/src/store.js +978 -27
- package/src/tools.js +244 -1
- package/src/user-settings.js +63 -7
- package/src/wiki/inject.js +164 -0
- package/src/wiki/partition.js +200 -0
- package/src/wiki/plan.js +214 -0
- package/src/wiki/queries.js +360 -0
- package/src/wiki/render.js +187 -0
- package/src/wiki/scan.js +371 -0
- package/src/wiki/t0.js +463 -0
- package/src/wiki/t1.js +496 -0
- package/src/write-admission.js +5 -3
- package/test/api-routes-gaps.test.js +131 -1
- package/test/api.test.js +9 -1
- package/test/bootstrap.test.js +8 -0
- package/test/choro-command.test.js +218 -0
- package/test/client.test.js +802 -21
- package/test/config-keys-routes.test.js +233 -0
- package/test/config.test.js +79 -0
- package/test/contrast.test.js +14 -7
- package/test/document.test.js +32 -0
- package/test/embedding-cache-f32.test.js +276 -0
- package/test/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -0
- package/test/inject.test.js +51 -0
- package/test/llm-audit.test.js +29 -1
- package/test/persona-settings.test.js +31 -0
- package/test/profile-facts.test.js +492 -0
- package/test/quality-backfill.test.js +290 -0
- package/test/quality-filter.test.js +113 -4
- package/test/service.test.js +1 -1
- package/test/session-gate.test.js +1 -1
- package/test/test-count-sync.test.js +14 -3
- package/test/tool-state-ignore.test.js +65 -0
- package/test/tools-dto-schema-matrix.test.js +23 -2
- package/test/tools.test.js +144 -7
- package/test/wiki-api.test.js +273 -0
- package/test/wiki-inject.test.js +265 -0
- package/test/wiki-partition.test.js +112 -0
- package/test/wiki-plan.test.js +80 -0
- package/test/wiki-queries.test.js +270 -0
- package/test/wiki-scan.test.js +208 -0
- package/test/wiki-store.test.js +190 -0
- package/test/wiki-t0.test.js +380 -0
- package/test/wiki-t1.test.js +235 -0
- package/test/wiki-tools.test.js +176 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// 「Agent 眼中的你」自动事实(2026-11-06):逐卡事实清单 + dream 后自动维护轮 +
|
|
2
|
+
// 面板手动轮 + 注入段。单独立用例文件的理由与 profile-extract.test.js 同源——这条链
|
|
3
|
+
// 的产品承诺很窄也很硬,全是「做错了界面上看不出来」的那类,逐条锁:
|
|
4
|
+
// ① 手填画像神圣:任何路径都不写 user_settings(事实住 profile_facts 独立三表);
|
|
5
|
+
// ② 没有增量输入就不调模型(游标之后没有新行 = noop,回执可查);
|
|
6
|
+
// ③ 模型吐的不是可用 ops JSON 就 failed,绝不冒充「这轮跑过了」;
|
|
7
|
+
// ④ 失败不推进游标——failed 轮之后重跑同一窗口(蒸馏游标同款纪律);
|
|
8
|
+
// ⑤ 卡间不互串:输入行按 agent_scope 严格分区(store 层),注入按 strict 寻址
|
|
9
|
+
// (user-settings 层),LLM 幻觉出的别卡 id 落不进本卡(apply 层)。
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { createStore } from "../src/store.js";
|
|
14
|
+
import { createService } from "../src/service.js";
|
|
15
|
+
import { createSettings } from "../src/settings.js";
|
|
16
|
+
import { createApi } from "../src/api.js";
|
|
17
|
+
import { createProfileFacts, ProfileFactsError, parseFactOps, isValidFactOps } from "../src/profile-facts.js";
|
|
18
|
+
import { renderUserSettingsBlock, resolveFactsCard } from "../src/user-settings.js";
|
|
19
|
+
|
|
20
|
+
// 假的 ctx.llm:协议与 profile-extract.test.js 的 fakeLlm 同款(用量嵌在 chunk.usage)。
|
|
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 okOps = (ops) => [
|
|
35
|
+
{ type: "text-delta", text: "```json\n" },
|
|
36
|
+
{ type: "text-delta", text: JSON.stringify({ ops }) },
|
|
37
|
+
{ type: "text-delta", text: "\n```" },
|
|
38
|
+
{ type: "usage", usage: { inputTokens: 100, outputTokens: 20 } },
|
|
39
|
+
{ type: "finish", reason: { kind: "stop" } }
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function setup(chunks, opts = {}) {
|
|
43
|
+
const { config = {} } = 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 facts = createProfileFacts({
|
|
50
|
+
ctx: { ...fake, agentDefaultModel: { currentSelection: () => null } },
|
|
51
|
+
service,
|
|
52
|
+
store,
|
|
53
|
+
config: { dreamProvider: "fake", dreamModel: "m1", ...config },
|
|
54
|
+
logger
|
|
55
|
+
});
|
|
56
|
+
return { store, service, settings, facts, fake, logger };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 种记忆行;updated_at 由 save() 铸造(不接受传值),所以游标断言一律读行取值。 */
|
|
60
|
+
function seedRows(store, n, over = {}) {
|
|
61
|
+
const ids = [];
|
|
62
|
+
for (let i = 0; i < n; i++) {
|
|
63
|
+
const id = `m-${over.agent_scope ?? "d"}-${i}`;
|
|
64
|
+
store.save({ id, type: "project", title: `行 ${i}`, content: `第 ${i} 行的正文`, ...over });
|
|
65
|
+
ids.push(id);
|
|
66
|
+
}
|
|
67
|
+
return ids;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("解析器:围栏 JSON 可取;未知 op / 缺 content / 非 JSON 一律 null,绝不冒充空成功", () => {
|
|
71
|
+
assert.deepEqual(parseFactOps('{"ops":[]}'), [], "空 ops 是合法的「没什么可改」");
|
|
72
|
+
assert.equal(parseFactOps("我觉得她很好。"), null);
|
|
73
|
+
assert.equal(parseFactOps('{"ops":[{"op":"delete","id":"x"}]}'), null, "delete 不在 v1 op 集里");
|
|
74
|
+
assert.equal(parseFactOps('{"ops":[{"op":"add"}]}'), null, "add 缺 content = 形状不合格");
|
|
75
|
+
assert.equal(parseFactOps('{"ops":[{"op":"update"}]}'), null, "update 缺 id = 形状不合格");
|
|
76
|
+
// update 缺 content 是评审 P1 实测复现的洞:String(undefined) 会把「undefined」
|
|
77
|
+
// 写进事实正文并从此每帧注入。谓词一份皮,解析层与 /apply 的 400 闸同源。
|
|
78
|
+
assert.equal(parseFactOps('{"ops":[{"op":"update","id":"x"}]}'), null, "update 缺 content 同样不合格");
|
|
79
|
+
assert.equal(isValidFactOps([{ op: "update", id: "x" }]), false);
|
|
80
|
+
assert.equal(isValidFactOps([{ op: "update", id: "x", content: " " }]), false, "空白 content 不算有");
|
|
81
|
+
assert.equal(isValidFactOps([]), false, "空数组走「没什么可改」的合法路径,但谓词口径是待写入批必须非空");
|
|
82
|
+
const bare = parseFactOps('[{"op":"add","content":"裸数组也收"}]');
|
|
83
|
+
assert.equal(bare[0].content, "裸数组也收", "宽取对裸数组同样生效");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("no-route:抛错、零模型调用,且 failed 回执已落库、游标未推进", async () => {
|
|
87
|
+
const store = createStore(":memory:");
|
|
88
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
89
|
+
const facts = createProfileFacts({
|
|
90
|
+
ctx: { agentDefaultModel: { currentSelection: () => null } },
|
|
91
|
+
service, store, config: {}, logger: { warn() {}, info() {}, error() {} }
|
|
92
|
+
});
|
|
93
|
+
seedRows(store, 2);
|
|
94
|
+
await assert.rejects(() => facts.runFor("", { trigger: "manual" }),
|
|
95
|
+
(err) => err instanceof ProfileFactsError && err.code === "no-route");
|
|
96
|
+
const runs = store.listProfileFactRuns("", { limit: 5 });
|
|
97
|
+
assert.equal(runs.length, 1, "failed 轮照样落回执(账面上留痕)");
|
|
98
|
+
assert.equal(runs[0].status, "failed");
|
|
99
|
+
assert.equal(store.profileFactsCursor(""), null, "失败不推进游标");
|
|
100
|
+
store.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("noop:游标之后没有新行就不调模型,回执 noop、游标不动", async () => {
|
|
104
|
+
const { store, facts, fake } = setup(okOps([]));
|
|
105
|
+
const ids = seedRows(store, 1);
|
|
106
|
+
const rows = store.listFactInputRows("", { since: null, limit: 10 });
|
|
107
|
+
// 手动把游标推到这行的 updated_at 之后:模拟「上一轮已经看过它」。
|
|
108
|
+
store.insertProfileFactRun({
|
|
109
|
+
id: "r-seed", agentKey: "", triggerSource: "manual", status: "ok",
|
|
110
|
+
cursor: rows[0].updated_at, inputCount: 1, opsApplied: 0, detail: null,
|
|
111
|
+
startedAt: "2026-01-01T00:00:00.000Z", finishedAt: "2026-01-01T00:00:01.000Z"
|
|
112
|
+
});
|
|
113
|
+
const receipt = await facts.runFor("", { trigger: "manual" });
|
|
114
|
+
assert.equal(receipt.status, "noop");
|
|
115
|
+
assert.equal(fake.calls.length, 0, "没有增量输入就不该花这次调用");
|
|
116
|
+
assert.equal(store.profileFactsCursor(""), rows[0].updated_at);
|
|
117
|
+
store.close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("成功路径:ops 落库、账本逐 op、审计一行、游标推进到本轮消费点;重跑即 noop", async () => {
|
|
121
|
+
const { store, service, settings, facts, fake } = setup(okOps([
|
|
122
|
+
{ op: "add", content: "用户在准备考研", source_ids: ["m-d-0"] },
|
|
123
|
+
{ op: "add", content: "用户在准备考研" }, // 与上一条精确重复:跳过不落
|
|
124
|
+
{ op: "add", content: "幻觉事实" }
|
|
125
|
+
]));
|
|
126
|
+
const ids = seedRows(store, 2);
|
|
127
|
+
settings.setProfile("手写原文,不许动");
|
|
128
|
+
const profileBefore = settings.getProfile();
|
|
129
|
+
const receipt = await facts.runFor("", { trigger: "auto" });
|
|
130
|
+
assert.equal(receipt.status, "ok");
|
|
131
|
+
assert.equal(receipt.opsApplied, 2, "重复 add 被跳过,不落库");
|
|
132
|
+
assert.equal(receipt.detail.dupsSkipped, 1);
|
|
133
|
+
const active = store.listProfileFacts("", { includeArchived: false });
|
|
134
|
+
assert.equal(active.length, 2);
|
|
135
|
+
assert.equal(store.profileFactsCursor(""), receipt.cursor, "ok 轮推进游标");
|
|
136
|
+
const rows = store.listFactInputRows("", { since: null, limit: 10 });
|
|
137
|
+
assert.equal(receipt.cursor, rows.map((r) => r.updated_at).sort().at(-1), "游标 = 本轮窗口内最大的 updated_at");
|
|
138
|
+
const journal = store.listProfileFactJournal("", { limit: 10 });
|
|
139
|
+
assert.equal(journal.length, 2, "每条落地的 op 一行账");
|
|
140
|
+
assert.equal(journal[0].op, "add");
|
|
141
|
+
assert.equal(journal[0].actor, "auto");
|
|
142
|
+
assert.ok(journal[0].run_id, "自动轮的账带 run_id");
|
|
143
|
+
const audits = service.listLlmAudits({ limit: 10 });
|
|
144
|
+
assert.equal(audits.length, 1);
|
|
145
|
+
assert.equal(audits[0].trigger_source, "profileFacts");
|
|
146
|
+
assert.equal(audits[0].status, "success");
|
|
147
|
+
assert.equal(audits[0].input_tokens, 100, "用量从 chunk.usage 取");
|
|
148
|
+
// listLlmAudits 的读侧已把 related_memory_ids 解回数组;输入行按 updated_at
|
|
149
|
+
// DESC 喂给模型,比对时排序对齐。
|
|
150
|
+
assert.deepEqual([...audits[0].related_memory_ids].sort(), [...ids].sort(), "审计关联到本轮输入行");
|
|
151
|
+
// 手填画像神圣:维护轮跑得再热闹,user_settings 一个字节没动。
|
|
152
|
+
assert.equal(settings.getProfile(), profileBefore);
|
|
153
|
+
assert.equal(store.listActiveFactsGrouped().default.length, 2);
|
|
154
|
+
// 游标已推进、没有新行:重跑必须 noop(幂等),不再花模型。
|
|
155
|
+
const again = await facts.runFor("", { trigger: "auto" });
|
|
156
|
+
assert.equal(again.status, "noop");
|
|
157
|
+
assert.equal(fake.calls.length, 1);
|
|
158
|
+
store.close();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("update/archive 只认本卡的事实:LLM 幻觉出的 id 落进 failures,不误伤别卡", async () => {
|
|
162
|
+
const { store, facts } = setup(okOps([
|
|
163
|
+
{ op: "update", id: "ghost-id", content: "改写不存在的事实" },
|
|
164
|
+
{ op: "archive", id: "ghost-id" }
|
|
165
|
+
]));
|
|
166
|
+
seedRows(store, 1);
|
|
167
|
+
// 另一张卡上真实存在一条事实——幻觉 id 恰好不该碰到它。
|
|
168
|
+
facts.applyManualOps("botA", [{ op: "add", content: "botA 卡的事实" }]);
|
|
169
|
+
const receipt = await facts.runFor("", { trigger: "manual" });
|
|
170
|
+
assert.equal(receipt.status, "ok");
|
|
171
|
+
assert.equal(receipt.opsApplied, 0);
|
|
172
|
+
assert.equal(receipt.detail.failures.length, 2);
|
|
173
|
+
assert.equal(store.listProfileFacts("botA", { includeArchived: true })[0].content, "botA 卡的事实", "别卡的行不许被动");
|
|
174
|
+
store.close();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("同批 [update A→B, add B] 只落一条:去重集合要跟上 update 的新值(评审 P2)", () => {
|
|
178
|
+
const { store, facts } = setup(okOps([]));
|
|
179
|
+
facts.applyManualOps("", [{ op: "add", content: "旧表述 A" }]);
|
|
180
|
+
const id = store.listProfileFacts("", { includeArchived: false })[0].id;
|
|
181
|
+
const out = facts.applyManualOps("", [
|
|
182
|
+
{ op: "update", id, content: "新表述 B" },
|
|
183
|
+
{ op: "add", content: "新表述 B" }
|
|
184
|
+
]);
|
|
185
|
+
assert.equal(out.applied, 1, "update 落地,add 撞上去重集被跳过");
|
|
186
|
+
assert.equal(out.dupsSkipped, 1);
|
|
187
|
+
const active = store.listProfileFacts("", { includeArchived: false });
|
|
188
|
+
assert.equal(active.length, 1, "不收新值的话这里会是两条相同事实");
|
|
189
|
+
assert.equal(active[0].content, "新表述 B");
|
|
190
|
+
store.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("unparseable:审计记 error、failed 回执落库、游标不动——下一轮重跑同一窗口", async () => {
|
|
194
|
+
const { store, service, facts } = setup([
|
|
195
|
+
{ type: "text-delta", text: "我觉得用户挺好的。" },
|
|
196
|
+
{ type: "finish", reason: { kind: "stop" } }
|
|
197
|
+
]);
|
|
198
|
+
seedRows(store, 2);
|
|
199
|
+
await assert.rejects(() => facts.runFor("", { trigger: "manual" }),
|
|
200
|
+
(err) => err instanceof ProfileFactsError && err.code === "unparseable");
|
|
201
|
+
const audits = service.listLlmAudits({ limit: 10 });
|
|
202
|
+
assert.equal(audits[0].status, "error", "吐了文字但没有可用产物 = 失败调用");
|
|
203
|
+
assert.equal(store.profileFactsCursor(""), null, "失败不消费窗口");
|
|
204
|
+
assert.equal(store.listFactInputRows("", { since: null, limit: 10 }).length, 2, "同一窗口还在等下一轮");
|
|
205
|
+
store.close();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("输入行严格分区:agent 卡只吃自己 scope 的行,default 只吃 NULL 行,外来行整体排除", async () => {
|
|
209
|
+
const { store } = setup(okOps([]));
|
|
210
|
+
seedRows(store, 2, { agent_scope: "botA", agent_scope_source: "auto" });
|
|
211
|
+
seedRows(store, 2);
|
|
212
|
+
store.save({ type: "project", title: "外来", content: "别人导入的原文", source: "imported" });
|
|
213
|
+
store.save({ type: "project", title: "外来A", content: "别人导入的 botA 行", source: "imported", agent_scope: "botA", agent_scope_source: "explicit" });
|
|
214
|
+
const def = store.listFactInputRows("", { since: null, limit: 50 });
|
|
215
|
+
const a = store.listFactInputRows("botA", { since: null, limit: 50 });
|
|
216
|
+
assert.equal(def.length, 2, "default 卡只有 NULL scope 的本方行");
|
|
217
|
+
assert.equal(a.length, 2, "botA 卡只有自己 scope 的本方行");
|
|
218
|
+
assert.ok(def.every((r) => r.agent_scope === undefined || r.agent_scope === null));
|
|
219
|
+
assert.ok(a.every((r) => !r.content.includes("导入")));
|
|
220
|
+
store.close();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("autoPassCandidates:有增量输入的卡在名单里,没输入的不在;'' 恒在候选集里被评估", async () => {
|
|
224
|
+
const { store, facts } = setup(okOps([]));
|
|
225
|
+
seedRows(store, 1);
|
|
226
|
+
seedRows(store, 1, { agent_scope: "botA", agent_scope_source: "auto" });
|
|
227
|
+
let cands = facts.autoPassCandidates();
|
|
228
|
+
assert.ok(cands.includes(""));
|
|
229
|
+
assert.ok(cands.includes("botA"));
|
|
230
|
+
// 手动把两张卡都推到「已消费」:名单清空。
|
|
231
|
+
for (const key of ["", "botA"]) {
|
|
232
|
+
const rows = store.listFactInputRows(key, { since: null, limit: 10 });
|
|
233
|
+
store.insertProfileFactRun({
|
|
234
|
+
id: `seed-${key || "d"}`, agentKey: key, triggerSource: "manual", status: "ok",
|
|
235
|
+
cursor: rows.map((r) => r.updated_at).sort().at(-1), inputCount: rows.length,
|
|
236
|
+
opsApplied: 0, detail: null, startedAt: "2026-01-01T00:00:00.000Z", finishedAt: "2026-01-01T00:00:01.000Z"
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
cands = facts.autoPassCandidates();
|
|
240
|
+
assert.deepEqual(cands, [], "没有增量输入就没有自动轮(连 noop 回执都不落)");
|
|
241
|
+
store.close();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("账本裁剪:每卡只留最近 200 条,旧的删掉、新的完好", () => {
|
|
245
|
+
const store = createStore(":memory:");
|
|
246
|
+
for (let i = 0; i < 250; i++) {
|
|
247
|
+
store.insertProfileFactJournal({ at: "2026-11-06T00:00:00.000Z", agentKey: "", op: "add", after: `第 ${i} 条`, actor: "auto" });
|
|
248
|
+
}
|
|
249
|
+
const pruned = store.pruneProfileFactJournal("", 200);
|
|
250
|
+
assert.equal(pruned, 50);
|
|
251
|
+
const left = store.listProfileFactJournal("", { limit: 1000 });
|
|
252
|
+
assert.equal(left.length, 200);
|
|
253
|
+
assert.equal(left[0].after, "第 249 条", "保留的是最近的");
|
|
254
|
+
assert.equal(left.at(-1).after, "第 50 条");
|
|
255
|
+
store.close();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ---- 注入渲染(renderUserSettingsBlock 的 factsProvider 半边) ----------------
|
|
259
|
+
|
|
260
|
+
const settingsStub = {
|
|
261
|
+
getProfile: () => "手填画像原文",
|
|
262
|
+
getRules: () => ["规则一"],
|
|
263
|
+
getPersona: () => ({ default: { enabled: false }, byAgent: {} })
|
|
264
|
+
};
|
|
265
|
+
const configOn = { language: "zh", profileFactsEnabled: true };
|
|
266
|
+
|
|
267
|
+
test("注入顺序:人格 → 手填画像 → 自动事实 → 规则;事实段带表头与逐条行", () => {
|
|
268
|
+
const provider = () => ({ default: ["事实甲", "事实乙"], byAgent: {} });
|
|
269
|
+
const out = renderUserSettingsBlock({ settings: settingsStub, config: configOn, agentId: null, factsProvider: provider });
|
|
270
|
+
const t = out.text;
|
|
271
|
+
const iProfile = t.indexOf("用户画像:");
|
|
272
|
+
const iFacts = t.indexOf("关于用户的观察记录");
|
|
273
|
+
const iRules = t.indexOf("规则:规则一");
|
|
274
|
+
assert.ok(iProfile >= 0 && iFacts > iProfile && iRules > iFacts, "设计稿⑥顺序的延伸:画像 < 事实 < 规则");
|
|
275
|
+
assert.ok(t.includes(" - 事实甲") && t.includes(" - 事实乙"), "事实逐条行");
|
|
276
|
+
assert.ok(t.includes("(2 条,自动维护)"), "表头带条数");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("strict 寻址:有身份没卡时不落 default 的事实(两 bot 的认知不互串的注入半边)", () => {
|
|
280
|
+
const provider = () => ({ default: ["默认卡事实"], byAgent: { botA: ["botA 卡事实"] } });
|
|
281
|
+
const byAgent = renderUserSettingsBlock({ settings: settingsStub, config: configOn, agentId: "botA", factsProvider: provider });
|
|
282
|
+
assert.ok(byAgent.text.includes("botA 卡事实"));
|
|
283
|
+
assert.ok(!byAgent.text.includes("默认卡事实"));
|
|
284
|
+
const strictNone = renderUserSettingsBlock({
|
|
285
|
+
settings: settingsStub,
|
|
286
|
+
config: { ...configOn, personaStrictAddressing: true },
|
|
287
|
+
agentId: "botB", factsProvider: provider
|
|
288
|
+
});
|
|
289
|
+
assert.ok(!strictNone.text.includes("默认卡事实"), "strict 下没卡的 bot 不吃 default 事实");
|
|
290
|
+
const loose = renderUserSettingsBlock({ settings: settingsStub, config: configOn, agentId: "botB", factsProvider: provider });
|
|
291
|
+
assert.ok(loose.text.includes("默认卡事实"), "非 strict 落 default(与人格寻址同口径)");
|
|
292
|
+
const resolved = resolveFactsCard(provider(), "botA", { strict: false });
|
|
293
|
+
assert.equal(resolved.source, "byAgent");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("开关关着:注入文本与「从没装配过事实」逐字节一致(关 = 零读数,provider 都不调)", () => {
|
|
297
|
+
let reads = 0;
|
|
298
|
+
const provider = () => { reads += 1; return { default: ["事实甲"], byAgent: {} }; };
|
|
299
|
+
const off = renderUserSettingsBlock({
|
|
300
|
+
settings: settingsStub, config: { ...configOn, profileFactsEnabled: false },
|
|
301
|
+
agentId: null, factsProvider: provider
|
|
302
|
+
});
|
|
303
|
+
const never = renderUserSettingsBlock({ settings: settingsStub, config: { ...configOn, profileFactsEnabled: false }, agentId: null });
|
|
304
|
+
assert.equal(off.text, never.text, "关着时输出必须与无 provider 时逐字节一致");
|
|
305
|
+
assert.equal(reads, 0, "「关」是零读数:provider 一次都不该被调");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("注入预算:40 行帽 + 尾注如实报数;事实里的 {{ }} 被转义(宿主模板插值的雷)", () => {
|
|
309
|
+
const many = Array.from({ length: 50 }, (_, i) => `事实${i}`);
|
|
310
|
+
const long = renderUserSettingsBlock({
|
|
311
|
+
settings: settingsStub, config: configOn, agentId: null,
|
|
312
|
+
factsProvider: () => ({ default: many, byAgent: {} })
|
|
313
|
+
});
|
|
314
|
+
assert.ok(long.text.includes("另有 10 条未注入"), "截断不静默");
|
|
315
|
+
assert.ok(!long.text.includes("事实49\n- 规则"), "第 41 条之后不注入");
|
|
316
|
+
const braces = renderUserSettingsBlock({
|
|
317
|
+
settings: settingsStub, config: configOn, agentId: null,
|
|
318
|
+
factsProvider: () => ({ default: ["模板 {{x}} 事实"], byAgent: {} })
|
|
319
|
+
});
|
|
320
|
+
assert.ok(!braces.text.includes("{{x}}"), "裸 {{ 必须被转义,否则宿主模板插值会整轮崩");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ---- HTTP 面(GET /apply /run 三条路由) --------------------------------------
|
|
324
|
+
|
|
325
|
+
class FakeRes extends EventEmitter {
|
|
326
|
+
constructor() { super(); this.statusCode = 200; this.headers = null; this.body = ""; }
|
|
327
|
+
writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
|
|
328
|
+
end(text) { this.body = text ?? ""; this.emit("end"); return this; }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function httpReq(method, body = null, url = "/api/dsh-mneme/profile-facts") {
|
|
332
|
+
const r = new EventEmitter();
|
|
333
|
+
r.method = method;
|
|
334
|
+
r.url = url;
|
|
335
|
+
r.headers = {};
|
|
336
|
+
if (body !== null) {
|
|
337
|
+
process.nextTick(() => {
|
|
338
|
+
r.emit("data", Buffer.from(JSON.stringify(body)));
|
|
339
|
+
r.emit("end");
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return r;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function setupApi({ config = {}, facts = null, apiToken = "" } = {}) {
|
|
346
|
+
const store = createStore(":memory:");
|
|
347
|
+
const service = createService({ store, mirror: null, config });
|
|
348
|
+
const settings = createSettings(store.db);
|
|
349
|
+
const routes = [];
|
|
350
|
+
const ctx = { webServer: { register: (route) => { routes.push(route); return () => {}; } }, logger: { warn() {}, info() {}, error() {} } };
|
|
351
|
+
createApi(ctx, service, settings, { add: () => {}, remove: () => {}, list: () => [] },
|
|
352
|
+
undefined, undefined, apiToken, config, null, null, null, facts);
|
|
353
|
+
const find = (p) => routes.find((r) => r.path === p);
|
|
354
|
+
return { store, service, settings, routes, getRoute: find };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 真 facts 装配(带真 store),路由层用。 */
|
|
358
|
+
function realFacts({ config = {} } = {}) {
|
|
359
|
+
const store = createStore(":memory:");
|
|
360
|
+
const service = createService({ store, mirror: null, config });
|
|
361
|
+
const facts = createProfileFacts({
|
|
362
|
+
ctx: { agentDefaultModel: { currentSelection: () => null } },
|
|
363
|
+
service, store, config, logger: { warn() {}, info() {}, error() {} }
|
|
364
|
+
});
|
|
365
|
+
return { store, service, facts };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
test("GET:无装配回 503;有装配回 cards/facts/journal/runs 四件,flag 关着也照读", async () => {
|
|
369
|
+
const route = setupApi({ facts: null }).getRoute("/api/dsh-mneme/profile-facts");
|
|
370
|
+
const res404 = new FakeRes();
|
|
371
|
+
await route.handler(httpReq("GET"), res404);
|
|
372
|
+
assert.equal(res404.statusCode, 503);
|
|
373
|
+
|
|
374
|
+
const { store, facts } = realFacts();
|
|
375
|
+
facts.applyManualOps("", [{ op: "add", content: "事实甲" }]);
|
|
376
|
+
const { getRoute } = setupApi({ facts, config: {} });
|
|
377
|
+
const res = new FakeRes();
|
|
378
|
+
await getRoute("/api/dsh-mneme/profile-facts").handler(httpReq("GET", null, "/api/dsh-mneme/profile-facts"), res);
|
|
379
|
+
assert.equal(res.statusCode, 200);
|
|
380
|
+
const body = JSON.parse(res.body);
|
|
381
|
+
assert.ok(body.cards.includes(""));
|
|
382
|
+
assert.equal(body.facts.length, 1);
|
|
383
|
+
assert.equal(body.journal.length, 1);
|
|
384
|
+
assert.equal(body.runs.length, 0);
|
|
385
|
+
store.close();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("apply:405 / 400(空 ops 与非法 op)/ 200(applied+failures+回读 facts)", async () => {
|
|
389
|
+
const { store, facts } = realFacts();
|
|
390
|
+
const { getRoute } = setupApi({ facts });
|
|
391
|
+
const route = getRoute("/api/dsh-mneme/profile-facts/apply");
|
|
392
|
+
let res = new FakeRes();
|
|
393
|
+
await route.handler(httpReq("GET", null, "/api/dsh-mneme/profile-facts/apply"), res);
|
|
394
|
+
assert.equal(res.statusCode, 405);
|
|
395
|
+
|
|
396
|
+
res = new FakeRes();
|
|
397
|
+
await route.handler(httpReq("POST", { agent: "", ops: [] }, "/api/dsh-mneme/profile-facts/apply"), res);
|
|
398
|
+
assert.equal(res.statusCode, 400);
|
|
399
|
+
|
|
400
|
+
res = new FakeRes();
|
|
401
|
+
await route.handler(httpReq("POST", { agent: "", ops: [{ op: "nuke" }] }, "/api/dsh-mneme/profile-facts/apply"), res);
|
|
402
|
+
assert.equal(res.statusCode, 400, "非法 op 形状在路由层就拒,不进领域层");
|
|
403
|
+
|
|
404
|
+
res = new FakeRes();
|
|
405
|
+
await route.handler(httpReq("POST", { agent: "", ops: [{ op: "update", id: "x" }] }, "/api/dsh-mneme/profile-facts/apply"), res);
|
|
406
|
+
assert.equal(res.statusCode, 400, "update 缺 content 在 400 闸就拒(评审 P1:会写成 undefined 正文)");
|
|
407
|
+
|
|
408
|
+
res = new FakeRes();
|
|
409
|
+
await route.handler(httpReq("POST", {
|
|
410
|
+
agent: "",
|
|
411
|
+
ops: [{ op: "add", content: "事实甲" }, { op: "update", id: "ghost", content: "x" }]
|
|
412
|
+
}, "/api/dsh-mneme/profile-facts/apply"), res);
|
|
413
|
+
assert.equal(res.statusCode, 200);
|
|
414
|
+
const body = JSON.parse(res.body);
|
|
415
|
+
assert.equal(body.applied, 1);
|
|
416
|
+
assert.equal(body.failures.length, 1, "幽灵 id 如实回 failures,不冒充成功");
|
|
417
|
+
assert.equal(body.facts.length, 1, "回读最新事实,面板不用二次 GET");
|
|
418
|
+
store.close();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("run:405 / 409(flag 关)/ noop 200;错误码映射 no-route→503、unparseable→502", async () => {
|
|
422
|
+
const { store, facts } = realFacts();
|
|
423
|
+
const { getRoute } = setupApi({ facts, config: { profileFactsEnabled: false } });
|
|
424
|
+
let res = new FakeRes();
|
|
425
|
+
await getRoute("/api/dsh-mneme/profile-facts/run").handler(httpReq("POST", { agent: "" }, "/api/dsh-mneme/profile-facts/run"), res);
|
|
426
|
+
assert.equal(res.statusCode, 409);
|
|
427
|
+
|
|
428
|
+
const { getRoute: getRoute2 } = setupApi({ facts, config: { profileFactsEnabled: true } });
|
|
429
|
+
const run = getRoute2("/api/dsh-mneme/profile-facts/run");
|
|
430
|
+
res = new FakeRes();
|
|
431
|
+
await run.handler(httpReq("GET", null, "/api/dsh-mneme/profile-facts/run"), res);
|
|
432
|
+
assert.equal(res.statusCode, 405, "run 无读分支,GET 必须被挡(policy token 的前提)");
|
|
433
|
+
|
|
434
|
+
// noop 路径:种游标消费掉全部输入后 run 回 200 noop。
|
|
435
|
+
const rows = store.listFactInputRows("", { since: null, limit: 10 });
|
|
436
|
+
store.insertProfileFactRun({
|
|
437
|
+
id: "seed", agentKey: "", triggerSource: "manual", status: "ok",
|
|
438
|
+
cursor: "2999-12-31T00:00:00.000Z", inputCount: rows.length, opsApplied: 0,
|
|
439
|
+
detail: null, startedAt: "2026-01-01T00:00:00.000Z", finishedAt: "2026-01-01T00:00:01.000Z"
|
|
440
|
+
});
|
|
441
|
+
res = new FakeRes();
|
|
442
|
+
await run.handler(httpReq("POST", { agent: "" }, "/api/dsh-mneme/profile-facts/run"), res);
|
|
443
|
+
assert.equal(res.statusCode, 200);
|
|
444
|
+
assert.equal(JSON.parse(res.body).status, "noop");
|
|
445
|
+
|
|
446
|
+
// 错误映射:把 facts.runFor 换成抛错的桩,逐 code 对状态码。
|
|
447
|
+
for (const [code, status] of [["no-route", 503], ["unparseable", 502], ["stream-failed", 502]]) {
|
|
448
|
+
const stub = { runFor: async () => { throw new ProfileFactsError(code, code); }, applyManualOps() {}, listFacts() { return []; }, listJournal() { return []; }, listRuns() { return []; }, cardKeys() { return [""]; }, factsProvider() { return { default: [], byAgent: {} }; } };
|
|
449
|
+
const { getRoute: getRoute3 } = setupApi({ facts: stub, config: { profileFactsEnabled: true } });
|
|
450
|
+
const r = new FakeRes();
|
|
451
|
+
await getRoute3("/api/dsh-mneme/profile-facts/run").handler(httpReq("POST", { agent: "" }, "/api/dsh-mneme/profile-facts/run"), r);
|
|
452
|
+
assert.equal(r.statusCode, status, `${code} 的状态码`);
|
|
453
|
+
assert.equal(JSON.parse(r.body).error, code);
|
|
454
|
+
}
|
|
455
|
+
store.close();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("run 的 policy 是 token:配了 token 又不带 token 的 POST 被挡在维护轮之前", async () => {
|
|
459
|
+
const { store, facts } = realFacts();
|
|
460
|
+
let called = 0;
|
|
461
|
+
const stub = { ...facts, runFor: async () => { called += 1; return { status: "ok" }; } };
|
|
462
|
+
const { getRoute } = setupApi({ facts: stub, config: { profileFactsEnabled: true }, apiToken: "sekret" });
|
|
463
|
+
const res = new FakeRes();
|
|
464
|
+
await getRoute("/api/dsh-mneme/profile-facts/run").handler(httpReq("POST", { agent: "" }, "/api/dsh-mneme/profile-facts/run"), res);
|
|
465
|
+
assert.equal(res.statusCode, 401);
|
|
466
|
+
assert.equal(called, 0, "鉴权必须在这次模型调用之前");
|
|
467
|
+
store.close();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("strict 寻址:事实一张身份卡都没建时仍落 default(#587 例外,与人格同口径)", () => {
|
|
471
|
+
// 人格那张例外同样适用于事实:只建了别的身份卡、没这一张 → 不落 default(上面
|
|
472
|
+
// 那条已锁);一张都没建 → default 是唯一那份认知,不能因为开了严格寻址就丢掉。
|
|
473
|
+
const onlyDefault = () => ({ default: ["默认卡事实"], byAgent: {} });
|
|
474
|
+
const strictEmpty = renderUserSettingsBlock({
|
|
475
|
+
settings: settingsStub,
|
|
476
|
+
config: { ...configOn, personaStrictAddressing: true },
|
|
477
|
+
agentId: "standard",
|
|
478
|
+
factsProvider: onlyDefault
|
|
479
|
+
});
|
|
480
|
+
assert.ok(strictEmpty.text.includes("默认卡事实"), "byAgent 整体为空时严格寻址仍落 default 事实");
|
|
481
|
+
assert.equal(resolveFactsCard(onlyDefault(), "standard", { strict: true }).source, "default");
|
|
482
|
+
// 一旦建过任意一张身份卡,例外失效,严格语义恢复
|
|
483
|
+
const withOne = () => ({ default: ["默认卡事实"], byAgent: { botA: ["botA 卡事实"] } });
|
|
484
|
+
assert.equal(resolveFactsCard(withOne(), "standard", { strict: true }).source, "none");
|
|
485
|
+
assert.equal(resolveFactsCard(withOne(), "botA", { strict: true }).source, "byAgent");
|
|
486
|
+
// 关着的身份卡也算「建过」。⚠️ 生产不产空数组键:唯一注入读口 listActiveFactsGrouped
|
|
487
|
+
// 建键必带一条内容((grouped.byAgent[k] ??= []).push(content)),settings.js 三个写入点
|
|
488
|
+
// 也是空卡直接 delete。这条是防御性形状断言——锁住判据只看键数、不数条目,将来谁改成
|
|
489
|
+
// 「有内容才算建过」就会被它拦住(Ephor 复核 #587 时指出,2026-11-06)。
|
|
490
|
+
const closedOne = () => ({ default: ["默认卡事实"], byAgent: { botA: [] } });
|
|
491
|
+
assert.equal(resolveFactsCard(closedOne(), "standard", { strict: true }).source, "none");
|
|
492
|
+
});
|