@modusensus/dsh-mneme 0.9.5 → 0.10.1

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 (85) hide show
  1. package/README.md +10 -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 +76 -6
  9. package/lib/client.js +259 -11
  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/graph/anchoring.js +7 -2
  17. package/lib/index.js +15 -6
  18. package/lib/inject.js +9 -4
  19. package/lib/lang.js +17 -12
  20. package/lib/pipeline-nudge-auto.js +3 -0
  21. package/lib/pipeline-nudge.js +41 -8
  22. package/lib/pipeline-runner.js +15 -0
  23. package/lib/pre-inject-gate.js +14 -0
  24. package/lib/profile-extract.js +4 -1
  25. package/lib/provenance-http.js +162 -37
  26. package/lib/service.js +99 -8
  27. package/lib/session-gate.js +91 -0
  28. package/lib/settings.js +13 -1
  29. package/lib/store.js +681 -18
  30. package/lib/summarize.js +152 -15
  31. package/lib/tools.js +26 -6
  32. package/package.json +4 -2
  33. package/src/api-standalone.js +11 -3
  34. package/src/api.js +76 -6
  35. package/src/config.js +33 -1
  36. package/src/continuity.js +11 -3
  37. package/src/dream/decisions.js +88 -27
  38. package/src/dream/sleep.js +467 -81
  39. package/src/dream.js +110 -13
  40. package/src/fence.js +24 -0
  41. package/src/graph/anchoring.js +7 -2
  42. package/src/index.js +15 -6
  43. package/src/inject.js +9 -4
  44. package/src/lang.js +17 -12
  45. package/src/pipeline-nudge-auto.js +3 -0
  46. package/src/pipeline-nudge.js +41 -8
  47. package/src/pipeline-runner.js +15 -0
  48. package/src/pre-inject-gate.js +14 -0
  49. package/src/profile-extract.js +4 -1
  50. package/src/provenance-http.js +162 -37
  51. package/src/service.js +99 -8
  52. package/src/session-gate.js +91 -0
  53. package/src/settings.js +13 -1
  54. package/src/store.js +681 -18
  55. package/src/summarize.js +152 -15
  56. package/src/tools.js +26 -6
  57. package/test/api.test.js +153 -4
  58. package/test/client.test.js +380 -0
  59. package/test/config.test.js +8 -0
  60. package/test/conflict-reviewed.test.js +79 -0
  61. package/test/continuity.test.js +29 -1
  62. package/test/dependency-overrides.test.js +78 -0
  63. package/test/differentiate-feedback.test.js +248 -0
  64. package/test/dream-decision-evidence.test.js +314 -0
  65. package/test/dream-fence.test.js +43 -0
  66. package/test/graph-boosted-relations.test.js +189 -0
  67. package/test/graph-multipath.test.js +269 -0
  68. package/test/inject-pools.test.js +2 -1
  69. package/test/mint-source.test.js +83 -0
  70. package/test/pipeline-nudge-auto.test.js +26 -2
  71. package/test/pipeline-nudge.test.js +87 -0
  72. package/test/pipeline-runner.test.js +26 -0
  73. package/test/prompt-stance.test.js +128 -0
  74. package/test/provenance-http.test.js +200 -17
  75. package/test/release-docs.test.js +7 -1
  76. package/test/security-doc.test.js +43 -4
  77. package/test/session-gate.test.js +264 -0
  78. package/test/settings.test.js +4 -0
  79. package/test/sleep-action-set.test.js +16 -11
  80. package/test/sleep-catchup.test.js +315 -0
  81. package/test/sleep-failure-backoff.test.js +156 -0
  82. package/test/sleep.test.js +293 -0
  83. package/test/standalone-api.test.js +50 -0
  84. package/test/store.test.js +220 -0
  85. package/test/summarize.test.js +172 -0
package/src/summarize.js CHANGED
@@ -6,6 +6,12 @@ import { STR, langOf } from "./lang.js";
6
6
  import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
7
7
  // v0.9.0(M.O. 项目记忆):蒸馏写入复用工具路径那把作用域解析器,见 createSummarizer。
8
8
  import { createScopeResolver } from "./scope.js";
9
+ // #493 补充 §③:断链与巩固侧同一份实现(src/fence.js)。转录里出现闭合标记时
10
+ // 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。
11
+ import { breakFenceMarkers } from "./fence.js";
12
+ // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它——
13
+ // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。
14
+ import { randomUUID } from "node:crypto";
9
15
 
10
16
  // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
11
17
  // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
@@ -18,6 +24,12 @@ import { createScopeResolver } from "./scope.js";
18
24
  // occurred_from 过滤。未知/无法换算的相对时间必须整条省略字段,不能落 null。
19
25
  // 纯函数:唯一外部输入是显式入参 nowMs(测试注入假时钟即可确定性复现)。
20
26
  const OCCURRED_AT_FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000;
