@modusensus/dsh-mneme 0.10.1 → 0.11.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.
Files changed (75) hide show
  1. package/README.md +10 -3
  2. package/bin/dsh-mneme-choro.mjs +254 -0
  3. package/docs/CONFIGURATION.md +8 -1
  4. package/lib/api-standalone.js +17 -21
  5. package/lib/api.js +247 -11
  6. package/lib/bootstrap-http.js +105 -0
  7. package/lib/bootstrap.js +5 -1
  8. package/lib/choro-command.js +104 -0
  9. package/lib/client.js +1028 -102
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +28 -0
  12. package/lib/index.js +50 -4
  13. package/lib/inject.js +15 -3
  14. package/lib/lang.js +54 -0
  15. package/lib/quality-filter.js +48 -12
  16. package/lib/service.js +3 -0
  17. package/lib/settings.js +8 -1
  18. package/lib/store.js +502 -0
  19. package/lib/tools.js +127 -1
  20. package/lib/wiki/inject.js +164 -0
  21. package/lib/wiki/partition.js +200 -0
  22. package/lib/wiki/plan.js +214 -0
  23. package/lib/wiki/queries.js +220 -0
  24. package/lib/wiki/render.js +187 -0
  25. package/lib/wiki/scan.js +371 -0
  26. package/lib/wiki/t0.js +463 -0
  27. package/lib/wiki/t1.js +496 -0
  28. package/lib/write-admission.js +5 -3
  29. package/package.json +4 -2
  30. package/src/api-standalone.js +17 -21
  31. package/src/api.js +247 -11
  32. package/src/bootstrap-http.js +105 -0
  33. package/src/bootstrap.js +5 -1
  34. package/src/choro-command.js +104 -0
  35. package/src/config-keys.js +144 -0
  36. package/src/config.js +28 -0
  37. package/src/index.js +50 -4
  38. package/src/inject.js +15 -3
  39. package/src/lang.js +54 -0
  40. package/src/quality-filter.js +48 -12
  41. package/src/service.js +3 -0
  42. package/src/settings.js +8 -1
  43. package/src/store.js +502 -0
  44. package/src/tools.js +127 -1
  45. package/src/wiki/inject.js +164 -0
  46. package/src/wiki/partition.js +200 -0
  47. package/src/wiki/plan.js +214 -0
  48. package/src/wiki/queries.js +220 -0
  49. package/src/wiki/render.js +187 -0
  50. package/src/wiki/scan.js +371 -0
  51. package/src/wiki/t0.js +463 -0
  52. package/src/wiki/t1.js +496 -0
  53. package/src/write-admission.js +5 -3
  54. package/test/api-routes-gaps.test.js +131 -1
  55. package/test/api.test.js +7 -1
  56. package/test/bootstrap.test.js +8 -0
  57. package/test/choro-command.test.js +218 -0
  58. package/test/client.test.js +655 -19
  59. package/test/config-keys-routes.test.js +233 -0
  60. package/test/contrast.test.js +14 -7
  61. package/test/inject.test.js +51 -0
  62. package/test/llm-audit.test.js +29 -1
  63. package/test/quality-backfill.test.js +290 -0
  64. package/test/quality-filter.test.js +113 -4
  65. package/test/service.test.js +1 -1
  66. package/test/wiki-api.test.js +153 -0
  67. package/test/wiki-inject.test.js +265 -0
  68. package/test/wiki-partition.test.js +112 -0
  69. package/test/wiki-plan.test.js +80 -0
  70. package/test/wiki-queries.test.js +187 -0
  71. package/test/wiki-scan.test.js +208 -0
  72. package/test/wiki-store.test.js +190 -0
  73. package/test/wiki-t0.test.js +380 -0
  74. package/test/wiki-t1.test.js +235 -0
  75. package/test/wiki-tools.test.js +121 -0
