@modusensus/dsh-mneme 0.8.15 → 0.9.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 (100) hide show
  1. package/README.md +21 -3
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +4 -2
  4. package/docs/CONFIGURATION.md +16 -3
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/DISTILL.md +162 -0
  7. package/docs/STORAGE.md +11 -2
  8. package/docs/WORKFLOWS.md +299 -0
  9. package/lib/api-standalone.js +22 -39
  10. package/lib/api.js +294 -10
  11. package/lib/candidates.js +454 -0
  12. package/lib/capabilities.js +819 -0
  13. package/lib/client.js +2767 -337
  14. package/lib/config.js +99 -6
  15. package/lib/continuity.js +35 -6
  16. package/lib/dream/decisions.js +37 -1
  17. package/lib/dream/sleep.js +10 -2
  18. package/lib/dream.js +7 -1
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/hot-memory.js +62 -0
  21. package/lib/index.js +72 -18
  22. package/lib/inject.js +33 -64
  23. package/lib/maintenance.js +25 -5
  24. package/lib/pipeline.js +534 -0
  25. package/lib/recall-stats.js +22 -7
  26. package/lib/reclaim-http.js +150 -0
  27. package/lib/safe-slice.js +18 -0
  28. package/lib/scope.js +16 -4
  29. package/lib/serve.js +1 -1
  30. package/lib/service.js +83 -17
  31. package/lib/session-usage.js +350 -0
  32. package/lib/settings.js +19 -2
  33. package/lib/store.js +251 -10
  34. package/lib/summarize.js +239 -60
  35. package/lib/tools.js +11 -2
  36. package/package.json +1 -1
  37. package/scripts/publish-gate.mjs +197 -0
  38. package/src/api-standalone.js +22 -39
  39. package/src/api.js +294 -10
  40. package/src/candidates.js +454 -0
  41. package/src/capabilities.js +819 -0
  42. package/src/config.js +99 -6
  43. package/src/continuity.js +35 -6
  44. package/src/dream/decisions.js +37 -1
  45. package/src/dream/sleep.js +10 -2
  46. package/src/dream.js +7 -1
  47. package/src/entities/extractor.js +4 -0
  48. package/src/hot-memory.js +62 -0
  49. package/src/index.js +72 -18
  50. package/src/inject.js +33 -64
  51. package/src/maintenance.js +25 -5
  52. package/src/pipeline.js +534 -0
  53. package/src/recall-stats.js +22 -7
  54. package/src/reclaim-http.js +150 -0
  55. package/src/safe-slice.js +18 -0
  56. package/src/scope.js +16 -4
  57. package/src/serve.js +1 -1
  58. package/src/service.js +83 -17
  59. package/src/session-usage.js +350 -0
  60. package/src/settings.js +19 -2
  61. package/src/store.js +251 -10
  62. package/src/summarize.js +239 -60
  63. package/src/tools.js +11 -2
  64. package/test/api-routes-gaps.test.js +681 -0
  65. package/test/api.test.js +171 -8
  66. package/test/candidates.test.js +345 -0
  67. package/test/capabilities.test.js +577 -0
  68. package/test/client.test.js +715 -4
  69. package/test/continuity.test.js +84 -0
  70. package/test/contrast.test.js +314 -0
  71. package/test/dream-merge-guardrail.test.js +219 -0
  72. package/test/embed-ready-poll.test.js +94 -0
  73. package/test/entities.test.js +13 -2
  74. package/test/failure-query.test.js +147 -0
  75. package/test/failure-retention.test.js +105 -0
  76. package/test/graph-api.test.js +36 -2
  77. package/test/hot-memory.test.js +67 -1
  78. package/test/inject-recall.test.js +68 -1
  79. package/test/inject.test.js +23 -0
  80. package/test/llm-audit.test.js +190 -1
  81. package/test/maintenance.test.js +112 -2
  82. package/test/mint-source.test.js +202 -0
  83. package/test/pipeline.test.js +505 -0
  84. package/test/placement.test.js +1 -1
  85. package/test/pre-inject-gate.test.js +10 -0
  86. package/test/publish-gate.test.js +272 -0
  87. package/test/recall-retention.test.js +128 -0
  88. package/test/recall-stats.test.js +89 -4
  89. package/test/reclaim-http.test.js +209 -0
  90. package/test/release-workflow.test.js +77 -0
  91. package/test/scope-distill-stamp.test.js +135 -0
  92. package/test/serve.test.js +1 -1
  93. package/test/service.test.js +7 -2
  94. package/test/session-usage.test.js +308 -0
  95. package/test/settings.test.js +8 -0
  96. package/test/store-write-path-columns.test.js +191 -0
  97. package/test/store.test.js +108 -0
  98. package/test/summarize.test.js +350 -0
  99. package/test/supersede-state-tracking.test.js +4 -4
  100. package/test/tools.test.js +16 -1
