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