@@ -0,0 +1,233 @@
1
+ // issue #386 第 6 条(面板 bootstrap 入口)与第 7 条(配置键覆盖面)的服务端契约。
2
+ //
3
+ // 这一层要钉住的不是「路由存在」,而是三件容易在改动里悄悄走样的事:
4
+ // 1. /bootstrap 的 policy 是 token(写库),且先判 body 形状、再判能力接线——
5
+ // 顺序反了会把「你没给目录」报成「能力没接线」,把人引去查错的排查方向。
6
+ // 2. /config-keys 只回键名与计数,一个值都不回。apiToken 的名字必须出现(那正是
7
+ // 用户想知道它存在的原因),值必须不出现(那会是一处新泄漏面)。
8
+ // 3. 三份清单是真算出来的:panelEditable = 有默认值的白名单键,
9
+ // fileOnly = schema 声明 − 白名单,undefaulted = 白名单 − 有默认值。
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 { createApi } from "../src/api.js";
16
+ import { createSettings } from "../src/settings.js";
17
+ import { Config } from "../src/config.js";
18
+ import { describeConfigKeys, schemaLeafKeys, defaultedLeafKeys, flagKeysFromSpec } from "../src/config-keys.js";
19
+ import { bootstrapReply } from "../src/bootstrap-http.js";
20
+
21
+ class FakeRes extends EventEmitter {
22
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
23
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
24
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
25
+ }
26
+
27
+ function req(path, { method = "GET", body = null } = {}) {
28
+ const r = new EventEmitter();
29
+ r.url = path;
30
+ r.method = method;
31
+ r.headers = {};
32
+ // readBody 是事件式的(data/end),不是 async iterable:必须真的把事件发出去,
33
+ // 否则 handler 会永远挂在那个 Promise 上(第一版测试就是这么挂死的)。
34
+ setImmediate(() => {
35
+ if (body !== null) r.emit("data", Buffer.from(body, "utf8"));
36
+ r.emit("end");
37
+ });
38
+ return r;
39
+ }
40
+
41
+ function setup({ config = {}, apiToken = "" } = {}) {
42
+ const store = createStore(":memory:");
43
+ const service = createService({ store, mirror: null, config });
44
+ const settings = createSettings(store.db);
45
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
46
+ const routes = [];
47
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
48
+ createApi(ctx, service, settings, commands, null, undefined, apiToken, config);
49
+ return { store, service, settings, routes };
50
+ }
51
+
52
+ const BOOTSTRAP = "/api/dsh-mneme/bootstrap";
53
+ const CONFIG_KEYS = "/api/dsh-mneme/config-keys";
54
+
55
+ async function call(routes, path, opts) {
56
+ const route = routes.find((r) => r.path === path);
57
+ assert.ok(route, `route ${path} must be registered`);
58
+ const res = new FakeRes();
59
+ await route.handler(req(path, opts), res);
60
+ return { route, res, json: res.body ? JSON.parse(res.body) : null };
61
+ }
62
+
63
+ // --- /bootstrap -------------------------------------------------------------
64
+
65
+ test("bootstrap route is registered with the token policy", () => {
66
+ const { routes } = setup({ apiToken: "secret-token" });
67
+ const route = routes.find((r) => r.path === BOOTSTRAP);
68
+ assert.ok(route, "route must be registered");
69
+ // 它写库(虽然幂等),所以不能是 open;也不该借 token-write 的读写双义——这条
70
+ // 路由没有 GET 分支,声明 token-write 只会让 SECURITY.md 的开放读清单多一条假的。
71
+ assert.equal(route.policy, "token");
72
+ });
73
+
74
+ test("bootstrap demands a token when one is configured", async () => {
75
+ const { routes } = setup({ apiToken: "secret-token" });
76
+ const { res } = await call(routes, BOOTSTRAP, { method: "POST", body: JSON.stringify({ dir: "/tmp" }) });
77
+ assert.equal(res.statusCode, 401);
78
+ assert.equal(JSON.parse(res.body).error, "unauthorized");
79
+ });
80
+
81
+ test("bootstrap rejects GET with 404 (no read branch exists)", async () => {
82
+ const { routes } = setup();
83
+ const { res } = await call(routes, BOOTSTRAP, { method: "GET" });
84
+ assert.equal(res.statusCode, 404);
85
+ assert.equal(JSON.parse(res.body).error, "not-found");
86
+ });
87
+
88
+ test("bootstrap separates malformed JSON from a non-object body", async () => {
89
+ const { routes } = setup();
90
+ const bad = await call(routes, BOOTSTRAP, { method: "POST", body: "{not json" });
91
+ assert.equal(bad.res.statusCode, 400);
92
+ assert.equal(bad.json.error, "invalid-json");
93
+ // 数组是合法 JSON 但不是「对象」:两个码分开,调用方才知道该改哪里。
94
+ const arr = await call(routes, BOOTSTRAP, { method: "POST", body: "[]" });
95
+ assert.equal(arr.res.statusCode, 400);
96
+ assert.equal(arr.json.error, "invalid-body");
97
+ });
98
+
99
+ test("bootstrap validates dir before it decides whether the capability is wired", async () => {
100
+ // 这条是顺序锁:给一个没有 saveWithDedupe 的装配 + 缺 dir 的 body。若先判能力,
101
+ // 用户会看到「能力没接线」——而真正的问题是他没填目录。
102
+ const store = createStore(":memory:");
103
+ const settings = createSettings(store.db);
104
+ const routes = [];
105
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
106
+ createApi(ctx, {}, settings, { add: () => {}, remove: () => {}, list: () => [] }, null, undefined, "", {});
107
+ const { res, json } = await call(routes, BOOTSTRAP, { method: "POST", body: JSON.stringify({}) });
108
+ assert.equal(res.statusCode, 400);
109
+ assert.equal(json.error, "missing-dir", "缺 dir 必须报 missing-dir,不能报 bootstrap-unavailable");
110
+ });
111
+
112
+ test("bootstrap reports the coded error when the directory does not exist", async () => {
113
+ const { routes, service } = setup();
114
+ const { res, json } = await call(routes, BOOTSTRAP, {
115
+ method: "POST",
116
+ body: JSON.stringify({ dir: "/definitely/not/here/386-bootstrap" })
117
+ });
118
+ assert.equal(res.statusCode, 400);
119
+ assert.equal(json.error, "dir-not-found");
120
+ // 失败路径不能留下半截记忆:目录都没读到,一条都不该落库。
121
+ assert.equal(service.count({}), 0);
122
+ });
123
+
124
+ test("bootstrapReply survives a logger that throws (the layer never throws)", async () => {
125
+ // 这一层的契约是「从不抛」:warn 裸调时,宿主 logger 自己坏掉会从 catch 块里再抛出去,
126
+ // 而两侧 handler 的外层 try 在 .then 回调里早已退栈——现场就是「一个字节都没回」
127
+ // (#410,share-http.js 的 warnSafely 注释记着同一件事)。守卫必须住在层里。
128
+ const reply = await bootstrapReply({
129
+ bootstrapper: () => Promise.reject(new Error("disk on fire")),
130
+ dir: "/tmp",
131
+ warn: () => { throw new Error("logger is broken too"); }
132
+ });
133
+ assert.equal(reply.status, 500);
134
+ assert.deepEqual(reply.body, { error: "internal" });
135
+ });
136
+
137
+ test("bootstrap imports deterministic artifacts from a real directory", async () => {
138
+ const { routes, service } = setup();
139
+ const os = await import("node:os");
140
+ const fs = await import("node:fs");
141
+ const path = await import("node:path");
142
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mneme-386-"));
143
+ try {
144
+ fs.writeFileSync(path.join(dir, "README.md"), "# sample project\n\nA tiny fixture.\n", "utf8");
145
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "sample", scripts: { build: "tsc -p ." } }), "utf8");
146
+ const { res, json } = await call(routes, BOOTSTRAP, { method: "POST", body: JSON.stringify({ dir }) });
147
+ assert.equal(res.statusCode, 200);
148
+ assert.ok(json.created >= 2, `expected at least 2 imported rows, got ${JSON.stringify(json)}`);
149
+ assert.equal(json.dir, fs.realpathSync(dir));
150
+ // 「返回成功不等于生效」:去看库里到底有没有那几行。
151
+ const rows = service.list({ limit: 20, includeArchived: false });
152
+ assert.ok(rows.length >= 2, "the response must correspond to rows that actually exist");
153
+ assert.ok(rows.every((m) => (m.tags || []).includes("bootstrap")), "every imported row carries the bootstrap tag");
154
+ } finally {
155
+ fs.rmSync(dir, { recursive: true, force: true });
156
+ }
157
+ });
158
+
159
+ // --- /config-keys -----------------------------------------------------------
160
+
161
+ test("config-keys route is open and read-only", async () => {
162
+ const { routes } = setup({ apiToken: "secret-token" });
163
+ const { route, res } = await call(routes, CONFIG_KEYS, { method: "GET" });
164
+ assert.equal(route.policy, "open");
165
+ assert.equal(res.statusCode, 200, "read-only route must not demand a token");
166
+ });
167
+
168
+ test("config-keys rejects non-GET", async () => {
169
+ const { routes } = setup();
170
+ const { res } = await call(routes, CONFIG_KEYS, { method: "POST", body: "{}" });
171
+ assert.equal(res.statusCode, 404);
172
+ });
173
+
174
+ test("config-keys returns key names but never the apiToken value", async () => {
175
+ const { routes } = setup({ config: { apiToken: "super-secret-value" }, apiToken: "super-secret-value" });
176
+ const { res } = await call(routes, CONFIG_KEYS, { method: "GET" });
177
+ assert.equal(res.statusCode, 200);
178
+ // 名字必须在:用户正是要靠这份清单知道「apiToken 只能改配置文件」。
179
+ assert.ok(res.body.includes("apiToken"), "the key name must be listed");
180
+ // 值必须不在:这条断言是这整条路由存在的安全边界。
181
+ assert.ok(!res.body.includes("super-secret-value"), "the value must never leave the server");
182
+ });
183
+
184
+ test("config-keys only reads the schema — it never echoes a configured value", async () => {
185
+ // 上一条锁的是「没漏出去」,这条锁的是「不是靠脱敏才没漏」:即便装了一个
186
+ // 非敏感键的值,响应里也不该出现它——这份清单只谈键名与计数。
187
+ const { routes } = setup({ config: { memoryDir: "/unique/marker/path/386" } });
188
+ const { res } = await call(routes, CONFIG_KEYS, { method: "GET" });
189
+ assert.ok(!res.body.includes("/unique/marker/path/386"), "no configured value may appear in this response");
190
+ });
191
+
192
+ test("config-keys partitions the two source lists without losing anything", async () => {
193
+ const d = describeConfigKeys();
194
+ const declared = schemaLeafKeys();
195
+ const defaulted = defaultedLeafKeys();
196
+ const flags = flagKeysFromSpec();
197
+ assert.equal(d.counts.schemaLeaf, declared.length);
198
+ assert.equal(d.counts.flag, flags.length);
199
+ // 并集必须正好铺满「schema 声明 ∪ 白名单」,一个键都不许漏在三个桶之外。
200
+ const union = new Set([...d.panelEditable, ...d.fileOnly, ...d.undefaulted]);
201
+ const expected = new Set([...declared, ...flags]);
202
+ assert.deepEqual([...union].sort(), [...expected].sort());
203
+ // 两个等式同时成立,才说明三个桶互不相交且覆盖完整。注意第一个等式要把
204
+ // undefaulted 也加进去:dreamProvider / dreamModel 既是 schema 声明的键、又挂在
205
+ // 白名单上,它既不属于 panelEditable(没默认值)也不属于 fileOnly(白名单有它),
206
+ // 只落在 undefaulted 这一个桶里。
207
+ assert.equal(d.panelEditable.length + d.fileOnly.length + d.undefaulted.length, declared.length);
208
+ assert.equal(d.panelEditable.length + d.undefaulted.length, flags.length);
209
+ // 白名单里每个键都必须在 schema 里声明过——否则那是登记了一个不存在的键。
210
+ for (const key of flags) assert.ok(expected.has(key), `flag ${key} is not declared in the schema`);
211
+ // 有默认值的键必须都被 schema 声明覆盖(反向包含,防 schema 漏声明)。
212
+ for (const key of defaulted) assert.ok(declared.includes(key), `defaulted key ${key} is missing from the schema declaration`);
213
+ });
214
+
215
+ test("config-keys flags keys that the whitelist registers without a schema default", async () => {
216
+ const d = describeConfigKeys();
217
+ // dreamProvider / dreamModel 在 settings.js 白名单里,但 config.js 只有
218
+ // z.string().description(...) 没有 .default()。面板因此有个永远不进 effective 的
219
+ // 输入框——这正是 #386 第 7 条说的「不对称」,必须被告知而不是被悄悄抹平。
220
+ assert.deepEqual(d.undefaulted.sort(), ["dreamModel", "dreamProvider"]);
221
+ });
222
+
223
+ test("config-keys lists file-only keys that users actually hit (apiToken, memoryDir, maxInjectedItems)", async () => {
224
+ const d = describeConfigKeys();
225
+ // apiToken 是最要紧的一个:它没有默认值,所以「拿解析结果当 schema」的写法会把它
226
+ // 整个漏掉——第一版正是这么错的,这条用例是那个错的回归锁。
227
+ assert.ok(d.fileOnly.includes("apiToken"), "apiToken must be visible as file-only");
228
+ // 另外两个是 issue 原文点名的键:用户能理解、却在面板上找不到。
229
+ assert.ok(d.fileOnly.includes("memoryDir"));
230
+ assert.ok(d.fileOnly.includes("maxInjectedItems"));
231
+ // 反向:一个面板确实能改的键,绝不能被列进 fileOnly。
232
+ assert.ok(!d.fileOnly.includes("autoInject"));
233
+ });
@@ -233,13 +233,16 @@ test("面板引用的宿主 token 必须真实存在(幻影 token 回归锁)
233
233
  assert.deepEqual(unknown, [], "面板引用了宿主快照里没有的 token(幻影名或快照过期):" + unknown.join(", "));
234
234
  });