@@ -2,7 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { EventEmitter } from "node:events";
4
4
  import { DatabaseSync } from "node:sqlite";
5
- import { mkdtempSync } from "node:fs";
5
+ import { mkdtempSync, readFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { Context } from "@deepseek-ai/cordis";
@@ -468,6 +468,67 @@ test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates by source and statu
468
468
  store.close();
469
469
  });
470
470
 
471
+ // by_operation / by_day 是面板「token 去向」环形图与 7 天柱状的数据源。
472
+ // 锁三件事:① 环形图直接按返回顺序画段,所以 by_operation 必须按
473
+ // total_tokens 降序、by_day 必须按 day 升序(前端不再排);② 每行带完整的
474
+ // in/out/total 拆分(by_source 只有 total,画不出输入/输出对照);
475
+ // ③ write_admission 只计量不调 LLM,token 恒 0 是设计——它必须作为一行
476
+ // 出现且为 0,防的是将来有人把 0 行过滤掉或当成异常补齐。
477
+ test("GET /api/dsh-mneme/semantic/llm-audit/stats breaks spend down by operation and by day", async () => {
478
+ const { store, service, routes } = apiSetup();
479
+ const day1 = new Date(Date.now() - 2 * 86400000).toISOString();
480
+ const day2 = new Date(Date.now() - 1 * 86400000).toISOString();
481
+ service.saveLlmAudit({
482
+ timestamp: day1,
483
+ trigger_source: "autoDream", operation_type: "dream_consolidate",
484
+ model_id: "m", input_tokens: 100, output_tokens: 40, status: "success"
485
+ });
486
+ service.saveLlmAudit({
487
+ timestamp: day1,
488
+ trigger_source: "writeAdmission", operation_type: "write_admission",
489
+ model_id: "m", input_tokens: 0, output_tokens: 0, status: "success"
490
+ });
491
+ service.saveLlmAudit({
492
+ timestamp: day2,
493
+ trigger_source: "autoDream", operation_type: "dream_consolidate",
494
+ model_id: "m", input_tokens: 10, output_tokens: 5, status: "success"
495
+ });
496
+ service.saveLlmAudit({
497
+ timestamp: day2,
498
+ trigger_source: "autoSummarize", operation_type: "summarize_compress",
499
+ model_id: "m", input_tokens: 30, output_tokens: 10, status: "success"
500
+ });
501
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit/stats");
502
+ const res = new FakeRes();
503
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit/stats?days=7"), res);
504
+ const stats = JSON.parse(res.body);
505
+ assert.equal(res.statusCode, 200);
506
+
507
+ // by_operation:total_tokens 降序,含 in/out/total 拆分
508
+ assert.deepEqual(
509
+ stats.by_operation.map((o) => o.operation),
510
+ ["dream_consolidate", "summarize_compress", "write_admission"]
511
+ );
512
+ const consolidate = stats.by_operation[0];
513
+ assert.equal(consolidate.c, 2);
514
+ assert.equal(consolidate.input_tokens, 110);
515
+ assert.equal(consolidate.output_tokens, 45);
516
+ assert.equal(consolidate.total_tokens, 155);
517
+ const admission = stats.by_operation.find((o) => o.operation === "write_admission");
518
+ assert.equal(admission.c, 1);
519
+ assert.equal(admission.total_tokens, 0);
520
+
521
+ // by_day:date(timestamp)(UTC)分组,day 升序
522
+ const d1 = day1.slice(0, 10);
523
+ const d2 = day2.slice(0, 10);
524
+ assert.deepEqual(stats.by_day.map((d) => d.day), [d1, d2]);
525
+ assert.equal(stats.by_day[0].c, 2);
526
+ assert.equal(stats.by_day[0].total_tokens, 140);
527
+ assert.equal(stats.by_day[1].c, 2);
528
+ assert.equal(stats.by_day[1].total_tokens, 55);
529
+ store.close();
530
+ });
531
+
471
532
  // ------------------------------------------- entity extraction (issue #250)
472
533
  // 实体抽取是「每次写入记忆都会跑」的后台 LLM 链路,但 createEntityStreamAdapter
473
534
  // 只拿到 llm 句柄、没有 service,所以从来没有写审计的能力——面板因此回答不了
