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