@modusensus/dsh-mneme 0.9.5 → 0.10.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 (81) hide show
  1. package/README.md +9 -5
  2. package/docs/CONFIGURATION.md +21 -0
  3. package/docs/DAEMON.md +6 -2
  4. package/docs/DISTILL.md +90 -6
  5. package/docs/SLEEP.md +20 -6
  6. package/docs/STORAGE.md +1 -0
  7. package/lib/api-standalone.js +11 -3
  8. package/lib/api.js +25 -5
  9. package/lib/client.js +137 -10
  10. package/lib/config.js +33 -1
  11. package/lib/continuity.js +11 -3
  12. package/lib/dream/decisions.js +88 -27
  13. package/lib/dream/sleep.js +467 -81
  14. package/lib/dream.js +110 -13
  15. package/lib/fence.js +24 -0
  16. package/lib/index.js +15 -6
  17. package/lib/inject.js +9 -4
  18. package/lib/lang.js +17 -12
  19. package/lib/pipeline-nudge-auto.js +3 -0
  20. package/lib/pipeline-nudge.js +41 -8
  21. package/lib/pipeline-runner.js +15 -0
  22. package/lib/pre-inject-gate.js +14 -0
  23. package/lib/profile-extract.js +4 -1
  24. package/lib/provenance-http.js +162 -37
  25. package/lib/service.js +30 -0
  26. package/lib/session-gate.js +91 -0
  27. package/lib/settings.js +13 -1
  28. package/lib/store.js +526 -18
  29. package/lib/summarize.js +152 -15
  30. package/lib/tools.js +26 -6
  31. package/package.json +4 -2
  32. package/src/api-standalone.js +11 -3
  33. package/src/api.js +25 -5
  34. package/src/config.js +33 -1
  35. package/src/continuity.js +11 -3
  36. package/src/dream/decisions.js +88 -27
  37. package/src/dream/sleep.js +467 -81
  38. package/src/dream.js +110 -13
  39. package/src/fence.js +24 -0
  40. package/src/index.js +15 -6
  41. package/src/inject.js +9 -4
  42. package/src/lang.js +17 -12
  43. package/src/pipeline-nudge-auto.js +3 -0
  44. package/src/pipeline-nudge.js +41 -8
  45. package/src/pipeline-runner.js +15 -0
  46. package/src/pre-inject-gate.js +14 -0
  47. package/src/profile-extract.js +4 -1
  48. package/src/provenance-http.js +162 -37
  49. package/src/service.js +30 -0
  50. package/src/session-gate.js +91 -0
  51. package/src/settings.js +13 -1
  52. package/src/store.js +526 -18
  53. package/src/summarize.js +152 -15
  54. package/src/tools.js +26 -6
  55. package/test/api.test.js +153 -4
  56. package/test/client.test.js +215 -0
  57. package/test/config.test.js +8 -0
  58. package/test/conflict-reviewed.test.js +79 -0
  59. package/test/continuity.test.js +29 -1
  60. package/test/dependency-overrides.test.js +78 -0
  61. package/test/differentiate-feedback.test.js +248 -0
  62. package/test/dream-decision-evidence.test.js +314 -0
  63. package/test/dream-fence.test.js +43 -0
  64. package/test/inject-pools.test.js +2 -1
  65. package/test/mint-source.test.js +83 -0
  66. package/test/pipeline-nudge-auto.test.js +26 -2
  67. package/test/pipeline-nudge.test.js +87 -0
  68. package/test/pipeline-runner.test.js +26 -0
  69. package/test/prompt-stance.test.js +128 -0
  70. package/test/provenance-http.test.js +200 -17
  71. package/test/release-docs.test.js +7 -1
  72. package/test/security-doc.test.js +43 -4
  73. package/test/session-gate.test.js +264 -0
  74. package/test/settings.test.js +4 -0
  75. package/test/sleep-action-set.test.js +16 -11
  76. package/test/sleep-catchup.test.js +315 -0
  77. package/test/sleep-failure-backoff.test.js +156 -0
  78. package/test/sleep.test.js +293 -0
  79. package/test/standalone-api.test.js +50 -0
  80. package/test/store.test.js +220 -0
  81. package/test/summarize.test.js +172 -0
