@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
package/test/api.test.js CHANGED
@@ -676,8 +676,15 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
676
676
  // issue #339 新增 dreamMergeGuard、
677
677
  // issue #218/E5 新增 injectHeatEnabled、
678
678
  // issue #164 A2 新增 sensitiveScanEnabled(顶层扁平键,走 configFlagValue 默认分支)、
679
- // issue #380 新增 preInjectGate.enabled/preInjectGate.enforce(注入前判定两级开关))
680
- assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 2);
679
+ // issue #380 新增 preInjectGate.enabled/preInjectGate.enforce(注入前判定两级开关)、
680
+ // #135 成本审计派生新增 dreamMaxMergePerRun(单轮 merge 决策上限))
681
+ // issue #399 新增 distillRecords.enabled/distillRecords.retentionDays(蒸馏原文留存 +
682
+ // 溯源:开关与保留期都在嵌套对象里,两个都进 NESTED_FLAG_PATHS))
683
+ // v0.9.0 新增 actionPipelineEnabled(M.O. beta 门控;顶层扁平键,
684
+ // 走 configFlagValue 默认分支,不需要 NESTED_FLAG_PATHS 条目)。
685
+ // 计数锁钉的是「白名单键 ↔ config schema 成对」:schema 加了键而漏了白名单
686
+ // (或反过来)时,effective 会静默少键/多键,面板读不到也报不出来。
687
+ assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 2 + 1 + 2 + 1);
681
688
  assert.equal(data.effective.dreamSkipInvalid, true);
682
689
  assert.equal(data.effective.allowCrossTypeMerge, false);
683
690
  assert.equal(data.effective.dreamMergeGuard, true);
@@ -688,6 +695,7 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
688
695
  assert.equal(data.effective.dreamMinIntervalMinutes, 0);
689
696
  assert.equal(data.effective.autoDreamFailureBackoff, false);
690
697
  assert.equal(data.effective.dreamMaxTokens, 131072);
698
+ assert.equal(data.effective.dreamMaxMergePerRun, 40);
691
699
  assert.equal(data.effective.sleepProvider, "");
692
700
  assert.equal(data.effective.sleepModel, "");
693
701
  assert.equal(data.effective.autoInject, true);
@@ -706,6 +714,13 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
706
714
  assert.equal(data.effective["writeAdmission.enforce"], false);
707
715
  // #164 A2:密钥/PII 判据自己的闸,同样默认关(#332 的行为逐字节保留)。
708
716
  assert.equal(data.effective.sensitiveScanEnabled, false);
717
+ // #399:蒸馏原文留存默认关(这是一份对话原文副本,不该由升级默认打开),
718
+ // 保留期默认 90 天(与 llmAudit.retentionDays 同款)。
719
+ assert.equal(data.effective["distillRecords.enabled"], false);
720
+ assert.equal(data.effective["distillRecords.retentionDays"], 90);
721
+ // v0.9.0:M.O. beta 门控默认关——客户端 GET /features 必须能读到这个键
722
+ // (读不到就静默不注册任何座位),且默认值必须是 false(beta opt-in)。
723
+ assert.equal(data.effective.actionPipelineEnabled, false);
709
724
  // 新增字符串 / URL / 枚举键
710
725
  assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
711
726
  assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
@@ -738,14 +753,16 @@ test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists"
738
753
  const { routes, settings } = setup(undefined, "", FLAGS_CFG);
739
754
  const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
740
755
  const res = new FakeRes();
741
- await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000 }), res);
756
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true }), res);
742
757
  assert.equal(res.statusCode, 200);
743
758
  const data = JSON.parse(res.body);
744
- assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000 });
759
+ assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true });
745
760
  assert.equal(data.effective.autoDream, false, "override wins over config default");
746
761
  assert.equal(data.effective.distillMaxChars, 48000);
762
+ // v0.9.0:默认关的键也能被 PUT 打开并读回(beta 功能的启用路径)。
763
+ assert.equal(data.effective.actionPipelineEnabled, true, "override flips the beta gate on");
747
764
  assert.equal(data.effective.autoInject, true, "keys not in the patch still report config");
748
- assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000 });
765
+ assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true });
749
766
  });
750
767
 
751
768
  test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys", async () => {
@@ -763,6 +780,10 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
763
780
  // #380:同款点号键往返——白名单与 NESTED_FLAG_PATHS 成对缺一即静默缺键。
764
781
  "preInjectGate.enabled": true,
765
782
  "preInjectGate.enforce": true,
783
+ // #399:布尔与整数范围两类点号键都往返一次(retentionDays 是 int,同样必须进
784
+ // NESTED_FLAG_PATHS——否则 configFlagValue 落到顶层同名键上,effective 静默缺键)。
785
+ "distillRecords.enabled": true,
786
+ "distillRecords.retentionDays": 30,
766
787
  // #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返;
767
788
  // 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。