27
+ // #493 补充 §②:持久化的解析失败计数超过这么久没更新就按 0 算(读侧失效,见
28
+ // store.getDistillParseFailureStreak)。7 天:够长,能跨一次长假/一次停机;够短,
29
+ // 不至于让「停了很久的会话」拿几周前的 streak 直接熔断一个其实健康的窗口。
30
+ // 刻意不做成配置键:护栏本身默认关(summarizeParseFailureMaxRetries=0),一个只为
31
+ // 护栏服务的失效窗口不该再占一档用户可见配置(AGENTS.md 旗标计数锁也要跟着动)。
32
+ const PARSE_FAILURE_STREAK_STALE_MS = 7 * 24 * 60 * 60 * 1000;
21
33
  function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) {
22
34
  if (typeof raw !== "string") return undefined;
23
35
  const text = raw.trim();
@@ -466,7 +478,7 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
466
478
  // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是
467
479
  // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。
468
480
  // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。
469
- text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${transcriptBody}\n${STR.transcriptFenceClose[language]}`
481
+ text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${breakFenceMarkers(transcriptBody)}\n${STR.transcriptFenceClose[language]}`
470
482
  }],
471
483
  source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
472
484
  })]
@@ -572,7 +584,7 @@ function enqueueDistill(task, intervalMs = 0) {
572
584
  // import + re-export 两行都要。
573
585
  import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
574
586
  export { parsePeakSpec, isInPeakWindow, nextOffPeakAt };
575
- export function createSummarizer(ctx, service, config, deps = {}) {
587
+ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = null) {
576
588
  if (!config.autoSummarize) return { dispose: () => {} };
577
589
 
578
590
  // Issue #239(第 4 项):时钟与定时器可注入——排程不绑死真实时钟,测试才能确定性
@@ -603,9 +615,54 @@ export function createSummarizer(ctx, service, config, deps = {}) {
603
615
  // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
604
616
  const lastDistilledSeq = new Map();
605
617
  // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性
606
- // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即
607
- // 清零;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。
608
- const parseFailureStreak = new Map();
618
+ // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即清零。
619
+ //
620
+ // #493 补充 §②:**落库**,不再是进程内 Map。原先的 Map 有两个洞:重启即清零
621
+ // (护栏可被重启绕过,而它防的正是「确定性失败反复重试」),以及进程内无法跨
622
+ // 两个宿主共享同一个库时保持一致。库里读出来的值带读侧失效窗口(见
623
+ // PARSE_FAILURE_STREAK_STALE_MS),停了很久的会话不会被旧 streak 误伤。
624
+ // service 没这一步(旧装配 / 测试替身)时退回进程内 Map,行为与历史版本一致。
625
+ const parseFailureStreakFallback = new Map();
626
+ const persistentStreak = () => typeof service?.getDistillParseFailureStreak === "function"
627
+ && typeof service?.bumpDistillParseFailureStreak === "function"
628
+ && typeof service?.clearDistillParseFailureStreak === "function";
629
+ function readParseFailureStreak(sessionId) {
630
+ if (!persistentStreak()) return parseFailureStreakFallback.get(sessionId) ?? 0;
631
+ try {
632
+ return Number(service.getDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS })) || 0;
633
+ } catch (error) {
634
+ // 读数坏了不许把蒸馏本身拖下水:退回进程内计数(最坏是多试一次)。
635
+ ctx.logger?.warn?.(`dsh-mneme: parse failure streak read failed: ${String(error)}`);
636
+ return parseFailureStreakFallback.get(sessionId) ?? 0;
637
+ }
638
+ }
639
+ function bumpParseFailureStreak(sessionId) {
640
+ if (!persistentStreak()) {
641
+ const next = (parseFailureStreakFallback.get(sessionId) ?? 0) + 1;
642
+ parseFailureStreakFallback.set(sessionId, next);
643
+ return next;
644
+ }
645
+ try {
646
+ // 失效判断由 store 那一侧负责(同一个 staleAfterMs),这里不再自己算一遍——
647
+ // 算两遍就等于有两份失效口径,迟早分叉。
648
+ const written = Number(service.bumpDistillParseFailureStreak(sessionId, { staleAfterMs: PARSE_FAILURE_STREAK_STALE_MS }));
649
+ if (Number.isFinite(written) && written > 0) return written;
650
+ } catch (error) {
651
+ ctx.logger?.warn?.(`dsh-mneme: parse failure streak write failed: ${String(error)}`);
652
+ }
653
+ const next = readParseFailureStreak(sessionId) + 1;
654
+ parseFailureStreakFallback.set(sessionId, next);
655
+ return next;
656
+ }
657
+ function clearParseFailureStreak(sessionId) {
658
+ parseFailureStreakFallback.delete(sessionId);
659
+ if (!persistentStreak()) return;
660
+ try {
661
+ service.clearDistillParseFailureStreak(sessionId);
662
+ } catch (error) {
663
+ ctx.logger?.warn?.(`dsh-mneme: parse failure streak clear failed: ${String(error)}`);
664
+ }
665
+ }
609
666
  let disposed = false;
610
667
 
611
668
  /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
@@ -670,6 +727,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
670
727
 
671
728
  async function summarize(session, triggerEvent, opts = {}) {
672
729
  if (disposed || inFlight.has(session.id)) return;
730
+ if (sessionGate?.isOff(session)) return;
673
731
 
674
732
  const header = session.requestHeader?.()?.config;
675
733
  // Config override takes priority, then session header, then nothing.
@@ -734,6 +792,14 @@ export function createSummarizer(ctx, service, config, deps = {}) {
734
792
  // ever blocking the summarization itself.
735
793
  let audit = null;
736
794
  let abortedRun = false;
795
+ // #512:run 的三个事实必须活在 try 之外——finally 要落 run 行,而 finally 看不见
796
+ // try 块内声明的 let/const(会 ReferenceError,且那一下会把 inFlight 的清理一起跳过)。
797
+ let distillRunId = null;
798
+ let distillRunOpenedAt = null;
799
+ let distilledMemoryCount = 0;
800
+ let distilledWindow = null;
801
+ let distillRunStatus = null;
802
+ let distillRunError = null;
737
803
  try {
738
804
  if (!route) return;
739
805
  const persistedCursor = typeof service.getDistillCursor === "function"
@@ -800,6 +866,19 @@ export function createSummarizer(ctx, service, config, deps = {}) {
800
866
  lastRunAt.set(session.id, Date.now());
801
867
  runsUsed.set(session.id, (prevRuns ?? 0) + 1);
802
868
 
869
+ // #512:一轮蒸馏 = 一个 run。落点选在这里而不是更早——上面所有 return(间隔门、
870
+ // 高峰门、run 预算、空窗口、窗口过小)都不是一轮真蒸馏,各有自己的审计口径;
871
+ // 从这里往下就是「调用确实发生过」,与 lastRunAt 那两行的边界完全重合。
872
+ // run 的落盘在 finally 里独立 try/catch:归因是审计主体,写不成只该 warn,
873
+ // 不该把已经落盘的记忆连坐。所以本函数在 store 没有该能力时整体降级为无归因。
874
+ // 归因用的三件事都从**真正发生的事**里取,不从推断里取:区间取实际写进
875
+ // memory_provenance 的那个窗口(transcript 关了就没有),条数取实际写成功的
876
+ // 记忆数(含 merged——被并入的既有行确实由这段原文改动)。
877
+ if (typeof service?.saveDistillRun === "function") {
878
+ distillRunId = randomUUID();
879
+ distillRunOpenedAt = new Date(now()).toISOString();
880
+ }
881
+
803
882
  if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
804
883
  audit = {
805
884
  route,
@@ -941,6 +1020,13 @@ export function createSummarizer(ctx, service, config, deps = {}) {
941
1020
  const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
942
1021
  if (aborted) {
943
1022
  abortedRun = true;
1023
+ // #512:aborted 不等于「没发生」——LLM 调用真的发出去了,窗口也因此没被消费。
1024
+ // 只有 dispose / 会话关闭才是「这一轮被主动打断」;模型侧流失败是一次失败的轮次,
1025
+ // 两者在 run 台账上必须分得开,否则「这个窗口为什么一直没记忆」查不到原因。
1026
+ distillRunStatus = controller.signal.aborted ? "aborted" : "failed";
1027
+ distillRunError = controller.signal.aborted
1028
+ ? "disposed"
1029
+ : (streamFailure || "llm stream aborted");
944
1030
  return;
945
1031
  }
946
1032
  const parsedResult = parseSummaryJsonResult(text || assembledText);
@@ -950,8 +1036,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
950
1036
  // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何
951
1037
  // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。
952
1038
  const raw = text || assembledText || "";
953
- const streak = (parseFailureStreak.get(session.id) ?? 0) + 1;
954
- parseFailureStreak.set(session.id, streak);
1039
+ const streak = bumpParseFailureStreak(session.id);
955
1040
  // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。
956
1041
  // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随
957
1042
  // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与
@@ -965,13 +1050,17 @@ export function createSummarizer(ctx, service, config, deps = {}) {
965
1050
  if (skipWindow) {
966
1051
  // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。
967
1052
  commitCursor(session.id, nextSeq);
968
- parseFailureStreak.delete(session.id);
1053
+ clearParseFailureStreak(session.id);
969
1054
  }
1055
+ // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
1056
+ // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
1057
+ const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
1058
+ // #512:解析失败也是失败轮,run 要和 audit 记同一句原因(两个口径不一致
1059
+ // 等于让人去猜哪一边是对的)。
1060
+ distillRunStatus = "failed";
1061
+ distillRunError = failureAudit.errorMessage;
970
1062
  if (audit) {
971
1063
  audit.status = "error";
972
- // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
973
- // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
974
- const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
975
1064
  audit.errorMessage = failureAudit.errorMessage;
976
1065
  audit.metadata = {
977
1066
  distillChars,
@@ -983,7 +1072,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
983
1072
  }
984
1073
  return;
985
1074
  }
986
- parseFailureStreak.delete(session.id);
1075
+ clearParseFailureStreak(session.id);
987
1076
  const parsed = parsedResult.entries;
988
1077
  // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
989
1078
  // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
@@ -1062,8 +1151,14 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1062
1151
  // 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。
1063
1152
  if (outcome?.memory?.id) savedIds.push(outcome.memory.id);
1064
1153
  }
1154
+ // #512:run 的条数口径是「本次写成功的记忆行数」,与 savedIds 同源——
1155
+ // 另起一个计数器会在 merged 分支上与它对不上(那一路也 push 了 id)。
1156
+ distilledMemoryCount = savedIds.length;
1065
1157
  if (transcript && transcript.records.length) {
1066
1158
  service.saveDistillRecords(transcript.records);
1159
+ if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined) {
1160
+ distilledWindow = { seqStart: transcript.seqStart, seqEnd: transcript.seqEnd };
1161
+ }
1067
1162
  if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined
1068
1163
  && typeof service.saveMemoryProvenance === "function") {
1069
1164
  for (const memoryId of savedIds) {
@@ -1072,7 +1167,11 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1072
1167
  sessionId: session.id,
1073
1168
  seqStart: transcript.seqStart,
1074
1169
  seqEnd: transcript.seqEnd,
1075
- createdAt: distilledAt
1170
+ createdAt: distilledAt,
1171
+ // #512:链接指回产出它的那一轮。distillRecords 关着时这里本就不跑,
1172
+ // 于是那一轮不会留下任何窗口级归因——这是诚实的(没存原文就没有
1173
+ // 「这段原文」可指),run 行本身照落,条数与状态仍然可查。
1174
+ distillRunId: distillRunId ?? undefined
1076
1175
  });
1077
1176
  }
1078
1177
  }
@@ -1095,6 +1194,12 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1095
1194
  // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有
1096
1195
  // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下
1097
1196
  // 一条都不留——下一级预判规则的阈值就无从定标。
1197
+ // #512:走到这里说明整轮真的落地了(记忆写入 + 游标提交都没抛)。状态按
1198
+ // 实际产出分档,与 dream_runs 的 status 词表对齐:没有可写的记忆是 noop,
1199
+ // 有记忆就是 ok。degraded 留给「写了但解析有损」那一路(下面按 salvaged 判)。
1200
+ distillRunStatus = distilledMemoryCount > 0
1201
+ ? (parsedResult.salvaged ? "degraded" : "ok")
1202
+ : "noop";
1098
1203
  if (audit) {
1099
1204
  audit.metadata = {
1100
1205
  distillChars,
@@ -1108,6 +1213,8 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1108
1213
  // 都会在此之前退出,从而保留窗口供下一次重试。
1109
1214
  if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
1110
1215
  } catch (error) {
1216
+ distillRunStatus = abortedRun || controller.signal.aborted ? "aborted" : "failed";
1217
+ distillRunError = String(error?.message ?? error);
1111
1218
  if (audit) {
1112
1219
  audit.status = "error";
1113
1220
  audit.errorMessage = String(error?.message ?? error);
@@ -1124,9 +1231,14 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1124
1231
  if (prevRuns === undefined) runsUsed.delete(session.id);
1125
1232
  else runsUsed.set(session.id, prevRuns);
1126
1233
  }
1234
+ // #512:run 与它的开销读数(audit 行)分开落,且都不许把已经写好的记忆连坐——
1235
+ // 归因是审计,写不成只该 warn。顺序刻意是「先 audit 后 run」:run.audit_id 要
1236
+ // 引用刚插进去的那一行,而 saveLlmAudit 是按 lastInsertRowid 回读的
1237
+ // (#534/#535 那条注释),拿到的就是自己那行,不会被并发写串。
1238
+ let auditRow = null;
1127
1239
  if (audit) {
1128
1240
  try {
1129
- service.saveLlmAudit({
1241
+ auditRow = service.saveLlmAudit({
1130
1242
  timestamp: audit.timestamp,
1131
1243
  trigger_source: "autoSummarize",
1132
1244
  operation_type: "summarize_compress",
@@ -1147,6 +1259,31 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1147
1259
  ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
1148
1260
  }
1149
1261
  }
1262
+ // #512:一轮真蒸馏对应一行 distill_runs,落点必须在这里(finally)而不是
1263
+ // 事务里:run 是审计主体,事务只负责「记忆 + 原文 + 链接」同生共死;把 run
1264
+ // 塞进事务会让一个审计写失败回滚掉已经算出来的记忆。
1265
+ // status 为 null 说明在开轮之前就退出了(间隔门/高峰门/短窗口),那不是一轮。
1266
+ if (distillRunId && distillRunStatus) {
1267
+ try {
1268
+ service.saveDistillRun({
1269
+ id: distillRunId,
1270
+ created_at: distillRunOpenedAt,
1271
+ status: distillRunStatus,
1272
+ error: distillRunError ?? undefined,
1273
+ run_type: "autoSummarize",
1274
+ trigger_source: "autoSummarize",
1275
+ session_id: session.id,
1276
+ seq_start: distilledWindow?.seqStart,
1277
+ seq_end: distilledWindow?.seqEnd,
1278
+ memory_count: distilledMemoryCount,
1279
+ provider: audit?.route?.provider ?? route?.provider,
1280
+ model: audit?.route?.model ?? route?.model,
1281
+ audit_id: auditRow?.id
1282
+ });
1283
+ } catch (runError) {
1284
+ ctx.logger?.warn?.(`dsh-mneme: distill run write failed: ${String(runError)}`);
1285
+ }
1286
+ }
1150
1287
  inFlight.delete(session.id);
1151
1288
  }
1152
1289
  }
@@ -1173,7 +1310,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1173
1310
  inFlight.clear();
1174
1311
  lastRunAt.clear();
1175
1312
  lastDistilledSeq.clear();
1176
- parseFailureStreak.clear();
1313
+ parseFailureStreakFallback.clear();
1177
1314
  }
1178
1315
  };
1179
1316
  }
package/src/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
- import { createScopeResolver, normalizeExplicitScope, sessionKeyOf } from "./scope.js";
2
+ import { createScopeResolver, normalizeExplicitScope, sessionKeyOf, sessionOf } from "./scope.js";
3
3
  import { describeLocalRuntime, resolveRuntimeEntry } from "./runtime/loader.js";
4
4
  import { defaultRuntimeDir } from "./runtime/layout.js";
5
5
  import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js";
@@ -76,13 +76,15 @@ const MEMORY_PROVENANCE = (m) => {
76
76
  return parts.length ? ` | ${parts.join(" | ")}` : "";
77
77
  };
78
78
 
79
- export function createTools(ctx, service, config, embedder) {
79
+ export function createTools(ctx, service, config, embedder, sessionGate = null) {
80
80
  const toolsRegistry = ctx.tools;
81
- let registeredTools = REGISTERED_TOOLS.get(toolsRegistry);
82
- if (!registeredTools) {
83
- registeredTools = new Set();
84
- REGISTERED_TOOLS.set(toolsRegistry, registeredTools);
81
+ let registration = REGISTERED_TOOLS.get(toolsRegistry);
82
+ if (!registration) {
83
+ registration = { names: new Set(), sessionGate: null };
84
+ REGISTERED_TOOLS.set(toolsRegistry, registration);
85
85
  }
86
+ registration.sessionGate = sessionGate;
87
+ const registeredTools = registration.names;
86
88
  // v0.8.0 A1/A2(issue #17):会话 scope 解析(agentPreset + workspace 反查)。
87
89
  // flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
88
90
  // logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
@@ -1054,6 +1056,24 @@ export function createTools(ctx, service, config, embedder) {
1054
1056
  ctx.logger?.warn?.(`[dsh-mneme] tool "${tool.name}" already registered, skipping duplicate`);
1055
1057
  continue;
1056
1058
  }
1059
+ // #539:闸门包在**注册期**,而不是在 execute 内部各写一遍。已注册的执行器
1060
+ // 不会被宿主反注册(同上面 disableMemorySearch 那段 v0.8.5 的定性),所以
1061
+ // 包一层的意义在于:热重载后 registration.sessionGate 换成新实例,旧的执行器
1062
+ // 也跟着读新的闸门,不会拿着过期 config 继续放行。
1063
+ //
1064
+ // 取会话走 sessionOf(exec) = exec.agent.session。ToolExecutionInput.agent 在
1065
+ // 宿主类型里是**可选**的,PTC 内层派发可能压根不带 agent;这种调用会拿到
1066
+ // null → isOff(null) 为 false → 放行。这是有意的:不认识调用者的工具调用
1067
+ // 不能一律拒绝(那会把正常会话也打死),闸门只对「能证明自己是谁」的调用生效。
1068
+ if (typeof tool.execute === "function") {
1069
+ const execute = tool.execute.bind(tool);
1070
+ tool.execute = async (args, exec) => {
1071
+ if (registration.sessionGate?.isOff(sessionOf(exec))) {
1072
+ throw new Error("dsh-mneme: memory tools are disabled for this incognito session");
1073
+ }
1074
+ return execute(args, exec);
1075
+ };
1076
+ }
1057
1077
  registeredTools.add(tool.name);
1058
1078
  ctx.tools.register(tool);
1059
1079
  }
package/test/api.test.js CHANGED
@@ -754,7 +754,13 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
754
754
  // 不需要 NESTED_FLAG_PATHS 条目)= 104。
755
755
  // 人格寻址口径新增 personaStrictAddressing(顶层扁平键,同上)= 105。
756
756
  // issue #497 新增 profileExtractEnabled(画像提取草稿;顶层扁平键,同上)= 106。
757
- assert.equal(Object.keys(data.effective).length, 106);
757
+ // issue #539 新增 sessionGateEnabled / sessionGateSubagents(逐会话无痕闸门,总开关
758
+ // 与子代理选择器;manual id 名单是数组配置,不进 feature_flags)= 108。
759
+ // issue #529 新增 sleepConflictMaxPairs / sleepConflictMaxPairsPerRun(冲突阶段的
760
+ // 输入侧预算,两个 int 键都走 INT_RANGES 表)= 110。
761
+ // issue #529 再新增 sleepFailureBackoff(sleep 连续失败退避;顶层布尔键,
762
+ // 不需要 NESTED_FLAG_PATHS 条目)= 111。
763
+ assert.equal(Object.keys(data.effective).length, 111);
758
764
  assert.equal(data.effective.dreamSkipInvalid, true);
759
765
  assert.equal(data.effective.allowCrossTypeMerge, false);
760
766
  assert.equal(data.effective.dreamMergeGuard, true);
@@ -803,6 +809,8 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
803
809
  assert.equal(data.effective.personaStrictAddressing, false);
804
810
  // 画像提取默认关:面板按钮在用户没开启前不该偷偷花一次 LLM 调用。
805
811
  assert.equal(data.effective.profileExtractEnabled, false);
812
+ assert.equal(data.effective.sessionGateEnabled, false);
813
+ assert.equal(data.effective.sessionGateSubagents, true);
806
814
  // v0.9.3:M.O. 自动沉淀的第三个键。与 actionPipelineEnabled(看图)、
807
815
  // actionRunEnabled(记账)都不是一件事——它挂 session/event 并解压会话日志,
808
816
  // 是三者里最重的一条,所以独立开关、默认关、且进 LIGHT_MODE_OFF。
@@ -883,16 +891,36 @@ test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists"
883
891
  const { routes, settings } = setup(undefined, "", FLAGS_CFG);
884
892
  const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
885
893
  const res = new FakeRes();
886
- await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true }), res);
894
+ await route.handler(req("/api/dsh-mneme/features", "PUT", {
895
+ autoDream: false,
896
+ distillMaxChars: 48000,
897
+ actionPipelineEnabled: true,
898
+ sessionGateEnabled: true,
899
+ sessionGateSubagents: false
900
+ }), res);
887
901
  assert.equal(res.statusCode, 200);
888
902
  const data = JSON.parse(res.body);
889
- assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true });
903
+ assert.deepEqual(data.overrides, {
904
+ autoDream: false,
905
+ distillMaxChars: 48000,
906
+ actionPipelineEnabled: true,
907
+ sessionGateEnabled: true,
908
+ sessionGateSubagents: false
909
+ });
890
910
  assert.equal(data.effective.autoDream, false, "override wins over config default");
891
911
  assert.equal(data.effective.distillMaxChars, 48000);
892
912
  // v0.9.0:默认关的键也能被 PUT 打开并读回(beta 功能的启用路径)。
893
913
  assert.equal(data.effective.actionPipelineEnabled, true, "override flips the beta gate on");
914
+ assert.equal(data.effective.sessionGateEnabled, true);
915
+ assert.equal(data.effective.sessionGateSubagents, false);
894
916
  assert.equal(data.effective.autoInject, true, "keys not in the patch still report config");
895
- assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true });
917
+ assert.deepEqual(settings.getFeatureFlags(), {
918
+ autoDream: false,
919
+ distillMaxChars: 48000,
920
+ actionPipelineEnabled: true,
921
+ sessionGateEnabled: true,
922
+ sessionGateSubagents: false
923
+ });
896
924
  });