@@ -561,6 +622,97 @@ test("entity extraction writes no audit row when llmAudit.enabled === false", as
561
622
  store.close();
562
623
  });
563
624
 
625
+ // #405:实体抽取的审计行此前把失败原因压成一句常量、related_memory_ids 恒空,于是
626
+ // 429 / 配额 / 鉴权 / 超时在库里长得一模一样(主库 735 条 entity_extract error 行全
627
+ // 是「llm stream aborted or errored」),也查不出是哪条记忆没抽到实体。四条锁:原因
628
+ // 落进 error_message(复用 dream 的 describeStreamFailure,全仓归一只此一份)、无
629
+ // 细节时退回旧字面量、归属 id 传得进来且不凭空造、抛错路径同样带归属。
630
+ test("#405: entity extraction audit records the stream failure cause, not a constant", async () => {
631
+ const store = createStore(":memory:");
632
+ const service = createService({ store, mirror: null, config: {} });
633
+ const streamEntityText = createEntityStreamAdapter({
634
+ llm: {
635
+ async *stream() {
636
+ yield {
637
+ type: "finish",
638
+ reason: { kind: "error", failure: { code: "rate_limited", message: "429 Too Many Requests", status: 429 } }
639
+ };
640
+ }
641
+ },
642
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
643
+ logger: { warn: () => {} },
644
+ service
645
+ });
646
+ const text = await streamEntityText(
647
+ [{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
648
+ { memoryId: "mem-1" }
649
+ );
650
+ assert.equal(text, undefined, "a failed stream yields no text");
651
+ const row = service.listLlmAudits({ source: "entityExtract" })[0];
652
+ assert.equal(row.status, "error");
653
+ assert.equal(row.error_message, "rate_limited: 429 Too Many Requests",
654
+ "code + message from chunk.reason.failure survive into the audit row");
655
+ assert.notEqual(row.error_message, "llm stream aborted or errored",
656
+ "the constant that made every provider failure look identical is gone");
657
+ assert.deepEqual(row.related_memory_ids, ["mem-1"],
658
+ "the row is attributable to the memory that was being extracted");
659
+ store.close();
660
+ });
661
+
662
+ test("#405: entity extraction audit keeps the legacy wording when the failure has no detail", async () => {
663
+ const store = createStore(":memory:");
664
+ const service = createService({ store, mirror: null, config: {} });
665
+ const streamEntityText = createEntityStreamAdapter({
666
+ llm: {
667
+ async *stream() {
668
+ // 老形态:只有 kind,既没有 failure 也没有 message(dsh-llm 早期版本、或
669
+ // 替身)。这时退回旧字面量——把 error_message 写成空串在库里比一句常量更
670
+ // 难查,那等于用另一种方式把原因丢掉。
671
+ yield { type: "finish", reason: { kind: "aborted" } };
672
+ }
673
+ },
674
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
675
+ logger: { warn: () => {} },
676
+ service
677
+ });
678
+ await streamEntityText([{ role: "user", content: [{ type: "text", text: "记忆内容" }] }], {});
679
+ const row = service.listLlmAudits({ source: "entityExtract" })[0];
680
+ assert.equal(row.error_message, "llm stream aborted or errored");
681
+ assert.deepEqual(row.related_memory_ids, [], "no memoryId → still empty, never fabricated");
682
+ store.close();
683
+ });
684
+
685
+ test("#405: entity extraction audit attributes a thrown stream error to the memory too", async () => {
686
+ const store = createStore(":memory:");
687
+ const service = createService({ store, mirror: null, config: {} });
688
+ const streamEntityText = createEntityStreamAdapter({
689
+ llm: {
690
+ async *stream() {
691
+ throw new Error("socket hang up");
692
+ }
693
+ },
694
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
695
+ logger: { warn: () => {} },
696
+ service
697
+ });
698
+ await streamEntityText([{ role: "user", content: [{ type: "text", text: "记忆内容" }] }], { memoryId: "mem-9" });
699
+ const row = service.listLlmAudits({ source: "entityExtract" })[0];
700
+ assert.equal(row.status, "error");
701
+ assert.equal(row.error_message, "socket hang up");
702
+ assert.deepEqual(row.related_memory_ids, ["mem-9"], "the throw path attributes too");
703
+ store.close();
704
+ });
705
+
706
+ test("#405: the failure-cause formatter stays a single implementation", async () => {
707
+ const indexSrc = readFileSync(new URL("../src/index.js", import.meta.url), "utf8");
708
+ const dreamSrc = readFileSync(new URL("../src/dream.js", import.meta.url), "utf8");
709
+ assert.match(indexSrc, /describeStreamFailure/, "index.js reuses dream.js's formatter");
710
+ // 有人在这里就地重写 code/message 拼接,就又多出一份口径——#411 的教训是「复制
711
+ // 粘贴漂移不是设计,是漂移」。锁死两端:归一逻辑只许在 dream.js 里存在一次。
712
+ assert.equal(indexSrc.match(/failure\.code/g), null, "index.js must not re-format the failure code itself");
713
+ assert.equal(dreamSrc.match(/function describeStreamFailure\(/g).length, 1, "exactly one definition");
714
+ });
715
+
564
716
  // 启动期保留清理(Bug8)必须与写入侧读**同一份**配置。面板把 llmAudit.enabled 写进
565
717
  // kv、经 nestedFlags 合进 cfg,bundle 里可能是关的:清理若读 rawCfg,就会出现「面板
566
718
  // 说开 → 各写入点照写,raw 说关 → 不清理」这种无保留期的增长(写入准入 #254 也是读
@@ -596,3 +748,40 @@ test("boot retention purge reads the assembled config, not rawCfg", async () =>
596
748
  after.close();
597
749
  assert.equal(left, 0, "面板开着的审计必须按 cfg 清理,否则审计行只增不减");
598
750
  });
751
+
752
+ // #399 的启动期裁剪同理:留存开关与保留期都要取装配后的 cfg。这里额外锁一条
753
+ // 「默认关就不动手」——留存是 opt-in,默认关时一张空表不该被每次启动扫一遍。
754
+ test("boot purge trims expired distill records only when retention is on (issue #399)", async () => {
755
+ const dir = mkdtempSync(join(tmpdir(), "mneme-distill-retention-"));
756
+ const dbPath = join(dir, "memory.db");
757
+
758
+ const seed = createStore(dbPath);
759
+ createSettings(seed.db).setFeatureFlags({ "distillRecords.enabled": true, "distillRecords.retentionDays": 1 });
760
+ seed.saveDistillRecords([
761
+ {
762
+ sessionId: "s-boot",
763
+ seq: 1,
764
+ role: "user",
765
+ text: "过期原文",
766
+ windowId: "s-boot:1",
767
+ distilledAt: new Date(Date.now() - 2 * 86400000).toISOString()
768
+ }
769
+ ]);
770
+ assert.equal(seed.db.prepare("SELECT count(*) AS c FROM distill_records").get().c, 1, "先放一行过期原文");
771
+ seed.close();
772
+
773
+ const ctx = new Context();
774
+ ctx.provide("tools", { register() { return () => {}; } });
775
+ ctx.provide("commands", { register() { return () => {}; } });
776
+ ctx.provide("systemPrompt", { context() { return () => {}; } });
777
+ ctx.provide("agentDefaultModel", { currentSelection() { return { provider: "mock", model: "mock" }; } });
778
+ ctx.provide("llm", { async *stream() { yield { type: "finish", reason: { kind: "stop" } }; } });
779
+ ctx.provide("webServer", { register() { return () => {}; } });
780
+ // bundle 说关、面板说开(保留期 1 天,那行已过期 2 天):只有读装配后的 cfg 才会清掉
781
+ await ctx.plugin(mneme, { memoryDir: dir, distillRecords: { enabled: false, retentionDays: 1 } });
782
+
783
+ const after = new DatabaseSync(dbPath);
784
+ const left = after.prepare("SELECT count(*) AS c FROM distill_records").get().c;
785
+ after.close();
786
+ assert.equal(left, 0, "面板开着的留存必须按 cfg 裁剪,否则原文只增不减");
787
+ });
@@ -102,8 +102,8 @@ test("dry-run:报出将清理的行数与列文本大小,一个字节都不
102
102
 
103
103
  assert.equal(report.dryRun, true);
104
104
  assert.equal(report.olderThanDays, 7, "默认保留窗口 7 天");
105
- assert.deepEqual(report.planned, { dream_run_inputs: 1, archived_embeddings: 1 });
106
- assert.deepEqual(report.cleared, { dream_run_inputs: 0, archived_embeddings: 0 });
105
+ assert.deepEqual(report.planned, { dream_run_inputs: 1, archived_embeddings: 1, distill_records: 0 });
106
+ assert.deepEqual(report.cleared, { dream_run_inputs: 0, archived_embeddings: 0, distill_records: 0 });
107
107
  assert.ok(report.column_bytes.dream_run_inputs > 600, "列文本大小要给真实数字(它是上界)");
108
108
  assert.equal(typeof report.size.before_bytes, "number");
109
109
  assert.equal(report.vacuum.ran, false);
@@ -116,6 +116,41 @@ test("dry-run:报出将清理的行数与列文本大小,一个字节都不
116
116
  }
117
117
  });
118
118
 
119
+ test("dry-run 与执行:蒸馏原文进报告,裁剪只删原文、溯源链接留住(#399)", () => {
120
+ const { store, close } = fileStore();
121
+ try {
122
+ seed(store);
123
+ const old = new Date(NOW - 30 * DAY).toISOString();
124
+ const fresh = new Date(NOW - 1 * DAY).toISOString();
125
+ store.saveDistillRecords([
126
+ { sessionId: "s-old", seq: 1, role: "user", text: "z".repeat(600), windowId: "s-old:1", distilledAt: old },
127
+ { sessionId: "s-fresh", seq: 1, role: "user", text: "新窗口的原文", windowId: "s-fresh:1", distilledAt: fresh }
128
+ ]);
129
+ const memoryId = store.all()[0].id;
130
+ store.saveMemoryProvenance({ memoryId, sessionId: "s-old", seqStart: 1, seqEnd: 1, createdAt: old });
131
+
132
+ // dry-run:第三项要报出真实行数与列文本大小(上界),且一个字节都不改
133
+ const dry = maintenanceFor(store).reclaim({ dryRun: true });
134
+ assert.equal(dry.planned.distill_records, 1, "只有窗口外那行算数");
135
+ assert.ok(dry.column_bytes.distill_records >= 600, "蒸馏原文的列文本大小要给真实数字");
136
+ assert.equal(store.db.prepare("SELECT count(*) AS c FROM distill_records").get().c, 2, "dry-run 不删原文");
137
+
138
+ const applied = maintenanceFor(store).reclaim({ dryRun: false });
139
+ assert.equal(applied.cleared.distill_records, 1);
140
+ const rows = store.db.prepare("SELECT session_id FROM distill_records").all();
141
+ assert.deepEqual(rows.map((r) => r.session_id), ["s-fresh"], "窗口内原文留住");
142
+ // 裁剪只删原文:链接与记忆都留着,面板/CLI 显示「来源已过期」而不是查无此记忆。
143
+ assert.equal(
144
+ store.db.prepare("SELECT count(*) AS c FROM memory_provenance").get().c,
145
+ 1,
146
+ "溯源链接刻意保留,指向已空区间"
147
+ );
148
+ assert.equal(rawMemoryCount(store), 2, "裁剪原文不删记忆");
149
+ } finally {
150
+ close();
151
+ }
152
+ });
153
+
119
154
  test("执行:只清窗口外的输入快照,run 的骨架与决策原文留住", () => {
120
155
  const { store, close } = fileStore();
121
156
  try {
@@ -191,6 +226,8 @@ test("VACUUM 失败不回吞:清理照做、报告与回执都带上失败原
191
226
  clearDreamRunInputs: (b) => store.clearDreamRunInputs(b),
192
227
  archivedEmbeddingStats: () => store.archivedEmbeddingStats(),
193
228
  clearArchivedEmbeddings: () => store.clearArchivedEmbeddings(),
229
+ distillRecordStats: (b) => store.distillRecordStats(b),
230
+ clearDistillRecords: (b) => store.clearDistillRecords(b),
194
231
  storageStats: () => store.storageStats(),
195
232
  saveLlmAudit: (e) => store.saveLlmAudit(e),
196
233
  vacuum: () => { throw new Error("SQLITE_BUSY: database is locked"); }
@@ -389,3 +426,76 @@ test("CLI:--vacuum 单独用会被拒(dry-run 没有可回收的页),也
389
426
  assert.equal(result.status, 1);
390
427
  assert.match(result.stderr, /--vacuum 需要配 --apply/);
391
428
  });
429
+
430
+ test("HTTP 面(数据面):窗口上界与确认门里的抛错都跟面板侧同口径(#411)", async () => {
431
+ // 这张脸曾是面板路由的整段复制,于是 #410 修好面板侧之后它停在旧形态:#508 没有
432
+ // 上界(1e20 打进来 → maintenance 算日期抛 RangeError → 一个字节都不回),#523 的
433
+ // confirm-required 报告也没兜错。现在两处口径都来自 src/reclaim-http.js,这条锁从
434
+ // **数据面自己的端口**打真 HTTP,证明它真的走的是同一份。
435
+ const { store, close } = fileStore();
436
+ const maintenance = maintenanceFor(store);
437
+ const service = createService({ store, mirror: null, config: {} });
438
+ const settings = createSettings(store.db);
439
+ const api = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0, maintenance });
440
+ await api.ready;
441
+ const url = `http://127.0.0.1:${api.port}/maintenance/reclaim`;
442
+ const post = (body) =>
443
+ fetch(url, {
444
+ method: "POST",
445
+ headers: { authorization: `Bearer ${api.token}`, "content-type": "application/json" },
446
+ body: JSON.stringify(body)
447
+ });
448
+ // 第二个实例:不传 maintenance(「没接线」的那一档)。在 try 之外建、在 finally 里
449
+ // 关——它要是漏关,node --test 会挂着一个开着的 server 句柄永不退出(变异检验里
450
+ // 一旦断言先炸就正好踩这条)。
451
+ const unplugged = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0 });
452
+ await unplugged.ready;
453
+ try {
454
+ const { archived } = seed(store);
455
+
456
+ // 顺序也是契约:端点整个没接线时,报「窗口不合法」是误导——调用方会去改窗口,
457
+ // 改完照样 503。所以 503 必须赢过字段校验(面板侧同一条,见 api-routes-gaps.test.js)。
458
+ const unpluggedRes = await fetch(`http://127.0.0.1:${unplugged.port}/maintenance/reclaim`, {
459
+ method: "POST",
460
+ headers: { authorization: `Bearer ${unplugged.token}`, "content-type": "application/json" },
461
+ body: JSON.stringify({ olderThanDays: 1e20 })
462
+ });
463
+ assert.equal(unpluggedRes.status, 503, "没接线要赢过字段校验");
464
+ assert.equal((await unpluggedRes.json()).error, "maintenance-unavailable");
465
+
466
+ // 量级超界:旧形态下这一发既不改数据、也不回任何字节。
467
+ for (const huge of [3651, 1e20]) {
468
+ const out = await post({ dryRun: false, olderThanDays: huge });
469
+ assert.equal(out.status, 400, `olderThanDays=${huge} 必须被拒,而不是把连接挂住`);
470
+ assert.equal((await out.json()).error, "invalid-older-than-days");
471
+ }
472
+ const atLimit = await post({ olderThanDays: 3650 });
473
+ assert.equal(atLimit.status, 200, "上界本身合法");
474
+ assert.equal((await atLimit.json()).olderThanDays, 3650);
475
+ assert.ok(rawVec(store, archived.id), "被拒的那几发一个字节都不能改");
476
+
477
+ // 确认门的报告要描述「将要执行的那个动作」,且窗口是调用方问的那个。
478
+ const refused = await post({ dryRun: false, olderThanDays: 30, vacuum: true });
479
+ assert.equal(refused.status, 400);
480
+ const refusedBody = await refused.json();
481
+ assert.equal(refusedBody.error, "confirm-required");
482
+ assert.equal(refusedBody.report.olderThanDays, 30, "报告必须说的是被问的那个窗口");
483
+ assert.equal(refusedBody.report.vacuum.requested, true, "报告必须描述将要执行的那个动作(含 vacuum)");
484
+ assert.ok(rawVec(store, archived.id), "缺 confirm 时一个字节都不改");
485
+
486
+ const applied = await post({ dryRun: false, confirm: true, vacuum: true });
487
+ assert.equal(applied.status, 200);
488
+ const appliedBody = await applied.json();
489
+ assert.equal(appliedBody.olderThanDays, 7, "不传窗口就是默认 7 天");
490
+ assert.equal(appliedBody.cleared.dream_run_inputs, 1);
491
+ assert.equal(appliedBody.vacuum.requested, true, "勾了 VACUUM 就必须真带上");
492
+ assert.equal(appliedBody.vacuum.ran, true);
493
+ assert.equal(rawVec(store, archived.id), null);
494
+ } finally {
495
+ unplugged.server.closeIdleConnections?.();
496
+ unplugged.server.close();
497
+ api.server.closeIdleConnections?.();
498
+ api.server.close();
499
+ close();
500
+ }
501
+ });
@@ -0,0 +1,202 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { createService } from "../src/service.js";
5
+ import { applyDecisions, validateDecisions } from "../src/dream/decisions.js";
6
+ import { runSleep } from "../src/dream/sleep.js";
7
+ import { createDreamScheduler } from "../src/dream.js";
8
+
9
+ // 机器 mint 的记忆必须带真 source。
10
+ //
11
+ // 背景(现场实测,2026-10-14):pattern 表的 144 行 source 全是 "manual",
12
+ // 而按 dream_runs 的 patterns.applied 逐轮求和恰好也是 144——它们全是 sleep 的
13
+ // phasePatterns mint 的,那个 "manual" 只是 saveWithDedupe 没拿到 source 时的
14
+ // 兜底值。于是「这类条目从哪来」在数据面上不可读,靠它做的判断(service.js 里
15
+ // 那条「pattern 只由手工写入」)也就跟着错了。
16
+ //
17
+ // 这组用例锁两件事:① 三条 mint 管道各自声明自己的来源;② 来源只能由调用方给,
18
+ // 模型写在决策对象上的 source 一律不算数——validateDecisions 不剥离未知字段,
19
+ // 若 applyCreate 信了 d.source,模型就能给自己的输出盖「人写的」戳。
20
+
21
+ function setup() {
22
+ const store = createStore(":memory:");
23
+ const service = createService({ store, mirror: null, config: {} });
24
+ return { store, service };
25
+ }
26
+
27
+ function mint(service, extra = {}) {
28
+ return service
29
+ .saveWithDedupe({ type: "project", title: `铺垫${Math.random()}`, content: "给扫描窗垫一条普通记忆", importance: 3 })
30
+ .memory;
31
+ }
32
+
33
+ // ---------------------------------------------------------------- applyDecisions
34
+
35
+ test("mint source: applyDecisions stamps the caller's mintSource on create", () => {
36
+ const { store, service } = setup();
37
+ const { applied } = applyDecisions(
38
+ [{ action: "create", type: "pattern", title: "真模式", content: "从重复里抽出来的规律", importance: 3 }],
39
+ service,
40
+ null,
41
+ null,
42
+ { mintSource: "sleep" }
43
+ );
44
+ assert.equal(applied, 1);
45
+ const row = service.all().find((m) => m.type === "pattern");
46
+ assert.equal(row.source, "sleep", "minted row carries the pipeline provenance");
47
+ store.close();
48
+ });
49
+
50
+ test("mint source: dream pipeline stamps 'dream'", () => {
51
+ const { store, service } = setup();
52
+ applyDecisions(
53
+ [{ action: "create", type: "pattern", title: "真模式", content: "规律", importance: 3 }],
54
+ service,
55
+ null,
56
+ null,
57
+ { mintSource: "dream" }
58
+ );
59
+ const row = service.all().find((m) => m.type === "pattern");
60
+ assert.equal(row.source, "dream");
61
+ store.close();
62
+ });
63
+
64
+ // 对抗条:来源取自调用方,不取自决策对象。模型自报的 source 必须被忽略——
65
+ // 否则「机器写的不冒充人写的」这条防线就形同虚设。
66
+ test("mint source: a source on the decision object is ignored, pipeline wins", () => {
67
+ const { store, service } = setup();
68
+ const decisions = [
69
+ { action: "create", type: "pattern", title: "真模式", content: "规律", importance: 3, source: "manual" }
70
+ ];
71
+ // 先证明威胁真实存在:这类决策能整个通过校验走到 applyCreate(校验不剥离未知
72
+ // 字段)。若哪天真加了剥离,这条会红——那时下面的对抗断言也就不再承重了。
73
+ const { ok } = validateDecisions(decisions, new Map(), { maxCreatePerRun: 3 });
74
+ assert.equal(ok, true, "a create carrying its own source still validates — that is the threat");
75
+
76
+ applyDecisions(decisions, service, null, null, { mintSource: "sleep" });
77
+ const row = service.all().find((m) => m.type === "pattern");
78
+ assert.equal(row.source, "sleep", "the model-authored source must not win");
79
+ store.close();
80
+ });
81
+
82
+ // 反向:模型自报 source 而没有管道来源时,也不许它自己说了算——回落到兜底,
83
+ // 而不是让模型指哪打哪。
84
+ test("mint source: without a pipeline source the model-authored one still does not win", () => {
85
+ const { store, service } = setup();
86
+ applyDecisions(
87
+ [{ action: "create", type: "pattern", title: "真模式", content: "规律", importance: 3, source: "human" }],
88
+ service
89
+ );
90
+ const row = service.all().find((m) => m.type === "pattern");
91
+ assert.equal(row.source, "manual", "falls back to the existing default, not the model's claim");
92
+ store.close();
93
+ });
94
+
95
+ // 不传 mintSource 时行为与改前逐字一致(不放大行为面)。
96
+ test("mint source: absent mintSource keeps the previous fallback", () => {
97
+ const { store, service } = setup();
98
+ applyDecisions([{ action: "create", type: "pattern", title: "真模式", content: "规律", importance: 3 }], service);
99
+ const row = service.all().find((m) => m.type === "pattern");
100
+ assert.equal(row.source, "manual");
101
+ store.close();
102
+ });
103
+
104
+ // 空白串按「没给」处理,不能写出 source="" 这种假装有来源的空值。
105
+ test("mint source: blank mintSource is treated as absent", () => {
106
+ const { store, service } = setup();
107
+ applyDecisions(
108
+ [{ action: "create", type: "pattern", title: "真模式", content: "规律", importance: 3 }],
109
+ service,
110
+ null,
111
+ null,
112
+ { mintSource: " " }
113
+ );
114
+ const row = service.all().find((m) => m.type === "pattern");
115
+ assert.equal(row.source, "manual");
116
+ store.close();
117
+ });
118
+
119
+ // ------------------------------------------------------------------ end to end
120
+
121
+ // sleep 的 pattern 相位(唯一的真实 mint 路径)端到端:断言落库行的 source。
122
+ // 不能靠等下一次做梦验收(要调度 + LLM 路由),所以在这里驱动相位。
123
+ function sleepCtx(decisionsJson) {
124
+ return {
125
+ logger: { warn: () => {}, info: () => {} },
126
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
127
+ llm: {
128
+ async *stream(options) {
129
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
130
+ yield { type: "text-delta", index: 0, text: userText.startsWith("id=") ? decisionsJson : "[]" };
131
+ yield { type: "finish", reason: { kind: "stop" } };
132
+ }
133
+ }
134
+ };
135
+ }
136
+
137
+ test("mint source: sleep pattern phase mints with source=sleep", async () => {
138
+ const { store, service } = setup();
139
+ mint(service);
140
+ mint(service);
141
+ const ctx = sleepCtx(
142
+ JSON.stringify([{ action: "create", type: "pattern", title: "真模式", content: "从 a 和 b 提取", importance: 3 }])
143
+ );
144
+ const config = {
145
+ sleepModeEnabled: true,
146
+ sleepPatternMinMemories: 10,
147
+ sleepMaxPatternPerRun: 3
148
+ };
149
+ const result = await runSleep(ctx, service, config, ctx.logger, null, null);
150
+ assert.equal(result.phases.patterns.status, "ok", "pattern phase minted");
151
+
152
+ const row = service.all().find((m) => m.type === "pattern");
153
+ assert.ok(row, "pattern minted");
154
+ assert.equal(row.source, "sleep", "the sleep phase declares its own provenance");
155
+ store.close();
156
+ });
157
+
158
+ test("mint source: dream main chain mints with source=dream", async () => {
159
+ const { store, service } = setup();
160
+ const seed = service.saveWithDedupe({ type: "project", title: "旧条目", content: "第一段内容", importance: 3 }).memory;
161
+ let call = 0;
162
+ const ctx = {
163
+ logger: { warn: () => {}, info: () => {} },
164
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
165
+ llm: {
166
+ async *stream(options) {
167
+ call++;
168
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
169
+ const text = call === 1
170
+ ? JSON.stringify([
171
+ { action: "keep", ids: [seed.id] },
172
+ { action: "create", type: "pattern", title: "主链路模式", content: "巩固时发现的规律", importance: 3 }
173
+ ])
174
+ : "记忆库总览摘要文本";
175
+ void userText;
176
+ yield { type: "text-delta", text };
177
+ yield { type: "finish", reason: { kind: "ok" } };
178
+ }
179
+ }
180
+ };
181
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
182
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
183
+ assert.equal(result.ok, true);
184
+
185
+ const row = service.all().find((m) => m.type === "pattern");
186
+ assert.ok(row, "the create decision minted a row");
187
+ assert.equal(row.source, "dream");
188
+ store.close();
189
+ });
190
+
191
+ // 验收口径本身:全表分组统计随之可读——同一次运行 mint 的条目能按来源数出来。
192
+ test("mint source: rows are countable by source after minting", () => {
193
+ const { store, service } = setup();
194
+ applyDecisions([{ action: "create", type: "pattern", title: "甲", content: "规律甲", importance: 3 }], service, null, null, { mintSource: "sleep" });
195
+ applyDecisions([{ action: "create", type: "pattern", title: "乙", content: "规律乙", importance: 3 }], service, null, null, { mintSource: "dream" });
196
+ const bySource = new Map();
197
+ for (const m of service.all()) bySource.set(m.source, (bySource.get(m.source) ?? 0) + 1);
198
+ assert.equal(bySource.get("sleep"), 1);
199
+ assert.equal(bySource.get("dream"), 1);
200
+ assert.equal(bySource.get("manual"), undefined, "nothing minted here fell back to the default");
201
+ store.close();
202
+ });