@modusensus/dsh-mneme 0.9.4 → 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 (126) hide show
  1. package/README.md +57 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +25 -2
  4. package/docs/DAEMON.md +13 -2
  5. package/docs/DISTILL.md +150 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +21 -7
  8. package/docs/STORAGE.md +2 -1
  9. package/lib/api-standalone.js +294 -8
  10. package/lib/api.js +217 -3
  11. package/lib/client.js +919 -39
  12. package/lib/config.js +33 -1
  13. package/lib/continuity.js +11 -3
  14. package/lib/document.js +15 -2
  15. package/lib/dream/decisions.js +176 -30
  16. package/lib/dream/sleep.js +530 -94
  17. package/lib/dream.js +174 -21
  18. package/lib/fence.js +24 -0
  19. package/lib/index.js +15 -6
  20. package/lib/inject.js +9 -4
  21. package/lib/lang.js +38 -16
  22. package/lib/memory-source.js +9 -0
  23. package/lib/organize.js +5 -1
  24. package/lib/pipeline-nudge-auto.js +3 -0
  25. package/lib/pipeline-nudge.js +41 -8
  26. package/lib/pipeline-runner.js +15 -0
  27. package/lib/pipeline.js +62 -1
  28. package/lib/pre-inject-gate.js +14 -0
  29. package/lib/profile-extract.js +4 -1
  30. package/lib/provenance-http.js +251 -0
  31. package/lib/serve.js +13 -0
  32. package/lib/service.js +97 -4
  33. package/lib/session-gate.js +91 -0
  34. package/lib/settings.js +13 -1
  35. package/lib/share-http.js +281 -0
  36. package/lib/share-import.js +234 -0
  37. package/lib/share.js +335 -0
  38. package/lib/store.js +797 -28
  39. package/lib/summarize.js +209 -23
  40. package/lib/tools.js +26 -6
  41. package/package.json +4 -2
  42. package/src/api-standalone.js +294 -8
  43. package/src/api.js +217 -3
  44. package/src/config.js +33 -1
  45. package/src/continuity.js +11 -3
  46. package/src/document.js +15 -2
  47. package/src/dream/decisions.js +176 -30
  48. package/src/dream/sleep.js +530 -94
  49. package/src/dream.js +174 -21
  50. package/src/fence.js +24 -0
  51. package/src/index.js +15 -6
  52. package/src/inject.js +9 -4
  53. package/src/lang.js +38 -16
  54. package/src/memory-source.js +9 -0
  55. package/src/organize.js +5 -1
  56. package/src/pipeline-nudge-auto.js +3 -0
  57. package/src/pipeline-nudge.js +41 -8
  58. package/src/pipeline-runner.js +15 -0
  59. package/src/pipeline.js +62 -1
  60. package/src/pre-inject-gate.js +14 -0
  61. package/src/profile-extract.js +4 -1
  62. package/src/provenance-http.js +251 -0
  63. package/src/serve.js +13 -0
  64. package/src/service.js +97 -4
  65. package/src/session-gate.js +91 -0
  66. package/src/settings.js +13 -1
  67. package/src/share-http.js +281 -0
  68. package/src/share-import.js +234 -0
  69. package/src/share.js +335 -0
  70. package/src/store.js +797 -28
  71. package/src/summarize.js +209 -23
  72. package/src/tools.js +26 -6
  73. package/test/api.test.js +523 -6
  74. package/test/audit.test.js +2 -2
  75. package/test/cli-receive.test.js +390 -0
  76. package/test/cli-share.test.js +437 -0
  77. package/test/client-share.test.js +225 -0
  78. package/test/client.test.js +354 -0
  79. package/test/config.test.js +8 -0
  80. package/test/conflict-reviewed.test.js +79 -0
  81. package/test/continuity.test.js +29 -1
  82. package/test/dedupe-candidate-window.test.js +180 -0
  83. package/test/dependency-overrides.test.js +78 -0
  84. package/test/differentiate-feedback.test.js +248 -0
  85. package/test/dream-candidate-hybrid.test.js +2 -2
  86. package/test/dream-decision-evidence.test.js +314 -0
  87. package/test/dream-fence.test.js +207 -0
  88. package/test/dream.test.js +87 -5
  89. package/test/helpers/dream-mock.js +6 -2
  90. package/test/inject-pools.test.js +2 -1
  91. package/test/llm-message-source.test.js +1 -1
  92. package/test/mint-source.test.js +84 -1
  93. package/test/pipeline-nudge-auto.test.js +26 -2
  94. package/test/pipeline-nudge.test.js +87 -0
  95. package/test/pipeline-r1r2.test.js +129 -0
  96. package/test/pipeline-runner.test.js +26 -0
  97. package/test/policy-epoch.test.js +2 -2
  98. package/test/prompt-stance.test.js +128 -0
  99. package/test/provenance-http.test.js +367 -0
  100. package/test/readme-claims.test.js +45 -0
  101. package/test/reasoning-effort.test.js +10 -10
  102. package/test/release-docs.test.js +7 -1
  103. package/test/scope-candidates.test.js +7 -4
  104. package/test/scope-state.test.js +130 -1
  105. package/test/security-doc.test.js +43 -4
  106. package/test/serve.test.js +62 -0
  107. package/test/session-gate.test.js +264 -0
  108. package/test/settings.test.js +4 -0
  109. package/test/share-foreign-rows.test.js +144 -0
  110. package/test/share-http.test.js +144 -0
  111. package/test/share-import.test.js +577 -0
  112. package/test/share-imported-guard.test.js +311 -0
  113. package/test/share-imported-identity.test.js +111 -0
  114. package/test/share-pack.test.js +511 -0
  115. package/test/share-receive-http.test.js +756 -0
  116. package/test/share-routes.test.js +337 -0
  117. package/test/share-store.test.js +133 -0
  118. package/test/share-validate.test.js +101 -0
  119. package/test/sleep-action-set.test.js +16 -11
  120. package/test/sleep-catchup.test.js +315 -0
  121. package/test/sleep-failure-backoff.test.js +156 -0
  122. package/test/sleep.test.js +293 -0
  123. package/test/standalone-api.test.js +574 -5
  124. package/test/store.test.js +220 -0
  125. package/test/summarize-parse-diagnostics.test.js +75 -0
  126. package/test/summarize.test.js +187 -2
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();
@@ -33,11 +45,56 @@ function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) {
33
45
  }