@@ -1637,6 +1637,82 @@ test("issue#399: expiring records keeps the memories and leaves provenance dangl
1637
1637
  assert.ok(store.provenanceFor(memoryId).length >= 1, "面板/CLI 仍能回答「这条记忆出自哪个窗口」,只是来源已过期");
1638
1638
  });
1639
1639
 
1640
+ // ── Issue #512:蒸馏归因补 run 概念 ────────────────────────────────────────
1641
+ //
1642
+ // 这四条锁的是「这条记忆是哪一次蒸馏带进来的」这条问句现在回答得出来:
1643
+ // ① 一轮真蒸馏落一行 distill_runs,链接上带同一个 distill_run_id;
1644
+ // ② run 与它的开销读数(llm_audit_logs)靠 audit_id 直连,不靠时间戳猜;
1645
+ // ③ llmAudit 关掉时 run 仍然成立(归因不该依赖计费开关),只是没有 audit_id;
1646
+ // ④ 失败轮也留一行——「那一轮跑了但没成」本身就是归因要回答的事。
1647
+ // 反面(历史链接保持 NULL、不回填)在 test/store.test.js 的迁移用例里锁。
1648
+
1649
+ test("issue#512: a distilled window records one run, and every link points at it", async () => {
1650
+ const { events, store } = setup(RECORDS_ON);
1651
+ const session = recordsSession("s512-ok", [userMessage("这轮要能追到 run", 1), { seq: 2, type: "turn/end" }]);
1652
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1653
+
1654
+ const runs = store.listDistillRuns();
1655
+ assert.equal(runs.length, 1, "一轮真蒸馏就是一行 run");
1656
+ const run = runs[0];
1657
+ assert.equal(run.status, "ok");
1658
+ assert.equal(run.run_type, "autoSummarize");
1659
+ assert.equal(run.trigger_source, "autoSummarize");
1660
+ assert.equal(run.session_id, "s512-ok");
1661
+ assert.deepEqual([run.seq_start, run.seq_end], [1, 2], "run 记的是这一步真正蒸馏掉的窗口");
1662
+ assert.equal(run.memory_count, 2, "记忆条数是实际写成功的条数");
1663
+
1664
+ const links = store.db.prepare("SELECT memory_id, distill_run_id FROM memory_provenance").all();
1665
+ assert.equal(links.length, 2);
1666
+ for (const link of links) {
1667
+ assert.equal(link.distill_run_id, run.id, "每条记忆的链接都指向这一轮,不是 NULL、也不是别的轮");
1668
+ }
1669
+ });
1670
+
1671
+ test("issue#512: the run and its spend row are joined by audit_id, not guessed from timestamps", async () => {
1672
+ const { events, store } = setup(RECORDS_ON);
1673
+ const session = recordsSession("s512-audit", [userMessage("归因要连到开销行", 1), { seq: 2, type: "turn/end" }]);
1674
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1675
+
1676
+ const run = store.listDistillRuns()[0];
1677
+ const audits = store.listLlmAudits({ source: "autoSummarize" });
1678
+ assert.equal(audits.length, 1);
1679
+ assert.equal(run.audit_id, audits[0].id, "run 直接指着它自己那一行,不靠时间窗匹配");
1680
+ // 反向也成立:从 run 拿到的 id 读回来就是那一条。
1681
+ const join = store.getDistillRun(run.id);
1682
+ assert.equal(join.audit_id, audits[0].id);
1683
+ });
1684
+
1685
+ test("issue#512: with llmAudit off the run still lands — attribution must not depend on the spend switch", async () => {
1686
+ const { events, store } = setup({ ...RECORDS_ON, llmAudit: { enabled: false } });
1687
+ const session = recordsSession("s512-noaudit", [userMessage("关了计费也要能归因", 1), { seq: 2, type: "turn/end" }]);
1688
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1689
+
1690
+ assert.equal(store.listLlmAudits().length, 0, "开关关着,一行 audit 都不写");
1691
+ const runs = store.listDistillRuns();
1692
+ assert.equal(runs.length, 1, "但 run 照落");
1693
+ assert.equal(runs[0].status, "ok");
1694
+ assert.equal(runs[0].audit_id, undefined, "没有开销行时这一列是空的——不填假 id");
1695
+ });
1696
+
1697
+ test("issue#512: a failed round still leaves a run row saying it failed", async () => {
1698
+ // 流失败:窗口保留待重试,但「跑过一轮且失败」这件事必须留痕,否则归因只记得住成的。
1699
+ const { events, store } = setup(RECORDS_ON, {
1700
+ stream: () => (async function* () {
1701
+ yield { type: "block-start", block: { type: "text" } };
1702
+ yield { type: "finish", kind: "error", error: { message: "upstream exploded" } };
1703
+ })()
1704
+ });
1705
+ const session = recordsSession("s512-fail", [userMessage("这轮会失败", 1), { seq: 2, type: "turn/end" }]);
1706
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1707
+
1708
+ const runs = store.listDistillRuns();
1709
+ assert.equal(runs.length, 1, "失败轮也是一轮");
1710
+ assert.equal(runs[0].status, "failed");
1711
+ assert.ok(runs[0].error, "失败要有原因,空错误等于没说");
1712
+ assert.equal(runs[0].memory_count, 0);
1713
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0, "失败轮不落原文");
1714
+ });
1715
+
1640
1716
  test("issue#399: enabled=false (the default) writes nothing at all", async () => {
1641
1717
  const { events, store, service } = setup({ distillRateLimitIntervalMs: 0 });
1642
1718
  let saverCalls = 0;
@@ -2051,3 +2127,99 @@ test("#493 失败护栏:一次成功即清零计数,不会隔窗累计", asy
2051
2127
  summarizer.dispose();
2052
2128
  store.close();
2053
2129
  });
2130
+
2131
+ // ============================ #493 补充:失败计数落库(不再只在进程内)
2132
+ // 报告者 strange2000(2026-11-02)第 ① 条:别只存裸计数。原先 streak 活在
2133
+ // createSummarizer 闭包里的一个 Map,重启即清零——而这道护栏要防的恰恰是「确定性
2134
+ // 失败反复重试」,重启一次就绕过去了。两条用例各自钉一半:
2135
+ // 1) 换一个 summarizer 实例(= 重启)后计数仍然接得上,阈值照旧触发;
2136
+ // 2) 会话停了很久之后计数失效,不会被几周前攒下的 streak 误伤(他特别点名的那个
2137
+ // 反例:护栏自己变成新的误伤路径)。
2138
+ test("#493 补充:失败计数落库——换一个 summarizer 实例(模拟重启)仍然接得上", async () => {
2139
+ const store = createStore(":memory:");
2140
+ const outputs = ["不是 JSON", "还不是 JSON", "依然不是 JSON"];
2141
+ let attempt = 0;
2142
+ const failStream = () => (async function* () {
2143
+ yield { type: "block-start", block: { type: "text" } };
2144
+ yield { type: "text-delta", delta: outputs[attempt++] ?? "[]" };
2145
+ yield { type: "finish", kind: "ok" };
2146
+ })();
2147
+ const config = { summarizeParseFailureMaxRetries: 3, distillRateLimitIntervalMs: 0 };
2148
+ const sessionOf = () => ({
2149
+ id: "s493-persisted-streak",
2150
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2151
+ events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }]
2152
+ });
2153
+
2154
+ // 第一个实例:失败两次就「重启」(dispose + 丢掉闭包),阈值 3 还没到。
2155
+ const first = setup(config, { store, stream: failStream });
2156
+ const handlerA = eventHandler(first.events);
2157
+ const sessionA = sessionOf();
2158
+ await handlerA(sessionA, { seq: 2, type: "turn/end" });
2159
+ await handlerA(sessionA, { seq: 2, type: "turn/end" });
2160
+ first.summarizer.dispose();
2161
+ assert.equal(first.service.getDistillCursor("s493-persisted-streak"), undefined, "两次失败还没到阈值,游标不推进");
2162
+ assert.equal(first.store.getDistillParseFailureStreak("s493-persisted-streak"), 2,
2163
+ "计数必须已经在库里——进程内 Map 在这里会是 0(重启就丢)");
2164
+
2165
+ // 第二个实例:同一个库、同一个会话。第 3 次失败必须凑满阈值并放弃窗口。
2166
+ const second = setup(config, { store, stream: failStream });
2167
+ const handlerB = eventHandler(second.events);
2168
+ const sessionB = sessionOf();
2169
+ await handlerB(sessionB, { seq: 2, type: "turn/end" });
2170
+
2171
+ assert.equal(second.service.getDistillCursor("s493-persisted-streak")?.last_seq, 2,
2172
+ "重启前攒下的 2 次 + 这次的 1 次 = 阈值,窗口必须被放弃(进程内 Map 到这儿是 1,不会放弃)");
2173
+ const abandoned = second.service.listLlmAudits().filter((r) => r.status === "error")
2174
+ .find((r) => r.metadata?.skipped === "parse-failure-streak");
2175
+ assert.equal(abandoned?.metadata?.streak, 3, "审计里的 streak 是**累计**值,不是本次进程内从 1 起算");
2176
+ assert.equal(second.store.getDistillParseFailureStreak("s493-persisted-streak"), 0,
2177
+ "放弃后清零:窗口已消费,下一窗从全新状态起算");
2178
+ second.summarizer.dispose();
2179
+ store.close();
2180
+ });
2181
+
2182
+ test("#493 补充:停了很久的会话,旧 streak 失效,不会被几周前的失败误伤", async () => {
2183
+ const store = createStore(":memory:");
2184
+ let attempts = 0;
2185
+ const { events, calls, service, summarizer } = setup(
2186
+ { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
2187
+ {
2188
+ store,
2189
+ stream() {
2190
+ return (async function* () {
2191
+ yield { type: "block-start", block: { type: "text" } };
2192
+ yield { type: "text-delta", delta: attempts++ === 0 ? "不是 JSON" : "[]" };
2193
+ yield { type: "finish", kind: "ok" };
2194
+ })();
2195
+ }
2196
+ }
2197
+ );
2198
+ const handler = eventHandler(events);
2199
+ const session = {
2200
+ id: "s493-stale-streak",
2201
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2202
+ events: [userMessage("隔了很久才回来的窗口", 1), { seq: 2, type: "turn/end" }]
2203
+ };
2204
+
2205
+ // 先在库里种一条「很久以前失败过一次」的记录(阈值 2,再有一次就会熔断)。
2206
+ service.bumpDistillParseFailureStreak("s493-stale-streak");
2207
+ const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
2208
+ store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, "s493-stale-streak");
2209
+ assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 1, "不判失效时它还在");
2210
+ assert.equal(store.getDistillParseFailureStreak("s493-stale-streak", { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0,
2211
+ "读侧带失效窗口时必须当 0:30 天前的失败不算「连续失败」");
2212
+
2213
+ // 于是这次的第一次失败应当只算 streak=1,不熔断;成功一次即清零。
2214
+ await handler(session, { seq: 2, type: "turn/end" });
2215
+ const firstFail = service.listLlmAudits().find((r) => r.status === "error");
2216
+ assert.equal(firstFail?.metadata?.streak, 1, "旧记录失效之后,这次失败是「新的第一次」");
2217
+ assert.equal(firstFail?.metadata?.skipped, undefined, "不许因为几周前的失败就直接熔断当前窗口");
2218
+ assert.equal(service.getDistillCursor("s493-stale-streak"), undefined, "游标没被跳过,窗口仍可重试");
2219
+
2220
+ await handler(session, { seq: 2, type: "turn/end" });
2221
+ assert.equal(calls.length, 2);
2222
+ assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 0, "成功即清零(DELETE,不是写 0)");
2223
+ summarizer.dispose();
2224
+ store.close();
2225
+ });