@modusensus/dsh-mneme 0.11.0 → 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 +10 -6
- package/docs/CONFIGURATION.md +2 -0
- package/lib/api.js +172 -6
- package/lib/client.js +619 -18
- package/lib/config.js +32 -1
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/index.js +40 -4
- package/lib/inject.js +4 -2
- package/lib/lang.js +28 -0
- package/lib/profile-facts.js +356 -0
- package/lib/service.js +109 -4
- package/lib/settings.js +5 -0
- package/lib/store.js +483 -34
- package/lib/tools.js +140 -23
- package/lib/user-settings.js +63 -7
- package/lib/wiki/queries.js +149 -9
- package/package.json +1 -1
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api.js +172 -6
- package/src/config.js +32 -1
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/index.js +40 -4
- package/src/inject.js +4 -2
- package/src/lang.js +28 -0
- package/src/profile-facts.js +356 -0
- package/src/service.js +109 -4
- package/src/settings.js +5 -0
- package/src/store.js +483 -34
- package/src/tools.js +140 -23
- package/src/user-settings.js +63 -7
- package/src/wiki/queries.js +149 -9
- package/test/api.test.js +3 -1
- package/test/client.test.js +147 -2
- package/test/config.test.js +79 -0
- 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/persona-settings.test.js +31 -0
- package/test/profile-facts.test.js +492 -0
- 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 +121 -1
- package/test/wiki-queries.test.js +84 -1
- package/test/wiki-tools.test.js +131 -76
|
@@ -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
|
+
});
|
|
@@ -204,7 +204,7 @@ test("retained tool executors use the latest gate after live registry reapply",
|
|
|
204
204
|
await assert.rejects(retainedSave.execute(args, privateExec), /incognito session/);
|
|
205
205
|
const openGate = createSessionGate({ sessionGateEnabled: false });
|
|
206
206
|
createTools({ tools: registry }, service, {}, null, openGate);
|
|
207
|
-
assert.equal(registered.length,
|
|
207
|
+
assert.equal(registered.length, 12, "live reapply keeps the host's existing tool registrations");
|
|
208
208
|
assert.equal((await retainedSave.execute(args, privateExec)).action, "created", "off restores the original tool behavior");
|
|
209
209
|
const closedGate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
210
210
|
createTools({ tools: registry }, service, {}, null, closedGate);
|
|
@@ -32,8 +32,8 @@ test("双 README 的测试数处处一致(徽章与所有注释同数)", ()
|
|
|
32
32
|
// 自动发版路径因此反倒成了漂移来源。
|
|
33
33
|
test("applyTestCount 覆盖徽章与全部四种注释形状(真实漂移样本)", () => {
|
|
34
34
|
const drifted = [
|
|
35
|
-
'<img src="https://img.shields.io/badge/tests-1431%
|
|
36
|
-
"[](https://github.com/slow-stack/mneme)",
|
|
37
37
|
"npm test # 1431 个测试",
|
|
38
38
|
"npm test # 运行 1431 个测试",
|
|
39
39
|
"test/ # 1431 个 node:test 测试(审计与三轴线压测不变量)",
|
|
@@ -47,7 +47,7 @@ test("applyTestCount 覆盖徽章与全部四种注释形状(真实漂移样
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
test("applyTestCount 幂等:同一个数重复施加不再产生变化", () => {
|
|
50
|
-
const once = applyTestCount("tests-9%
|
|
50
|
+
const once = applyTestCount("tests-9%20total-3E63DD\n# 9 个测试\n# 9 tests", 12);
|
|
51
51
|
assert.equal(applyTestCount(once, 12), once);
|
|
52
52
|
});
|
|
53
53
|
|
|
@@ -58,3 +58,14 @@ test("applyTestCount 不碰历史版本表里的「N 测试全绿」", () => {
|
|
|
58
58
|
const history = "| **v0.8.0** | 745 测试全绿 |\n| **v0.7.9** | 790 通过 |\n| v0.2.7 | 263 |";
|
|
59
59
|
assert.equal(applyTestCount(history, 1437), history);
|
|
60
60
|
});
|
|
61
|
+
// 徽章报的是套件总数(`ℹ tests N`),不是通过数:本地 Windows 未开开发者模式时有一条
|
|
62
|
+
// 符号链接用例被条件跳过,pass 数会比总数少 1。徽章字面量若退回 `%20passed`,读者会把它
|
|
63
|
+
// 读成「N 个用例都通过了」,而它其实只表示「跑了 N 个套件」——#599 评审指出这一处措辞
|
|
64
|
+
// 与实际口径不符。这条用例锁住那个词,改回 `passed` 立刻转红。
|
|
65
|
+
test("双 README 的徽章字面量报总数(total),不谎称全部通过(passed)", () => {
|
|
66
|
+
for (const f of READMES) {
|
|
67
|
+
const text = readFileSync(f, "utf8");
|
|
68
|
+
assert.ok(/tests-\d+%20total/.test(text), `${f} 徽章必须写 %20total:${text.match(/tests-\d+%20\w+/)}`);
|
|
69
|
+
assert.ok(!/tests-\d+%20passed/.test(text), `${f} 徽章不得退回 %20passed`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// 锁「本地工具状态目录不得再进库」这条预防层,以及它配套的那条公开记档。
|
|
2
|
+
//
|
|
3
|
+
// 背景:2026-11-05 分支 feat/choro-panel-run-and-vectors 有一次粗粒度 git add 把 .mimosa/
|
|
4
|
+
// (本地扫描钩子的状态目录,22 个文件)吸进了功能提交。文件两个提交后删掉了,但 blob 留在历史里、
|
|
5
|
+
// 仓库是 PUBLIC,其中一条 hook-status 记着一台机器的绝对路径。删文件不是补偿——删只把暴露从
|
|
6
|
+
// 目录树挪进对象存储(详见 SECURITY.md 的 2026-11-05 条目)。所以真正的防线只有两层:
|
|
7
|
+
//
|
|
8
|
+
// 1. 忽略规则(本文件第 1 组用例)——两个 .gitignore 都必须有。仓库里有两个 .mimosa 前缀
|
|
9
|
+
// (仓库根与 dsh-mneme/),只写一处时另一处照样会被 add 进来;历史重写那次就栽在这里。
|
|
10
|
+
// 2. 忽略规则若被谁「顺手清掉」,本文件必须变红——否则下一次 add -A 又是同一个坑。
|
|
11
|
+
//
|
|
12
|
+
// 第 2 组用例守的是记档本身:SECURITY.md 要中英各一条(两份文档都在同一文件里,缺一边等于
|
|
13
|
+
// 一种语言的读者看不到这件事),且条目里不得出现任何本机绝对路径——一份记录泄露的文档
|
|
14
|
+
// 自己再泄露一次就是二次事故。
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
22
|
+
const repoRoot = path.dirname(pkgRoot);
|
|
23
|
+
|
|
24
|
+
// 两个前缀都必须覆盖:历史上漏的就是「只写了 dsh-mneme/.mimosa」。
|
|
25
|
+
const gitignores = [
|
|
26
|
+
{ label: "仓库根 .gitignore", file: path.join(repoRoot, ".gitignore") },
|
|
27
|
+
{ label: "dsh-mneme/.gitignore", file: path.join(pkgRoot, ".gitignore") },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
for (const { label, file } of gitignores) {
|
|
31
|
+
test(`${label} 忽略 .mimosa/(本地工具状态不进库)`, () => {
|
|
32
|
+
const lines = readFileSync(file, "utf8").split(/\r?\n/).map((l) => l.trim());
|
|
33
|
+
assert.ok(
|
|
34
|
+
lines.includes(".mimosa/"),
|
|
35
|
+
`${label} 缺少 .mimosa/ 忽略规则。少了它,下一次 git add -A 会把钩子状态目录再带进库——` +
|
|
36
|
+
"2026-11-05 已经发生过一次,且 blob 至今仍可从历史取到。两处都要写:本仓有两个 .mimosa 前缀。"
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("SECURITY.md 有这次暴露的中英双份记档,且记档本身不含本机绝对路径", () => {
|
|
42
|
+
const doc = readFileSync(path.join(repoRoot, "SECURITY.md"), "utf8");
|
|
43
|
+
const headings = doc.split(/\r?\n/).filter((l) => l.startsWith("### 2026-11-05 — "));
|
|
44
|
+
assert.equal(
|
|
45
|
+
headings.length,
|
|
46
|
+
2,
|
|
47
|
+
`SECURITY.md 应有中英各一条「### 2026-11-05 — 」记档,实际 ${headings.length} 条。` +
|
|
48
|
+
"只写一边等于另一种语言的读者看不到这条已知暴露。"
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// 记档里点名的是「本机目录结构」这件事本身,不能反过来把路径抄进去:
|
|
52
|
+
// 一条记录泄露的条目自己再泄露一次,就是把事故重演一遍。
|
|
53
|
+
// 只扫记档那两段(每条从标题到下一个 `### `),别拿整篇文档去撞历史遗留的行。
|
|
54
|
+
const lines = doc.split(/\r?\n/);
|
|
55
|
+
const section = [];
|
|
56
|
+
let inside = false;
|
|
57
|
+
for (const l of lines) {
|
|
58
|
+
if (l.startsWith("### ")) inside = l.startsWith("### 2026-11-05 — ");
|
|
59
|
+
if (inside) section.push(l);
|
|
60
|
+
}
|
|
61
|
+
const leaked = section.filter((l) =>
|
|
62
|
+
/[A-Za-z]:\\Users\\/.test(l) || l.includes("/Users/") || l.includes("AppData")
|
|
63
|
+
);
|
|
64
|
+
assert.deepEqual(leaked, [], `记档不得出现本机绝对路径,实际命中:${leaked.join(" | ")}`);
|
|
65
|
+
});
|
|
@@ -37,6 +37,7 @@ const TOOL_NAMES = [
|
|
|
37
37
|
"memory_delete",
|
|
38
38
|
"memory_forget",
|
|
39
39
|
"memory_archive",
|
|
40
|
+
"memory_pin",
|
|
40
41
|
"memory_register_document",
|
|
41
42
|
"memory_runtime",
|
|
42
43
|
"memory_organize",
|
|
@@ -126,7 +127,7 @@ test("工具矩阵与 TOOL_NAMES 清单一致(新增工具必须补进矩阵
|
|
|
126
127
|
assert.deepEqual(Object.keys(byName).sort(), [...TOOL_NAMES].sort());
|
|
127
128
|
});
|
|
128
129
|
|
|
129
|
-
test("A 实跑:
|
|
130
|
+
test("A 实跑:13 个工具每个可安全触达的分支,真实返回 ⊆ 声明的 output schema", async () => {
|
|
130
131
|
const { store, service, byName } = setup({ documentMemoryEnabled: true, organizeEnabled: true });
|
|
131
132
|
const shapes = seedShapes(store);
|
|
132
133
|
const annotated = shapes[shapes.length - 1];
|
|
@@ -202,6 +203,26 @@ test("A 实跑:12 个工具每个可安全触达的分支,真实返回 ⊆
|
|
|
202
203
|
assert.equal((await drive(byName, "memory_archive", { id: archiveTarget.id, archived: false })).memory.archived, false);
|
|
203
204
|
note("memory_archive");
|
|
204
205
|
|
|
206
|
+
// --- memory_pin(#249 第二批):钉上 / 松开 / 幂等三条分支。
|
|
207
|
+
// 幂等单独驱动是因为回执要报 pinned:false 而不是报错,这条分支最容易在
|
|
208
|
+
// 「本来没钉也算成功」的语义上写错。
|
|
209
|
+
const pinTarget = store.save({ type: "constraint", title: "pin-me", content: "每轮都要在。", source: "test" });
|
|
210
|
+
const pinned = await drive(byName, "memory_pin", { id: pinTarget.id, note: "matrix" });
|
|
211
|
+
assert.equal(pinned.pinned, true);
|
|
212
|
+
assert.equal(pinned.memory_id, pinTarget.id);
|
|
213
|
+
assert.equal(pinned.note, "matrix");
|
|
214
|
+
// #593:pin 池由 pinnedInjectBudget 开关,本矩阵用的默认配置里它是 0(关闭),
|
|
215
|
+
// 所以钉上了也不会注入 —— 回执必须如实报 false。这里断 false 而不是 true,
|
|
216
|
+
// 锁的正是「返回成功不等于生效」那条口径。
|
|
217
|
+
assert.equal(pinned.injectable, false);
|
|
218
|
+
const unpinned = await drive(byName, "memory_pin", { id: pinTarget.id, unpin: true });
|
|
219
|
+
assert.equal(unpinned.pinned, false);
|
|
220
|
+
assert.equal(store.countPinned(), 0);
|
|
221
|
+
// 本来没钉时再松一次:返回 pinned:false,不抛错(幂等)。
|
|
222
|
+
assert.equal((await drive(byName, "memory_pin", { id: pinTarget.id, unpin: true })).pinned, false);
|
|
223
|
+
note("memory_pin");
|
|
224
|
+
|
|
225
|
+
|
|
205
226
|
// --- set_scope_state(#471):两个方向的分支——按下与松开各驱动一次 ---
|
|
206
227
|
const pressed = await drive(byName, "set_scope_state", { dimension: "workspace", scope_value: "proj-alpha", state: "paused" });
|
|
207
228
|
assert.equal(pressed.scope.state, "paused");
|
|
@@ -337,7 +358,7 @@ test("B' search / list / get 三个消费方共用同一份 item schema(#184
|
|
|
337
358
|
|
|
338
359
|
// 同清单比对那条:opt-in 工具在默认配置下不在注册表里,C 层按 TOOL_NAMES 取
|
|
339
360
|
// byName[name],所以必须用暴露闸全开的配置,否则新工具的 schema 结构从未被检查。
|
|
340
|
-
test("C 结构:全部
|
|
361
|
+
test("C 结构:全部 13 个工具的 output schema 闭合、required ⊆ properties、每项带 type", () => {
|
|
341
362
|
const { byName } = setup({ organizeEnabled: true });
|
|
342
363
|
for (const name of TOOL_NAMES) {
|
|
343
364
|
const schema = byName[name].output.schema;
|