235
235
 
236
- // 变量层必须挂满四个渲染根:面板不只长在 .mneme-x 下——记忆库 sheet portal 到
237
- // body(.mneme-overlay)、侧边栏入口在宿主插槽(.mneme-triggerwrap)、顶部入口
238
- // portal 到宿主侧边栏(.mneme-topentry)。少挂一个,那个根下的文字就退回宿主的
239
- // 低对比度 token,而且上面的主锁**照样全绿**(主锁只算颜色,不知道变量有没有继承到)。
240
- test("面板语义色层覆盖四个渲染根", () => {
236
+ // 变量层必须挂满五个渲染根:面板不只长在 .mneme-x 下——老宿主上记忆库 sheet
237
+ // portal 到 body(.mneme-overlay)、侧边栏入口在宿主插槽(.mneme-triggerwrap)、
238
+ // 顶部入口 portal 到宿主侧边栏(.mneme-topentry);新宿主的侧栏图标长在宿主
239
+ // panelGlyph 里、不在 .mneme-x 子树内(.mneme-paneliglyph),它挂着冲突红点,
240
+ // 必须吃到 --mneme-danger。
241
+ // 少挂一个,那个根下的文字/图形就退回宿主的低对比度 token,而且上面的主锁
242
+ // **照样全绿**(主锁只算颜色,不知道变量有没有继承到)。
243
+ test("面板语义色层覆盖五个渲染根", () => {
241
244
  const sel = layer.sel;
242
- assert.equal(sel, ".mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry");
245
+ assert.equal(sel, ".mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry,.mneme-paneliglyph");
243
246
  });
244
247
 
245
248
  // 图形色的对比度(WCAG 1.4.11,非文字 3:1)。上面那条主锁只看 `color:`,
@@ -275,7 +278,11 @@ const GRAPHICS = [
275
278
  { sel: ".mneme-heatcell.l1", prop: "background" },
276
279
  { sel: ".mneme-heatcell.l2", prop: "background" },
277
280
  { sel: ".mneme-heatcell.l3", prop: "background" },
278
- { sel: ".mneme-heatcell.l4", prop: "background" }
281
+ { sel: ".mneme-heatcell.l4", prop: "background" },
282
+ // 8 周沉淀的两根柱:高度差就是「进来的 vs 稳住的」这层信息本身(新增高、
283
+ // 巩固矮=库在膨胀),柱子掉回浅灰等于把结论擦掉,所以按图形色 3:1 锁。
284
+ { sel: ".mneme-sinkbar--added", prop: "background" },
285
+ { sel: ".mneme-sinkbar--merged", prop: "background" }
279
286
  ];
280
287
  test("图形色(热度/打卡/连线)在浅深两主题下都达到 3:1", () => {
281
288
  const bySel = new Map(rules.map((r) => [r.sel, r]));
@@ -441,3 +441,54 @@ test("throwing surface getter degrades to full-log hot memory instead of a blank
441
441
  assert.ok(text.includes("热记忆不该因此整块消失"), "轮次回复应照旧进入");
442
442
  assert.equal(getInjectionSnapshot().query, "把投影插件卸了会怎样", "语义查询不受 getter 抛错影响");
443
443
  });
444
+
445
+ // M3 仓库地志(chorography):注入块折进同一个 memory context(不新开 context),
446
+ // 次序是 时间 → 地志 → 热上下文 → 长期记忆。这三例锁的是「折进来之后仍只有一个块」
447
+ // 与「空串不占位」——后者是宿主契约(空文本 section 被 filter 掉),不是我们自己加的。
448
+ function setupWithChoro(choroText, over = {}) {
449
+ const store = createStore(":memory:");
450
+ const service = createService({ store, mirror: null, config: {} });
451
+ const settings = createSettings(store.db);
452
+ const contexts = [];
453
+ const ctx = { systemPrompt: { context(def) { contexts.push(def); return () => {}; } } };
454
+ const calls = [];
455
+ const choroInject = {
456
+ tier: "light",
457
+ text(cwd) { calls.push(cwd); return typeof choroText === "function" ? choroText(cwd) : choroText; },
458
+ dispose() {}
459
+ };
460
+ const config = { maxInjectedItems: 3, importanceThreshold: 3, ...over };
461
+ const injector = createInjector(ctx, service, settings, config, null, null, choroInject);
462
+ return { store, service, contexts, injector, calls, ctx };
463
+ }
464
+
465
+ test("M3: 地志块折进 memory context,不新开第二个 context", () => {
466
+ const { contexts } = setupWithChoro("[仓库地志]");
467
+ assert.equal(contexts.length, 2, "折进来就仍是两块;多开一块会改宿主的装配顺序");
468
+ assert.equal(contexts[0].name, "memory");
469
+ });
470
+
471
+ test("M3: 地志块排在热上下文与长期记忆之前,且 cwd 取自 scope.workspace_scope", () => {
472
+ const { contexts, service, calls } = setupWithChoro("[仓库地志]");
473
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "用户用中文交流", importance: 5 });
474
+ const events = [
475
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "这个仓库怎么组织" }] } },
476
+ { type: "assistant/message", data: { message: { content: [{ type: "text", text: "按模块分组" }] } } }
477
+ ];
478
+ // scope.workspace_scope 不是入参,是 scope.js 现场解析出来的(registry 反查,
479
+ // header.cwd 兜底);测试只能提供 header.cwd,才能真验「注入器吃的是那一份口径」。
480
+ const text = contexts[0].text({ agent: { session: { header: { cwd: "/repo" }, snapshotEvents: () => events } } });
481
+ const iChoro = text.indexOf("[仓库地志]");
482
+ const iHot = text.indexOf("[短期上下文]");
483
+ const iMem = text.indexOf("[记忆库]");
484
+ assert.ok(iChoro >= 0 && iHot > iChoro && iMem > iHot, "次序 地志 → 热上下文 → 长期记忆:" + text);
485
+ assert.ok(calls.includes("/repo"), "cwd 只能用 scope.workspace_scope 这一份口径(P27),calls=" + JSON.stringify(calls));
486
+ });
487
+
488
+ test("M3: 地志为空串时不占位,块首尾不留空行;choroChars 记实际体积", () => {
489
+ const { contexts, service } = setupWithChoro("");
490
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "用户用中文交流", importance: 5 });
491
+ const text = contexts[0].text({});
492
+ assert.equal(text.startsWith("[记忆库]"), true, "空串拼接不许在块首留下空行:" + JSON.stringify(text.slice(0, 8)));
493
+ assert.equal(getInjectionSnapshot().choroChars, 0);
494
+ });
@@ -552,7 +552,7 @@ test("entity extraction writes an llm_audit row with the streamed usage tokens",
552
552
  });