897
925
 
898
926
  test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys", async () => {
@@ -1400,6 +1428,7 @@ test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", as
1400
1428
  assert.equal(data.runs.length, 2);
1401
1429
  assert.equal(data.runs[0].created_at, "2026-01-02T00:00:00.000Z", "created_at DESC");
1402
1430
  assert.deepEqual(data.lastRun, data.runs[0]);
1431
+ assert.deepEqual(data.lastConsolidation, data.runs[0], "no organize row => same as lastRun");
1403
1432
  // #521:skippedCount / skippedReasons 是本轮新增的两个字段(面板按归一化原因
1404
1433
  // 显示跳过明细)。这行锁的是 dream-status 的响应形状,加字段必须同步改这里。
1405
1434
  assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "demotion", "error", "model", "provider", "run_type", "skippedCount", "skippedReasons", "status"]);
@@ -1419,6 +1448,59 @@ test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", as
1419
1448
  assert.deepEqual(afterData.pendingMemoryIds, []);
1420
1449
  });
1421
1450
 
1451
+ // Issue #495:organize 与巩固共用 dream_runs,一次 agent 主动整理写两行(dryRun
1452
+ // 报告 + apply 回执)。面板那张卡的标题是「最近巩固」,若继续取 runs[0],一次整理
1453
+ // 就会把它顶成整理回执——测的是「卡面数字取的是巩固轮」,不是「runs 里没有 organize」。
1454
+ test("GET /api/dsh-mneme/dream-status keeps organize rows out of lastConsolidation (issue #495)", async () => {
1455
+ const { routes, service } = setup();
1456
+ service.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", snapshot_hash: "h1", input_count: 2, receipt: "r1" });
1457
+ // 一次整理的两行:报告在前、回执在后,都晚于那次巩固。
1458
+ service.saveDreamRun({
1459
+ created_at: "2026-01-02T00:00:00.000Z", status: "ok", run_type: "organize",
1460
+ snapshot_hash: "h2", input_count: 0, receipt: "r2",
1461
+ outcome: { dryRun: true, byId: {} }
1462
+ });
1463
+ service.saveDreamRun({
1464
+ created_at: "2026-01-02T00:00:01.000Z", status: "ok", run_type: "organize",
1465
+ snapshot_hash: "h3", input_count: 0, receipt: "r3",
1466
+ outcome: { dry_run_id: "dry-1", byId: {} }
1467
+ });
1468
+
1469
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status");
1470
+ const res = new FakeRes();
1471
+ await route.handler(req("/api/dsh-mneme/dream-status"), res);
1472
+ const data = JSON.parse(res.body);
1473
+
1474
+ // 审计面不缩:runs 仍是全类型 5 条,organize 照旧排在最前。
1475
+ assert.equal(data.runs.length, 3, "runs keeps every run type — the audit surface is not filtered");
1476
+ assert.equal(data.runs[0].run_type, "organize");
1477
+ assert.equal(data.lastRun.run_type, "organize", "lastRun still means the newest row of any type");
1478
+ // 卡面取数:跳过 organize,回到那次真巩固。
1479
+ assert.ok(data.lastConsolidation, "lastConsolidation must not be empty when a consolidation exists");
1480
+ assert.equal(data.lastConsolidation.run_type, "auto");
1481
+ assert.equal(data.lastConsolidation.created_at, "2026-01-01T00:00:00.000Z");
1482
+ // 两条路径共用同一份投影,字段形状必须一致(否则面板得写两套渲染)。
1483
+ assert.deepEqual(Object.keys(data.lastConsolidation).sort(), Object.keys(data.lastRun).sort());
1484
+ });
1485
+
1486
+ // 只有 organize 跑过时:宁可留空让面板说「尚未运行」并加脚注,也不能拿整理回执
1487
+ // 冒充巩固——那是这条 issue 要治的那个错。
1488
+ test("dream-status: organize-only history leaves lastConsolidation null, never the organize row (issue #495)", async () => {
1489
+ const { routes, service } = setup();
1490
+ service.saveDreamRun({
1491
+ created_at: "2026-01-02T00:00:00.000Z", status: "ok", run_type: "organize",
1492
+ snapshot_hash: "h1", input_count: 0, receipt: "r1",
1493
+ outcome: { dry_run_id: "dry-1", byId: {} }
1494
+ });
1495
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status");
1496
+ const res = new FakeRes();
1497
+ await route.handler(req("/api/dsh-mneme/dream-status"), res);
1498
+ const data = JSON.parse(res.body);
1499
+ assert.equal(data.lastRun.run_type, "organize");
1500
+ assert.equal(data.lastConsolidation, null, "an organize receipt must never be reported as the last consolidation");
1501
+ assert.equal(data.runs.length, 1);
1502
+ });
1503
+
1422
1504
  // v0.8.0 冲突集中处理:GET /conflicts 队列联表 + POST /conflicts/resolve 人工确认。