768
789
  sensitiveScanEnabled: true,
@@ -782,6 +803,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
782
803
  "writeAdmission.enforce": true,
783
804
  "preInjectGate.enabled": true,
784
805
  "preInjectGate.enforce": true,
806
+ "distillRecords.enabled": true,
807
+ "distillRecords.retentionDays": 30,
785
808
  sensitiveScanEnabled: true,
786
809
  embedProvider: "local",
787
810
  ollamaBaseUrl: "http://127.0.0.1:11434",
@@ -796,6 +819,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
796
819
  // #380:往返回来的覆盖值压过 bundle 配置默认
797
820
  assert.equal(data.effective["preInjectGate.enabled"], true);
798
821
  assert.equal(data.effective["preInjectGate.enforce"], true);
822
+ assert.equal(data.effective["distillRecords.enabled"], true);
823
+ assert.equal(data.effective["distillRecords.retentionDays"], 30, "整数点号键也要读得回来");
799
824
  assert.equal(data.effective.sensitiveScanEnabled, true);
800
825
  assert.equal(data.effective.embedProvider, "local");
801
826
  assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
@@ -1162,6 +1187,13 @@ test("GET /api/dsh-mneme/conflicts joins both sides of unresolved conflicts", as
1162
1187
  );
1163
1188
  assert.equal(data.items[0].memory_a.archived, false);
1164
1189
  assert.equal(data.items[0].memory_b.archived, false);
1190
+ // 裁决依据(冲突窗可读性报障):两侧必须带新旧时间戳,否则面板只能显示
1191
+ // A 方/B 方两个标签,用户没有客观依据判断该留哪条。
1192
+ for (const key of ["memory_a", "memory_b"]) {
1193
+ assert.equal(typeof data.items[0][key].created_at, "string", `${key} must carry created_at`);
1194
+ assert.equal(typeof data.items[0][key].updated_at, "string", `${key} must carry updated_at`);
1195
+ assert.ok(!Number.isNaN(Date.parse(data.items[0][key].updated_at)), `${key}.updated_at must parse`);
1196
+ }
1165
1197
  // 幽灵侧容忍缺失
1166
1198
  service.saveConflictPending({ memory_a: a.id, memory_b: "ghost-id", reason: "对方已删" });
1167
1199
  const res2 = new FakeRes();
@@ -1412,8 +1444,12 @@ const MOCK_LLM = {
1412
1444
  if (provider === "broken") throw new Error("provider not reachable");
1413
1445
  return [{ id: "deepseek-chat", name: "DeepSeek Chat" }, { id: "deepseek-reasoner", name: "DeepSeek Reasoner" }];
1414
1446
  },