553
553
  const text = await streamEntityText(
554
554
  [{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
555
- {}
555
+ { memoryId: "mem-ok-1" }
556
556
  );
557
557
  assert.ok(text.includes("entities"), "the extracted text still reaches the extractor");
558
558
 
@@ -565,6 +565,34 @@ test("entity extraction writes an llm_audit row with the streamed usage tokens",
565
565
  assert.equal(row.input_tokens, 700, "input tokens come from chunk.usage");
566
566
  assert.equal(row.output_tokens, 180, "output tokens come from chunk.usage");
567
567
  assert.equal(row.total_tokens, 880);
568
+ // success 行同样必须可归因。此前这里不传 memoryId、也不断言 id,于是
569
+ // writeAudit("success", null) 省略第三参的缺陷在绿测试下活了很久——活库
570
+ // 6256 条 success 行 related_memory_ids 100% 空数组,成功的那些抽取反而
571
+ // 一条都对不上记忆,#405 只修了 error 半边。
572
+ assert.deepEqual(row.related_memory_ids, ["mem-ok-1"],
573
+ "a successful extraction is attributable to the memory it read");
574
+ store.close();
575
+ });
576
+
577
+ test("entity extraction success row still leaves the id empty when there is none to attribute", async () => {
578
+ const store = createStore(":memory:");
579
+ const service = createService({ store, mirror: null, config: {} });
580
+ const streamEntityText = createEntityStreamAdapter({
581
+ llm: {
582
+ async *stream() {
583
+ yield { type: "text-delta", index: 0, text: '{"entities":[],"relations":[]}' };
584
+ yield { type: "finish", reason: { kind: "stop" } };
585
+ }
586
+ },
587
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
588
+ logger: { warn: () => {} },
589
+ service
590
+ });
591
+ await streamEntityText([{ role: "user", content: [{ type: "text", text: "记忆内容" }] }], {});
592
+ const row = service.listLlmAudits({ source: "entityExtract" })[0];
593
+ assert.equal(row.status, "success");
594
+ assert.deepEqual(row.related_memory_ids, [],
595
+ "no memoryId → empty, never a fabricated id");
568
596
  store.close();
569
597
  });
570
598