1423
1505
  test("GET /api/dsh-mneme/conflicts joins both sides of unresolved conflicts", async () => {
1424
1506
  const { routes, service } = setup();
@@ -2458,3 +2540,70 @@ test("GET /api/dsh-mneme/memory/provenance:lightMode 预设把关掉 → 409
2458
2540
  assert.equal(res.statusCode, 409);
2459
2541
  assert.equal(JSON.parse(res.body).error, "distill-records-disabled");
2460
2542
  });
2543
+
2544
+ // #524 方向 2:按会话列窗口(store 侧 GROUP BY 成一行一窗口)。这条用例盯三件事:
2545
+ // 同一窗口派生的多条记忆不许变成多行、区间按 seq_start 排、答复用 sessionId 认领。
2546
+ test("GET /api/dsh-mneme/memory/provenance?sessionId= 列出该会话的蒸馏窗口(#524)", async () => {
2547
+ const { routes, service, store } = setup(null, "secret-token", RECORDS_ON);
2548
+ service.saveWithDedupe({ type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 });
2549
+ service.saveWithDedupe({ type: "decision", title: "备选", content: "先用 JSON 文件顶着", importance: 3 });
2550
+ const [m1, m2] = service.all();
2551
+ // 两个窗口:后一个 seq 更小,用来验证答复按 seq_start 排而不是按写入序。
2552
+ store.saveMemoryProvenance({ memoryId: m1.id, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "t2" });
2553
+ store.saveMemoryProvenance({ memoryId: m2.id, sessionId: "s1", seqStart: 10, seqEnd: 12, createdAt: "t2" });
2554
+ store.saveMemoryProvenance({ memoryId: m1.id, sessionId: "s1", seqStart: 1, seqEnd: 3, createdAt: "t1" });
2555
+ // 另一个会话的窗口不该出现。
2556
+ store.saveMemoryProvenance({ memoryId: m1.id, sessionId: "s2", seqStart: 50, seqEnd: 51, createdAt: "t3" });
2557
+
2558
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2559
+ const res = new FakeRes();
2560
+ const r = req("/api/dsh-mneme/memory/provenance?sessionId=s1");
2561
+ r.headers = { authorization: "Bearer secret-token" };
2562
+ await route.handler(r, res);
2563
+ assert.equal(res.statusCode, 200);
2564
+ const data = JSON.parse(res.body);
2565
+ assert.equal(data.sessionId, "s1", "方向 2 的答复用 sessionId 认领,不冒充 memoryId");
2566
+ assert.equal(data.memoryId, undefined, "两个方向的认领键不许同时出现");
2567
+ assert.equal(data.truncated, false);
2568
+ assert.deepEqual(data.windows.map((w) => [w.seq_start, w.seq_end]), [[1, 3], [10, 12]],
2569
+ "同一 (seq_start, seq_end) 的两条记忆必须并成一行,且按 seq_start 升序");
2570
+ assert.deepEqual(data.windows.map((w) => w.link_count), [1, 2],
2571
+ "link_count 说明该窗口派生出几条记忆——这是一行一窗口之后唯一还剩的「多」的信息");
2572
+ });
2573
+
2574
+ test("GET /api/dsh-mneme/memory/provenance:两个主键都给 → 400,不静默择一(#524)", async () => {
2575
+ const { routes } = setup(null, "secret-token", RECORDS_ON);
2576
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2577
+ const res = new FakeRes();
2578
+ const r = req("/api/dsh-mneme/memory/provenance?id=m1&sessionId=s1");
2579
+ r.headers = { authorization: "Bearer secret-token" };
2580
+ await route.handler(r, res);
2581
+ assert.equal(res.statusCode, 400);
2582
+ assert.equal(JSON.parse(res.body).error, "ambiguous-id");
2583
+ });
2584
+
2585
+ test("GET /api/dsh-mneme/memory/provenance?sessionId=:limit 越界 → 400;撞上限时 truncated=true(#524)", async () => {
2586
+ const { routes, service, store } = setup(null, "secret-token", RECORDS_ON);
2587
+ service.saveWithDedupe({ type: "decision", title: "核心决策", content: "x", importance: 5 });
2588
+ const memory = service.all()[0];
2589
+ for (const n of [1, 2, 3]) {
2590
+ store.saveMemoryProvenance({ memoryId: memory.id, sessionId: "s1", seqStart: n, seqEnd: n, createdAt: "t" });
2591
+ }
2592
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/provenance");
2593
+
2594
+ const bad = new FakeRes();
2595
+ const badReq = req("/api/dsh-mneme/memory/provenance?sessionId=s1&limit=0");
2596
+ badReq.headers = { authorization: "Bearer secret-token" };
2597
+ await route.handler(badReq, bad);
2598
+ assert.equal(bad.statusCode, 400);
2599
+ assert.equal(JSON.parse(bad.body).error, "invalid-limit");
2600
+
2601
+ const ok = new FakeRes();
2602
+ const okReq = req("/api/dsh-mneme/memory/provenance?sessionId=s1&limit=2");
2603
+ okReq.headers = { authorization: "Bearer secret-token" };
2604
+ await route.handler(okReq, ok);
2605
+ assert.equal(ok.statusCode, 200);
2606
+ const data = JSON.parse(ok.body);
2607
+ assert.equal(data.windows.length, 2);
2608
+ assert.equal(data.truncated, true, "还有第三个窗口没回——必须说出来,否则半份转录会被当成全部");
2609
+ });