@modusensus/dsh-mneme 0.9.4 → 0.9.5

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 (80) hide show
  1. package/README.md +53 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +4 -2
  4. package/docs/DAEMON.md +9 -2
  5. package/docs/DISTILL.md +66 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +1 -1
  8. package/docs/STORAGE.md +1 -1
  9. package/lib/api-standalone.js +286 -8
  10. package/lib/api.js +195 -1
  11. package/lib/client.js +783 -30
  12. package/lib/document.js +15 -2
  13. package/lib/dream/decisions.js +88 -3
  14. package/lib/dream/sleep.js +63 -13
  15. package/lib/dream.js +65 -9
  16. package/lib/lang.js +23 -6
  17. package/lib/memory-source.js +9 -0
  18. package/lib/organize.js +5 -1
  19. package/lib/pipeline.js +62 -1
  20. package/lib/provenance-http.js +126 -0
  21. package/lib/serve.js +13 -0
  22. package/lib/service.js +67 -4
  23. package/lib/share-http.js +281 -0
  24. package/lib/share-import.js +234 -0
  25. package/lib/share.js +335 -0
  26. package/lib/store.js +272 -11
  27. package/lib/summarize.js +60 -11
  28. package/package.json +1 -1
  29. package/src/api-standalone.js +286 -8
  30. package/src/api.js +195 -1
  31. package/src/document.js +15 -2
  32. package/src/dream/decisions.js +88 -3
  33. package/src/dream/sleep.js +63 -13
  34. package/src/dream.js +65 -9
  35. package/src/lang.js +23 -6
  36. package/src/memory-source.js +9 -0
  37. package/src/organize.js +5 -1
  38. package/src/pipeline.js +62 -1
  39. package/src/provenance-http.js +126 -0
  40. package/src/serve.js +13 -0
  41. package/src/service.js +67 -4
  42. package/src/share-http.js +281 -0
  43. package/src/share-import.js +234 -0
  44. package/src/share.js +335 -0
  45. package/src/store.js +272 -11
  46. package/src/summarize.js +60 -11
  47. package/test/api.test.js +370 -2
  48. package/test/audit.test.js +2 -2
  49. package/test/cli-receive.test.js +390 -0
  50. package/test/cli-share.test.js +437 -0
  51. package/test/client-share.test.js +225 -0
  52. package/test/client.test.js +139 -0
  53. package/test/dedupe-candidate-window.test.js +180 -0
  54. package/test/dream-candidate-hybrid.test.js +2 -2
  55. package/test/dream-fence.test.js +164 -0
  56. package/test/dream.test.js +87 -5
  57. package/test/helpers/dream-mock.js +6 -2
  58. package/test/llm-message-source.test.js +1 -1
  59. package/test/mint-source.test.js +1 -1
  60. package/test/pipeline-r1r2.test.js +129 -0
  61. package/test/policy-epoch.test.js +2 -2
  62. package/test/provenance-http.test.js +184 -0
  63. package/test/readme-claims.test.js +45 -0
  64. package/test/reasoning-effort.test.js +10 -10
  65. package/test/scope-candidates.test.js +7 -4
  66. package/test/scope-state.test.js +130 -1
  67. package/test/serve.test.js +62 -0
  68. package/test/share-foreign-rows.test.js +144 -0
  69. package/test/share-http.test.js +144 -0
  70. package/test/share-import.test.js +577 -0
  71. package/test/share-imported-guard.test.js +311 -0
  72. package/test/share-imported-identity.test.js +111 -0
  73. package/test/share-pack.test.js +511 -0
  74. package/test/share-receive-http.test.js +756 -0
  75. package/test/share-routes.test.js +337 -0
  76. package/test/share-store.test.js +133 -0
  77. package/test/share-validate.test.js +101 -0
  78. package/test/standalone-api.test.js +524 -5
  79. package/test/summarize-parse-diagnostics.test.js +75 -0
  80. package/test/summarize.test.js +15 -2
package/test/api.test.js CHANGED
@@ -8,7 +8,7 @@ import { createApi } from "../src/api.js";
8
8
  import { createInjector } from "../src/inject.js";
9
9
  import { createSettings } from "../src/settings.js";
10
10
  import { createVectorIndex } from "../src/vector-index.js";
11
- import { Config } from "../src/config.js";
11
+ import { Config, applyLightModePreset } from "../src/config.js";
12
12
  import { parseHumanEdits } from "../src/mirror.js";