1447
+ // 每次 stream 的 options 留档:既给「字段省略 / 透传」的断言用,也让「档位被拒
1448
+ // 后重试一次」能数出调用次数(降级必须恰好两次,多一次就是盲重试)。
1449
+ streamCalls: [],
1415
1450
  async *stream(options) {
1416
1451
  MOCK_LLM.lastOptions = options;
1452
+ MOCK_LLM.streamCalls.push(options);
1417
1453
  if (options.model === "bad-model") {
1418
1454
  yield {
1419
1455
  type: "finish",
@@ -1421,6 +1457,26 @@ const MOCK_LLM = {
1421
1457
  };
1422
1458
  return;
1423
1459
  }
1460
+ // 流级档位拒收:rc.1 起的适配器把 provider 的拒收折叠成终止 error finish
1461
+ // chunk(不抛错),这条路径曾让 withEffortFallback 的重试形同死代码。
1462
+ if (options.model === "stream-reject" && options.reasoningEffort) {
1463
+ yield {
1464
+ type: "finish",
1465
+ reason: {
1466
+ kind: "error",
1467
+ failure: {
1468
+ code: "UNSUPPORTED_REASONING_EFFORT",
1469
+ message: `provider "mock" model "mock-model" does not support reasoning effort "${options.reasoningEffort}"`
1470
+ }
1471
+ }
1472
+ };
1473
+ return;
1474
+ }
1475
+ // 抛错型真失败(网络中断 / provider 崩溃):错误以异常形式冒到 catch 路径,
1476
+ // 必须原样上报——「档位被拒才重试」不能退化成「见到错误就重试」。
1477
+ if (options.model === "crash-model") {
1478
+ throw new Error("socket hang up");
1479
+ }
1424
1480
  if (options.reasoningEffort === "reject") {
1425
1481
  throw new Error('UNSUPPORTED_REASONING_EFFORT: provider "mock" model "mock-model" does not support reasoning effort "reject"');
1426
1482
  }
@@ -1494,15 +1550,122 @@ test("POST /api/dsh-mneme/test-model reports stream-level failure with 502", asy
1494
1550
  assert.match(data.error, /AUTH_FAILED/, "stream failure reason surfaced like audit rows");
1495
1551
  });
1496
1552
 
1497
- test("POST /api/dsh-mneme/test-model reports thrown failure with 502", async () => {
1553
+ // issue #215 的后续:探针是仓里唯一「接受并转发调用方档位、却此前没有兜底」的
1554
+ // LLM 调用面(直调 llm.stream 的其余各面要么在 withEffortFallback 之内,要么
1555
+ // 是 src/index.js 的手写版兜底,要么像 pre-inject-gate 的判定池那样不转发档位),
1556
+ // 档位被拒则丢掉字段重试一次,于是同一份配置「后台跑得通、点测试报红」,用户被
1557
+ // 引向「换哪家 Provider 都失败」。探针必须与真实 run 同语义:降级跑通 → 200 +
1558
+ // effortDropped:true(不是 502)。
1559
+ test("POST /api/dsh-mneme/test-model degrades on a rejected effort instead of failing (issue #215 follow-up)", async () => {
1560
+ const { routes } = setupLlm(MOCK_LLM);
1561
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1562
+ const before = MOCK_LLM.streamCalls.length;
1563
+ const res = new FakeRes();
1564
+ await route.handler(
1565
+ req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }),
1566
+ res
1567
+ );
1568
+ assert.equal(res.statusCode, 200, "a rejected effort is a downgrade, not a connectivity failure");
1569
+ const data = JSON.parse(res.body);
1570
+ assert.equal(data.ok, true);
1571
+ assert.equal(data.reply, "ok", "the no-effort retry actually answered");
1572
+ assert.equal(data.effortDropped, true, "the caller must be able to tell 'connected, effort dropped' from 'connected as configured'");
1573
+ const calls = MOCK_LLM.streamCalls.slice(before);
1574
+ assert.equal(calls.length, 2, "exactly one retry: attempt with the effort, then once without");
1575
+ assert.equal(calls[0].reasoningEffort, "reject", "first attempt carries the configured effort");
1576
+ assert.equal("reasoningEffort" in calls[1], false, "the retry omits the field entirely");
1577
+ });
1578
+
1579
+ test("POST /api/dsh-mneme/test-model degrades on a STREAM-level effort rejection too", async () => {
1580
+ // rc.1 起适配器不抛错,把 provider 的拒收折叠成终止 error finish chunk——只在
1581
+ // catch 里做兜底的话这条路径就是死的,探针会照旧 502。
1582
+ const { routes } = setupLlm(MOCK_LLM);
1583
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1584
+ const before = MOCK_LLM.streamCalls.length;
1585
+ const res = new FakeRes();
1586
+ await route.handler(
1587
+ req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "stream-reject", reasoningEffort: "high" }),
1588
+ res
1589
+ );
1590
+ assert.equal(res.statusCode, 200, "stream-level rejection also downgrades");
1591
+ const data = JSON.parse(res.body);
1592
+ assert.equal(data.ok, true);
1593
+ assert.equal(data.effortDropped, true);
1594
+ const calls = MOCK_LLM.streamCalls.slice(before);
1595
+ assert.equal(calls.length, 2, "stream rejection retries once without the effort");
1596
+ assert.equal("reasoningEffort" in calls[1], false);
1597
+ });
1598
+
1599
+ test("POST /api/dsh-mneme/test-model keeps real failures red and never reports a phantom downgrade", async () => {
1600
+ // 非档位原因的失败绝不能被兜底吞掉:真连不上还得是 502、还得带原始原因。
1498
1601
  const { routes } = setupLlm(MOCK_LLM);
1499
1602
  const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1603
+ const before = MOCK_LLM.streamCalls.length;
1500
1604
  const res = new FakeRes();
1501
- await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }), res);
1605
+ await route.handler(
1606
+ req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "bad-model", reasoningEffort: "low" }),
1607
+ res
1608
+ );
1609
+ assert.equal(res.statusCode, 502);
1610
+ const data = JSON.parse(res.body);
1611
+ assert.equal(data.ok, false);
1612
+ assert.match(data.error, /AUTH_FAILED/);
1613
+ assert.equal(data.effortDropped, undefined, "a genuine failure is not a downgrade");
1614
+ assert.equal(MOCK_LLM.streamCalls.length - before, 1, "non-effort failures are not retried blindly");
1615
+ });
1616
+
1617
+ test("POST /api/dsh-mneme/test-model reports a thrown transport failure as-is (catch path)", async () => {
1618
+ // 错误以异常形式到达时(socket 断开 / provider 崩溃)走 catch 路径:谎报成功不行,
1619
+ // 拿 without-effort 的裸奔重试去掩盖真故障更不行——「档位被拒才重试」不能退化成
1620
+ // 「见到错误就重试」,否则真故障会被一次盲重试糊住(还多烧一次配额)。
1621
+ const { routes } = setupLlm(MOCK_LLM);
1622
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1623
+ const before = MOCK_LLM.streamCalls.length;
1624
+ const res = new FakeRes();
1625
+ await route.handler(
1626
+ req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "crash-model", reasoningEffort: "low" }),
1627
+ res
1628
+ );
1502
1629
  assert.equal(res.statusCode, 502);
