@modusensus/dsh-mneme 0.8.14 → 0.8.15

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 (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -673
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -843
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -503
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -229
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -215
  65. package/lib/service.js +2663 -2605
  66. package/lib/settings.js +517 -511
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +118 -118
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -36
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -673
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -843
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -503
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -229
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -215
  137. package/src/service.js +2663 -2605
  138. package/src/settings.js +517 -511
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1690
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -60
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -77
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -367
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -121
  246. package/test/serve.test.js +196 -196
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -751
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
package/src/summarize.js CHANGED
@@ -1,856 +1,891 @@
1
- import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
- import { STR, langOf } from "./lang.js";
3
- // Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝
4
- // 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
5
- // 三条链路同一降级语义,不另造第二份实现。
6
- import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
7
-
8
- // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
9
- // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
10
- // title/content 单列结构(store 无结构化字段),信息浓缩进 content。
11
- /** 解析 LLM 输出中的 JSON 数组,并保留数组是否有效的结果。 */
12
- function parseSummaryJsonResult(raw) {
13
- const text = String(raw ?? "");
14
- const start = text.indexOf("[");
15
- const end = text.lastIndexOf("]");
16
- if (start === -1 || end === -1 || end <= start) return { ok: false, entries: [] };
17
- let arr;
18
- let salvaged = false;
19
- try {
20
- arr = JSON.parse(text.slice(start, end + 1));
21
- } catch {
22
- // issue #339 / E8 实测:10-20% 的窗口输出了含记忆条目的完整对象,只因中段
23
- // 一处语法错误被整窗 JSON.parse 拒收;而失败路径的生产行为是游标不推进 +
24
- // 温度 0 重试同文同错 = 该窗口记忆静默丢失。这里做失败路径修复:括号配对
25
- // 扫描截出完整的顶层对象逐个 parse,救活多少算多少;截出 0 个不视为「模型
26
- // 显式说无内容」(那是 ok:true 专属于真实空数组的语义)。
27
- arr = salvageArrayItems(text.slice(start, end + 1));
28
- salvaged = true;
29
- }
30
- if (!Array.isArray(arr)) return { ok: false, entries: [] };
31
- const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]);
32
- const entries = arr.filter(
33
- (item) =>
34
- item &&
35
- typeof item === "object" &&
36
- VALID.has(item.type) &&
37
- typeof item.title === "string" &&
38
- item.title.trim() &&
39
- typeof item.content === "string" &&
40
- item.content.trim()
41
- ).map((item) => ({
42
- type: item.type,
43
- title: item.title.trim(),
44
- content: item.content.trim(),
45
- importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3
46
- }));
47
- // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效,
48
- // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径
49
- // 没有「显式空数组」可言——ok 只看是否救回了条目。
50
- return {
51
- ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0),
52
- entries,
53
- salvaged
54
- };
55
- }
56
-
57
- /**
58
- * Salvage scanner for malformed JSON arrays (issue #339): extracts every
59
- * complete top-level {...} span, parsing each independently. Two passes,
60
- * merged with de-duplication:
61
- * - pass 1 is string-aware (handles valid objects whose string values
62
- * legitimately contain braces);
63
- * - pass 2 ignores string state entirely — E8 现场的坏对象带奇数个引号
64
- * (stray quote),pass 1 的奇偶失配会把后续对象的收尾 } 吞进字符串里,
65
- * 盲扫按括号深度截取反而能救回它们。坏对象两种扫法都 parse 失败,自然
66
- * 被丢弃;两遍的去重靠序列化键。
67
- */
68
- function salvageArrayItems(chunk) {
69
- const out = [];
70
- const seen = new Set();
71
- const push = (obj) => {
72
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
73
- const key = JSON.stringify(obj);
74
- if (!seen.has(key)) { seen.add(key); out.push(obj); }
75
- };
76
- for (const obj of scanBraceSpans(chunk, true)) push(obj);
77
- for (const obj of scanBraceSpans(chunk, false)) push(obj);
78
- return out;
79
- }
80
-
81
- function scanBraceSpans(chunk, respectStrings) {
82
- // 修订(CodeRabbit review on #350):只接受「最外层数组的直接子对象」——
83
- // 候选 { 必须满足 arrayDepth===1 && objDepth===0 且前一非空白 token 是 [ 或
84
- // ,。否则 (a) 嵌套子数组里的对象会被误捞成顶层记忆;(b) 盲扫遇到字符串值
85
- // 里的 } 会提前断 span,把属性值对象当独立记忆落库。误拦的代价只是少救回
86
- // (安全侧),误捞的代价是写进假记忆。
87
- const items = [];
88
- let arrayDepth = 0;
89
- let objDepth = 0;
90
- let inString = false;
91
- let escape = false;
92
- let objStart = -1;
93
- let prev = "";
94
- for (let i = 0; i < chunk.length; i++) {
95
- const ch = chunk[i];
96
- if (respectStrings && inString) {
97
- if (escape) escape = false;
98
- else if (ch === "\\") escape = true;
99
- else if (ch === '"') inString = false;
100
- continue;
101
- }
102
- if (respectStrings && ch === '"') { inString = true; continue; }
103
- if (ch === "[") {
104
- arrayDepth++;
105
- prev = "[";
106
- continue;
107
- }
108
- if (ch === "]") {
109
- arrayDepth = Math.max(0, arrayDepth - 1);
110
- prev = "]";
111
- continue;
112
- }
113
- if (ch === "{") {
114
- if (arrayDepth === 1 && objDepth === 0 && (prev === "[" || prev === ",")) {
115
- objStart = i;
116
- }
117
- objDepth++;
118
- prev = "{";
119
- continue;
120
- }
121
- if (ch === "}") {
122
- objDepth = Math.max(0, objDepth - 1);
123
- if (objDepth === 0 && objStart !== -1) {
124
- try { items.push(JSON.parse(chunk.slice(objStart, i + 1))); } catch { /* 坏对象跳过 */ }
125
- objStart = -1;
126
- }
127
- prev = "}";
128
- continue;
129
- }
130
- if (ch === ",") { prev = ","; continue; }
131
- if (!/\s/.test(ch)) prev = ch;
132
- }
133
- return items;
134
- }
135
-
136
- /** Extract a JSON array from LLM output that may contain prose around it. */
137
- export function parseSummaryJson(raw) {
138
- return parseSummaryJsonResult(raw).entries;
139
- }
140
-
141
- // The dsh-llm StreamChunk protocol BlockAssembler.push() consumes:
142
- // block-start {index, blockType}, text-delta {index, text},
143
- // block-end {index, block}, finish {reason}. Some consumers observe a
144
- // looser shape ({block} / {delta} / {kind}); normalize before pushing so
145
- // both real adapter streams and shape-tolerant test doubles assemble.
146
- const STREAM_CHUNK_TYPES = new Set([
147
- "block-start",
148
- "text-delta",
149
- "reasoning-delta",
150
- "tool-call-delta",
151
- "block-end",
152
- "usage",
153
- "finish"
154
- ]);
155
-
156
- function toProtocolChunk(chunk) {
157
- switch (chunk.type) {
158
- case "block-start":
159
- return { type: "block-start", index: chunk.index ?? 0, blockType: chunk.blockType ?? chunk.block?.type ?? "text" };
160
- case "text-delta":
161
- return { type: "text-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
162
- case "reasoning-delta":
163
- return { type: "reasoning-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
164
- case "block-end":
165
- return { type: "block-end", index: chunk.index ?? 0, block: chunk.block ?? { type: "text" } };
166
- case "finish":
167
- return {
168
- type: "finish",
169
- reason: chunk.reason ?? { kind: chunk.kind === "error" ? "error" : "stop" },
170
- replayState: chunk.replayState
171
- };
172
- default:
173
- return chunk;
174
- }
175
- }
176
-
177
- // Only direct human prompts are summarized: plugin-injected context
178
- // (AGENTS.md, skill bodies, file-change notices) and other machine-originated
179
- // events must not leak into the memory store. Events without a data payload
180
- // (minimal test doubles) pass the kind check and are handled by the content
181
- // check below.
182
- //
183
- // codingRetrospect: each new distill window contains the complete available
184
- // transcript for that window — user prompts plus assistant public replies, tool
185
- // calls + results and code dispatch output — so the summarizer can see tool
186
- // errors and extract pitfall root causes, not just what the user typed. The same
187
- // filtering stays: only source.kind === "user" prompts enter (plugin/machine
188
- // content is excluded). The result is a single text transcript passed to the
189
- // LLM as one user message (SUMMARY_PROMPT already says "根据下面的会话内容").
190
- //
191
- // Privacy: assistant `reasoning` (private thought) blocks are deliberately NOT
192
- // collected — distilled memories must never sink private reasoning chains.
193
- // Only public text blocks (type "text") reach the summarizer.
194
- function eventSeq(event) {
195
- return Number.isSafeInteger(event?.seq) ? event.seq : undefined;
196
- }
197
-
198
- const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]);
199
-
200
- // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events。
201
- // snapshotEvents 的范围参数按 seq 传入;再做一次事件级过滤,是为了兼容
202
- // 旧实现忽略参数、或把边界解释为闭区间的情况,同时避免用数组下标充当游标。
203
- function snapshotSessionEvents(session, afterSeq, throughSeq) {
204
- let events;
205
- if (typeof session?.snapshotEvents === "function") {
206
- events = afterSeq === undefined && throughSeq === undefined
207
- ? session.snapshotEvents()
208
- : session.snapshotEvents(afterSeq, throughSeq);
209
- } else {
210
- events = session?.events ?? [];
211
- }
212
- if (!Array.isArray(events)) events = session?.events ?? [];
213
- if (!Array.isArray(events)) return [];
214
- return events.filter((event) => {
215
- const seq = eventSeq(event);
216
- // 一旦已有 seq 游标,没有 seq 的事件就无法证明是新增事件,不能重新打开
217
- // 已经成功消费过的窗口。
218
- if (afterSeq !== undefined && (seq === undefined || seq <= afterSeq)) return false;
219
- if (throughSeq !== undefined && seq !== undefined && seq > throughSeq) return false;
220
- return true;
221
- });
222
- }
223
-
224
- function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, throughSeq) {
225
- const events = snapshotSessionEvents(session, afterSeq, throughSeq);
226
- const lines = [];
227
- const seenSubagentMessages = new Set();
228
- // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
229
- // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
230
- const textOf = (content) => {
231
- if (typeof content === "string") return content;
232
- if (!Array.isArray(content)) return "";
233
- return content
234
- .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
235
- .filter((s) => s)
236
- .join("\n");
237
- };
238
- const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
239
- const trimTranscript = (s, n) => {
240
- if (typeof s !== "string" || s.length <= n) return s;
241
- if (n <= 1) return "…".slice(0, n);
242
- return `…${s.slice(-(n - 1))}`;
243
- };
244
- const appendSubagentMessage = (message) => {
245
- const kind = message?.source?.kind;
246
- if (!SUBAGENT_MESSAGE_KINDS.has(kind)) return;
247
- const text = textOf(message?.content);
248
- if (!text.trim()) return;
249
- const key = typeof message?.id === "string" && message.id
250
- ? `id:${message.id}`
251
- : `content:${kind}:${text}`;
252
- if (seenSubagentMessages.has(key)) return;
253
- seenSubagentMessages.add(key);
254
- lines.push(STR.transcriptAgent[language](text));
255
- };
256
- for (const event of events) {
257
- const data = event?.data ?? {};
258
- const kind = data?.source?.kind;
259
- switch (event.type) {
260
- case "user/message": {
261
- if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break;
262
- const text = textOf(data?.content);
263
- if (!text.trim()) break;
264
- if (SUBAGENT_MESSAGE_KINDS.has(kind)) appendSubagentMessage(data);
265
- else lines.push(STR.transcriptUser[language](text));
266
- break;
267
- }
268
- case "agent/inbox/spliced": {
269
- const inserted = Array.isArray(data?.inserted) ? data.inserted : [];
270
- for (const message of inserted) appendSubagentMessage(message);
271
- break;
272
- }
273
- case "assistant/message": {
274
- const msg = data?.message;
275
- const blocks = Array.isArray(msg?.content) ? msg.content : [];
276
- const text = textOf(blocks);
277
- if (text.trim()) lines.push(STR.transcriptAssistant[language](text));
278
- // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
279
- break;
280
- }
281
- case "tool/call": {
282
- const args = typeof data.arguments === "string"
283
- ? data.arguments
284
- : data.arguments ? JSON.stringify(data.arguments) : "";
285
- lines.push(STR.transcriptToolCall[language](data.name ?? "?", trim(args, 300)));
286
- break;
287
- }
288
- case "tool/result": {
289
- // 工具结果有两种形状:旧形状把载荷与 isError 装在 content 内的
290
- // `type:"tool-result"` 块上;DSH 0.2.x 起内核改为直接投递 `role:"tool"`
291
- // 消息本身,content 里只有 `type:"text"` 块、isError 挂在消息上。只认
292
- // 旧块形状时新形状落进 undefined 分支,工具输出与工具报错被静默丢弃。
293
- const message = data?.message;
294
- const blocks = Array.isArray(message?.content) ? message.content : [];
295
- const legacy = blocks.find((block) => block?.type === "tool-result");
296
- const result = legacy ?? (message?.role === "tool" ? message : undefined);
297
- const out = textOf(result?.content);
298
- const status = result?.isError === true ? STR.statusFail[language] : STR.statusOk[language];
299
- lines.push(STR.transcriptToolResult[language](status, trim(out, 500)));
300
- break;
301
- }
302
- case "tool/code-dispatch": {
303
- const out = textOf(data.content);
304
- const status = data.isError === true ? STR.statusFail[language] : STR.statusOk[language];
305
- lines.push(STR.transcriptCode[language](status, trim(out, 500)));
306
- break;
307
- }
308
- default:
309
- break;
310
- }
311
- }
312
- const lastSeq = events.reduce((max, event) => {
313
- const seq = eventSeq(event);
314
- if (seq === undefined) return max;
315
- return max === undefined ? seq : Math.max(max, seq);
316
- }, undefined);
317
- return {
318
- messages: lines.length
319
- ? [createUserMessage({ content: [{ type: "text", text: trimTranscript(lines.join("\n"), maxChars) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })]
320
- : [],
321
- hasEvents: events.length > 0,
322
- lastSeq
323
- };
324
- }
325
-
326
- // ── 智能调速器(429 保护)─────────────────────────────────────────────
327
- // 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的
328
- // 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔
329
- // distillRateLimitIntervalMs 分批放行;命中 429 时按 distillRateLimitBaseDelayMs
330
- // 指数退避(1s→2s→4s…)自动重试 distillRateLimitRetries 次,全程对用户透明,
331
- // 不把 429 错误码直接抛出去。
332
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
333
-
334
- function isRateLimited(error) {
335
- const status = error?.status ?? error?.statusCode ?? error?.response?.status;
336
- if (status === 429) return true;
337
- const msg = String(error?.message ?? error ?? "");
338
- return /429|rate.?limit|too many requests|请求过于频繁/i.test(msg);
339
- }
340
-
341
- // 全局串行链:每个蒸馏任务在前一个结束后才开始,单次失败不阻塞后续。
342
- // 相邻请求间隔按"距上一个结束不足 intervalMs 就补齐等待"实现——单次蒸馏
343
- // 零延迟(上次结束距今已超过间隔,直接放行),只有连续批量蒸馏才触发限速。
344
- let distillQueue = Promise.resolve();
345
- let lastDistillEnd = 0;
346
- function enqueueDistill(task, intervalMs = 0) {
347
- const run = distillQueue.then(async () => {
348
- if (intervalMs > 0) {
349
- const wait = Math.max(0, intervalMs - (Date.now() - lastDistillEnd));
350
- if (wait > 0) await sleep(wait);
351
- }
352
- lastDistillEnd = Date.now();
353
- return task();
354
- });
355
- distillQueue = run.catch(() => {});
356
- return run;
357
- }
358
-
359
- // Issue #239(第 4 项,错峰队列):高峰时段解析与判定已抽到独立零依赖模块
360
- // src/peak-hours.js(PR #320 review:#316 后 summarize 反向依赖 dream,dream 镜像
361
- // 错峰再 import 本文件会成真循环;时段调度本就不是蒸馏私有语义)。这里 re-export
362
- // 兼容既有调用方与测试(test/summarize.test.js 从本文件 import),零改动。
363
- // 注意 re-export 不引入本地作用域——本文件 331/387 行仍调用这两个函数,
364
- // import + re-export 两行都要。
365
- import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
366
- export { parsePeakSpec, isInPeakWindow, nextOffPeakAt };
367
- export function createSummarizer(ctx, service, config, deps = {}) {
368
- if (!config.autoSummarize) return { dispose: () => {} };
369
-
370
- // Issue #239(第 4 项):时钟与定时器可注入——排程不绑死真实时钟,测试才能确定性
371
- // 覆盖「高峰顺延 → 非高峰补跑」。房型同 dream/sleep.js 的注入参数。
372
- const now = typeof deps.now === "function" ? deps.now : () => Date.now();
373
- const setTimer = typeof deps.setTimeoutFn === "function" ? deps.setTimeoutFn : setTimeout;
374
- const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
375
-
376
- const inFlight = new Map();
377
- // Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
378
- // 同生命周期,dispose 时一并清空。
379
- const lastRunAt = new Map();
380
- // Issue #239:per-session 已发起的蒸馏次数(有界检查点用)。只在实际发起 LLM
381
- // 调用时自增,因此被零 LLM 预判挡下的窗口不消耗预算。
382
- const runsUsed = new Map();
383
- // Issue #239(第 4 项):每会话最多挂一个待补跑定时器(错峰队列用);dispose 时
384
- // 一并清理,避免插件卸载后还留着一个会调模型的定时器。
385
- const deferredRuns = new Map();
386
- // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
387
- const lastDistilledSeq = new Map();
388
- let disposed = false;
389
-
390
- /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
391
- function writeAudit(entry) {
392
- if (config?.llmAudit?.enabled === false || typeof service.saveLlmAudit !== "function") return;
393
- try {
394
- service.saveLlmAudit({
395
- trigger_source: "autoSummarize",
396
- operation_type: "summarize_compress",
397
- related_memory_ids: [],
398
- ...entry
399
- });
400
- } catch (auditError) {
401
- ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
402
- }
403
- }
404
-
405
- function persistCursor(sessionId, nextSeq) {
406
- if (!Number.isFinite(nextSeq)) return;
407
- if (typeof service.setDistillCursor !== "function") {
408
- // 第三方宿主拿旧版 service 构造时没有持久化游标能力:降级为内存游标
409
- // (#274 之前的行为),本进程内不重复蒸馏,重启后窗口重放由
410
- // saveWithDedupe 三元组兜底。方法存在但抛错仍向上传播——那是
411
- // 「写失败须回滚」的恰一次语义,不能吞(见 summarize.test.js 回滚用例)。
412
- ctx.logger?.warn?.("dsh-mneme: service.setDistillCursor unavailable, distill cursor falls back to in-memory");
413
- return;
414
- }
415
- service.setDistillCursor(sessionId, nextSeq);
416
- }
417
-
418
- function commitCursor(sessionId, nextSeq) {
419
- persistCursor(sessionId, nextSeq);
420
- if (Number.isFinite(nextSeq)) lastDistilledSeq.set(sessionId, nextSeq);
421
- }
422
-
423
- /**
424
- * Issue #239(第 4 项):把这一轮蒸馏推迟到最近的「高峰结束」时刻。每会话只挂
425
- * 一个定时器(重复触发不叠加);被 summarizePeakMaxDeferMinutes 截断时到点照跑
426
- * (bypassPeak),长高峰不会把蒸馏饿死。定时器 unref——不阻止宿主退出。
427
- */
428
- function scheduleDeferredRun(session) {
429
- if (disposed || deferredRuns.has(session.id)) return;
430
- const at = nextOffPeakAt(new Date(now()), config.summarizePeakHours ?? "");
431
- if (!at) return;
432
- const maxDeferMs = (config.summarizePeakMaxDeferMinutes ?? 0) * 60000;
433
- let delay = Math.max(0, at.getTime() - now());
434
- const capped = maxDeferMs > 0 && delay > maxDeferMs;
435
- if (capped) delay = maxDeferMs;
436
- const timer = setTimer(() => {
437
- deferredRuns.delete(session.id);
438
- if (disposed) return;
439
- // 不传触发事件:补跑要蒸的是「累积后的整窗」(从上次成功游标到最新事件),
440
- // 拿老 triggerEvent 反而会把窗口卡在它那个 seq 上。
441
- summarize(session, undefined, { bypassPeak: capped }).catch((error) => {
442
- if (disposed || error?.name === "AbortError") return;
443
- ctx.logger?.warn?.(`dsh-mneme: deferred summarization failed: ${String(error)}`);
444
- });
445
- }, delay);
446
- timer?.unref?.();
447
- deferredRuns.set(session.id, timer);
448
- }
449
-
450
- async function summarize(session, triggerEvent, opts = {}) {
451
- if (disposed || inFlight.has(session.id)) return;
452
-
453
- const header = session.requestHeader?.()?.config;
454
- // Config override takes priority, then session header, then nothing.
455
- const route = (config.summarizeProvider && config.summarizeModel)
456
- ? { provider: config.summarizeProvider, model: config.summarizeModel }
457
- : (header?.provider && header?.model)
458
- ? { provider: header.provider, model: header.model }
459
- : undefined;
460
-
461
- // Issue #127 间隔门:同一会话两次蒸馏之间的最小间隔(0 = 现状,不限)。进入
462
- // 即打点——失败/degraded 的 run 也占用间隔,与 dreamMinIntervalMinutes 语义
463
- // 一致,防止失败连发。被挡下的 turn/end 静默跳过,但留一行 status='skipped'
464
- // 审计,否则「这一轮为什么没蒸馏」对用户完全不可观测。
465
- const gapMs = (config.summarizeMinIntervalMinutes ?? 0) * 60000;
466
- const prevClaim = lastRunAt.get(session.id);
467
- if (gapMs > 0 && Date.now() - (prevClaim ?? 0) < gapMs) {
468
- writeAudit({
469
- timestamp: new Date().toISOString(),
470
- model_id: route ? `${route.provider}:${route.model}` : "unknown",
471
- status: "skipped",
472
- error_message: "min-interval"
473
- });
474
- return;
475
- }
476
- // Issue #239(有界检查点):每会话 run 预算。放在间隔门之后,两者都命中时
477
- // 先报间隔——间隔是「刚刚跑过」,预算是「额度用尽」,前者信息量更大。skip
478
- // 仍写审计(沿用间隔门的 error_message 口径),否则「为什么不再蒸馏了」对
479
- // 用户不可观测。prevRuns 在这里捕获、finally 里按 aborted 回滚(同 lastRunAt)。
480
- // 游标刻意不消费:预算是「这个会话先不再蒸馏了」,把窗口留在原地,日后调高
481
- // 额度(或宿主重启)仍能蒸馏到它;这与 window-too-small 主动消费游标相反——
482
- // 后者是「这些事件不值得蒸馏」,留着只会每个 turn/end 重评一次。
483
- // Issue #239(第 4 项,错峰队列):高峰期不调模型——只登记一行 skip 审计并择时
484
- // 补跑。游标刻意不消费:窗口继续累积,留到非高峰一次性蒸馏(批量比逐轮碎蒸更
485
- // 省)。被 maxDefer 截断后到点仍处高峰时,由 opts.bypassPeak 放行。
486
- if (!opts.bypassPeak && isInPeakWindow(new Date(now()), config.summarizePeakHours ?? "")) {
487
- writeAudit({
488
- timestamp: new Date(now()).toISOString(),
489
- model_id: route ? `${route.provider}:${route.model}` : "unknown",
490
- status: "skipped",
491
- error_message: "peak-hours"
492
- });
493
- scheduleDeferredRun(session);
494
- return;
495
- }
496
- const prevRuns = runsUsed.get(session.id);
497
- const maxRuns = config.summarizeMaxRunsPerSession ?? 0;
498
- if (maxRuns > 0 && (prevRuns ?? 0) >= maxRuns) {
499
- writeAudit({
500
- timestamp: new Date().toISOString(),
501
- model_id: route ? `${route.provider}:${route.model}` : "unknown",
502
- status: "skipped",
503
- error_message: "max-runs-per-session"
504
- });
505
- return;
506
- }
507
- const controller = new AbortController();
508
- inFlight.set(session.id, controller);
509
- // audit state for the compression call. null = no audit for this run
510
- // (disabled, or no LLM call was actually made). The audit row is written in
511
- // the finally below — once, regardless of which exit path the call took —
512
- // so a failed/aborted stream still leaves a status='error' trail without
513
- // ever blocking the summarization itself.
514
- let audit = null;
515
- let abortedRun = false;
516
- try {
517
- if (!route) return;
518
- const persistedCursor = typeof service.getDistillCursor === "function"
519
- ? service.getDistillCursor(session.id)
520
- : undefined;
521
- const previousSeq = persistedCursor?.last_seq ?? lastDistilledSeq.get(session.id);
522
- const triggerSeq = eventSeq(triggerEvent);
523
- // 只读取上次成功游标之后、当前 turn/end 之前的事件。旧版 snapshotEvents
524
- // 即使忽略范围参数,collectMessages 仍会按事件 seq 二次过滤。
525
- const collected = collectMessages(
526
- session,
527
- config.distillMaxChars ?? 24000,
528
- langOf(config),
529
- previousSeq,
530
- triggerSeq
531
- );
532
- const hasNewEvents = collected.hasEvents
533
- || (triggerSeq !== undefined && (previousSeq === undefined || triggerSeq > previousSeq));
534
- if (!hasNewEvents) return;
535
-
536
- const nextSeq = Math.max(collected.lastSeq ?? Number.NEGATIVE_INFINITY, triggerSeq ?? Number.NEGATIVE_INFINITY);
537
- if (!collected.messages.length) {
538
- // 没有可蒸馏的公开文本也算成功消费当前事件窗口,避免每个 turn/end
539
- // 都重新扫描同一批无内容事件;没有 seq 时则不提交不可验证的游标。
540
- commitCursor(session.id, nextSeq);
541
- return;
542
- }
543
- const messages = collected.messages;
544
- // Issue #239(成本感知级联第一级):零 LLM 预判。窗口内可蒸馏文本不足阈值
545
- // 时直接跳过——短窗口交给模型的产出多是泛泛而谈,代价却是一次完整调用。
546
- // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个
547
- // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载
548
- // 决定下一级规则的依据。
549
- const minWindowChars = config.summarizeMinWindowChars ?? 0;
550
- if (minWindowChars > 0) {
551
- const distillChars = messages.reduce(
552
- (total, message) => total + (Array.isArray(message.content) ? message.content : [])
553
- .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
554
- 0
555
- );
556
- if (distillChars < minWindowChars) {
557
- commitCursor(session.id, nextSeq);
558
- writeAudit({
559
- timestamp: new Date().toISOString(),
560
- model_id: route ? `${route.provider}:${route.model}` : "unknown",
561
- status: "skipped",
562
- error_message: "window-too-small"
563
- });
564
- return;
565
- }
566
- }
567
- // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按
568
- // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。
569
- lastRunAt.set(session.id, Date.now());
570
- runsUsed.set(session.id, (prevRuns ?? 0) + 1);
571
-
572
- if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
573
- audit = {
574
- route,
575
- timestamp: new Date().toISOString(),
576
- startedAt: Date.now(),
577
- inputTokens: 0,
578
- outputTokens: 0,
579
- status: "success",
580
- errorMessage: null
581
- };
582
- }
583
-
584
- // Issue #315:蒸馏思考强度。'none'/未配置都不发送字段(服务商默认生效,
585
- // 行为与历史版本一致);off/low/medium/high 原样传递。
586
- const summarizeEffort = config.summarizeReasoningEffort;
587
- const withEffort = typeof summarizeEffort === "string" && summarizeEffort !== "none";
588
-
589
- const options = {
590
- provider: route.provider,
591
- model: route.model,
592
- purpose: "summarization",
593
- messages: [
594
- { role: "system", content: [{ type: "text", text: config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
595
- ...messages
596
- ],
597
- signal: controller.signal
598
- };
599
- // 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
600
- // 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
601
- const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
602
- // Issue #315:流式失败原因暂存槽。蒸馏的流失败以 aborted 结果而非 throw
603
- // 返回,withEffortFallback 靠它甄别「effort 被拒收」型 aborted(dream 侧
604
- // runNarratives 的 streamFailure 同款模式)。
605
- let streamFailure = "";
606
- const runDistill = (withEffort) => {
607
- streamFailure = "";
608
- return enqueueDistill(async () => {
609
- const retries = config.distillRateLimitRetries ?? 3;
610
- const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
611
- // 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的
612
- // 不带 reasoningEffort,不能复用带字段的同一 options 对象。
613
- const callOptions = withEffort && summarizeEffort
614
- ? { ...options, reasoningEffort: summarizeEffort }
615
- : options;
616
- for (let attempt = 0; ; attempt++) {
617
- // 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置
618
- // error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败
619
- // (否则「摘要成功但审计报失败」,污染 llm_audit_logs 统计)。
620
- if (audit) audit.status = "success";
621
- const assembler = new BlockAssembler();
622
- let text = "";
623
- let aborted = false;
624
- try {
625
- for await (const chunk of ctx.llm.stream(callOptions)) {
626
- if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
627
- if (chunk.type === "text-delta") {
628
- text += chunk.text ?? chunk.delta ?? "";
629
- }
630
- if (chunk.type === "usage" && audit) {
631
- // 同 dream.js:真实协议是 {type:"usage", usage:TokenUsage},用量不在
632
- // chunk 顶层;顶层读取只作为把用量平铺的替身兜底。
633
- const usage = chunk.usage ?? chunk;
634
- const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
635
- const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
636
- if (Number.isFinite(i)) audit.inputTokens = i;
637
- if (Number.isFinite(o)) audit.outputTokens = o;
638
- }
639
- if (chunk.type === "finish") {
640
- const reasonKind = chunk.reason?.kind ?? chunk.kind;
641
- if (reasonKind === "error" || reasonKind === "aborted") {
642
- // 429 也可能以 finish reason error 携带 rate-limit 信息,统一
643
- // 转抛错走指数退避重试。
644
- if (isRateLimited(chunk.reason ?? chunk)) {
645
- throw Object.assign(new Error("rate limited"), { status: 429 });
646
- }
647
- // Issue #315:把失败原因原样留给外层的 effort 甄别
648
- // (withEffortFallback 只认「effort 被拒收」型失败)。
649
- streamFailure = describeStreamFailure(chunk.reason ?? chunk);
650
- if (audit) {
651
- audit.status = "error";
652
- audit.errorMessage = `llm stream ${reasonKind}`;
653
- }
654
- aborted = true;
655
- break;
656
- }
657
- }
658
- }
659
- // Direct delta accumulation is the primary extraction path (it works
660
- // for real protocol chunks {index,text} and looser {delta} shapes
661
- // alike); the assembler blocks are a fallback for streams that only
662
- // deliver text inside block-end. This dsh-llm exposes no public
663
- // no-arg assemble() — blocks() is the message-level API.
664
- const blocks = assembler.blocks();
665
- const assembledText = blocks
666
- .filter((b) => b.type === "text")
667
- .map((b) => b.text ?? "")
668
- .join("");
669
- return { text, assembledText, aborted };
670
- } catch (error) {
671
- if (error?.name === "AbortError" || controller.signal.aborted) throw error; // dispose 中止直接放行
672
- if (isRateLimited(error) && attempt < retries) {
673
- const delay = baseDelayMs * 2 ** attempt;
674
- ctx.logger?.warn?.(
675
- `dsh-mneme: 蒸馏请求过于频繁(429),为避免限流等待 ${delay}ms 后自动重试(第 ${attempt + 1}/${retries} 次)`
676
- );
677
- await sleep(delay);
678
- continue;
679
- }
680
- // 429 重试耗尽或非 429 错误:记 audit 后抛出,保持原失败路径。
681
- if (audit) {
682
- audit.status = "error";
683
- audit.errorMessage = String(error?.message ?? error);
684
- }
685
- throw error;
686
- }
687
- }
688
- }, intervalMs);
689
- };
690
- // Issue #315:effort 被拒收时自动去掉字段重试一次(dream/sleep/entity
691
- // 同一降级策略,withEffortFallback 共享)。蒸馏的流失败以 aborted 结果
692
- // 而非 throw/undefined 返回,因此甄别在这里做:effort 型失败折叠成
693
- // undefined,让 withEffortFallback 走 streamFailure 甄别分支触发重试;
694
- // 非 effort 的 aborted 原样返回,仍走既有的 abortedRun 路径。
695
- const result = await withEffortFallback(
696
- ctx,
697
- summarizeEffort,
698
- () => runDistill(withEffort).then((r) =>
699
- (r?.aborted && EFFORT_REJECT_RE.test(streamFailure)) ? undefined : r),
700
- () => runDistill(false),
701
- () => streamFailure
702
- );
703
- const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
704
- if (aborted) {
705
- abortedRun = true;
706
- return;
707
- }
708
- const parsedResult = parseSummaryJsonResult(text || assembledText);
709
- // 解析失败不推进 seq 游标,下一次 turn/end 仍会重试同一窗口。
710
- if (!parsedResult.ok) {
711
- if (audit) {
712
- audit.status = "error";
713
- audit.errorMessage = "invalid summary JSON";
714
- }
715
- return;
716
- }
717
- const parsed = parsedResult.entries;
718
- // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
719
- // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
720
- const cap = config.summarizeMaxEntriesPerRun ?? 0;
721
- const entries = cap > 0 ? parsed.slice(0, cap) : parsed;
722
- const capped = parsed.length - entries.length;
723
- const dedupeMode = config.summarizeDedupeMode ?? "off";
724
- let deduped = 0;
725
- let dedupeMaxSim = 0;
726
- const writes = [];
727
- for (const entry of entries) {
728
- // Provenance: the summarizer runs on a real session (turn/end hook), so
729
- // session.id is always available here — it rides the human-readable
730
- // source label.
731
- // 编码记忆类型(codingRetrospect)不带 tag:读取侧门控/加权靠 m.type
732
- // (rejected_solution/pitfall/constraint)区分即可,tag 体系
733
- // sanitizeTags 不认 `type:` 前缀反而会清空 tags 列(额外一次 UPDATE)。
734
- const source = `session:${session.id}`;
735
- // Issue #127 落库前去重(opt-in,默认 off = 现状):命中则并入既有条目
736
- // (saveWithDedupe 的 _mergeInto 分支),不再新造一行。作用域 = 同会话 +
737
- // 时间窗;跨会话的同类事实仍交给 autoDream / sleep 的批量裁决。
738
- const dup = (dedupeMode !== "off" && typeof service.findSessionDuplicate === "function")
739
- ? await service.findSessionDuplicate(
740
- { ...entry, source },
741
- {
742
- mode: dedupeMode,
743
- minSim: config.summarizeDedupeMinSim ?? 0.92,
744
- windowHours: config.summarizeDedupeWindowHours ?? 24,
745
- source
746
- }
747
- )
748
- : undefined;
749
- if (dup) {
750
- deduped++;
751
- dedupeMaxSim = Math.max(dedupeMaxSim, dup.sim ?? 0);
752
- }
753
- writes.push({ entry, source, dup });
754
- }
755
- if (writes.length) {
756
- // 查询去重目标可以异步执行,但记忆写入必须在同一个事务中完成。
757
- // 否则第 N 条写入失败时,前 N-1 条会残留,而重试同一 seq 窗口会重复
758
- // 追加它们。createService 始终提供 transaction;缺失时直接失败并保留
759
- // 游标窗口,避免退回到不具备原子性的部分写入。
760
- if (typeof service.transaction !== "function") {
761
- throw new Error("dsh-mneme: atomic summarization writes require service.transaction");
762
- }
763
- service.transaction(() => {
764
- for (const { entry, source, dup } of writes) {
765
- service.saveWithDedupe({
766
- ...entry,
767
- source,
768
- ...(dup ? { _mergeInto: dup.memory.id } : {})
769
- });
770
- }
771
- persistCursor(session.id, nextSeq);
772
- });
773
- } else {
774
- // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。
775
- persistCursor(session.id, nextSeq);
776
- }
777
- if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) {
778
- audit.metadata = {
779
- parsed: parsed.length,
780
- ...(parsedResult.salvaged ? { json_salvaged: true } : {}),
781
- ...(capped > 0 ? { capped } : {}),
782
- ...(deduped > 0 ? { deduped, mode: dedupeMode, maxSim: Number(dedupeMaxSim.toFixed(4)) } : {})
783
- };
784
- }
785
- // 记忆写入和解析都成功后才提交窗口;流失败、中止、解析失败或写入异常
786
- // 都会在此之前退出,从而保留窗口供下一次重试。
787
- if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
788
- } catch (error) {
789
- if (audit) {
790
- audit.status = "error";
791
- audit.errorMessage = String(error?.message ?? error);
792
- }
793
- throw error;
794
- } finally {
795
- // Issue #127:aborted(会话关闭 / 插件 dispose)不占间隔,避免误伤该会话的
796
- // 下一次蒸馏;其余情况(含失败)的打点保留,与 dreamMinIntervalMinutes 一致。
797
- if (abortedRun || controller.signal.aborted) {
798
- if (prevClaim === undefined) lastRunAt.delete(session.id);
799
- else lastRunAt.set(session.id, prevClaim);
800
- // Issue #239:aborted 同样不占 run 预算——调用没有真正发生,占了会误伤
801
- // 该会话后续的蒸馏(与上面的间隔回滚同一口径)。
802
- if (prevRuns === undefined) runsUsed.delete(session.id);
803
- else runsUsed.set(session.id, prevRuns);
804
- }
805
- if (audit) {
806
- try {
807
- service.saveLlmAudit({
808
- timestamp: audit.timestamp,
809
- trigger_source: "autoSummarize",
810
- operation_type: "summarize_compress",
811
- model_id: `${audit.route.provider}:${audit.route.model}`,
812
- input_tokens: audit.inputTokens,
813
- output_tokens: audit.outputTokens,
814
- total_tokens: audit.inputTokens + audit.outputTokens,
815
- cost_usd: 0,
816
- duration_ms: Date.now() - audit.startedAt,
817
- status: audit.status,
818
- error_message: audit.errorMessage,
819
- related_memory_ids: [],
820
- // Issue #127:parsed / capped / deduped 的观测数据。零 schema 变更
821
- // (llm_audit_logs.metadata 已存在),一条 SQL 即可验证节流是否生效。
822
- metadata: audit.metadata
823
- });
824
- } catch (auditError) {
825
- ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
826
- }
827
- }
828
- inFlight.delete(session.id);
829
- }
830
- }
831
-
832
- const unsubscribe = ctx.on("session/event", (session, event) => {
833
- if (disposed || event.type !== "turn/end") return;
834
- // Return the summarization promise so awaiters observe the writes; the
835
- // catch keeps listener dispatch from rejecting. Dispose-initiated aborts
836
- // and external AbortErrors are silent.
837
- return summarize(session, event).catch((error) => {
838
- if (disposed || error?.name === "AbortError") return;
839
- ctx.logger?.warn?.(`dsh-mneme: summarization failed: ${String(error)}`);
840
- });
841
- });
842
-
843
- return {
844
- dispose() {
845
- if (disposed) return;
846
- disposed = true;
847
- unsubscribe?.();
848
- for (const timer of deferredRuns.values()) clearTimer(timer);
849
- deferredRuns.clear();
850
- for (const controller of inFlight.values()) controller.abort();
851
- inFlight.clear();
852
- lastRunAt.clear();
853
- lastDistilledSeq.clear();
854
- }
855
- };
856
- }
1
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
+ import { STR, langOf } from "./lang.js";
3
+ // Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝
4
+ // 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
5
+ // 三条链路同一降级语义,不另造第二份实现。
6
+ import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
7
+
8
+ // 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
9
+ // 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
10
+ // title/content 单列结构(store 无结构化字段),信息浓缩进 content。
11
+ /** 解析 LLM 输出中的 JSON 数组,并保留数组是否有效的结果。 */
12
+ // 蒸馏产物的事件时间锚定(SimpleMem,arXiv 2601.02553:抽取与时间锚定在同一次
13
+ // 生成里完成,消融去掉后时间类问题 F1 掉 56.7%)。归一放在解析层而不是交给
14
+ // store.normalizeOccurredAt:后者只判「可解析就 UTC ISO、否则 NULL」,拦不住模型
15
+ // 编造的未来时间——而未来时间的记忆会污染注入侧的时间排序与 memory_search 的
16
+ // occurred_from 过滤。未知/无法换算的相对时间必须整条省略字段,不能落 null。
17
+ // 纯函数:唯一外部输入是显式入参 nowMs(测试注入假时钟即可确定性复现)。
18
+ const OCCURRED_AT_FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000;
19
+ function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) {
20
+ if (typeof raw !== "string") return undefined;
21
+ const text = raw.trim();
22
+ if (!text) return undefined;
23
+ const ms = Date.parse(text);
24
+ if (!Number.isFinite(ms)) return undefined;
25
+ // 容忍 1 天:模型给的绝对时间可能略晚于当下(时区/时钟偏移),超出即视为瞎填。
26
+ if (ms - nowMs > OCCURRED_AT_FUTURE_TOLERANCE_MS) return undefined;
27
+ try {
28
+ return new Date(ms).toISOString();
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ function parseSummaryJsonResult(raw) {
35
+ const text = String(raw ?? "");
36
+ const start = text.indexOf("[");
37
+ const end = text.lastIndexOf("]");
38
+ if (start === -1 || end === -1 || end <= start) return { ok: false, entries: [] };
39
+ let arr;
40
+ let salvaged = false;
41
+ try {
42
+ arr = JSON.parse(text.slice(start, end + 1));
43
+ } catch {
44
+ // issue #339 / E8 实测:10-20% 的窗口输出了含记忆条目的完整对象,只因中段
45
+ // 一处语法错误被整窗 JSON.parse 拒收;而失败路径的生产行为是游标不推进 +
46
+ // 温度 0 重试同文同错 = 该窗口记忆静默丢失。这里做失败路径修复:括号配对
47
+ // 扫描截出完整的顶层对象逐个 parse,救活多少算多少;截出 0 个不视为「模型
48
+ // 显式说无内容」(那是 ok:true 专属于真实空数组的语义)。
49
+ arr = salvageArrayItems(text.slice(start, end + 1));
50
+ salvaged = true;
51
+ }
52
+ if (!Array.isArray(arr)) return { ok: false, entries: [] };
53
+ const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]);
54
+ const entries = arr.filter(
55
+ (item) =>
56
+ item &&
57
+ typeof item === "object" &&
58
+ VALID.has(item.type) &&
59
+ typeof item.title === "string" &&
60
+ item.title.trim() &&
61
+ typeof item.content === "string" &&
62
+ item.content.trim()
63
+ ).map((item) => {
64
+ const occurredAt = normalizeDistilledOccurredAt(item.occurred_at);
65
+ return {
66
+ type: item.type,
67
+ title: item.title.trim(),
68
+ content: item.content.trim(),
69
+ importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3,
70
+ // 不可解析 / 未来时间:省略该键(不写 null、不写空串),让「模型没给」与
71
+ // 「给了但站不住」在库里同为「无时间锚」,避免下游把空值当成已锚定。
72
+ ...(occurredAt !== undefined ? { occurred_at: occurredAt } : {})
73
+ };
74
+ });
75
+ // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效,
76
+ // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径
77
+ // 没有「显式空数组」可言——ok 只看是否救回了条目。
78
+ return {
79
+ ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0),
80
+ entries,
81
+ salvaged
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Salvage scanner for malformed JSON arrays (issue #339): extracts every
87
+ * complete top-level {...} span, parsing each independently. Two passes,
88
+ * merged with de-duplication:
89
+ * - pass 1 is string-aware (handles valid objects whose string values
90
+ * legitimately contain braces);
91
+ * - pass 2 ignores string state entirely — E8 现场的坏对象带奇数个引号
92
+ * (stray quote),pass 1 的奇偶失配会把后续对象的收尾 } 吞进字符串里,
93
+ * 盲扫按括号深度截取反而能救回它们。坏对象两种扫法都 parse 失败,自然
94
+ * 被丢弃;两遍的去重靠序列化键。
95
+ */
96
+ function salvageArrayItems(chunk) {
97
+ const out = [];
98
+ const seen = new Set();
99
+ const push = (obj) => {
100
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
101
+ const key = JSON.stringify(obj);
102
+ if (!seen.has(key)) { seen.add(key); out.push(obj); }
103
+ };
104
+ for (const obj of scanBraceSpans(chunk, true)) push(obj);
105
+ for (const obj of scanBraceSpans(chunk, false)) push(obj);
106
+ return out;
107
+ }
108
+
109
+ function scanBraceSpans(chunk, respectStrings) {
110
+ // 修订(CodeRabbit review on #350):只接受「最外层数组的直接子对象」——
111
+ // 候选 { 必须满足 arrayDepth===1 && objDepth===0 且前一非空白 token 是 [ 或
112
+ // ,。否则 (a) 嵌套子数组里的对象会被误捞成顶层记忆;(b) 盲扫遇到字符串值
113
+ // 里的 } 会提前断 span,把属性值对象当独立记忆落库。误拦的代价只是少救回
114
+ // (安全侧),误捞的代价是写进假记忆。
115
+ const items = [];
116
+ let arrayDepth = 0;
117
+ let objDepth = 0;
118
+ let inString = false;
119
+ let escape = false;
120
+ let objStart = -1;
121
+ let prev = "";
122
+ for (let i = 0; i < chunk.length; i++) {
123
+ const ch = chunk[i];
124
+ if (respectStrings && inString) {
125
+ if (escape) escape = false;
126
+ else if (ch === "\\") escape = true;
127
+ else if (ch === '"') inString = false;
128
+ continue;
129
+ }
130
+ if (respectStrings && ch === '"') { inString = true; continue; }
131
+ if (ch === "[") {
132
+ arrayDepth++;
133
+ prev = "[";
134
+ continue;
135
+ }
136
+ if (ch === "]") {
137
+ arrayDepth = Math.max(0, arrayDepth - 1);
138
+ prev = "]";
139
+ continue;
140
+ }
141
+ if (ch === "{") {
142
+ if (arrayDepth === 1 && objDepth === 0 && (prev === "[" || prev === ",")) {
143
+ objStart = i;
144
+ }
145
+ objDepth++;
146
+ prev = "{";
147
+ continue;
148
+ }
149
+ if (ch === "}") {
150
+ objDepth = Math.max(0, objDepth - 1);
151
+ if (objDepth === 0 && objStart !== -1) {
152
+ try { items.push(JSON.parse(chunk.slice(objStart, i + 1))); } catch { /* 坏对象跳过 */ }
153
+ objStart = -1;
154
+ }
155
+ prev = "}";
156
+ continue;
157
+ }
158
+ if (ch === ",") { prev = ","; continue; }
159
+ if (!/\s/.test(ch)) prev = ch;
160
+ }
161
+ return items;
162
+ }
163
+
164
+ /** Extract a JSON array from LLM output that may contain prose around it. */
165
+ export function parseSummaryJson(raw) {
166
+ return parseSummaryJsonResult(raw).entries;
167
+ }
168
+
169
+ // The dsh-llm StreamChunk protocol BlockAssembler.push() consumes:
170
+ // block-start {index, blockType}, text-delta {index, text},
171
+ // block-end {index, block}, finish {reason}. Some consumers observe a
172
+ // looser shape ({block} / {delta} / {kind}); normalize before pushing so
173
+ // both real adapter streams and shape-tolerant test doubles assemble.
174
+ const STREAM_CHUNK_TYPES = new Set([
175
+ "block-start",
176
+ "text-delta",
177
+ "reasoning-delta",
178
+ "tool-call-delta",
179
+ "block-end",
180
+ "usage",
181
+ "finish"
182
+ ]);
183
+
184
+ function toProtocolChunk(chunk) {
185
+ switch (chunk.type) {
186
+ case "block-start":
187
+ return { type: "block-start", index: chunk.index ?? 0, blockType: chunk.blockType ?? chunk.block?.type ?? "text" };
188
+ case "text-delta":
189
+ return { type: "text-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
190
+ case "reasoning-delta":
191
+ return { type: "reasoning-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
192
+ case "block-end":
193
+ return { type: "block-end", index: chunk.index ?? 0, block: chunk.block ?? { type: "text" } };
194
+ case "finish":
195
+ return {
196
+ type: "finish",
197
+ reason: chunk.reason ?? { kind: chunk.kind === "error" ? "error" : "stop" },
198
+ replayState: chunk.replayState
199
+ };
200
+ default:
201
+ return chunk;
202
+ }
203
+ }
204
+
205
+ // Only direct human prompts are summarized: plugin-injected context
206
+ // (AGENTS.md, skill bodies, file-change notices) and other machine-originated
207
+ // events must not leak into the memory store. Events without a data payload
208
+ // (minimal test doubles) pass the kind check and are handled by the content
209
+ // check below.
210
+ //
211
+ // codingRetrospect: each new distill window contains the complete available
212
+ // transcript for that window — user prompts plus assistant public replies, tool
213
+ // calls + results and code dispatch output — so the summarizer can see tool
214
+ // errors and extract pitfall root causes, not just what the user typed. The same
215
+ // filtering stays: only source.kind === "user" prompts enter (plugin/machine
216
+ // content is excluded). The result is a single text transcript passed to the
217
+ // LLM as one user message (SUMMARY_PROMPT already says "根据下面的会话内容").
218
+ //
219
+ // Privacy: assistant `reasoning` (private thought) blocks are deliberately NOT
220
+ // collected — distilled memories must never sink private reasoning chains.
221
+ // Only public text blocks (type "text") reach the summarizer.
222
+ function eventSeq(event) {
223
+ return Number.isSafeInteger(event?.seq) ? event.seq : undefined;
224
+ }
225
+
226
+ const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]);
227
+
228
+ // DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events。
229
+ // snapshotEvents 的范围参数按 seq 传入;再做一次事件级过滤,是为了兼容
230
+ // 旧实现忽略参数、或把边界解释为闭区间的情况,同时避免用数组下标充当游标。
231
+ function snapshotSessionEvents(session, afterSeq, throughSeq) {
232
+ let events;
233
+ if (typeof session?.snapshotEvents === "function") {
234
+ events = afterSeq === undefined && throughSeq === undefined
235
+ ? session.snapshotEvents()
236
+ : session.snapshotEvents(afterSeq, throughSeq);
237
+ } else {
238
+ events = session?.events ?? [];
239
+ }
240
+ if (!Array.isArray(events)) events = session?.events ?? [];
241
+ if (!Array.isArray(events)) return [];
242
+ return events.filter((event) => {
243
+ const seq = eventSeq(event);
244
+ // 一旦已有 seq 游标,没有 seq 的事件就无法证明是新增事件,不能重新打开
245
+ // 已经成功消费过的窗口。
246
+ if (afterSeq !== undefined && (seq === undefined || seq <= afterSeq)) return false;
247
+ if (throughSeq !== undefined && seq !== undefined && seq > throughSeq) return false;
248
+ return true;
249
+ });
250
+ }
251
+
252
+ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, throughSeq) {
253
+ const events = snapshotSessionEvents(session, afterSeq, throughSeq);
254
+ const lines = [];
255
+ const seenSubagentMessages = new Set();
256
+ // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
257
+ // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
258
+ const textOf = (content) => {
259
+ if (typeof content === "string") return content;
260
+ if (!Array.isArray(content)) return "";
261
+ return content
262
+ .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
263
+ .filter((s) => s)
264
+ .join("\n");
265
+ };
266
+ const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
267
+ const trimTranscript = (s, n) => {
268
+ if (typeof s !== "string" || s.length <= n) return s;
269
+ if (n <= 1) return "…".slice(0, n);
270
+ return `…${s.slice(-(n - 1))}`;
271
+ };
272
+ const appendSubagentMessage = (message) => {
273
+ const kind = message?.source?.kind;
274
+ if (!SUBAGENT_MESSAGE_KINDS.has(kind)) return;
275
+ const text = textOf(message?.content);
276
+ if (!text.trim()) return;
277
+ const key = typeof message?.id === "string" && message.id
278
+ ? `id:${message.id}`
279
+ : `content:${kind}:${text}`;
280
+ if (seenSubagentMessages.has(key)) return;
281
+ seenSubagentMessages.add(key);
282
+ lines.push(STR.transcriptAgent[language](text));
283
+ };
284
+ for (const event of events) {
285
+ const data = event?.data ?? {};
286
+ const kind = data?.source?.kind;
287
+ switch (event.type) {
288
+ case "user/message": {
289
+ if (kind !== undefined && kind !== "user" && !SUBAGENT_MESSAGE_KINDS.has(kind)) break;
290
+ const text = textOf(data?.content);
291
+ if (!text.trim()) break;
292
+ if (SUBAGENT_MESSAGE_KINDS.has(kind)) appendSubagentMessage(data);
293
+ else lines.push(STR.transcriptUser[language](text));
294
+ break;
295
+ }
296
+ case "agent/inbox/spliced": {
297
+ const inserted = Array.isArray(data?.inserted) ? data.inserted : [];
298
+ for (const message of inserted) appendSubagentMessage(message);
299
+ break;
300
+ }
301
+ case "assistant/message": {
302
+ const msg = data?.message;
303
+ const blocks = Array.isArray(msg?.content) ? msg.content : [];
304
+ const text = textOf(blocks);
305
+ if (text.trim()) lines.push(STR.transcriptAssistant[language](text));
306
+ // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
307
+ break;
308
+ }
309
+ case "tool/call": {
310
+ const args = typeof data.arguments === "string"
311
+ ? data.arguments
312
+ : data.arguments ? JSON.stringify(data.arguments) : "";
313
+ lines.push(STR.transcriptToolCall[language](data.name ?? "?", trim(args, 300)));
314
+ break;
315
+ }
316
+ case "tool/result": {
317
+ // 工具结果有两种形状:旧形状把载荷与 isError 装在 content 内的
318
+ // `type:"tool-result"` 块上;DSH 0.2.x 起内核改为直接投递 `role:"tool"`
319
+ // 消息本身,content 里只有 `type:"text"` 块、isError 挂在消息上。只认
320
+ // 旧块形状时新形状落进 undefined 分支,工具输出与工具报错被静默丢弃。
321
+ const message = data?.message;
322
+ const blocks = Array.isArray(message?.content) ? message.content : [];
323
+ const legacy = blocks.find((block) => block?.type === "tool-result");
324
+ const result = legacy ?? (message?.role === "tool" ? message : undefined);
325
+ const out = textOf(result?.content);
326
+ const status = result?.isError === true ? STR.statusFail[language] : STR.statusOk[language];
327
+ lines.push(STR.transcriptToolResult[language](status, trim(out, 500)));
328
+ break;
329
+ }
330
+ case "tool/code-dispatch": {
331
+ const out = textOf(data.content);
332
+ const status = data.isError === true ? STR.statusFail[language] : STR.statusOk[language];
333
+ lines.push(STR.transcriptCode[language](status, trim(out, 500)));
334
+ break;
335
+ }
336
+ default:
337
+ break;
338
+ }
339
+ }
340
+ const lastSeq = events.reduce((max, event) => {
341
+ const seq = eventSeq(event);
342
+ if (seq === undefined) return max;
343
+ return max === undefined ? seq : Math.max(max, seq);
344
+ }, undefined);
345
+ return {
346
+ messages: lines.length
347
+ ? [createUserMessage({ content: [{ type: "text", text: trimTranscript(lines.join("\n"), maxChars) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })]
348
+ : [],
349
+ hasEvents: events.length > 0,
350
+ lastSeq
351
+ };
352
+ }
353
+
354
+ // ── 智能调速器(429 保护)─────────────────────────────────────────────
355
+ // 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的
356
+ // 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔
357
+ // distillRateLimitIntervalMs 分批放行;命中 429 时按 distillRateLimitBaseDelayMs
358
+ // 指数退避(1s→2s→4s…)自动重试 distillRateLimitRetries 次,全程对用户透明,
359
+ // 不把 429 错误码直接抛出去。
360
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
361
+
362
+ function isRateLimited(error) {
363
+ const status = error?.status ?? error?.statusCode ?? error?.response?.status;
364
+ if (status === 429) return true;
365
+ const msg = String(error?.message ?? error ?? "");
366
+ return /429|rate.?limit|too many requests|请求过于频繁/i.test(msg);
367
+ }
368
+
369
+ // 全局串行链:每个蒸馏任务在前一个结束后才开始,单次失败不阻塞后续。
370
+ // 相邻请求间隔按"距上一个结束不足 intervalMs 就补齐等待"实现——单次蒸馏
371
+ // 零延迟(上次结束距今已超过间隔,直接放行),只有连续批量蒸馏才触发限速。
372
+ let distillQueue = Promise.resolve();
373
+ let lastDistillEnd = 0;
374
+ function enqueueDistill(task, intervalMs = 0) {
375
+ const run = distillQueue.then(async () => {
376
+ if (intervalMs > 0) {
377
+ const wait = Math.max(0, intervalMs - (Date.now() - lastDistillEnd));
378
+ if (wait > 0) await sleep(wait);
379
+ }
380
+ lastDistillEnd = Date.now();
381
+ return task();
382
+ });
383
+ distillQueue = run.catch(() => {});
384
+ return run;
385
+ }
386
+
387
+ // Issue #239(第 4 项,错峰队列):高峰时段解析与判定已抽到独立零依赖模块
388
+ // src/peak-hours.js(PR #320 review:#316 后 summarize 反向依赖 dream,dream 镜像
389
+ // 错峰再 import 本文件会成真循环;时段调度本就不是蒸馏私有语义)。这里 re-export
390
+ // 兼容既有调用方与测试(test/summarize.test.js 从本文件 import),零改动。
391
+ // 注意 re-export 不引入本地作用域——本文件 331/387 行仍调用这两个函数,
392
+ // import + re-export 两行都要。
393
+ import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
394
+ export { parsePeakSpec, isInPeakWindow, nextOffPeakAt };
395
+ export function createSummarizer(ctx, service, config, deps = {}) {
396
+ if (!config.autoSummarize) return { dispose: () => {} };
397
+
398
+ // Issue #239(第 4 项):时钟与定时器可注入——排程不绑死真实时钟,测试才能确定性
399
+ // 覆盖「高峰顺延 → 非高峰补跑」。房型同 dream/sleep.js 的注入参数。
400
+ const now = typeof deps.now === "function" ? deps.now : () => Date.now();
401
+ const setTimer = typeof deps.setTimeoutFn === "function" ? deps.setTimeoutFn : setTimeout;
402
+ const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
403
+
404
+ const inFlight = new Map();
405
+ // Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
406
+ // 同生命周期,dispose 时一并清空。
407
+ const lastRunAt = new Map();
408
+ // Issue #239:per-session 已发起的蒸馏次数(有界检查点用)。只在实际发起 LLM
409
+ // 调用时自增,因此被零 LLM 预判挡下的窗口不消耗预算。
410
+ const runsUsed = new Map();
411
+ // Issue #239(第 4 项):每会话最多挂一个待补跑定时器(错峰队列用);dispose 时
412
+ // 一并清理,避免插件卸载后还留着一个会调模型的定时器。
413
+ const deferredRuns = new Map();
414
+ // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
415
+ const lastDistilledSeq = new Map();
416
+ let disposed = false;
417
+
418
+ /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
419
+ function writeAudit(entry) {
420
+ if (config?.llmAudit?.enabled === false || typeof service.saveLlmAudit !== "function") return;
421
+ try {
422
+ service.saveLlmAudit({
423
+ trigger_source: "autoSummarize",
424
+ operation_type: "summarize_compress",
425
+ related_memory_ids: [],
426
+ ...entry
427
+ });
428
+ } catch (auditError) {
429
+ ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
430
+ }
431
+ }
432
+
433
+ function persistCursor(sessionId, nextSeq) {
434
+ if (!Number.isFinite(nextSeq)) return;
435
+ if (typeof service.setDistillCursor !== "function") {
436
+ // 第三方宿主拿旧版 service 构造时没有持久化游标能力:降级为内存游标
437
+ // (#274 之前的行为),本进程内不重复蒸馏,重启后窗口重放由
438
+ // saveWithDedupe 三元组兜底。方法存在但抛错仍向上传播——那是
439
+ // 「写失败须回滚」的恰一次语义,不能吞(见 summarize.test.js 回滚用例)。
440
+ ctx.logger?.warn?.("dsh-mneme: service.setDistillCursor unavailable, distill cursor falls back to in-memory");
441
+ return;
442
+ }
443
+ service.setDistillCursor(sessionId, nextSeq);
444
+ }
445
+
446
+ function commitCursor(sessionId, nextSeq) {
447
+ persistCursor(sessionId, nextSeq);
448
+ if (Number.isFinite(nextSeq)) lastDistilledSeq.set(sessionId, nextSeq);
449
+ }
450
+
451
+ /**
452
+ * Issue #239(第 4 项):把这一轮蒸馏推迟到最近的「高峰结束」时刻。每会话只挂
453
+ * 一个定时器(重复触发不叠加);被 summarizePeakMaxDeferMinutes 截断时到点照跑
454
+ * (bypassPeak),长高峰不会把蒸馏饿死。定时器 unref——不阻止宿主退出。
455
+ */
456
+ function scheduleDeferredRun(session) {
457
+ if (disposed || deferredRuns.has(session.id)) return;
458
+ const at = nextOffPeakAt(new Date(now()), config.summarizePeakHours ?? "");
459
+ if (!at) return;
460
+ const maxDeferMs = (config.summarizePeakMaxDeferMinutes ?? 0) * 60000;
461
+ let delay = Math.max(0, at.getTime() - now());
462
+ const capped = maxDeferMs > 0 && delay > maxDeferMs;
463
+ if (capped) delay = maxDeferMs;
464
+ const timer = setTimer(() => {
465
+ deferredRuns.delete(session.id);
466
+ if (disposed) return;
467
+ // 不传触发事件:补跑要蒸的是「累积后的整窗」(从上次成功游标到最新事件),
468
+ // 拿老 triggerEvent 反而会把窗口卡在它那个 seq 上。
469
+ summarize(session, undefined, { bypassPeak: capped }).catch((error) => {
470
+ if (disposed || error?.name === "AbortError") return;
471
+ ctx.logger?.warn?.(`dsh-mneme: deferred summarization failed: ${String(error)}`);
472
+ });
473
+ }, delay);
474
+ timer?.unref?.();
475
+ deferredRuns.set(session.id, timer);
476
+ }
477
+
478
+ async function summarize(session, triggerEvent, opts = {}) {
479
+ if (disposed || inFlight.has(session.id)) return;
480
+
481
+ const header = session.requestHeader?.()?.config;
482
+ // Config override takes priority, then session header, then nothing.
483
+ const route = (config.summarizeProvider && config.summarizeModel)
484
+ ? { provider: config.summarizeProvider, model: config.summarizeModel }
485
+ : (header?.provider && header?.model)
486
+ ? { provider: header.provider, model: header.model }
487
+ : undefined;
488
+
489
+ // Issue #127 间隔门:同一会话两次蒸馏之间的最小间隔(0 = 现状,不限)。进入
490
+ // 即打点——失败/degraded 的 run 也占用间隔,与 dreamMinIntervalMinutes 语义
491
+ // 一致,防止失败连发。被挡下的 turn/end 静默跳过,但留一行 status='skipped'
492
+ // 审计,否则「这一轮为什么没蒸馏」对用户完全不可观测。
493
+ const gapMs = (config.summarizeMinIntervalMinutes ?? 0) * 60000;
494
+ const prevClaim = lastRunAt.get(session.id);
495
+ if (gapMs > 0 && Date.now() - (prevClaim ?? 0) < gapMs) {
496
+ writeAudit({
497
+ timestamp: new Date().toISOString(),
498
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
499
+ status: "skipped",
500
+ error_message: "min-interval"
501
+ });
502
+ return;
503
+ }
504
+ // Issue #239(有界检查点):每会话 run 预算。放在间隔门之后,两者都命中时
505
+ // 先报间隔——间隔是「刚刚跑过」,预算是「额度用尽」,前者信息量更大。skip
506
+ // 仍写审计(沿用间隔门的 error_message 口径),否则「为什么不再蒸馏了」对
507
+ // 用户不可观测。prevRuns 在这里捕获、finally 里按 aborted 回滚(同 lastRunAt)。
508
+ // 游标刻意不消费:预算是「这个会话先不再蒸馏了」,把窗口留在原地,日后调高
509
+ // 额度(或宿主重启)仍能蒸馏到它;这与 window-too-small 主动消费游标相反——
510
+ // 后者是「这些事件不值得蒸馏」,留着只会每个 turn/end 重评一次。
511
+ // Issue #239(第 4 项,错峰队列):高峰期不调模型——只登记一行 skip 审计并择时
512
+ // 补跑。游标刻意不消费:窗口继续累积,留到非高峰一次性蒸馏(批量比逐轮碎蒸更
513
+ // 省)。被 maxDefer 截断后到点仍处高峰时,由 opts.bypassPeak 放行。
514
+ if (!opts.bypassPeak && isInPeakWindow(new Date(now()), config.summarizePeakHours ?? "")) {
515
+ writeAudit({
516
+ timestamp: new Date(now()).toISOString(),
517
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
518
+ status: "skipped",
519
+ error_message: "peak-hours"
520
+ });
521
+ scheduleDeferredRun(session);
522
+ return;
523
+ }
524
+ const prevRuns = runsUsed.get(session.id);
525
+ const maxRuns = config.summarizeMaxRunsPerSession ?? 0;
526
+ if (maxRuns > 0 && (prevRuns ?? 0) >= maxRuns) {
527
+ writeAudit({
528
+ timestamp: new Date().toISOString(),
529
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
530
+ status: "skipped",
531
+ error_message: "max-runs-per-session"
532
+ });
533
+ return;
534
+ }
535
+ const controller = new AbortController();
536
+ inFlight.set(session.id, controller);
537
+ // audit state for the compression call. null = no audit for this run
538
+ // (disabled, or no LLM call was actually made). The audit row is written in
539
+ // the finally below — once, regardless of which exit path the call took —
540
+ // so a failed/aborted stream still leaves a status='error' trail without
541
+ // ever blocking the summarization itself.
542
+ let audit = null;
543
+ let abortedRun = false;
544
+ try {
545
+ if (!route) return;
546
+ const persistedCursor = typeof service.getDistillCursor === "function"
547
+ ? service.getDistillCursor(session.id)
548
+ : undefined;
549
+ const previousSeq = persistedCursor?.last_seq ?? lastDistilledSeq.get(session.id);
550
+ const triggerSeq = eventSeq(triggerEvent);
551
+ // 只读取上次成功游标之后、当前 turn/end 之前的事件。旧版 snapshotEvents
552
+ // 即使忽略范围参数,collectMessages 仍会按事件 seq 二次过滤。
553
+ const collected = collectMessages(
554
+ session,
555
+ config.distillMaxChars ?? 24000,
556
+ langOf(config),
557
+ previousSeq,
558
+ triggerSeq
559
+ );
560
+ const hasNewEvents = collected.hasEvents
561
+ || (triggerSeq !== undefined && (previousSeq === undefined || triggerSeq > previousSeq));
562
+ if (!hasNewEvents) return;
563
+
564
+ const nextSeq = Math.max(collected.lastSeq ?? Number.NEGATIVE_INFINITY, triggerSeq ?? Number.NEGATIVE_INFINITY);
565
+ if (!collected.messages.length) {
566
+ // 没有可蒸馏的公开文本也算成功消费当前事件窗口,避免每个 turn/end
567
+ // 都重新扫描同一批无内容事件;没有 seq 时则不提交不可验证的游标。
568
+ commitCursor(session.id, nextSeq);
569
+ return;
570
+ }
571
+ const messages = collected.messages;
572
+ // Issue #239(成本感知级联第一级):零 LLM 预判。窗口内可蒸馏文本不足阈值
573
+ // 时直接跳过——短窗口交给模型的产出多是泛泛而谈,代价却是一次完整调用。
574
+ // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个
575
+ // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载
576
+ // 决定下一级规则的依据。
577
+ const minWindowChars = config.summarizeMinWindowChars ?? 0;
578
+ if (minWindowChars > 0) {
579
+ const distillChars = messages.reduce(
580
+ (total, message) => total + (Array.isArray(message.content) ? message.content : [])
581
+ .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
582
+ 0
583
+ );
584
+ if (distillChars < minWindowChars) {
585
+ commitCursor(session.id, nextSeq);
586
+ writeAudit({
587
+ timestamp: new Date().toISOString(),
588
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
589
+ status: "skipped",
590
+ error_message: "window-too-small"
591
+ });
592
+ return;
593
+ }
594
+ }
595
+ // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按
596
+ // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。
597
+ lastRunAt.set(session.id, Date.now());
598
+ runsUsed.set(session.id, (prevRuns ?? 0) + 1);
599
+
600
+ if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
601
+ audit = {
602
+ route,
603
+ timestamp: new Date().toISOString(),
604
+ startedAt: Date.now(),
605
+ inputTokens: 0,
606
+ outputTokens: 0,
607
+ status: "success",
608
+ errorMessage: null
609
+ };
610
+ }
611
+
612
+ // Issue #315:蒸馏思考强度。'none'/未配置都不发送字段(服务商默认生效,
613
+ // 行为与历史版本一致);off/low/medium/high 原样传递。
614
+ const summarizeEffort = config.summarizeReasoningEffort;
615
+ const withEffort = typeof summarizeEffort === "string" && summarizeEffort !== "none";
616
+
617
+ // 时间锚点:转录行不带时间戳,prompt 要模型把「昨天/上周」换算成绝对
618
+ // occurred_at,就必须给基准,否则它只能编(幻觉)。锚点取本次蒸馏触发时刻
619
+ // (可注入 now(),测试用假时钟);锚点行同时是 prompt 里「按提示末尾给出的
620
+ // 当前时间换算」那句的落点,改写/移动随两处一起动。
621
+ const distillPrompt = config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)];
622
+ const anchoredPrompt = `${distillPrompt}${STR.distillTimeAnchor[langOf(config)](new Date(now()).toISOString())}`;
623
+
624
+ const options = {
625
+ provider: route.provider,
626
+ model: route.model,
627
+ purpose: "summarization",
628
+ messages: [
629
+ { role: "system", content: [{ type: "text", text: anchoredPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
630
+ ...messages
631
+ ],
632
+ signal: controller.signal
633
+ };
634
+ // 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
635
+ // 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
636
+ const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
637
+ // Issue #315:流式失败原因暂存槽。蒸馏的流失败以 aborted 结果而非 throw
638
+ // 返回,withEffortFallback 靠它甄别「effort 被拒收」型 aborted(dream 侧
639
+ // runNarratives 的 streamFailure 同款模式)。
640
+ let streamFailure = "";
641
+ const runDistill = (withEffort) => {
642
+ streamFailure = "";
643
+ return enqueueDistill(async () => {
644
+ const retries = config.distillRateLimitRetries ?? 3;
645
+ const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
646
+ // 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的
647
+ // 不带 reasoningEffort,不能复用带字段的同一 options 对象。
648
+ const callOptions = withEffort && summarizeEffort
649
+ ? { ...options, reasoningEffort: summarizeEffort }
650
+ : options;
651
+ for (let attempt = 0; ; attempt++) {
652
+ // 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置
653
+ // error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败
654
+ // (否则「摘要成功但审计报失败」,污染 llm_audit_logs 统计)。
655
+ if (audit) audit.status = "success";
656
+ const assembler = new BlockAssembler();
657
+ let text = "";
658
+ let aborted = false;
659
+ try {
660
+ for await (const chunk of ctx.llm.stream(callOptions)) {
661
+ if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
662
+ if (chunk.type === "text-delta") {
663
+ text += chunk.text ?? chunk.delta ?? "";
664
+ }
665
+ if (chunk.type === "usage" && audit) {
666
+ // 同 dream.js:真实协议是 {type:"usage", usage:TokenUsage},用量不在
667
+ // chunk 顶层;顶层读取只作为把用量平铺的替身兜底。
668
+ const usage = chunk.usage ?? chunk;
669
+ const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
670
+ const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
671
+ if (Number.isFinite(i)) audit.inputTokens = i;
672
+ if (Number.isFinite(o)) audit.outputTokens = o;
673
+ }
674
+ if (chunk.type === "finish") {
675
+ const reasonKind = chunk.reason?.kind ?? chunk.kind;
676
+ if (reasonKind === "error" || reasonKind === "aborted") {
677
+ // 429 也可能以 finish reason error 携带 rate-limit 信息,统一
678
+ // 转抛错走指数退避重试。
679
+ if (isRateLimited(chunk.reason ?? chunk)) {
680
+ throw Object.assign(new Error("rate limited"), { status: 429 });
681
+ }
682
+ // Issue #315:把失败原因原样留给外层的 effort 甄别
683
+ // (withEffortFallback 只认「effort 被拒收」型失败)。
684
+ streamFailure = describeStreamFailure(chunk.reason ?? chunk);
685
+ if (audit) {
686
+ audit.status = "error";
687
+ audit.errorMessage = `llm stream ${reasonKind}`;
688
+ }
689
+ aborted = true;
690
+ break;
691
+ }
692
+ }
693
+ }
694
+ // Direct delta accumulation is the primary extraction path (it works
695
+ // for real protocol chunks {index,text} and looser {delta} shapes
696
+ // alike); the assembler blocks are a fallback for streams that only
697
+ // deliver text inside block-end. This dsh-llm exposes no public
698
+ // no-arg assemble() — blocks() is the message-level API.
699
+ const blocks = assembler.blocks();
700
+ const assembledText = blocks
701
+ .filter((b) => b.type === "text")
702
+ .map((b) => b.text ?? "")
703
+ .join("");
704
+ return { text, assembledText, aborted };
705
+ } catch (error) {
706
+ if (error?.name === "AbortError" || controller.signal.aborted) throw error; // dispose 中止直接放行
707
+ if (isRateLimited(error) && attempt < retries) {
708
+ const delay = baseDelayMs * 2 ** attempt;
709
+ ctx.logger?.warn?.(
710
+ `dsh-mneme: 蒸馏请求过于频繁(429),为避免限流等待 ${delay}ms 后自动重试(第 ${attempt + 1}/${retries} 次)`
711
+ );
712
+ await sleep(delay);
713
+ continue;
714
+ }
715
+ // 429 重试耗尽或非 429 错误:记 audit 后抛出,保持原失败路径。
716
+ if (audit) {
717
+ audit.status = "error";
718
+ audit.errorMessage = String(error?.message ?? error);
719
+ }
720
+ throw error;
721
+ }
722
+ }
723
+ }, intervalMs);
724
+ };
725
+ // Issue #315:effort 被拒收时自动去掉字段重试一次(dream/sleep/entity
726
+ // 同一降级策略,withEffortFallback 共享)。蒸馏的流失败以 aborted 结果
727
+ // 而非 throw/undefined 返回,因此甄别在这里做:effort 型失败折叠成
728
+ // undefined,让 withEffortFallback 走 streamFailure 甄别分支触发重试;
729
+ // 非 effort 的 aborted 原样返回,仍走既有的 abortedRun 路径。
730
+ const result = await withEffortFallback(
731
+ ctx,
732
+ summarizeEffort,
733
+ () => runDistill(withEffort).then((r) =>
734
+ (r?.aborted && EFFORT_REJECT_RE.test(streamFailure)) ? undefined : r),
735
+ () => runDistill(false),
736
+ () => streamFailure
737
+ );
738
+ const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
739
+ if (aborted) {
740
+ abortedRun = true;
741
+ return;
742
+ }
743
+ const parsedResult = parseSummaryJsonResult(text || assembledText);
744
+ // 解析失败不推进 seq 游标,下一次 turn/end 仍会重试同一窗口。
745
+ if (!parsedResult.ok) {
746
+ if (audit) {
747
+ audit.status = "error";
748
+ audit.errorMessage = "invalid summary JSON";
749
+ }
750
+ return;
751
+ }
752
+ const parsed = parsedResult.entries;
753
+ // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
754
+ // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
755
+ const cap = config.summarizeMaxEntriesPerRun ?? 0;
756
+ const entries = cap > 0 ? parsed.slice(0, cap) : parsed;
757
+ const capped = parsed.length - entries.length;
758
+ const dedupeMode = config.summarizeDedupeMode ?? "off";
759
+ let deduped = 0;
760
+ let dedupeMaxSim = 0;
761
+ const writes = [];
762
+ for (const entry of entries) {
763
+ // Provenance: the summarizer runs on a real session (turn/end hook), so
764
+ // session.id is always available here — it rides the human-readable
765
+ // source label.
766
+ // 编码记忆类型(codingRetrospect)不带 tag:读取侧门控/加权靠 m.type
767
+ // (rejected_solution/pitfall/constraint)区分即可,tag 体系
768
+ // sanitizeTags 不认 `type:` 前缀反而会清空 tags 列(额外一次 UPDATE)。
769
+ const source = `session:${session.id}`;
770
+ // Issue #127 落库前去重(opt-in,默认 off = 现状):命中则并入既有条目
771
+ // (saveWithDedupe 的 _mergeInto 分支),不再新造一行。作用域 = 同会话 +
772
+ // 时间窗;跨会话的同类事实仍交给 autoDream / sleep 的批量裁决。
773
+ const dup = (dedupeMode !== "off" && typeof service.findSessionDuplicate === "function")
774
+ ? await service.findSessionDuplicate(
775
+ { ...entry, source },
776
+ {
777
+ mode: dedupeMode,
778
+ minSim: config.summarizeDedupeMinSim ?? 0.92,
779
+ windowHours: config.summarizeDedupeWindowHours ?? 24,
780
+ source
781
+ }
782
+ )
783
+ : undefined;
784
+ if (dup) {
785
+ deduped++;
786
+ dedupeMaxSim = Math.max(dedupeMaxSim, dup.sim ?? 0);
787
+ }
788
+ writes.push({ entry, source, dup });
789
+ }
790
+ if (writes.length) {
791
+ // 查询去重目标可以异步执行,但记忆写入必须在同一个事务中完成。
792
+ // 否则第 N 条写入失败时,前 N-1 条会残留,而重试同一 seq 窗口会重复
793
+ // 追加它们。createService 始终提供 transaction;缺失时直接失败并保留
794
+ // 游标窗口,避免退回到不具备原子性的部分写入。
795
+ if (typeof service.transaction !== "function") {
796
+ throw new Error("dsh-mneme: atomic summarization writes require service.transaction");
797
+ }
798
+ service.transaction(() => {
799
+ for (const { entry, source, dup } of writes) {
800
+ service.saveWithDedupe({
801
+ ...entry,
802
+ source,
803
+ ...(dup ? { _mergeInto: dup.memory.id } : {})
804
+ });
805
+ }
806
+ persistCursor(session.id, nextSeq);
807
+ });
808
+ } else {
809
+ // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。
810
+ persistCursor(session.id, nextSeq);
811
+ }
812
+ if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) {
813
+ audit.metadata = {
814
+ parsed: parsed.length,
815
+ ...(parsedResult.salvaged ? { json_salvaged: true } : {}),
816
+ ...(capped > 0 ? { capped } : {}),
817
+ ...(deduped > 0 ? { deduped, mode: dedupeMode, maxSim: Number(dedupeMaxSim.toFixed(4)) } : {})
818
+ };
819
+ }
820
+ // 记忆写入和解析都成功后才提交窗口;流失败、中止、解析失败或写入异常
821
+ // 都会在此之前退出,从而保留窗口供下一次重试。
822
+ if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
823
+ } catch (error) {
824
+ if (audit) {
825
+ audit.status = "error";
826
+ audit.errorMessage = String(error?.message ?? error);
827
+ }
828
+ throw error;
829
+ } finally {
830
+ // Issue #127:aborted(会话关闭 / 插件 dispose)不占间隔,避免误伤该会话的
831
+ // 下一次蒸馏;其余情况(含失败)的打点保留,与 dreamMinIntervalMinutes 一致。
832
+ if (abortedRun || controller.signal.aborted) {
833
+ if (prevClaim === undefined) lastRunAt.delete(session.id);
834
+ else lastRunAt.set(session.id, prevClaim);
835
+ // Issue #239:aborted 同样不占 run 预算——调用没有真正发生,占了会误伤
836
+ // 该会话后续的蒸馏(与上面的间隔回滚同一口径)。
837
+ if (prevRuns === undefined) runsUsed.delete(session.id);
838
+ else runsUsed.set(session.id, prevRuns);
839
+ }
840
+ if (audit) {
841
+ try {
842
+ service.saveLlmAudit({
843
+ timestamp: audit.timestamp,
844
+ trigger_source: "autoSummarize",
845
+ operation_type: "summarize_compress",
846
+ model_id: `${audit.route.provider}:${audit.route.model}`,
847
+ input_tokens: audit.inputTokens,
848
+ output_tokens: audit.outputTokens,
849
+ total_tokens: audit.inputTokens + audit.outputTokens,
850
+ cost_usd: 0,
851
+ duration_ms: Date.now() - audit.startedAt,
852
+ status: audit.status,
853
+ error_message: audit.errorMessage,
854
+ related_memory_ids: [],
855
+ // Issue #127:parsed / capped / deduped 的观测数据。零 schema 变更
856
+ // (llm_audit_logs.metadata 已存在),一条 SQL 即可验证节流是否生效。
857
+ metadata: audit.metadata
858
+ });
859
+ } catch (auditError) {
860
+ ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
861
+ }
862
+ }
863
+ inFlight.delete(session.id);
864
+ }
865
+ }
866
+
867
+ const unsubscribe = ctx.on("session/event", (session, event) => {
868
+ if (disposed || event.type !== "turn/end") return;
869
+ // Return the summarization promise so awaiters observe the writes; the
870
+ // catch keeps listener dispatch from rejecting. Dispose-initiated aborts
871
+ // and external AbortErrors are silent.
872
+ return summarize(session, event).catch((error) => {
873
+ if (disposed || error?.name === "AbortError") return;
874
+ ctx.logger?.warn?.(`dsh-mneme: summarization failed: ${String(error)}`);
875
+ });
876
+ });
877
+
878
+ return {
879
+ dispose() {
880
+ if (disposed) return;
881
+ disposed = true;
882
+ unsubscribe?.();
883
+ for (const timer of deferredRuns.values()) clearTimer(timer);
884
+ deferredRuns.clear();
885
+ for (const controller of inFlight.values()) controller.abort();
886
+ inFlight.clear();
887
+ lastRunAt.clear();
888
+ lastDistilledSeq.clear();
889
+ }
890
+ };
891
+ }