13
13
 
14
14
  // 解析后的 schema 默认值,作为 /features effective 的 bundle 配置侧样本。
@@ -1272,6 +1272,120 @@ test("POST /api/dsh-mneme/import is token-gated", async () => {
1272
1272
  assert.equal(res.statusCode, 401);
1273
1273
  });
1274
1274
 
1275
+ // #521:面板要能一眼看出「一整类动作 100% 被拦」。此前 skipped 只存在库里,
1276
+ // 响应里没有聚合,status: degraded + applied: 3 从外面看完全是健康轮的样子。
1277
+ test("GET /api/dsh-mneme/dream-status aggregates skipped reasons by kind (issue #521)", async () => {
1278
+ const { routes, service } = setup();
1279
+ // 三条同因(序号前缀不同)+ 一条不同因,模拟 #521 现场:merge 被 mergeGuard
1280
+ // 全拦,前缀 decision[N]: 让朴素 GROUP BY 拆成三组。
1281
+ service.saveDreamRun({
1282
+ created_at: "2026-01-03T00:00:00.000Z",
1283
+ status: "degraded",
1284
+ snapshot_hash: "h3",
1285
+ input_count: 200,
1286
+ receipt: "r3",
1287
+ skipped: [
1288
+ { index: 0, action: "merge", ids: ["p1", "p2"], error: 'decision[0]: merge of long-retention type(s) preference is blocked by dreamMergeGuard' },
1289
+ { index: 1, action: "merge", ids: ["p3", "p4"], error: 'decision[1]: merge of long-retention type(s) preference is blocked by dreamMergeGuard' },
1290
+ { index: 2, action: "merge", ids: ["p5", "p6"], error: 'decision[2]: merge of long-retention type(s) preference is blocked by dreamMergeGuard' },
1291
+ { index: 3, action: "archive", ids: ["x"], error: 'decision[3]: archive of long-retention type(s) constraint needs a duplicate/outdated rationale' }
1292
+ ]
1293
+ });
1294
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status");
1295
+ const res = new FakeRes();
1296
+ await route.handler(req("/api/dsh-mneme/dream-status"), res);
1297
+ const data = JSON.parse(res.body);
1298
+ assert.equal(data.lastRun.skippedCount, 4, "raw skipped count is preserved");
1299
+ assert.equal(data.lastRun.skippedReasons.length, 2, "three same-cause skips collapse into one bucket");
1300
+ assert.equal(data.lastRun.skippedReasons[0].count, 3, "largest bucket first");
1301
+ assert.equal(data.lastRun.skippedReasons[0].action, "merge");
1302
+ assert.match(data.lastRun.skippedReasons[0].reason, /dreamMergeGuard/);
1303
+ assert.doesNotMatch(data.lastRun.skippedReasons[0].reason, /decision\[/, "index prefix is stripped");
1304
+ assert.equal(data.lastRun.skippedReasons[1].action, "archive", "same wording on a different action stays separate");
1305
+ });
1306
+
1307
+ // #522 评审:一条决策可能攒下多条错误(skipped[].error 用 "; " 拼),原先的剥
1308
+ // 前缀正则 ^ 锚定、只剥得掉第一个,后面的 decision[1]: 留在字符串中间,聚合时
1309
+ // 同类原因又被拆成多组。另外校验器回给模型的自由文本尾巴(got: "…")可能夹带
1310
+ // 记忆正文、也可能被截断成没有右引号,靠引号配对剥不掉——这条路由是 open 的,
1311
+ // 审计文本不该这样出网关。两件事一起锁在下面这条用例里。
1312
+ test("GET /api/dsh-mneme/dream-status strips every decision index and the free-text tail (issue #522)", async () => {
1313
+ const { routes, service } = setup();
1314
+ service.saveDreamRun({
1315
+ created_at: "2026-01-04T00:00:00.000Z",
1316
+ status: "degraded",
1317
+ snapshot_hash: "h4",
1318
+ input_count: 200,
1319
+ receipt: "r4",
1320
+ skipped: [
1321
+ {
1322
+ index: 0,
1323
+ action: "merge",
1324
+ ids: ["p1", "p2"],
1325
+ // 两条错误拼成一句:第二个前缀必须也剥掉,否则同一类原因会被拆成两组。
1326
+ error: 'decision[0]: merge of long-retention type(s) preference is blocked by dreamMergeGuard (needs a duplicate/outdated rationale, 重复/过时); decision[0]: unknown id "p9"'
1327
+ },
1328
+ {
1329
+ index: 1,
1330
+ action: "archive",
1331
+ ids: ["x"],
1332
+ // got: 后面是模型写的理由原文(这里故意放一段像记忆正文的话),整段该被丢掉。
1333
+ error: 'decision[1]: archive of long-retention type(s) constraint needs a duplicate/outdated rationale, got: "用户说过预算是 12400 元"'
1334
+ },
1335
+ {
1336
+ // #522 第二轮评审:模型写的理由里可能正好含 "; decision[0]: ",先切分就会把
1337
+ // 这段自由文本变成新的一段,而新段没有 got: 前缀,剥尾正则够不着它——原文
1338
+ // 于是随 open 路由出网关。切分必须排在剥 got: 之后。
1339
+ index: 2,
1340
+ action: "archive",
1341
+ ids: ["z"],
1342
+ error: 'decision[2]: archive of long-retention type(s) preference needs a duplicate/outdated rationale (重复/过时), got: "并不过时; decision[0]: email@example.com 说的"'
1343
+ },
1344
+ {
1345
+ index: 3,
1346
+ // action 不在 ACTIONS 枚举里:聚合结果会随 open 路由出网关,来路不明的动作名
1347
+ // 不该由上游脏数据自己决定响应形状,一律归成空串。
1348
+ action: "drop-everything",
1349
+ ids: ["y"],
1350
+ error: "decision[3]: unknown action"
1351
+ },
1352
+ {
1353
+ // #522 三轮评审:分隔符本身可能出现在 id 里。旧实现按 ", got: " 猜自由文本
1354
+ // 起点,会把这里的引号截断成未闭合状态,后面的引号配对替换随之失效,邮箱
1355
+ // 原样留下。判据必须只看引号,不看分隔符。
1356
+ index: 4,
1357
+ action: "merge",
1358
+ ids: ["q"],
1359
+ error: 'decision[4]: unknown id "alice@example.com, got: x"'
1360
+ },
1361
+ {
1362
+ // 旧版把理由先截到 60 字再 stringify,落库的历史行可能带着一个未闭合引号
1363
+ // (字符串从中间断开)。抹不干净就得砍到结尾——宁可少一句尾巴。
1364
+ index: 5,
1365
+ action: "archive",
1366
+ ids: ["w"],
1367
+ error: 'decision[5]: archive of long-retention type(s) constraint needs a duplicate/outdated rationale, got: "用户说过预算是 12400'
1368
+ }
1369
+ ]
1370
+ });
1371
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status");
1372
+ const res = new FakeRes();
1373
+ await route.handler(req("/api/dsh-mneme/dream-status"), res);
1374
+ const data = JSON.parse(res.body);
1375
+ assert.equal(data.lastRun.skippedCount, 6, "raw skipped count is preserved");
1376
+ const unknownAction = data.lastRun.skippedReasons.find((b) => b.reason === "unknown action");
1377
+ assert.equal(unknownAction.action, "", "an action outside the enum is not echoed back to the open route");
1378
+ for (const bucket of data.lastRun.skippedReasons) {
1379
+ assert.doesNotMatch(bucket.reason, /decision\[/, "every index prefix is stripped, not just the first");
1380
+ assert.doesNotMatch(bucket.reason, /got:/, "the free-text tail never leaves the gateway");
1381
+ assert.doesNotMatch(bucket.reason, /12400/, "model-authored text must not ride along with the audit reason");
1382
+ assert.doesNotMatch(bucket.reason, /email@example\.com/, "free text split on a fake decision boundary must not leak either");
1383
+ assert.doesNotMatch(bucket.reason, /并不过时/, "the whole free-text tail is dropped, not just its first half");
1384
+ assert.doesNotMatch(bucket.reason, /alice@example\.com/, "an id carrying the tail separator is masked by quote pairing, not by the separator");
1385
+ assert.doesNotMatch(bucket.reason, /用户说过预算是/, "a historical row whose reason was cut mid-string still gets masked");
1386
+ assert.doesNotMatch(bucket.reason, /\b12400\b/, "nor does the truncated number survive");
1387
+ }
1388
+ });
1275
1389
  test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", async () => {
1276
1390
  const { routes, service } = setup();
1277
1391
  service.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", provider: "ollama", model: "qwen", snapshot_hash: "h1", input_count: 2, receipt: "r1" });
@@ -1286,7 +1400,9 @@ test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", as
1286
1400
  assert.equal(data.runs.length, 2);
1287
1401
  assert.equal(data.runs[0].created_at, "2026-01-02T00:00:00.000Z", "created_at DESC");
1288
1402
  assert.deepEqual(data.lastRun, data.runs[0]);
1289
- assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "demotion", "error", "model", "provider", "run_type", "status"]);
1403
+ // #521:skippedCount / skippedReasons 是本轮新增的两个字段(面板按归一化原因
1404
+ // 显示跳过明细)。这行锁的是 dream-status 的响应形状,加字段必须同步改这里。
1405
+ assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "demotion", "error", "model", "provider", "run_type", "skippedCount", "skippedReasons", "status"]);
1290
1406
  assert.equal(data.lastRun.run_type, "auto", "default run_type is auto");