1503
1630
  const data = JSON.parse(res.body);
1504
1631
  assert.equal(data.ok, false);
1505
- assert.match(data.error, /UNSUPPORTED_REASONING_EFFORT/, "throw path surfaces the harness rejection");
1632
+ assert.match(data.error, /socket hang up/, "the thrown cause reaches the panel verbatim");
1633
+ assert.equal(data.effortDropped, undefined, "a thrown failure is not a downgrade");
1634
+ assert.equal(MOCK_LLM.streamCalls.length - before, 1, "a thrown failure is not retried blindly either");
1635
+ });
1636
+
1637
+ test("POST /api/dsh-mneme/test-model omits effortDropped when no effort was configured", async () => {
1638
+ const { routes } = setupLlm(MOCK_LLM);
1639
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
1640
+ const res = new FakeRes();
1641
+ await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
1642
+ assert.equal(res.statusCode, 200);
1643
+ const data = JSON.parse(res.body);
1644
+ assert.equal(data.effortDropped, undefined, "no effort configured -> nothing was dropped");
1645
+ });
1646
+
1647
+ // 防分叉静态锁(与 #405 的归一逻辑同款):档位拒收的判据与降级语义全仓只有一份
1648
+ // ——dream.js 的 EFFORT_REJECT_RE + withEffortFallback。api.js 手写正则或自己做
1649
+ // 重试,两条路必然漂移(漂移方向就是「探针与服务端行为不一致」,本次修的就是这个)。
1650
+ test("api.js reuses the single effort-rejection source instead of forking it", () => {
1651
+ const src = readFileSync(new URL("../src/api.js", import.meta.url), "utf8");
1652
+ assert.match(
1653
+ src,
1654
+ /import \{[^}]*withEffortFallback[^}]*\} from "\.\/dream\.js"/,
1655
+ "the probe must reuse dream.js's withEffortFallback instead of hand-rolling a retry"
1656
+ );
1657
+ assert.match(
1658
+ src,
1659
+ /import \{[^}]*EFFORT_REJECT_RE[^}]*\} from "\.\/dream\.js"/,
1660
+ "the rejection predicate must come from dream.js (single source), not a local regex"
1661
+ );
1662
+ assert.ok(
1663
+ !/\/reasoning\[/.test(src),
1664
+ "api.js must not carry its own copy of the effort-rejection pattern"
1665
+ );
1666
+ // 降级语义只能有一处:探针内不得再出现第二个「无档位重试」判据。
1667
+ const attempts = src.split("withEffortFallback(").length - 1;
1668
+ assert.equal(attempts, 1, `the probe must route through withEffortFallback exactly once (got ${attempts})`);
1506
1669
  });
1507
1670
 
