@modusensus/dsh-mneme 0.8.2 → 0.8.4
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 +718 -633
- package/bin/cli.mjs +0 -0
- package/bin/dsh-mneme-mcp.mjs +502 -0
- package/cordis.patch.yml +21 -21
- package/lib/api-standalone.js +495 -316
- package/lib/api.js +1382 -1316
- package/lib/bootstrap.js +211 -0
- package/lib/client.js +4475 -4362
- package/lib/commands.js +95 -95
- package/lib/config.js +533 -500
- 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 +700 -700
- package/lib/dream.js +1319 -1216
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +249 -249
- package/lib/heat.js +142 -136
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +515 -513
- package/lib/inject.js +299 -253
- package/lib/lang.js +202 -179
- package/lib/local-embedder.js +320 -308
- package/lib/mirror.js +198 -198
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +103 -0
- package/lib/reranker.js +269 -260
- 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 +235 -235
- package/lib/runtime/loader.js +249 -249
- package/lib/runtime/model-fetch.js +361 -0
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +191 -191
- package/lib/scope.js +118 -118
- package/lib/search/adaptive.js +22 -22
- package/lib/search/bm25.js +96 -96
- package/lib/service.js +2171 -2071
- package/lib/settings.js +430 -419
- package/lib/store.js +2448 -2367
- package/lib/summarize.js +575 -449
- package/lib/tools.js +574 -574
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/package.json +109 -108
- package/runtime-manifest.json +596 -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 +217 -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 +61 -61
- package/src/api-standalone.js +495 -316
- package/src/api.js +1382 -1316
- package/src/bootstrap.js +211 -0
- package/src/commands.js +95 -95
- package/src/config.js +533 -500
- 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 +700 -700
- package/src/dream.js +1319 -1216
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +249 -249
- package/src/heat.js +142 -136
- package/src/hot-memory.js +46 -46
- package/src/index.js +515 -513
- package/src/inject.js +299 -253
- package/src/lang.js +202 -179
- package/src/local-embedder.js +320 -308
- package/src/mirror.js +198 -198
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +103 -0
- package/src/reranker.js +269 -260
- 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 +235 -235
- package/src/runtime/loader.js +249 -249
- package/src/runtime/model-fetch.js +361 -0
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +191 -191
- package/src/scope.js +118 -118
- package/src/search/adaptive.js +22 -22
- package/src/search/bm25.js +96 -96
- package/src/service.js +2171 -2071
- package/src/settings.js +430 -419
- package/src/store.js +2448 -2367
- package/src/summarize.js +575 -449
- package/src/tools.js +574 -574
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1578 -1481
- 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 +799 -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/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 +1257 -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 +89 -0
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +61 -0
- package/test/inject-rotation.test.js +161 -0
- package/test/inject.test.js +249 -249
- package/test/lang.test.js +87 -87
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +309 -309
- package/test/llm-message-source.test.js +177 -177
- package/test/local-embedder.test.js +249 -229
- 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/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 +117 -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 +237 -237
- package/test/runtime-manifest.test.js +158 -158
- package/test/runtime-model-fetch.test.js +348 -0
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +214 -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 +373 -373
- package/test/standalone-api.test.js +544 -359
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +383 -383
- package/test/stress.test.js +209 -209
- package/test/summarize.test.js +884 -575
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +341 -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/test/summarize.test.js
CHANGED
|
@@ -1,575 +1,884 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { createStore } from "../src/store.js";
|
|
4
|
-
import { createService } from "../src/service.js";
|
|
5
|
-
import { createSummarizer, parseSummaryJson } from "../src/summarize.js";
|
|
6
|
-
import { createSettings } from "../src/settings.js";
|
|
7
|
-
|
|
8
|
-
function setup(over = {}, opts = {}) {
|
|
9
|
-
const store = createStore(":memory:");
|
|
10
|
-
const service = createService({ store, mirror: null, config: {} });
|
|
11
|
-
const events = [];
|
|
12
|
-
const calls = [];
|
|
13
|
-
const ctx = {
|
|
14
|
-
on(name, fn) {
|
|
15
|
-
events.push({ name, fn });
|
|
16
|
-
return () => {
|
|
17
|
-
const i = events.findIndex((e) => e.name === name && e.fn === fn);
|
|
18
|
-
if (i !== -1) events.splice(i, 1);
|
|
19
|
-
};
|
|
20
|
-
},
|
|
21
|
-
llm: {
|
|
22
|
-
stream(options) {
|
|
23
|
-
calls.push(options);
|
|
24
|
-
if (opts.stream) return opts.stream(options);
|
|
25
|
-
const json = JSON.stringify([
|
|
26
|
-
{ type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
|
|
27
|
-
{ type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
|
|
28
|
-
]);
|
|
29
|
-
return (async function* () {
|
|
30
|
-
yield { type: "block-start", block: { type: "text" } };
|
|
31
|
-
yield { type: "text-delta", delta: json };
|
|
32
|
-
yield { type: "block-end", block: { type: "text" } };
|
|
33
|
-
yield { type: "finish", kind: "ok" };
|
|
34
|
-
})();
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
const config = { autoSummarize: true, ...over };
|
|
39
|
-
const summarizer = createSummarizer(ctx, service, config);
|
|
40
|
-
return { store, service, events, calls, summarizer };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// A realistic direct human prompt event (source.kind === "user").
|
|
44
|
-
function userMessage(text) {
|
|
45
|
-
|
|
46
|
-
type: "user/message",
|
|
47
|
-
data: { source: { kind: "user" }, content: [{ type: "text", text }] }
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
assert.
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("
|
|
115
|
-
const { events,
|
|
116
|
-
const handler = events.find((e) => e.name === "session/event").fn;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
assert.
|
|
151
|
-
assert.equal(store.count(), 2);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("
|
|
155
|
-
const { events,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
assert.equal(calls.length, 1
|
|
172
|
-
assert.equal(
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test("
|
|
176
|
-
const { events, calls } = setup(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const handler = events.find((e) => e.name === "session/event").fn;
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
assert.
|
|
206
|
-
assert.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
const
|
|
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
|
-
await handler(
|
|
404
|
-
|
|
405
|
-
assert.equal(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
assert.equal(
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const { events, store
|
|
457
|
-
{
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
);
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
assert.equal(store.
|
|
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
|
-
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createSummarizer, parseSummaryJson } from "../src/summarize.js";
|
|
6
|
+
import { createSettings } from "../src/settings.js";
|
|
7
|
+
|
|
8
|
+
function setup(over = {}, opts = {}) {
|
|
9
|
+
const store = createStore(":memory:");
|
|
10
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
11
|
+
const events = [];
|
|
12
|
+
const calls = [];
|
|
13
|
+
const ctx = {
|
|
14
|
+
on(name, fn) {
|
|
15
|
+
events.push({ name, fn });
|
|
16
|
+
return () => {
|
|
17
|
+
const i = events.findIndex((e) => e.name === name && e.fn === fn);
|
|
18
|
+
if (i !== -1) events.splice(i, 1);
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
llm: {
|
|
22
|
+
stream(options) {
|
|
23
|
+
calls.push(options);
|
|
24
|
+
if (opts.stream) return opts.stream(options);
|
|
25
|
+
const json = JSON.stringify([
|
|
26
|
+
{ type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
|
|
27
|
+
{ type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
|
|
28
|
+
]);
|
|
29
|
+
return (async function* () {
|
|
30
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
31
|
+
yield { type: "text-delta", delta: json };
|
|
32
|
+
yield { type: "block-end", block: { type: "text" } };
|
|
33
|
+
yield { type: "finish", kind: "ok" };
|
|
34
|
+
})();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const config = { autoSummarize: true, ...over };
|
|
39
|
+
const summarizer = createSummarizer(ctx, service, config);
|
|
40
|
+
return { store, service, events, calls, summarizer };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A realistic direct human prompt event (source.kind === "user").
|
|
44
|
+
function userMessage(text, seq) {
|
|
45
|
+
const event = {
|
|
46
|
+
type: "user/message",
|
|
47
|
+
data: { source: { kind: "user" }, content: [{ type: "text", text }] }
|
|
48
|
+
};
|
|
49
|
+
if (seq !== undefined) event.seq = seq;
|
|
50
|
+
return event;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toolResultEvent(text, isError, seq) {
|
|
54
|
+
return {
|
|
55
|
+
seq,
|
|
56
|
+
type: "tool/result",
|
|
57
|
+
data: {
|
|
58
|
+
message: {
|
|
59
|
+
source: { kind: "tool", callId: `call-${seq}` },
|
|
60
|
+
content: [{
|
|
61
|
+
type: "tool-result",
|
|
62
|
+
toolCallId: `call-${seq}`,
|
|
63
|
+
isError,
|
|
64
|
+
content: [{ type: "text", text }]
|
|
65
|
+
}]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function codeDispatchEvent(text, isError, seq) {
|
|
72
|
+
return {
|
|
73
|
+
seq,
|
|
74
|
+
type: "tool/code-dispatch",
|
|
75
|
+
data: {
|
|
76
|
+
isError,
|
|
77
|
+
content: [{ type: "text", text }]
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inboxMessage(id, kind, text) {
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
role: "user",
|
|
86
|
+
source: { kind },
|
|
87
|
+
content: [{ type: "text", text }]
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test("parseSummaryJson extracts valid entries and skips malformed ones", () => {
|
|
92
|
+
const parsed = parseSummaryJson(`前导文字 {"a":1}
|
|
93
|
+
[
|
|
94
|
+
{"type":"decision","title":"t1","content":"c1","importance":4},
|
|
95
|
+
{"type":"nonsense","title":"bad","content":"x"},
|
|
96
|
+
"garbage",
|
|
97
|
+
{"type":"preference","title":"t2","content":"c2","importance":2}
|
|
98
|
+
]`);
|
|
99
|
+
assert.equal(parsed.length, 2);
|
|
100
|
+
assert.equal(parsed[0].type, "decision");
|
|
101
|
+
assert.equal(parsed[1].type, "preference");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("subscribes to session/event when autoSummarize enabled", () => {
|
|
105
|
+
const { events } = setup();
|
|
106
|
+
assert.ok(events.some((e) => e.name === "session/event"));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("does not subscribe when autoSummarize disabled", () => {
|
|
110
|
+
const { events } = setup({ autoSummarize: false });
|
|
111
|
+
assert.ok(!events.some((e) => e.name === "session/event"));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("turn/end event triggers summarization and stores entries", async () => {
|
|
115
|
+
const { events, store } = setup();
|
|
116
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
117
|
+
const session = {
|
|
118
|
+
id: "s1",
|
|
119
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
120
|
+
events: [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
|
|
121
|
+
};
|
|
122
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
123
|
+
assert.equal(store.count(), 2);
|
|
124
|
+
const all = store.all();
|
|
125
|
+
assert.ok(all.some((m) => m.type === "decision"));
|
|
126
|
+
assert.ok(all.some((m) => m.type === "preference"));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("skips summarization for events other than turn/end", async () => {
|
|
130
|
+
const { events, store, calls } = setup();
|
|
131
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
132
|
+
const session = { id: "s1", requestHeader: () => ({ config: {} }), events: [] };
|
|
133
|
+
await handler(session, { seq: 1, type: "user/message" });
|
|
134
|
+
assert.equal(store.count(), 0);
|
|
135
|
+
assert.equal(calls.length, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// DSH ≥0.1.2-rc removed Session.events; the session object only exposes
|
|
139
|
+
// snapshotEvents(). Regression for #59: with only the old .events path the
|
|
140
|
+
// collector saw an empty log and no LLM call ever happened.
|
|
141
|
+
test("reads events from snapshotEvents() when Session.events is absent", async () => {
|
|
142
|
+
const { events, store, calls } = setup();
|
|
143
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
144
|
+
const session = {
|
|
145
|
+
id: "s1",
|
|
146
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
147
|
+
snapshotEvents: () => [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
|
|
148
|
+
};
|
|
149
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
150
|
+
assert.equal(calls.length, 1, "an LLM call must be made");
|
|
151
|
+
assert.equal(store.count(), 2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("does not call the LLM when no event was added after the last successful seq", async () => {
|
|
155
|
+
const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
|
|
156
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
157
|
+
const transcript = [userMessage("首轮", 1), { seq: 2, type: "turn/end" }];
|
|
158
|
+
const ranges = [];
|
|
159
|
+
const session = {
|
|
160
|
+
id: "s-noop",
|
|
161
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
162
|
+
snapshotEvents(fromSeq, toSeq) {
|
|
163
|
+
ranges.push([fromSeq, toSeq]);
|
|
164
|
+
return transcript;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
169
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
170
|
+
|
|
171
|
+
assert.equal(calls.length, 1, "a successful window must not be distilled twice");
|
|
172
|
+
assert.equal(ranges[1][0], 2, "the successful event seq is passed as the next snapshot lower bound");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("distills only new seq events and keeps the recent tail when a window exceeds distillMaxChars", async () => {
|
|
176
|
+
const { events, calls } = setup(
|
|
177
|
+
{ distillMaxChars: 80, distillRateLimitIntervalMs: 0 },
|
|
178
|
+
{ stream: streamOf([]) }
|
|
179
|
+
);
|
|
180
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
181
|
+
const transcript = [
|
|
182
|
+
userMessage(`OLD_BEGINNING ${"x".repeat(160)} OLD_RECENT`, 1),
|
|
183
|
+
{ seq: 2, type: "assistant/message", data: { message: { content: [{ type: "text", text: "旧回复" }] } } },
|
|
184
|
+
{ seq: 3, type: "turn/end" }
|
|
185
|
+
];
|
|
186
|
+
const session = {
|
|
187
|
+
id: "s-incremental",
|
|
188
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
189
|
+
snapshotEvents: () => transcript
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
193
|
+
const firstInput = calls[0].messages.find((message) => message.role === "user").content[0].text;
|
|
194
|
+
|
|
195
|
+
transcript.push(
|
|
196
|
+
userMessage(`NEW_BEGINNING ${"y".repeat(160)} NEW_RECENT`, 4),
|
|
197
|
+
{ seq: 5, type: "assistant/message", data: { message: { content: [{ type: "text", text: "新回复" }] } } },
|
|
198
|
+
{ seq: 6, type: "turn/end" }
|
|
199
|
+
);
|
|
200
|
+
await handler(session, { seq: 6, type: "turn/end" });
|
|
201
|
+
const secondInput = calls[1].messages.find((message) => message.role === "user").content[0].text;
|
|
202
|
+
|
|
203
|
+
assert.notEqual(firstInput, secondInput, "successive distills must receive different windows");
|
|
204
|
+
assert.ok(secondInput.includes("NEW_RECENT"), "the newest event content remains in the bounded window");
|
|
205
|
+
assert.ok(!secondInput.includes("OLD_BEGINNING"), "the second window must not restart at the session beginning");
|
|
206
|
+
assert.ok(!secondInput.includes("OLD_RECENT"), "the previous successful window must not be repeated");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("dispose unsubscribes and stops later turn/end events from summarizing", async () => {
|
|
210
|
+
const { events, summarizer, calls } = setup();
|
|
211
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
212
|
+
summarizer.dispose();
|
|
213
|
+
// The ctx.on() disposer must have removed the listener.
|
|
214
|
+
assert.ok(!events.some((e) => e.name === "session/event"));
|
|
215
|
+
const session = {
|
|
216
|
+
id: "s1",
|
|
217
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
218
|
+
events: [userMessage("你好"), { seq: 2, type: "turn/end" }]
|
|
219
|
+
};
|
|
220
|
+
// Even a stale handler reference must not start a new LLM call.
|
|
221
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
222
|
+
assert.equal(calls.length, 0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("excludes plugin-injected user/message events from summarization input", async () => {
|
|
226
|
+
const { events, store, calls } = setup();
|
|
227
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
228
|
+
const session = {
|
|
229
|
+
id: "s2",
|
|
230
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
231
|
+
events: [
|
|
232
|
+
{
|
|
233
|
+
seq: 1,
|
|
234
|
+
type: "user/message",
|
|
235
|
+
data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 内容" }] }
|
|
236
|
+
},
|
|
237
|
+
userMessage("帮我看看这个报错"),
|
|
238
|
+
{ seq: 3, type: "turn/end" }
|
|
239
|
+
]
|
|
240
|
+
};
|
|
241
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
242
|
+
assert.equal(calls.length, 1);
|
|
243
|
+
const userMessages = calls[0].messages.filter((m) => m.role === "user");
|
|
244
|
+
assert.equal(userMessages.length, 1);
|
|
245
|
+
assert.ok(!JSON.stringify(calls[0].messages).includes("AGENTS.md"));
|
|
246
|
+
assert.equal(store.count(), 2);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("aborted finish does not store entries and leaves the seq window retryable", async () => {
|
|
250
|
+
let attempt = 0;
|
|
251
|
+
const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
|
|
252
|
+
stream() {
|
|
253
|
+
attempt++;
|
|
254
|
+
return (async function* () {
|
|
255
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
256
|
+
if (attempt === 1) {
|
|
257
|
+
yield { type: "text-delta", delta: "[]" };
|
|
258
|
+
yield { type: "finish", kind: "aborted" };
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试", content: "保留原窗口", importance: 3 }]) };
|
|
262
|
+
yield { type: "finish", kind: "ok" };
|
|
263
|
+
})();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
267
|
+
const session = {
|
|
268
|
+
id: "s3",
|
|
269
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
270
|
+
events: [userMessage("继续", 1), { seq: 2, type: "turn/end" }]
|
|
271
|
+
};
|
|
272
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
273
|
+
assert.equal(calls.length, 1); // the stream was actually reached
|
|
274
|
+
assert.equal(store.count(), 0);
|
|
275
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
276
|
+
assert.equal(calls.length, 2, "an aborted window must be retried instead of being marked consumed");
|
|
277
|
+
assert.equal(store.count(), 1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a stream failure leaves the seq window retryable", async () => {
|
|
281
|
+
let attempt = 0;
|
|
282
|
+
const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
|
|
283
|
+
stream() {
|
|
284
|
+
attempt++;
|
|
285
|
+
if (attempt === 1) {
|
|
286
|
+
return (async function* () {
|
|
287
|
+
throw new Error("temporary stream failure");
|
|
288
|
+
})();
|
|
289
|
+
}
|
|
290
|
+
return streamOf([{ type: "history", title: "重试成功", content: "失败窗口未丢失", importance: 3 }])();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
294
|
+
const session = {
|
|
295
|
+
id: "s-stream-failure",
|
|
296
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
297
|
+
events: [userMessage("保留失败窗口", 1), { seq: 2, type: "turn/end" }]
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
301
|
+
assert.equal(calls.length, 1);
|
|
302
|
+
assert.equal(store.count(), 0);
|
|
303
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
304
|
+
assert.equal(calls.length, 2, "a failed stream must be retried even without new events");
|
|
305
|
+
assert.equal(store.count(), 1);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("invalid summary JSON leaves the seq window retryable", async () => {
|
|
309
|
+
let attempt = 0;
|
|
310
|
+
const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
|
|
311
|
+
stream() {
|
|
312
|
+
attempt++;
|
|
313
|
+
const output = attempt === 1 ? "not a JSON array" : "[]";
|
|
314
|
+
return (async function* () {
|
|
315
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
316
|
+
yield { type: "text-delta", delta: output };
|
|
317
|
+
yield { type: "finish", kind: "ok" };
|
|
318
|
+
})();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
322
|
+
const session = {
|
|
323
|
+
id: "s-parse-failure",
|
|
324
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
325
|
+
events: [userMessage("保留解析窗口", 1), { seq: 2, type: "turn/end" }]
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
329
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
330
|
+
assert.equal(calls.length, 2, "a parse failure must not advance the seq cursor");
|
|
331
|
+
assert.equal(store.count(), 0, "the valid empty retry remains a no-op");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("an all-invalid summary leaves the same window retryable until valid memories are saved", async (t) => {
|
|
335
|
+
for (const invalid of [
|
|
336
|
+
[{}],
|
|
337
|
+
[{ type: "unknown", title: "无效类型", content: "不能消费窗口" }],
|
|
338
|
+
[{ type: "history", title: " ", content: "不能消费窗口" }, null, "garbage"]
|
|
339
|
+
]) {
|
|
340
|
+
await t.test(JSON.stringify(invalid), async (t) => {
|
|
341
|
+
let attempt = 0;
|
|
342
|
+
const valid = { type: "history", title: "重试成功", content: "全无效摘要后仍保留原窗口", importance: 3 };
|
|
343
|
+
const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
|
|
344
|
+
stream() {
|
|
345
|
+
return streamOf(attempt++ === 0 ? invalid : [valid])();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
t.after(() => { summarizer.dispose(); store.close(); });
|
|
349
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
350
|
+
const session = {
|
|
351
|
+
id: "s-all-invalid",
|
|
352
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
353
|
+
events: [userMessage("保留原窗口", 1), { seq: 2, type: "turn/end" }]
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
357
|
+
assert.equal(service.count(), 0);
|
|
358
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
359
|
+
assert.equal(calls.length, 2, "all-invalid entries must not consume the window");
|
|
360
|
+
const firstTranscript = calls[0].messages.find((message) => message.role === "user").content[0].text;
|
|
361
|
+
const retryTranscript = calls[1].messages.find((message) => message.role === "user").content[0].text;
|
|
362
|
+
assert.equal(retryTranscript, firstTranscript, "retry receives the original transcript");
|
|
363
|
+
assert.equal(service.count(), 1);
|
|
364
|
+
assert.equal(service.list()[0].content, valid.content);
|
|
365
|
+
assert.ok(service.listLlmAudits({ source: "autoSummarize" }).some(
|
|
366
|
+
(audit) => audit.status === "error" && audit.error_message === "invalid summary JSON"
|
|
367
|
+
), "an all-invalid summary must be audited as an error");
|
|
368
|
+
|
|
369
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
370
|
+
assert.equal(calls.length, 2, "the successfully retried window is consumed once");
|
|
371
|
+
assert.equal(service.count(), 1);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("rolls back partial memory writes so retrying a failed window does not duplicate entries", async () => {
|
|
377
|
+
const { events, store, service, calls } = setup(
|
|
378
|
+
{ distillRateLimitIntervalMs: 0 },
|
|
379
|
+
{
|
|
380
|
+
stream: streamOf([
|
|
381
|
+
{ type: "history", title: "第一条", content: "第一条内容", importance: 3 },
|
|
382
|
+
{ type: "decision", title: "第二条", content: "第二条内容", importance: 4 }
|
|
383
|
+
])
|
|
384
|
+
}
|
|
385
|
+
);
|
|
386
|
+
const originalSave = service.saveWithDedupe.bind(service);
|
|
387
|
+
let failOnSecondEntry = true;
|
|
388
|
+
service.saveWithDedupe = (memory) => {
|
|
389
|
+
const result = originalSave(memory);
|
|
390
|
+
if (failOnSecondEntry && memory.title === "第二条") {
|
|
391
|
+
failOnSecondEntry = false;
|
|
392
|
+
throw new Error("simulated memory write failure");
|
|
393
|
+
}
|
|
394
|
+
return result;
|
|
395
|
+
};
|
|
396
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
397
|
+
const session = {
|
|
398
|
+
id: "s-partial-write",
|
|
399
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
400
|
+
events: [userMessage("不要丢失这个窗口", 1), { seq: 2, type: "turn/end" }]
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
404
|
+
assert.equal(calls.length, 1);
|
|
405
|
+
assert.equal(store.count(), 0, "a failed write transaction must leave no partial memory");
|
|
406
|
+
|
|
407
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
408
|
+
assert.equal(calls.length, 2, "the failed seq window must be retried");
|
|
409
|
+
assert.equal(store.count(), 2);
|
|
410
|
+
assert.equal(store.all().find((memory) => memory.title === "第一条")?.content, "第一条内容");
|
|
411
|
+
|
|
412
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
413
|
+
assert.equal(calls.length, 2, "a successfully retried window must not be distilled again");
|
|
414
|
+
assert.equal(store.count(), 2);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("uses summarizeProvider/summarizeModel config override when set", async () => {
|
|
418
|
+
const { events, calls } = setup({
|
|
419
|
+
summarizeProvider: "aliyun",
|
|
420
|
+
summarizeModel: "qwen3.6-plus"
|
|
421
|
+
});
|
|
422
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
423
|
+
const session = {
|
|
424
|
+
id: "s4",
|
|
425
|
+
// Session header has a different model — config override should win.
|
|
426
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-v4-pro" } }),
|
|
427
|
+
events: [userMessage("测试覆盖"), { seq: 2, type: "turn/end" }]
|
|
428
|
+
};
|
|
429
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
430
|
+
assert.equal(calls.length, 1);
|
|
431
|
+
assert.equal(calls[0].provider, "aliyun");
|
|
432
|
+
assert.equal(calls[0].model, "qwen3.6-plus");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("falls back to session header when summarize config is empty", async () => {
|
|
436
|
+
const { events, calls } = setup({
|
|
437
|
+
summarizeProvider: "",
|
|
438
|
+
summarizeModel: ""
|
|
439
|
+
});
|
|
440
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
441
|
+
const session = {
|
|
442
|
+
id: "s5",
|
|
443
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
444
|
+
events: [userMessage("回退测试"), { seq: 2, type: "turn/end" }]
|
|
445
|
+
};
|
|
446
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
447
|
+
assert.equal(calls.length, 1);
|
|
448
|
+
assert.equal(calls[0].provider, "deepseek");
|
|
449
|
+
assert.equal(calls[0].model, "deepseek-chat");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// ── v0.7.11:智能调速器(429 保护)+ 完整转录/原子记忆 ─────────────────────
|
|
453
|
+
|
|
454
|
+
test("serializes distill LLM calls across sessions (global queue, no concurrency)", async () => {
|
|
455
|
+
const timeline = [];
|
|
456
|
+
const { events, store } = setup({ distillRateLimitIntervalMs: 0 }, {
|
|
457
|
+
stream() {
|
|
458
|
+
return (async function* () {
|
|
459
|
+
timeline.push(`start:${Date.now()}`);
|
|
460
|
+
await new Promise((r) => setTimeout(r, 8));
|
|
461
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
462
|
+
yield { type: "text-delta", delta: "[]" };
|
|
463
|
+
yield { type: "finish", kind: "ok" };
|
|
464
|
+
timeline.push(`end:${Date.now()}`);
|
|
465
|
+
})();
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
469
|
+
const mkSession = (id) => ({
|
|
470
|
+
id,
|
|
471
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
472
|
+
events: [userMessage(`问题${id}`), { seq: 2, type: "turn/end" }]
|
|
473
|
+
});
|
|
474
|
+
// 两个会话几乎同时 turn/end → 必须排队,第二个请求不能与第一个并发。
|
|
475
|
+
await Promise.all([
|
|
476
|
+
handler(mkSession("a"), { seq: 2, type: "turn/end" }),
|
|
477
|
+
handler(mkSession("b"), { seq: 2, type: "turn/end" })
|
|
478
|
+
]);
|
|
479
|
+
assert.equal(timeline.length, 4); // 每个流 start + end
|
|
480
|
+
const starts = timeline.filter((t) => t.startsWith("start")).map((t) => Number(t.slice(6)));
|
|
481
|
+
const ends = timeline.filter((t) => t.startsWith("end")).map((t) => Number(t.slice(4)));
|
|
482
|
+
assert.ok(starts[1] >= ends[0], "second distill must start only after the first finished (serial queue)");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("retries with exponential backoff on 429 and still stores entries", async () => {
|
|
486
|
+
let attempts = 0;
|
|
487
|
+
const { events, store } = setup(
|
|
488
|
+
{ distillRateLimitRetries: 3, distillRateLimitBaseDelayMs: 5, distillRateLimitIntervalMs: 0 },
|
|
489
|
+
{
|
|
490
|
+
stream() {
|
|
491
|
+
return (async function* () {
|
|
492
|
+
attempts++;
|
|
493
|
+
if (attempts < 3) throw Object.assign(new Error("rate limit exceeded"), { status: 429 });
|
|
494
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
495
|
+
yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试成功", content: "第三次请求成功", importance: 3 }]) };
|
|
496
|
+
yield { type: "finish", kind: "ok" };
|
|
497
|
+
})();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
);
|
|
501
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
502
|
+
const session = {
|
|
503
|
+
id: "s9",
|
|
504
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
505
|
+
events: [userMessage("限流测试"), { seq: 2, type: "turn/end" }]
|
|
506
|
+
};
|
|
507
|
+
const startedAt = Date.now();
|
|
508
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
509
|
+
// 429 两次 → 退避重试(5ms + 10ms),第三次成功入库。
|
|
510
|
+
assert.equal(attempts, 3);
|
|
511
|
+
assert.ok(Date.now() - startedAt >= 15, "backoff waits should be visible");
|
|
512
|
+
assert.equal(store.count(), 1);
|
|
513
|
+
assert.equal(store.all()[0].title, "重试成功");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("distills full transcript (tool calls, results, code output) with atomic-memory prompt", async () => {
|
|
517
|
+
const { events, calls } = setup();
|
|
518
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
519
|
+
const session = {
|
|
520
|
+
id: "s10",
|
|
521
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
522
|
+
events: [
|
|
523
|
+
userMessage("帮我修这个 bug"),
|
|
524
|
+
{ seq: 2, type: "tool/call", data: { name: "Bash", arguments: "node test.js" } },
|
|
525
|
+
toolResultEvent("TypeError: x is not a function", true, 3),
|
|
526
|
+
codeDispatchEvent("fixed", false, 4),
|
|
527
|
+
{ seq: 5, type: "turn/end" }
|
|
528
|
+
]
|
|
529
|
+
};
|
|
530
|
+
await handler(session, { seq: 5, type: "turn/end" });
|
|
531
|
+
const transcript = JSON.stringify(calls[0].messages);
|
|
532
|
+
assert.ok(transcript.includes("修这个 bug"));
|
|
533
|
+
assert.ok(transcript.includes("TypeError: x is not a function"));
|
|
534
|
+
assert.ok(transcript.includes("代码执行"));
|
|
535
|
+
// 原子记忆 prompt:不再"硬压 2-3 条",而是按需多提、贴近原始细节。
|
|
536
|
+
assert.ok(calls[0].messages[0].content[0].text.includes("原子记忆"));
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("collects real tool result and code dispatch payloads with failure status", async () => {
|
|
540
|
+
const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
|
|
541
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
542
|
+
const session = {
|
|
543
|
+
id: "s-real-payloads",
|
|
544
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
545
|
+
events: [
|
|
546
|
+
userMessage("检查真实事件形状", 1),
|
|
547
|
+
toolResultEvent("TypeError: tool failed", true, 2),
|
|
548
|
+
codeDispatchEvent("代码执行结果", false, 3),
|
|
549
|
+
{ seq: 4, type: "turn/end" }
|
|
550
|
+
]
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
await handler(session, { seq: 4, type: "turn/end" });
|
|
554
|
+
const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
|
|
555
|
+
assert.match(transcript, /工具结果(失败):TypeError: tool failed/);
|
|
556
|
+
assert.match(transcript, /代码执行(成功):代码执行结果/);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("collects subagent delivery once across inbox and user-message views", async () => {
|
|
560
|
+
const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
|
|
561
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
562
|
+
const delivery = inboxMessage("delivery-1", "subagent-settled", "子会话 closing message");
|
|
563
|
+
const agentMessage = inboxMessage("delivery-2", "agent-message", "子代理补充报告");
|
|
564
|
+
const injectedInstruction = inboxMessage("instruction-1", "agent-instructions", "不要把这条注入指令沉淀为记忆");
|
|
565
|
+
const session = {
|
|
566
|
+
id: "s-agent-delivery",
|
|
567
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
568
|
+
events: [
|
|
569
|
+
userMessage("检查子会话交付", 1),
|
|
570
|
+
{ seq: 2, type: "agent/inbox/spliced", data: { inserted: [delivery, agentMessage, injectedInstruction] } },
|
|
571
|
+
{ seq: 3, type: "user/message", data: delivery },
|
|
572
|
+
{ seq: 4, type: "turn/end" }
|
|
573
|
+
]
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
await handler(session, { seq: 4, type: "turn/end" });
|
|
577
|
+
const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
|
|
578
|
+
assert.equal(transcript.match(/子会话 closing message/g)?.length, 1);
|
|
579
|
+
assert.equal(transcript.match(/子代理补充报告/g)?.length, 1);
|
|
580
|
+
assert.ok(!transcript.includes("不要把这条注入指令沉淀为记忆"));
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("keeps prefix trimming for tool arguments and tool results", async () => {
|
|
584
|
+
const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
|
|
585
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
586
|
+
const session = {
|
|
587
|
+
id: "s-trim",
|
|
588
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
589
|
+
events: [
|
|
590
|
+
userMessage("检查截断", 1),
|
|
591
|
+
{ seq: 2, type: "tool/call", data: { name: "Bash", arguments: `ARG_START ${"a".repeat(350)} ARG_END` } },
|
|
592
|
+
toolResultEvent(`OUT_START ${"b".repeat(550)} OUT_END`, false, 3),
|
|
593
|
+
{ seq: 4, type: "turn/end" }
|
|
594
|
+
]
|
|
595
|
+
};
|
|
596
|
+
await handler(session, { seq: 4, type: "turn/end" });
|
|
597
|
+
const transcript = JSON.stringify(calls[0].messages);
|
|
598
|
+
assert.ok(transcript.includes("ARG_START"));
|
|
599
|
+
assert.ok(!transcript.includes("ARG_END"), "tool arguments keep their existing prefix trim");
|
|
600
|
+
assert.ok(transcript.includes("OUT_START"));
|
|
601
|
+
assert.ok(!transcript.includes("OUT_END"), "tool results keep their existing prefix trim");
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("distill excludes private assistant reasoning blocks from the transcript", async () => {
|
|
605
|
+
const { events, calls } = setup();
|
|
606
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
607
|
+
const session = {
|
|
608
|
+
id: "s12",
|
|
609
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
610
|
+
events: [
|
|
611
|
+
userMessage("解释一下这段代码"),
|
|
612
|
+
{
|
|
613
|
+
type: "assistant/message",
|
|
614
|
+
data: {
|
|
615
|
+
message: {
|
|
616
|
+
content: [
|
|
617
|
+
{ type: "reasoning", text: "私有推理:内部权衡过程不该进记忆" },
|
|
618
|
+
{ type: "text", text: "这段代码有死循环,第 3 行 while 条件恒真。" }
|
|
619
|
+
]
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
{ seq: 3, type: "turn/end" }
|
|
624
|
+
]
|
|
625
|
+
};
|
|
626
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
627
|
+
const transcript = JSON.stringify(calls[0].messages);
|
|
628
|
+
assert.ok(transcript.includes("有死循环,第 3 行"), "public assistant text still distills");
|
|
629
|
+
assert.ok(!transcript.includes("私有推理"), "private reasoning must never enter the distill context");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("codingRetrospect stores coding memory types in the coding memory type set", async () => {
|
|
633
|
+
const { events, store, calls } = setup({ codingRetrospect: true }, {
|
|
634
|
+
stream() {
|
|
635
|
+
return (async function* () {
|
|
636
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
637
|
+
yield { type: "text-delta", delta: JSON.stringify([
|
|
638
|
+
{ type: "rejected_solution", title: "弃用方案", content: "A 方案被否决,改用 B", importance: 4 }
|
|
639
|
+
]) };
|
|
640
|
+
yield { type: "finish", kind: "ok" };
|
|
641
|
+
})();
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
645
|
+
const session = {
|
|
646
|
+
id: "s11",
|
|
647
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
648
|
+
events: [userMessage("编码任务"), { seq: 2, type: "turn/end" }]
|
|
649
|
+
};
|
|
650
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
651
|
+
assert.equal(store.count(), 1);
|
|
652
|
+
const m = store.all()[0];
|
|
653
|
+
assert.equal(m.type, "rejected_solution");
|
|
654
|
+
// 读取侧门控靠 m.type(rejected_solution/pitfall/constraint ∈ INJECT_TYPES),
|
|
655
|
+
// sanitizeTags 不认 `type:` 前缀,所以不给编码记忆打 tag(会清空 tags 列)。
|
|
656
|
+
assert.deepEqual(m.tags, []);
|
|
657
|
+
// 编码模式用编码 prompt(含 rejected_solution 类型说明)。
|
|
658
|
+
assert.ok(calls[0].messages[0].content[0].text.includes("rejected_solution"));
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// ── Issue #127:节流 / 条数上限 / 同会话去重 ────────────────────────────────
|
|
662
|
+
|
|
663
|
+
/** A distill stream that always returns exactly these entries. */
|
|
664
|
+
function streamOf(entries) {
|
|
665
|
+
return () => (async function* () {
|
|
666
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
667
|
+
yield { type: "text-delta", delta: JSON.stringify(entries) };
|
|
668
|
+
yield { type: "finish", kind: "ok" };
|
|
669
|
+
})();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function sessionFor(id) {
|
|
673
|
+
return {
|
|
674
|
+
id,
|
|
675
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
676
|
+
events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
test("issue#127: min-interval gate suppresses the second turn/end and audits it as skipped", async () => {
|
|
681
|
+
const { events, store, calls } = setup({ summarizeMinIntervalMinutes: 30 });
|
|
682
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
683
|
+
const session = sessionFor("t1");
|
|
684
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
685
|
+
assert.equal(calls.length, 1, "the first turn/end distills");
|
|
686
|
+
assert.equal(store.count(), 2);
|
|
687
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
688
|
+
assert.equal(calls.length, 1, "the second turn/end inside the window makes no LLM call");
|
|
689
|
+
assert.equal(store.count(), 2, "nothing new is stored");
|
|
690
|
+
// 可观测性:被节流不再静默——留一行 status='skipped',一条 SQL 可自查。
|
|
691
|
+
const audits = store.listLlmAudits({ source: "autoSummarize" });
|
|
692
|
+
assert.ok(
|
|
693
|
+
audits.some((a) => a.status === "skipped" && a.error_message === "min-interval"),
|
|
694
|
+
"the suppression is visible as a status='skipped' audit row"
|
|
695
|
+
);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("issue#127: min-interval 0 still distills every new event window", async () => {
|
|
699
|
+
const { events, calls } = setup({ summarizeMinIntervalMinutes: 0 });
|
|
700
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
701
|
+
const session = sessionFor("t2");
|
|
702
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
703
|
+
session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
|
|
704
|
+
await handler(session, { seq: 4, type: "turn/end" });
|
|
705
|
+
assert.equal(calls.length, 2, "0 = no gate (zero behavior change)");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("issue#127: the interval gate is per-session (one session never throttles another)", async () => {
|
|
709
|
+
const { events, calls } = setup({ summarizeMinIntervalMinutes: 30 });
|
|
710
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
711
|
+
await handler(sessionFor("ta"), { seq: 2, type: "turn/end" });
|
|
712
|
+
await handler(sessionFor("tb"), { seq: 2, type: "turn/end" });
|
|
713
|
+
assert.equal(calls.length, 2, "a different session is not throttled by another's lastRunAt");
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test("issue#127: summarizeMaxEntriesPerRun caps the stored entries and audits parsed/capped", async () => {
|
|
717
|
+
const { events, store } = setup({ summarizeMaxEntriesPerRun: 1 }, {
|
|
718
|
+
stream: streamOf([
|
|
719
|
+
{ type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
|
|
720
|
+
{ type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
|
|
721
|
+
])
|
|
722
|
+
});
|
|
723
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
724
|
+
await handler(sessionFor("t3"), { seq: 2, type: "turn/end" });
|
|
725
|
+
assert.equal(store.count(), 1, "only the first capped entry is stored");
|
|
726
|
+
const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
|
|
727
|
+
assert.equal(audit.metadata?.parsed, 2, "the raw parsed count is kept for tuning the cap");
|
|
728
|
+
assert.equal(audit.metadata?.capped, 1);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("issue#127: title dedupe absorbs a normalized-same title", async () => {
|
|
732
|
+
const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
|
|
733
|
+
stream: streamOf([{ type: "pitfall", title: "Win7 OpenSSH 失效", content: "第二次记录", importance: 4 }])
|
|
734
|
+
});
|
|
735
|
+
// 既有条目:同会话、标题归一化后全等(大小写与空白差异)
|
|
736
|
+
const seeded = service.saveWithDedupe({
|
|
737
|
+
type: "pitfall", title: "win7 openssh 失效", content: "第一次记录", source: "session:t5"
|
|
738
|
+
}).memory;
|
|
739
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
740
|
+
await handler(sessionFor("t5"), { seq: 2, type: "turn/end" });
|
|
741
|
+
assert.equal(store.count(), 1, "the normalized-same title merged instead of creating a row");
|
|
742
|
+
assert.ok(store.getById(seeded.id).content.includes("第二次记录"), "content appended to the existing row");
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("issue#127: title dedupe leaves a rephrase alone (it only catches an exact same title)", async () => {
|
|
746
|
+
// 同一事实的另一种措辞 → title 档不命中,正常新建。这条界定了 title 档的边界,
|
|
747
|
+
// 避免被误当成 vector 档的替代(cos≥0.85 的簇里「归一化标题全同」为 0 个)。
|
|
748
|
+
const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
|
|
749
|
+
stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "新记录", importance: 4 }])
|
|
750
|
+
});
|
|
751
|
+
service.saveWithDedupe({
|
|
752
|
+
type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "旧记录", source: "session:other"
|
|
753
|
+
});
|
|
754
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
755
|
+
await handler(sessionFor("t7"), { seq: 2, type: "turn/end" });
|
|
756
|
+
assert.equal(store.count(), 2, "a rephrase (different title) is not merged by the title tier");
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("issue#127: vector dedupe merges a same-fact rephrase and keeps content_history", async () => {
|
|
760
|
+
const embedder = {
|
|
761
|
+
ready: true,
|
|
762
|
+
embedSingle: async (text) => (String(text).includes("OpenSSH") ? [1, 0, 0] : [0, 1, 0])
|
|
763
|
+
};
|
|
764
|
+
const { events, store, service } = setup(
|
|
765
|
+
{ summarizeDedupeMode: "vector", summarizeDedupeMinSim: 0.92 },
|
|
766
|
+
{ stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "重复踩坑", importance: 4 }]) }
|
|
767
|
+
);
|
|
768
|
+
service.setEmbedder(embedder);
|
|
769
|
+
// 同一事实的另一种措辞,已有向量 → 余弦 1.0 ≥ 0.92
|
|
770
|
+
const seeded = service.saveWithDedupe({
|
|
771
|
+
type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "第一次记录", source: "session:t8"
|
|
772
|
+
}).memory;
|
|
773
|
+
store.setEmbedding(seeded.id, [1, 0, 0]);
|
|
774
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
775
|
+
await handler(sessionFor("t8"), { seq: 2, type: "turn/end" });
|
|
776
|
+
assert.equal(store.count(), 1, "no new row: the rephrase was absorbed by cosine");
|
|
777
|
+
const merged = store.getById(seeded.id);
|
|
778
|
+
assert.ok(merged.content.includes("重复踩坑"), "content appended into the existing row");
|
|
779
|
+
assert.ok((merged.content_history ?? []).length >= 1, "content_history keeps the prior version");
|
|
780
|
+
const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
|
|
781
|
+
assert.equal(audit.metadata?.deduped, 1);
|
|
782
|
+
assert.equal(audit.metadata?.mode, "vector");
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("issue#127: no embedder means vector dedupe silently falls back to a normal write", async () => {
|
|
786
|
+
const { events, store } = setup(
|
|
787
|
+
{ summarizeDedupeMode: "vector" },
|
|
788
|
+
{ stream: streamOf([{ type: "pitfall", title: "无关的一条", content: "内容", importance: 4 }]) }
|
|
789
|
+
);
|
|
790
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
791
|
+
await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
|
|
792
|
+
assert.equal(store.count(), 1, "dedupe failure never blocks the write");
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("issue#127: the five summarize knobs are whitelisted and range-checked", () => {
|
|
796
|
+
const store = createStore(":memory:");
|
|
797
|
+
const settings = createSettings(store.db);
|
|
798
|
+
const merged = settings.setFeatureFlags({
|
|
799
|
+
summarizeMinIntervalMinutes: 30,
|
|
800
|
+
summarizeMaxEntriesPerRun: 5,
|
|
801
|
+
summarizeDedupeMode: "vector",
|
|
802
|
+
summarizeDedupeMinSim: 0.9,
|
|
803
|
+
summarizeDedupeWindowHours: 12
|
|
804
|
+
});
|
|
805
|
+
assert.equal(merged.summarizeMinIntervalMinutes, 30);
|
|
806
|
+
assert.equal(merged.summarizeDedupeMode, "vector");
|
|
807
|
+
assert.equal(merged.summarizeDedupeMinSim, 0.9, "float thresholds round-trip (new numbers whitelist)");
|
|
808
|
+
// 面板/API 写入走同一套逐键校验:越界与非法枚举必须被拒。
|
|
809
|
+
assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMinSim: 0.3 }), /number in \[0.5, 0.99\]/);
|
|
810
|
+
assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMode: "semantic" }), /one of: off, title, vector/);
|
|
811
|
+
store.close();
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// ── Issue #127 补测:fail-safe 与打点回滚的边界路径 ────────────────────────
|
|
815
|
+
|
|
816
|
+
test("issue#127: a throwing audit writer never breaks the min-interval skip path", async () => {
|
|
817
|
+
const { events, store, service } = setup({ summarizeMinIntervalMinutes: 30 });
|
|
818
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
819
|
+
const session = sessionFor("t10");
|
|
820
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
821
|
+
assert.equal(store.count(), 2);
|
|
822
|
+
// 审计写入器抛错:跳过路径必须吞掉异常——审计是观测手段,绝不能反过来打断蒸馏。
|
|
823
|
+
service.saveLlmAudit = () => { throw new Error("audit sink down"); };
|
|
824
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
825
|
+
assert.equal(store.count(), 2, "the throttled turn stays a no-op instead of rejecting");
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("issue#127: an aborted run rolls the interval claim back so the next turn is admitted", async () => {
|
|
829
|
+
// 间隔取毫秒级小数值(schema 的粒度是 1 分钟,测试直接构造 config 绕开它),
|
|
830
|
+
// 这样"回滚到上一次打点"与"保留本次打点"才有可观察差异:
|
|
831
|
+
// 回滚 → 下一次落在 T1+gap 之外,应被接纳;
|
|
832
|
+
// 不滚 → 距本次打点才几毫秒,必被挡。
|
|
833
|
+
// 若沿用 gap=0,两种实现的对外行为完全相同,测试无法发现 else 分支写错。
|
|
834
|
+
let call = 0;
|
|
835
|
+
const { events } = setup({ summarizeMinIntervalMinutes: 0.01, distillRateLimitIntervalMs: 0 }, {
|
|
836
|
+
stream() {
|
|
837
|
+
call++;
|
|
838
|
+
if (call === 2) {
|
|
839
|
+
return (async function* () {
|
|
840
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
841
|
+
yield { type: "text-delta", delta: "[]" };
|
|
842
|
+
yield { type: "finish", kind: "aborted" };
|
|
843
|
+
})();
|
|
844
|
+
}
|
|
845
|
+
return (async function* () {
|
|
846
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
847
|
+
yield { type: "text-delta", delta: JSON.stringify([{ type: "decision", title: `第${call}条`, content: "内容", importance: 3 }]) };
|
|
848
|
+
yield { type: "finish", kind: "ok" };
|
|
849
|
+
})();
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
853
|
+
const session = sessionFor("t11");
|
|
854
|
+
await handler(session, { seq: 2, type: "turn/end" }); // T1:成功,占用间隔
|
|
855
|
+
assert.equal(call, 1, "the first turn distills");
|
|
856
|
+
await new Promise((r) => setTimeout(r, 700)); // 越过 600ms 间隔窗
|
|
857
|
+
session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
|
|
858
|
+
await handler(session, { seq: 4, type: "turn/end" }); // T2:aborted → 回滚到 T1
|
|
859
|
+
assert.equal(call, 2, "the aborted turn still reached the LLM call");
|
|
860
|
+
await handler(session, { seq: 4, type: "turn/end" }); // 立刻再发:只有回滚到 T1 才会被接纳
|
|
861
|
+
assert.equal(call, 3, "the aborted run did not consume the interval window");
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("issue#127: a throwing candidate lookup degrades to undefined (service fail-safe)", async () => {
|
|
865
|
+
const { service } = setup({ summarizeDedupeMode: "vector" });
|
|
866
|
+
// 候选查询在 try 内抛错 → findSessionDuplicate 吞掉并返回 undefined(不向外抛)。
|
|
867
|
+
const hostile = { get type() { throw new Error("store down"); }, title: "标题", content: "内容" };
|
|
868
|
+
const hit = await service.findSessionDuplicate(hostile, { mode: "vector", source: "session:z" });
|
|
869
|
+
assert.equal(hit, undefined, "a failed lookup yields no duplicate instead of throwing");
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("issue#127: a dedupe lookup that yields nothing still lands the entry (never dropped)", async () => {
|
|
873
|
+
// 走真实的 session/event 落库路径。只让去重判定不可用(service 层为何返回 undefined
|
|
874
|
+
// 由上一条覆盖)——蒸馏必须照常写入,不能因为拿不到去重结论就把这一条丢掉。
|
|
875
|
+
const { events, store, service } = setup(
|
|
876
|
+
{ summarizeDedupeMode: "vector" },
|
|
877
|
+
{ stream: streamOf([{ type: "decision", title: "必须落库", content: "内容", importance: 3 }]) }
|
|
878
|
+
);
|
|
879
|
+
service.findSessionDuplicate = async () => undefined;
|
|
880
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
881
|
+
await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
|
|
882
|
+
assert.equal(store.count(), 1, "the entry is stored through the normal write path");
|
|
883
|
+
assert.equal(store.all()[0].title, "必须落库");
|
|
884
|
+
});
|