1291
1407
  assert.equal(data.lastRun.demotion, null, "no demotion info for non-sleep runs");
1292
1408
  assert.equal(data.runs[0].error, "boom");
@@ -2090,3 +2206,255 @@ test("GET /api/dsh-mneme/pipeline 带观察旁挂:observationsById 真的进
2090
2206
  rmSync(root, { recursive: true, force: true });
2091
2207
  }
2092
2208
  });
2209
+
2210
+ // --- 记忆级溯源读出口(#399 写、#421 读)---------------------------------------
2211
+ // distill_records 与 memory_provenance 自 #399 起就只有写入路径:库里有答案、
2212
+ // service.provenanceFor 能查,但外部(面板 / standalone API / 脚本)拿不到——
2213
+ // 「这条记忆是从哪几句蒸出来的」只能开 sqlite 手写 JOIN。
2214
+ //
2215
+ // #509 评审整改:读出口吃 distillRecords.enabled(与写侧同一个开关、同一个判据)。
2216
+ // 关着时 409 {error:"distill-records-disabled"}。所以这一族的用例必须显式把开关
2217
+ // 打开——setup 的 config 缺省 null,而 null 在这道门上的含义就是「关」(fail-closed)。
2218
+ const RECORDS_ON = { distillRecords: { enabled: true } };
2219
+
2220
+ test("GET /api/dsh-mneme/memory/provenance 返回窗口区间与原文行", async () => {
2221
+ const { routes, service, store } = setup(null, "secret-token", RECORDS_ON);
2222
+ // 直接落溯源链接与原文:读取面不关心是谁写的,只关心库里的形状。
2223
+ service.saveWithDedupe({ type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 });
2224
+ const memory = service.all()[0];
2225
+ store.saveMemoryProvenance({
2226
+ memoryId: memory.id, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "2026-10-23T00:00:00Z"
2227
+ });
2228
+ store.saveDistillRecords([
2229
+ { sessionId: "s1", seq: 10, role: "user", text: "第一句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" },
2230
+ { sessionId: "s1", seq: 11, role: "assistant", text: "第二句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" },
2231
+ { sessionId: "s1", seq: 12, role: "user", text: "第三句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" },
2232
+ // 区间外的一句:不该被带出来
2233
+ { sessionId: "s1", seq: 99, role: "user", text: "区间外", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" }
2234
+ ]);
2235
+
2236
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2237
+ assert.equal(route.policy, "token", "records 带会话原文,与开放的 /list 串起来即可枚举 id 后捞回原文——必须受门控");
2238
+
2239
+ const res = new FakeRes();
2240
+ const r = req("/api/dsh-mneme/memory/provenance?id=" + memory.id);
2241
+ r.headers = { authorization: "Bearer secret-token" };
2242
+ await route.handler(r, res);
2243
+ assert.equal(res.statusCode, 200);
2244
+ const data = JSON.parse(res.body);
2245
+ assert.equal(data.memoryId, memory.id);
2246
+ assert.equal(data.windows.length, 1, "一个链接一个窗口");
2247
+ const w = data.windows[0];
2248
+ assert.equal(w.session_id, "s1");
2249
+ assert.equal(w.seq_start, 10);
2250
+ assert.equal(w.seq_end, 12);
2251
+ assert.equal(w.created_at, "2026-10-23T00:00:00Z");
2252
+ assert.deepEqual(w.records.map((r) => r.text), ["第一句原文", "第二句原文", "第三句原文"],
2253
+ "原文按 seq 升序,且不越出区间");
2254
+ assert.deepEqual(w.records.map((r) => r.seq), [10, 11, 12]);
2255
+ });
2256
+
2257
+ test("GET /api/dsh-mneme/memory/provenance:无 token 401,装配没接线 503", async () => {
2258
+ const { routes, service } = setup(null, "secret-token", RECORDS_ON);
2259
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
2260
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2261
+
2262
+ // 没 token → 401(policy=token 的机械结果)
2263
+ const noAuth = new FakeRes();
2264
+ await route.handler(req("/api/dsh-mneme/memory/provenance?id=x"), noAuth);
2265
+ assert.equal(noAuth.statusCode, 401);
2266
+
2267
+ // 装配没接线(headless / 旧装配:service 上没有这两个方法)→ 503。
2268
+ // 这与「这条记忆没有来源」是两件事,必须分开读数——后者是事实,前者是接线坏了。
2269
+ const store = createStore(":memory:");
2270
+ const settings = createSettings(store.db);
2271
+ const bare = createService({ store, mirror: null, config: {} });
2272
+ delete bare.provenanceFor;
2273
+ delete bare.distillRecordsFor;
2274
+ const bareRoutes = [];
2275
+ createApi(
2276
+ { webServer: { register(r) { bareRoutes.push(r); return () => {}; } } },
2277
+ bare, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "secret-token",
2278
+ RECORDS_ON
2279
+ );
2280
+ const bareRoute = bareRoutes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2281
+ const res503 = new FakeRes();
2282
+ const authed = req("/api/dsh-mneme/memory/provenance?id=x");
2283
+ authed.headers = { authorization: "Bearer secret-token" };
2284
+ await bareRoute.handler(authed, res503);
2285
+ assert.equal(res503.statusCode, 503, "装配缺方法 → provenance-unavailable,不是空 windows");
2286
+ assert.equal(JSON.parse(res503.body).error, "provenance-unavailable");
2287
+ store.close();
2288
+ });
2289
+
2290
+ test("GET /api/dsh-mneme/memory/provenance:非蒸馏来源的记忆返回空窗口而非报错", async () => {
2291
+ // memory_save 写进来的记忆没有溯源链接——「这条不是蒸馏来的」本身是有效答案,
2292
+ // 不能 404/500。契约:空 windows,不是错误。
2293
+ const { routes, service } = setup(null, "secret-token", RECORDS_ON);
2294
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
2295
+ const memory = service.all()[0];
2296
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2297
+ const res = new FakeRes();
2298
+ const r = req("/api/dsh-mneme/memory/provenance?id=" + memory.id);
2299
+ r.headers = { authorization: "Bearer secret-token" };
2300
+ await route.handler(r, res);
2301
+ assert.equal(res.statusCode, 200);
2302
+ const data = JSON.parse(res.body);
2303
+ assert.deepEqual(data.windows, [], "没有链接 → 空窗口,不是错误");
2304
+ });
2305
+
2306
+ test("GET /api/dsh-mneme/memory/provenance 缺 id 报 400,未知 id 返回空窗口", async () => {
2307
+ const { routes } = setup(null, "secret-token", RECORDS_ON);
2308
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2309
+ // 400 要赢过 401:调用方发坏了,报「没鉴权」是误导(reclaimReply 同款顺序)。
2310
+ const bad = new FakeRes();
2311
+ const badReq = req("/api/dsh-mneme/memory/provenance");
2312
+ badReq.headers = { authorization: "Bearer secret-token" };
2313
+ await route.handler(badReq, bad);
2314
+ assert.equal(bad.statusCode, 400);
2315
+
2316
+ // 库里有 distill 数据但这条 id 不存在:读端点不替调用方判断记忆是否存在,
2317
+ // 「没有链接」对未知 id 与对非蒸馏记忆是同一个答案。
2318
+ const unknown = new FakeRes();
2319
+ const unknownReq = req("/api/dsh-mneme/memory/provenance?id=nope");
2320
+ unknownReq.headers = { authorization: "Bearer secret-token" };
2321
+ await route.handler(unknownReq, unknown);
2322
+ assert.equal(unknown.statusCode, 200);
2323
+ assert.deepEqual(JSON.parse(unknown.body).windows, []);
2324
+ });
2325
+
2326
+ test("GET /api/dsh-mneme/memory/provenance:读失败要留痕,500 不能把 warn 一起吞掉", async () => {
2327
+ // 共用模块的测试已经锁了「抛错 → 调 warn」(provenance-http.test.js),但锁不住
2328
+ // **壳把哪个 logger 递进去**。#509 第二次迭代在这里栽过:壳里写的是裸 `logger`,
2329
+ // 而 createApi 的作用域只有 ctx.logger——warn 回调自己抛 ReferenceError,被外层
2330
+ // catch 吞掉,响应还是 500,但「为什么失败」一个字都不留。下面这条把接线本身锁住。
2331
+ const store = createStore(":memory:");
2332
+ const settings = createSettings(store.db);
2333
+ const service = createService({ store, mirror: null, config: {} });
2334
+ const realFor = service.provenanceFor;
2335
+ service.provenanceFor = (id) => { realFor(id); throw new Error("db locked"); };
2336
+
2337
+ const routes = [];
2338
+ const warnings = [];
2339
+ createApi(
2340
+ {
2341
+ webServer: { register(r) { routes.push(r); return () => {}; } },
2342
+ logger: { warn: (m) => warnings.push(m) }
2343
+ },
2344
+ service, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "secret-token",
2345
+ RECORDS_ON
2346
+ );
2347
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2348
+ const res = new FakeRes();
2349
+ const r = req("/api/dsh-mneme/memory/provenance?id=m1");
2350
+ r.headers = { authorization: "Bearer secret-token" };
2351
+ await route.handler(r, res);
2352
+
2353
+ assert.equal(res.statusCode, 500, "读失败回 500");
2354
+ assert.equal(JSON.parse(res.body).error, "internal");
2355
+ assert.equal(warnings.length, 1, "500 必须伴随一条 warn——否则失败原因只存在于调用方的猜测里");
2356
+ assert.match(warnings[0], /memory provenance read failed/, "留痕要带原始异常");
2357
+ assert.match(warnings[0], /db locked/);
2358
+ store.close();
2359
+ });
2360
+
2361
+ // --- distillRecords.enabled 门(#509 评审整改)--------------------------------
2362
+ // 维护者在 #509 里指出的缺口:读出口此前一处都不读这个开关,于是「把 distill_records
2363
+ // 关掉」的语义是不再写、也不再删,却还多一个能读出去的口。门落在共用模块,两个壳
2364
+ // 都吃它;这里锁宿主面的外壳与顺序。
2365
+
2366
+ test("GET /api/dsh-mneme/memory/provenance:distillRecords.enabled 关着 → 409,不是 404 也不是空 windows", async () => {
2367
+ const { routes, service } = setup(null, "secret-token", { distillRecords: { enabled: false } });
2368
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
2369
+ const memory = service.all()[0];
2370
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2371
+ const res = new FakeRes();
2372
+ const r = req("/api/dsh-mneme/memory/provenance?id=" + memory.id);
2373
+ r.headers = { authorization: "Bearer secret-token" };
2374
+ await route.handler(r, res);
2375
+
2376
+ assert.equal(res.statusCode, 409, "用户关了功能 = 409(src/api.js:525 的 profileExtract 同型先例)");
2377
+ assert.deepEqual(JSON.parse(res.body), { error: "distill-records-disabled" });
2378
+ });
2379
+
2380
+ test("GET /api/dsh-mneme/memory/provenance:config 整个缺席也按关处理(fail-closed)", async () => {
2381
+ // 旧调用方直连 createApi 不传第 8 参、或宿主没装配 cfg:这个端点返回逐字原文,
2382
+ // 判不出来时必须往「不读」那一侧倒。
2383
+ const { routes } = setup(null, "secret-token");
2384
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2385
+ const res = new FakeRes();
2386
+ const r = req("/api/dsh-mneme/memory/provenance?id=whatever");
2387
+ r.headers = { authorization: "Bearer secret-token" };
2388
+ await route.handler(r, res);
2389
+ assert.equal(res.statusCode, 409);
2390
+ assert.deepEqual(JSON.parse(res.body), { error: "distill-records-disabled" });
2391
+ });
2392
+
2393
+ test("GET /api/dsh-mneme/memory/provenance:400 赢 409——缺 id 是语法畸形,与服务端状态无关", async () => {
2394
+ const { routes } = setup(null, "secret-token", { distillRecords: { enabled: false } });
2395
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2396
+ const res = new FakeRes();
2397
+ const r = req("/api/dsh-mneme/memory/provenance");
2398
+ r.headers = { authorization: "Bearer secret-token" };
2399
+ await route.handler(r, res);
2400
+ assert.equal(res.statusCode, 400, "请求本身不成立时报「功能关了」是误导");
2401
+ assert.equal(JSON.parse(res.body).error, "missing-id");
2402
+ });
2403
+
2404
+ test("GET /api/dsh-mneme/memory/provenance:409 赢 503——功能关比装配坏更具体", async () => {
2405
+ // 关着 + service 上连方法都没有:503 会让人去查一个没坏的部署。
2406
+ const store = createStore(":memory:");
2407
+ const settings = createSettings(store.db);
2408
+ const bare = createService({ store, mirror: null, config: {} });
2409
+ delete bare.provenanceFor;
2410
+ delete bare.distillRecordsFor;
2411
+ const bareRoutes = [];
2412
+ createApi(
2413
+ { webServer: { register(r) { bareRoutes.push(r); return () => {}; } } },
2414
+ bare, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "secret-token",
2415
+ { distillRecords: { enabled: false } }
2416
+ );
2417
+ const route = bareRoutes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2418
+ const res = new FakeRes();
2419
+ const r = req("/api/dsh-mneme/memory/provenance?id=x");
2420
+ r.headers = { authorization: "Bearer secret-token" };
2421
+ await route.handler(r, res);
2422
+ assert.equal(res.statusCode, 409);
2423
+ assert.equal(JSON.parse(res.body).error, "distill-records-disabled");
2424
+ store.close();
2425
+ });
2426
+
2427
+ test("GET /api/dsh-mneme/memory/provenance:关着时对已知 id 与未知 id 同一个答案——不当存在性预言机", async () => {
2428
+ // 如果关着只对「有原文的 id」回 409、对别的 id 回空 windows,调用方就能拿这个
2429
+ // 端点枚举「哪些记忆有蒸馏原文」。功能关是端点的属性,每个 id 答案必须一样。
2430
+ const { routes, service } = setup(null, "secret-token", { distillRecords: { enabled: false } });
2431
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
2432
+ const memory = service.all()[0];
2433
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2434
+ for (const id of [memory.id, "nope", ""]) {
2435
+ const res = new FakeRes();
2436
+ const r = req("/api/dsh-mneme/memory/provenance?id=" + id);
2437
+ r.headers = { authorization: "Bearer secret-token" };
2438
+ await route.handler(r, res);
2439
+ if (id === "") {
2440
+ assert.equal(res.statusCode, 400, "空 id 仍是语法问题,409 不该吃掉它");
2441
+ continue;
2442
+ }
2443
+ assert.equal(res.statusCode, 409, `${id} → 409`);
2444
+ assert.deepEqual(JSON.parse(res.body), { error: "distill-records-disabled" });
2445
+ }
2446
+ });
2447
+
2448
+ test("GET /api/dsh-mneme/memory/provenance:lightMode 预设把关掉 → 409(与写侧同源)", async () => {
2449
+ // applyLightModePreset 把 distillRecords.enabled 置 false(pre-inject-gate.test.js
2450
+ // 锁的是写入侧那个方向);读侧必须跟着变,否则轻量模式下用户以为自己关了、原文
2451
+ // 却还能从 HTTP 读出去。
2452
+ const { routes } = setup(null, "secret-token", applyLightModePreset({ lightMode: true, distillRecords: { enabled: true } }));
2453
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2454
+ const res = new FakeRes();
2455
+ const r = req("/api/dsh-mneme/memory/provenance?id=m1");
2456
+ r.headers = { authorization: "Bearer secret-token" };
2457
+ await route.handler(r, res);
2458
+ assert.equal(res.statusCode, 409);
2459
+ assert.equal(JSON.parse(res.body).error, "distill-records-disabled");
2460
+ });
@@ -30,9 +30,9 @@ function mockCtx({ onConsolidation, summaryText = "记忆库总览:用户偏
30
30
  llm: {
31
31
  async *stream(options) {
32
32
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
33
- if (userText.startsWith("id=")) {
33
+ if (userText.includes("| type=")) {
34
34
  yield { type: "block-start", index: 0, blockType: "text" };
35
- yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
35
+ yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText, options.purpose) : "[]" };
36
36
  yield { type: "block-end", index: 0, block: { type: "text" } };
37
37
  yield { type: "finish", reason: { kind: "stop" } };
38
38
  return;