1508
1671
  test("POST /api/dsh-mneme/test-model resolves empty body against the consolidation route", async () => {
@@ -0,0 +1,345 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ decideDetour,
8
+ scanIrreversible,
9
+ projectKey,
10
+ candidateDir,
11
+ candidateId,
12
+ validateCandidate,
13
+ loadCandidates,
14
+ saveCandidate,
15
+ recordDetour,
16
+ maybePropose,
17
+ archiveStale,
18
+ promoteCandidate,
19
+ summarizeCandidate,
20
+ readCandidateDoc,
21
+ removeCandidate,
22
+ IRREVERSIBLE_PATTERNS,
23
+ PROMOTE_MIN_SEEN,
24
+ STALE_AFTER_DAYS
25
+ } from "../src/candidates.js";
26
+
27
+ // 这一组守的是 M.O. 的**候选层与绕行判定**——这一版解决的是「刻板的图会让 agent 失去自主性」。
28
+ // 核心规则一句话:自主性住在 R0/R1,刚性只住在它必须住的地方。
29
+ // 里面有几条是**踩过坑才写下的**(子命令误拦、requires/forbids 语义搞反),
30
+ // 所以那些用例不是举例子,是回归锁。
31
+
32
+ function withDir(fn) {
33
+ const dir = mkdtempSync(join(tmpdir(), "mo-cand-"));
34
+ try {
35
+ return fn(dir);
36
+ } finally {
37
+ rmSync(dir, { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ const STEPS = [
42
+ { title: "看仓库干不干净", when: "发版前", do: "git status", done: "无未提交", risk: "R0" },
43
+ { title: "跳过 release-prep", when: "上一次已 bump", do: "手工改版本号", done: "版本号对", risk: "R1" }
44
+ ];
45
+
46
+ const okCandidate = (over = {}) => ({
47
+ id: "cand-abcdef123456",
48
+ origin: "detour",
49
+ steps: STEPS,
50
+ reason: "仓库干净,release-prep 这步多余",
51
+ counterfactual: null,
52
+ seen: [],
53
+ status: "collecting",
54
+ createdAt: new Date().toISOString(),
55
+ ...over
56
+ });
57
+
58
+ // ── 分档:B 档的核心规则 ────────────────────────────────────────────────────
59
+
60
+ test("R0/R1 且不跨闸门、兜底未命中 → 记账先走(自主性住在这里)", () => {
61
+ assert.equal(decideDetour({ risk: "R0", actions: ["gh pr view 438", "ls"] }).decidedBy, "model");
62
+ assert.equal(decideDetour({ risk: "R1", actions: ["npm test", "git status"] }).decidedBy, "model");
63
+ assert.equal(decideDetour({ risk: "R1", actions: [] }).decidedBy, "model");
64
+ });
65
+
66
+ test("R2/R3 → 停下等点头(刚性只住在这里)", () => {
67
+ assert.equal(decideDetour({ risk: "R2", actions: [] }).decidedBy, "human");
68
+ assert.equal(decideDetour({ risk: "R3", actions: [] }).decidedBy, "human");
69
+ });
70
+
71
+ test("兜底清单不看自报:自报 R0 但命令里有对外动作,照样拦", () => {
72
+ // 这条是「模型在自己上下文里给自己打分不可靠」的直接应用——不给单点判断。
73
+ const r = decideDetour({ risk: "R0", actions: ["git push origin main"] });
74
+ assert.equal(r.decidedBy, "human");
75
+ assert.equal(r.risk, "R2", "命中兜底时风险档要按较高者记,结算卡上才看得准");
76
+ assert.deepEqual(r.scan.matches.map((m) => m.id), ["git-push"]);
77
+ });
78
+
79
+ test("自报非法风险档 → 按最保守处理,不是放行", () => {
80
+ // 一个模型说「我的风险是 R9」时,正确的反应是别信它,而不是当成没风险。
81
+ assert.equal(decideDetour({ risk: "R9", actions: [] }).decidedBy, "human");
82
+ assert.equal(decideDetour({ risk: undefined, actions: [] }).decidedBy, "model");
83
+ });
84
+
85
+ test("闸门保护与风险档无关:纯只读绕行只要跨闸门也停下", () => {
86
+ // 闸门本来就放在「错了很难撤」的地方,绕开它等于把安全阀绕掉——
87
+ // 哪怕这条绕行本身全是只读动作。
88
+ const r = decideDetour({ risk: "R0", actions: ["ls"], crossesGate: true });
89
+ assert.equal(r.decidedBy, "human");
90
+ assert.match(r.reason, /闸门/);
91
+ });
92
+
93
+ // ── 兜底清单:子命令必须分得开 ──────────────────────────────────────────────
94
+
95
+ test("兜底清单分得清只读与写:同一工具的子命令,性质相反", () => {
96
+ // 回归锁:第一版把工具名整条当动作,`gh pr view`(只看一眼 PR)也被拦下了,
97
+ // 直接毁掉「自主性住在 R0/R1」。工具名 + 子命令才是动作。
98
+ const readOnly = [
99
+ "gh pr view 438", "gh pr list", "gh pr checks", "gh pr diff",
100
+ "gh issue view 12", "gh release view v1", "gh auth status",
101
+ "gh api repos/x/y", "gh repo view", "gh run list",
102
+ "git tag -l", "git status", "npm test",
103
+ "curl https://example.com", "Remove-Item x.txt"
104
+ ];
105
+ for (const cmd of readOnly) {
106
+ assert.equal(scanIrreversible([cmd]).hit, false, `只读的 ${cmd} 不该被拦`);
107
+ }
108
+ const writes = [
109
+ "git push origin main", "git tag v1.0.0", "git tag -d v1",
110
+ "gh pr create --title x", "gh pr merge 438", "gh pr close 438",
111
+ "gh issue create -t x", "gh release create v1",
112
+ "gh api -X POST /repos/x/y", "gh api -f name=v",
113
+ "gh auth login", "gh secret set FOO", "gh repo edit", "gh workflow run ci.yml",
114
+ "npm publish", "yarn publish", "pnpm publish",
115
+ "curl -X POST https://x.com", "curl --data a=1 https://x.com",
116
+ "Remove-Item -Recurse -Force x", "rm -rf build", "drop table memories"
117
+ ];
118
+ for (const cmd of writes) {
119
+ assert.equal(scanIrreversible([cmd]).hit, true, `要写的 ${cmd} 必须被拦`);
120
+ }
121
+ });
122
+
123
+ test("requires 与 forbids 的语义不能混:默认安全的动作只能用 requires", () => {
124
+ // 回归锁:`gh api` 不写方法时是 GET(安全),只有显式写方法与字段才危险。
125
+ // 用 forbids 表达「默认安全」是错的——不写方法时两个特征都不出现,
126
+ // forbids 不成立、规则照样命中,于是只读调用被误拦。
127
+ const api = IRREVERSIBLE_PATTERNS.find((p) => p.id === "gh-api-write");
128
+ assert.ok(api && Array.isArray(api.requires), "gh api 那条必须用 requires");
129
+ assert.equal(api.forbids, undefined, "同一语义不能既用 requires 又用 forbids");
130
+ // 而「默认危险、列出只读例外」的动作用 forbids。
131
+ const pr = IRREVERSIBLE_PATTERNS.find((p) => p.id === "gh-pr-write");
132
+ assert.ok(pr && Array.isArray(pr.forbids));
133
+ });
134
+
135
+ test("扫多条动作:任一命中即算命中,同一条不重复算", () => {
136
+ const r = scanIrreversible(["ls", "git push", "git push --tags"]);
137
+ assert.equal(r.hit, true);
138
+ assert.equal(r.matches.filter((m) => m.id === "git-push").length, 1);
139
+ assert.equal(scanIrreversible([]).hit, false);
140
+ assert.equal(scanIrreversible(null).hit, false);
141
+ });
142
+
143
+ // ── 候选的住址与身份 ───────────────────────────────────────────────────────
144
+
145
+ test("项目键稳定、不含绝对路径(文件名里不能有本机用户名)", () => {
146
+ const a = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme");
147
+ const b = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme");
148
+ const other = projectKey("C:\\Users\\someone\\Desktop\\another-project");
149
+ assert.equal(a, b, "同一个项目每次算到同一个键");
150
+ assert.notEqual(a, other, "不同项目必须分开");
151
+ assert.equal(a.includes("someone"), false, "不能把用户名带进文件名");
152
+ assert.ok(a.startsWith("dsh-mneme-"), "保留目录名便于人读");
153
+ assert.equal(projectKey(""), null);
154
+ assert.equal(projectKey(null), null);
155
+ });
156
+
157
+ test("项目键对两种分隔符一视同仁:Windows 路径不得退化成整条路径", () => {
158
+ // 回归锁,指向一个**只在 posix 上现形**的缺陷:原实现借 path.basename() 取目录名,
159
+ // 它跟着运行平台走——Linux 上 `C:\Users\someone\Desktop\dsh-mneme` 整条被当成
160
+ // 文件名,清洗后 C--Users-someone-Desktop-dsh-mne,用户名的残片进了目录名。
161
+ // 这正是 PR #439 里 ubuntu 四腿全红、windows 两腿全绿的那条。
162
+ //
163
+ // **这条锁在 Windows 上是绿的,且永远抓不住回退**:win32.basename 两种分隔符都认,
164
+ // 旧实现在本机结果就是对的。它的杀伤力只在 ubuntu 矩阵腿上——别删了它以为没事。
165
+ const nameOf = (key) => key.replace(/-[0-9a-f]{8}$/, "");
166
+ const bs = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme");
167
+ const fwd = projectKey("C:/Users/someone/Desktop/dsh-mneme");
168
+ const trail = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme\\");
169
+ assert.equal(nameOf(bs), "dsh-mneme", "反斜杠路径要取到目录名");
170
+ assert.equal(nameOf(fwd), "dsh-mneme", "正斜杠路径要取到目录名");
171
+ assert.equal(nameOf(trail), "dsh-mneme", "尾随分隔符不改变结果");
172
+ assert.equal(bs.includes("someone"), false, "用户名不得进目录名");
173
+ assert.equal(bs.includes("-Users-"), false, "整条路径不得被清洗成目录名");
174
+ assert.match(bs, /^[A-Za-z0-9._-]+$/, "目录名只许出现安全字符");
175
+ });
176
+
177
+
178
+ test("候选的家在 memoryDir 下、按项目分桶;拿不到项目根就没有家", () => {
179
+ const dir = candidateDir({ memoryDir: "~/.dsh/memory", projectRoot: "C:\\x\\y" });
180
+ assert.ok(dir && dir.includes("candidates"), "落在 candidates/ 下");
181
+ assert.equal(dir.includes("C:\\x\\y"), false, "路径不进字符串");
182
+ assert.equal(candidateDir({ memoryDir: "/tmp/m", projectRoot: null }), null);
183
+ });
184
+
185
+ test("候选 id 由步骤形状决定:同一段路走两次会落到同一条上", () => {
186
+ // 这正是「复用第二次才算类型,第一次只是实例」的落点——
187
+ // 如果每次绕行都生成新 id,第二次永远并不到第一次上,门槛就永远够不着。
188
+ const id1 = candidateId(STEPS);
189
+ const id2 = candidateId(STEPS.map((s) => ({ ...s })));
190
+ const id3 = candidateId([STEPS[0], { ...STEPS[1], title: "换了个做法" }]);
191
+ assert.equal(id1, id2);
192
+ assert.notEqual(id1, id3);
193
+ assert.match(id1, /^cand-[0-9a-f]{12}$/);
194
+ });
195
+
196
+ // ── 校验:候选松,但 reason 一个都不能少 ────────────────────────────────────
197
+
198
+ test("候选比定稿松,但「写不出理由就不许绕」是硬要求", () => {
199
+ assert.equal(validateCandidate(okCandidate()).ok, true);
200
+ // 定稿要求 id/name/project/version/status/done/trigger 一个不缺;候选只要这四样。
201
+ const minimal = { id: "cand-abcdef123456", origin: "manual", steps: STEPS, reason: "为什么", seen: [], status: "collecting" };
202
+ assert.equal(validateCandidate(minimal).ok, true, "候选不该要求定稿那一整套字段");
203
+
204
+ const noReason = validateCandidate(okCandidate({ reason: " " }));
205
+ assert.equal(noReason.ok, false);
206
+ assert.ok(noReason.problems.some((p) => p.includes("reason 必填")));
207
+
208
+ assert.equal(validateCandidate(okCandidate({ id: "bad-id" })).ok, false);
209
+ assert.equal(validateCandidate(okCandidate({ origin: "telepathy" })).ok, false);
210
+ assert.equal(validateCandidate(okCandidate({ status: "whatever" })).ok, false);
211
+ assert.equal(validateCandidate(okCandidate({ steps: [] })).ok, false);
212
+ assert.equal(validateCandidate(okCandidate({ steps: [{ title: "x" }] })).ok, false, "步骤缺字段要报出来");
213
+ assert.equal(validateCandidate(null).ok, false);
214
+ });
215
+
216
+ test("坏文件不静默跳过:与定稿同一纪律", () => {
217
+ withDir((dir) => {
218
+ saveCandidate(dir, okCandidate());
219
+ assert.equal(loadCandidates(dir).length, 1);
220
+ rmSync(join(dir, "cand-abcdef123456.json"));
221
+ writeFileSync(join(dir, "broken.json"), "{ not json", "utf8");
222
+ assert.throws(() => loadCandidates(dir), /broken\.json: JSON 解析失败/);
223
+ });
224
+ });
225
+
226
+ // ── 绕行 → 候选:两次才算数 ─────────────────────────────────────────────────
227
+
228
+ test("第一次绕行:落成候选,但还够不着提议", () => {
229
+ withDir((dir) => {
230
+ const r = recordDetour(dir, { steps: STEPS, reason: "仓库干净,这步多余", outcome: "worked", evidence: ["测试全绿"], runId: "r1" });
231
+ assert.equal(r.created, true);
232
+ assert.equal(r.promotedNow, false);
233
+ assert.equal(r.candidate.status, "collecting");
234
+ assert.equal(r.candidate.seen.length, 1);
235
+ assert.equal(readdirSync(dir).filter((f) => f.endsWith(".json")).length, 1);
236
+ });
237
+ });
238
+
239
+ test(`第二次走同一段路:并进同一条,翻成 proposed(门槛 ${PROMOTE_MIN_SEEN} 次)`, () => {
240
+ withDir((dir) => {
241
+ recordDetour(dir, { steps: STEPS, reason: "同上", outcome: "worked", evidence: ["绿"], runId: "r1" });
242
+ const b = recordDetour(dir, { steps: STEPS, reason: "同上", outcome: "worked", evidence: ["又绿"], runId: "r2" });
243
+ assert.equal(b.created, false, "不该新建——同一段路只有一条候选");
244
+ assert.equal(b.promotedNow, true);
245
+ assert.equal(b.candidate.status, "proposed");
246
+ assert.equal(b.candidate.seen.length, 2);
247
+ assert.equal(readdirSync(dir).filter((f) => f.endsWith(".json")).length, 1);
248
+ });
249
+ });
250
+
251
+ test("两次里只要有一次没跑通,就够不着提议", () => {
252
+ // 「复用第二次才算类型」的前提是**那次真的走通了**;两次都失败的路不该被提议进图。
253
+ const c = okCandidate({ seen: [{ outcome: "failed" }, { outcome: "unknown" }] });
254
+ assert.equal(maybePropose(c), false);
255
+ assert.equal(c.status, "collecting");
256
+ const d = okCandidate({ seen: [{ outcome: "failed" }, { outcome: "worked" }] });
257
+ assert.equal(maybePropose(d), true, "有一次跑通就算走通了这条路");
258
+ });
259
+
260
+ test("失败也留痕:走不通是知识,不是垃圾", () => {
261
+ withDir((dir) => {
262
+ recordDetour(dir, { steps: STEPS, reason: "试试", outcome: "failed", evidence: ["C2 直接报错"] });
263
+ const [c] = loadCandidates(dir);
264
+ assert.equal(c.seen[0].outcome, "failed");
265
+ assert.deepEqual(c.seen[0].evidence, ["C2 直接报错"]);
266
+ const row = summarizeCandidate(c);
267
+ assert.equal(row.failed, 1);
268
+ assert.equal(row.worked, 0);
269
+ });
270
+ });
271
+
272
+ // ── 归档与升格 ─────────────────────────────────────────────────────────────
273
+
274
+ test(`只走过一次、${STALE_AFTER_DAYS} 天没再被走到 → 归档;归档不删文件`, () => {
275
+ withDir((dir) => {
276
+ const old = okCandidate({
277
+ seen: [{ outcome: "worked", at: new Date(Date.now() - (STALE_AFTER_DAYS + 10) * 86400000).toISOString() }]
278
+ });
279
+ saveCandidate(dir, old);
280
+ const archived = archiveStale(dir, {});
281
+ assert.deepEqual(archived, [old.id]);
282
+ const after = loadCandidates(dir).find((c) => c.id === old.id);
283
+ assert.equal(after.status, "dropped");
284
+ assert.match(after.droppedReason, /没再被走到/);
285
+ assert.ok(readdirSync(dir).some((f) => f.startsWith(old.id)), "文件还在——留着当负清单的邻居");
286
+ });
287
+ });
288
+
289
+ test("归档只碰 collecting:够格的和已归档的都不动", () => {
290
+ withDir((dir) => {
291
+ const proposed = okCandidate({ id: "cand-proposed0001", status: "proposed", seen: [{ outcome: "worked", at: new Date(Date.now() - 100 * 86400000).toISOString() }] });
292
+ const fresh = okCandidate({ id: "cand-fresh0000001", seen: [{ outcome: "worked", at: new Date().toISOString() }] });
293
+ saveCandidate(dir, proposed);
294
+ saveCandidate(dir, fresh);
295
+ assert.deepEqual(archiveStale(dir, {}), []);
296
+ });
297
+ });
298
+
299
+ test("升格:补齐字段后必须过定稿的 validatePipeline", () => {
300
+ const cand = okCandidate();
301
+ const good = promoteCandidate(cand, { name: "发版(绕开 release-prep)", project: "slow-stack/mneme", done: "发出去且 registry 对得上" });
302
+ assert.equal(good.ok, true);
303
+ assert.equal(typeof good.pipeline.id, "string");
304
+ assert.equal(good.pipeline.id.startsWith("cand-"), false, "句柄要去掉 cand- 前缀");
305
+ assert.deepEqual(good.pipeline.steps[1].needs, ["S1"], "needs 要串成链,否则定稿那边会报图不连通");
306
+ assert.equal(good.pipeline.version, 0);
307
+ // 升格不是降低标准,是补齐标准:缺 project 照样过不了。
308
+ const bad = promoteCandidate(cand, { name: "缺 project" });
309
+ assert.equal(bad.ok, false);
310
+ assert.ok(bad.problems.some((p) => p.includes("project")));
311
+ });
312
+
313
+ test("升格带上候选的反事实——它是准入判据,不是备注", () => {
314
+ const cand = okCandidate({ counterfactual: "没有它,C 阶段会拿旧版本号发出去" });
315
+ const r = promoteCandidate(cand, { name: "x", project: "a/b" });
316
+ assert.equal(r.ok, true);
317
+ assert.deepEqual(r.pipeline.steps[0].evidence, ["没有它,C 阶段会拿旧版本号发出去"]);
318
+ });
319
+
320
+ // ── 投影与契约 ─────────────────────────────────────────────────────────────
321
+
322
+ test("清单行取整段路里最高的风险档(最坏情况才是它该被看待的档位)", () => {
323
+ const row = summarizeCandidate(okCandidate({ steps: [STEPS[0], STEPS[1], { title: "问人", when: "w", do: "d", done: "x", risk: "R2" }] }));
324
+ assert.equal(row.risk, "R2");
325
+ assert.equal(row.steps, 3);
326
+ });
327
+
328
+ test("契约把「这一刀不做的事」也写进去,免得以后有人以为忘了做", () => {
329
+ withDir((dir) => {
330
+ const doc = readCandidateDoc({ memoryDir: dir, projectRoot: "C:\\x\\y" });
331
+ assert.equal(doc.contract, "mneme.candidates/v1");
332
+ assert.deepEqual(doc.boundaries, { autoPromote: false, autoApprove: false, injectIntoMemory: false });
333
+ assert.deepEqual(doc.counts, { collecting: 0, proposed: 0, promoted: 0, dropped: 0 });
334
+ });
335
+ });
336
+
337
+ test("removeCandidate 只删指定的那条", () => {
338
+ withDir((dir) => {
339
+ saveCandidate(dir, okCandidate());
340
+ saveCandidate(dir, okCandidate({ id: "cand-second000001" }));
341
+ assert.equal(removeCandidate(dir, "cand-abcdef123456"), true);
342
+ assert.equal(loadCandidates(dir).length, 1);
343
+ assert.equal(removeCandidate(dir, "cand-nope00000000"), false);
344
+ });
345
+ });