34
46
  }
35
47
 
36
- function parseSummaryJsonResult(raw) {
48
+ /**
49
+ * 失败形态分类(#502),只在结果不 ok 时产出:
50
+ * - no-array:连 `[` 都没有——模型在说话,不是没按格式;
51
+ * - syntax:有方括号、整体 parse 失败且括号扫描也没救回条目(#339 那一族);
52
+ * - filtered-empty:JSON 合法、`arr` 非空,条目却被 VALID 全数滤掉。**这一种最贵**:
53
+ * 读起来像「没按格式写」,实际是分类词不在枚举内,而游标不推进会让整窗反复重放。
54
+ */
55
+ function describeParseFailure(arr, entries, salvaged, hasBracket) {
56
+ if (!Array.isArray(arr) || salvaged) return { kind: hasBracket ? "syntax" : "no-array", arrCount: 0, sampleTypes: [] };
57
+ if (arr.length === 0) return { kind: "no-array", arrCount: 0, sampleTypes: [] };
58
+ if (entries.length > 0) return null;
59
+ const sampleTypes = [...new Set(arr.map((item) => (typeof item?.type === "string" ? item.type.trim() : "")).filter(Boolean))].slice(0, 5);
60
+ return { kind: "filtered-empty", arrCount: arr.length, sampleTypes };
61
+ }
62
+
63
+ /**
64
+ * 失败行的审计读数(纯函数:不驱动整条蒸馏管线也能测)。filtered-empty 的
65
+ * errorMessage 直接写出 arr/valid 两个数,一眼可辨;metadata 补 failure_kind /
66
+ * arr_count / invalid_entries / sample_types / raw_tail——尾巴是给「先写几条合法
67
+ * 条目再跑偏」那种输出准备的,前 300 字看不出来。
68
+ */
69
+ export function summarizeParseFailureAudit(raw, failure) {
70
+ const text = String(raw ?? "");
71
+ const kind = failure?.kind ?? (text.includes("[") ? "syntax" : "no-array");
72
+ const tail = text.length > 500 ? text.slice(-200) : "";
73
+ const filtered = kind === "filtered-empty" ? failure : null;
74
+ return {
75
+ errorMessage: filtered ? `no valid summary entries (arr=${filtered.arrCount}, valid=0)` : "invalid summary JSON",
76
+ metadata: {
77
+ parse_failure: true,
78
+ failure_kind: kind,
79
+ raw_len: text.length,
80
+ raw_head: text.slice(0, 300),
81
+ ...(tail ? { raw_tail: tail } : {}),
82
+ raw_has_bracket: text.includes("["),
83
+ ...(filtered ? { arr_count: filtered.arrCount, invalid_entries: filtered.arrCount, sample_types: filtered.sampleTypes } : {})
84
+ }
85
+ };
86
+ }
87
+
88
+ /** 导出的原因:#502 要能锁住「哪一种失败」这条判据本身,而不是只锁它的下游读数。 */
89
+ export function parseSummaryJsonResult(raw) {
37
90
  const text = String(raw ?? "");
38
91
  const start = text.indexOf("[");
39
92
  const end = text.lastIndexOf("]");
40
- if (start === -1 || end === -1 || end <= start) return { ok: false, entries: [] };
93
+ // 连方括号都没有 = 模型在说话(#493 的 B 臂正是这一型)。早退也要带上下:否则下游
94
+ // 只能靠「输出里有没有 [」再猜一次,而猜出来的东西不该写进错误消息。
95
+ if (start === -1 || end === -1 || end <= start) {
96
+ return { ok: false, entries: [], failure: describeParseFailure(null, [], false, text.includes("[")) };
97
+ }
41
98
  let arr;
42
99
  let salvaged = false;
43
100
  try {
@@ -77,10 +134,16 @@ function parseSummaryJsonResult(raw) {
77
134
  // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效,
78
135
  // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径
79
136
  // 没有「显式空数组」可言——ok 只看是否救回了条目。
137
+ const ok = salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0);
80
138
  return {
81
- ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0),
139
+ ok,
82
140
  entries,
83
- salvaged
141
+ salvaged,
142
+ // #502:失败时要能说出**是哪一种**失败。「压根没写数组」与「写了合法数组、但条目
143
+ // type 全不在枚举里」过去共用一句 invalid summary JSON,而两者要修的地方完全不同
144
+ // ——后者是菜单与闸门不一致(summary prompt 只列 4 个类型、VALID 吃 7 个),且失败
145
+ // 路径游标不推进,同一窗口会被一遍遍重放(社区报告 #493 追加的实测形态)。
146
+ ...(!ok ? { failure: describeParseFailure(arr, entries, salvaged, text.includes("[")) } : {})
84
147
  };
85
148
  }
86
149
 
@@ -415,7 +478,7 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
415
478
  // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是
416
479
  // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。
417
480
  // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。
418
- 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]}`
419
482
  }],
420
483
  source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
421
484
  })]
@@ -521,7 +584,7 @@ function enqueueDistill(task, intervalMs = 0) {
521
584
  // import + re-export 两行都要。
522
585
  import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
523
586
  export { parsePeakSpec, isInPeakWindow, nextOffPeakAt };
524
- export function createSummarizer(ctx, service, config, deps = {}) {
587
+ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = null) {
525
588
  if (!config.autoSummarize) return { dispose: () => {} };
526
589
 
527
590
  // Issue #239(第 4 项):时钟与定时器可注入——排程不绑死真实时钟,测试才能确定性
@@ -552,9 +615,54 @@ export function createSummarizer(ctx, service, config, deps = {}) {
552
615
  // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
553
616
  const lastDistilledSeq = new Map();
554
617
  // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性
555
- // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即
556
- // 清零;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。
557
- 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
+ }
558
666
  let disposed = false;
559
667
 
560
668
  /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
@@ -619,6 +727,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
619
727
 
620
728
  async function summarize(session, triggerEvent, opts = {}) {
621
729
  if (disposed || inFlight.has(session.id)) return;
730
+ if (sessionGate?.isOff(session)) return;
622
731
 
623
732
  const header = session.requestHeader?.()?.config;
624
733
  // Config override takes priority, then session header, then nothing.
@@ -683,6 +792,14 @@ export function createSummarizer(ctx, service, config, deps = {}) {
683
792
  // ever blocking the summarization itself.
684
793
  let audit = null;
685
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;
686
803
  try {
687
804
  if (!route) return;
688
805
  const persistedCursor = typeof service.getDistillCursor === "function"
@@ -749,6 +866,19 @@ export function createSummarizer(ctx, service, config, deps = {}) {
749
866
  lastRunAt.set(session.id, Date.now());
750
867
  runsUsed.set(session.id, (prevRuns ?? 0) + 1);
751
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
+
752
882
  if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
753
883
  audit = {
754
884
  route,
@@ -890,6 +1020,13 @@ export function createSummarizer(ctx, service, config, deps = {}) {
890
1020
  const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
891
1021
  if (aborted) {
892
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");
893
1030
  return;
894
1031
  }
895
1032
  const parsedResult = parseSummaryJsonResult(text || assembledText);
@@ -899,8 +1036,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
899
1036
  // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何
900
1037
  // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。
901
1038
  const raw = text || assembledText || "";
902
- const streak = (parseFailureStreak.get(session.id) ?? 0) + 1;
903
- parseFailureStreak.set(session.id, streak);
1039
+ const streak = bumpParseFailureStreak(session.id);
904
1040
  // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。
905
1041
  // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随
906
1042
  // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与
@@ -914,19 +1050,21 @@ export function createSummarizer(ctx, service, config, deps = {}) {
914
1050
  if (skipWindow) {
915
1051
  // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。
916
1052
  commitCursor(session.id, nextSeq);
917
- parseFailureStreak.delete(session.id);
1053
+ clearParseFailureStreak(session.id);
918
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;
919
1062
  if (audit) {
920
1063
  audit.status = "error";
921
- audit.errorMessage = "invalid summary JSON";
1064
+ audit.errorMessage = failureAudit.errorMessage;
922
1065
  audit.metadata = {
923
1066
  distillChars,
924
- parse_failure: true,
925
- raw_len: raw.length,
926
- raw_head: raw.slice(0, 300),
927
- // 有没有出现过 `[` 是区分「模型没按格式写」与「格式对但内容救不回来」
928
- // 的唯一低成本判据,也正是 #493 现场那 20 条失败的关键特征。
929
- raw_has_bracket: raw.includes("["),
1067
+ ...failureAudit.metadata,
930
1068
  streak,
931
1069
  ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}),
932
1070
  ...(skipWindow ? { skipped: "parse-failure-streak" } : {})
@@ -934,7 +1072,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
934
1072
  }
935
1073
  return;
936
1074
  }
937
- parseFailureStreak.delete(session.id);
1075
+ clearParseFailureStreak(session.id);
938
1076
  const parsed = parsedResult.entries;
939
1077
  // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
940
1078
  // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
@@ -1013,8 +1151,14 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1013
1151
  // 被并入的既有记忆——本次窗口的原文确实贡献了它,链接它是诚实的。
1014
1152
  if (outcome?.memory?.id) savedIds.push(outcome.memory.id);
1015
1153
  }
1154
+ // #512:run 的条数口径是「本次写成功的记忆行数」,与 savedIds 同源——
1155
+ // 另起一个计数器会在 merged 分支上与它对不上(那一路也 push 了 id)。
1156
+ distilledMemoryCount = savedIds.length;
1016
1157
  if (transcript && transcript.records.length) {
1017
1158
  service.saveDistillRecords(transcript.records);
1159
+ if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined) {
1160
+ distilledWindow = { seqStart: transcript.seqStart, seqEnd: transcript.seqEnd };
1161
+ }
1018
1162
  if (transcript.seqStart !== undefined && transcript.seqEnd !== undefined
1019
1163
  && typeof service.saveMemoryProvenance === "function") {
1020
1164
  for (const memoryId of savedIds) {
@@ -1023,7 +1167,11 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1023
1167
  sessionId: session.id,
1024
1168
  seqStart: transcript.seqStart,
1025
1169
  seqEnd: transcript.seqEnd,
1026
- createdAt: distilledAt
1170
+ createdAt: distilledAt,
1171
+ // #512:链接指回产出它的那一轮。distillRecords 关着时这里本就不跑,
1172
+ // 于是那一轮不会留下任何窗口级归因——这是诚实的(没存原文就没有
1173
+ // 「这段原文」可指),run 行本身照落,条数与状态仍然可查。
1174
+ distillRunId: distillRunId ?? undefined
1027
1175
  });
1028
1176
  }
1029
1177
  }
@@ -1046,6 +1194,12 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1046
1194
  // Issue #239 定标:窗口字符数无条件随成功行落进 metadata。原先只有
1047
1195
  // capped / deduped / salvaged 其中一项发生时才建 metadata,默认档位下
1048
1196
  // 一条都不留——下一级预判规则的阈值就无从定标。
1197
+ // #512:走到这里说明整轮真的落地了(记忆写入 + 游标提交都没抛)。状态按
1198
+ // 实际产出分档,与 dream_runs 的 status 词表对齐:没有可写的记忆是 noop,
1199
+ // 有记忆就是 ok。degraded 留给「写了但解析有损」那一路(下面按 salvaged 判)。
1200
+ distillRunStatus = distilledMemoryCount > 0
1201
+ ? (parsedResult.salvaged ? "degraded" : "ok")
1202
+ : "noop";
1049
1203
  if (audit) {
1050
1204
  audit.metadata = {
1051
1205
  distillChars,
@@ -1059,6 +1213,8 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1059
1213
  // 都会在此之前退出,从而保留窗口供下一次重试。
1060
1214
  if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
1061
1215
  } catch (error) {
1216
+ distillRunStatus = abortedRun || controller.signal.aborted ? "aborted" : "failed";
1217
+ distillRunError = String(error?.message ?? error);
1062
1218
  if (audit) {
1063
1219
  audit.status = "error";
1064
1220
  audit.errorMessage = String(error?.message ?? error);
@@ -1075,9 +1231,14 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1075
1231
  if (prevRuns === undefined) runsUsed.delete(session.id);
1076
1232
  else runsUsed.set(session.id, prevRuns);
1077
1233
  }
1234
+ // #512:run 与它的开销读数(audit 行)分开落,且都不许把已经写好的记忆连坐——
1235
+ // 归因是审计,写不成只该 warn。顺序刻意是「先 audit 后 run」:run.audit_id 要
1236
+ // 引用刚插进去的那一行,而 saveLlmAudit 是按 lastInsertRowid 回读的
1237
+ // (#534/#535 那条注释),拿到的就是自己那行,不会被并发写串。
1238
+ let auditRow = null;
1078
1239
  if (audit) {
1079
1240
  try {
1080
- service.saveLlmAudit({
1241
+ auditRow = service.saveLlmAudit({
1081
1242
  timestamp: audit.timestamp,
1082
1243
  trigger_source: "autoSummarize",
1083
1244
  operation_type: "summarize_compress",
@@ -1098,6 +1259,31 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1098
1259
  ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
1099
1260
  }
1100
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
+ }
1101
1287
  inFlight.delete(session.id);
1102
1288
  }
1103
1289
  }
@@ -1124,7 +1310,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1124
1310
  inFlight.clear();
1125
1311
  lastRunAt.clear();
1126
1312
  lastDistilledSeq.clear();
1127
- parseFailureStreak.clear();
1313
+ parseFailureStreakFallback.clear();
1128
1314
  }
1129
1315
  };
1130
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
  }