@ilaomianyang/dsh-memory-graph 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +466 -0
  4. package/README.md +459 -0
  5. package/client.js +1953 -0
  6. package/cordis.patch.yml +13 -0
  7. package/docs/auto-settings.png +0 -0
  8. package/docs/distill-button.png +0 -0
  9. package/docs/graph-dark.png +0 -0
  10. package/docs/graph-light.png +0 -0
  11. package/docs/session-tab-cards.png +0 -0
  12. package/docs/session-tab-open.png +0 -0
  13. package/docs/session-tab.png +0 -0
  14. package/docs/vault-panel.png +0 -0
  15. package/eval/REPORT.md +437 -0
  16. package/eval/questions.json +277 -0
  17. package/eval/run.mjs +460 -0
  18. package/eval/seed.mjs +489 -0
  19. package/icon.svg +26 -0
  20. package/index.js +243 -0
  21. package/locale/en.json +6 -0
  22. package/locale/zh.json +6 -0
  23. package/package.json +77 -0
  24. package/src/auto.js +194 -0
  25. package/src/capture.js +237 -0
  26. package/src/config.js +280 -0
  27. package/src/distillapi.js +30 -0
  28. package/src/distillrun.js +106 -0
  29. package/src/extract.js +660 -0
  30. package/src/graph.js +1582 -0
  31. package/src/graphdata.js +475 -0
  32. package/src/http.js +32 -0
  33. package/src/pageapi.js +40 -0
  34. package/src/privacy.js +78 -0
  35. package/src/schema.js +323 -0
  36. package/src/search.js +317 -0
  37. package/src/tools.js +534 -0
  38. package/src/vault.js +888 -0
  39. package/test/auto.mjs +295 -0
  40. package/test/capture.mjs +264 -0
  41. package/test/client.mjs +1035 -0
  42. package/test/distillapi.mjs +212 -0
  43. package/test/extract.mjs +660 -0
  44. package/test/graphdata.mjs +295 -0
  45. package/test/live-supersede.mjs +78 -0
  46. package/test/live.mjs +156 -0
  47. package/test/page.mjs +116 -0
  48. package/test/schema.mjs +256 -0
  49. package/test/smoke.mjs +428 -0
  50. package/test/tools.mjs +238 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * Extractor test: prompt building, key resolution, response handling and the
3
+ * per-card project attribution, with `fetch` stubbed so it never leaves the box.
4
+ * node test/extract.mjs
5
+ */
6
+ import assert from 'node:assert/strict'
7
+ import fsp from 'node:fs/promises'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+
11
+ import { resolveConfig } from '../src/config.js'
12
+ import { createRedactor } from '../src/privacy.js'
13
+ import { createVault } from '../src/vault.js'
14
+ import { buildPrompt, callModel, distill, parseCards, plannedBatchSize, readCredentialRef, resolveKeyInfo, resolveRoute, selectionEffort } from '../src/extract.js'
15
+
16
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-extract-'))
17
+ const results = []
18
+ function check(name, fn) {
19
+ try {
20
+ fn()
21
+ results.push(` ok ${name}`)
22
+ } catch (error) {
23
+ results.push(` FAIL ${name}: ${error.message}`)
24
+ process.exitCode = 1
25
+ }
26
+ }
27
+ async function checkAsync(name, fn) {
28
+ try {
29
+ await fn()
30
+ results.push(` ok ${name}`)
31
+ } catch (error) {
32
+ results.push(` FAIL ${name}: ${error.message}`)
33
+ process.exitCode = 1
34
+ }
35
+ }
36
+
37
+ /* ------------------------------------------------------------- fetch stub */
38
+
39
+ const realFetch = globalThis.fetch
40
+ const calls = []
41
+ let reply = { status: 200, body: { choices: [{ message: { content: '{"cards":[]}' } }] } }
42
+
43
+ function stubFetch(handler) {
44
+ calls.length = 0
45
+ globalThis.fetch = async (url, init) => {
46
+ const entry = { url, body: JSON.parse(init.body), headers: init.headers }
47
+ calls.push(entry)
48
+ const next = handler ? await handler(entry, calls.length) : reply
49
+ return {
50
+ ok: next.status >= 200 && next.status < 300,
51
+ status: next.status,
52
+ text: async () => (typeof next.body === 'string' ? next.body : JSON.stringify(next.body)),
53
+ }
54
+ }
55
+ }
56
+ stubFetch()
57
+
58
+ const modelReply = (cards, extra = {}) =>
59
+ ({ status: 200, body: { choices: [{ message: { content: JSON.stringify({ cards, ...extra }) } }], usage: { total_tokens: 42 }, model: 'stub-1' } })
60
+
61
+ const draft = {
62
+ kind: 'decision',
63
+ title: 'A durable thing was decided',
64
+ body: 'Because of the reason, which will still matter later.',
65
+ entities: ['memory-graph'],
66
+ confidence: 'user_stated',
67
+ valid_from: '2026-11-01',
68
+ }
69
+
70
+ /* ------------------------------------------------------------------ parse */
71
+
72
+ check('parseCards reads a bare JSON object', () => {
73
+ const { cards } = parseCards(JSON.stringify({ cards: [draft] }))
74
+ assert.equal(cards.length, 1)
75
+ assert.equal(cards[0].kind, 'decision')
76
+ assert.equal(cards[0].validFrom, '2026-11-01')
77
+ })
78
+
79
+ check('parseCards reads a fenced code block', () => {
80
+ const { cards } = parseCards('```json\n{"cards":[{"kind":"fact","title":"Fenced","body":"b"}]}\n```')
81
+ assert.equal(cards.length, 1)
82
+ assert.equal(cards[0].title, 'Fenced')
83
+ })
84
+
85
+ check('parseCards reads a bare array', () => {
86
+ const { cards } = parseCards(JSON.stringify([{ title: 'Bare array', body: 'b' }]))
87
+ assert.equal(cards.length, 1)
88
+ })
89
+
90
+ check('parseCards drops unusable cards instead of throwing', () => {
91
+ const { cards } = parseCards(JSON.stringify({ cards: [null, 7, { body: 'no title' }, { title: ' ' }, { title: 'Kept' }] }))
92
+ assert.equal(cards.length, 1)
93
+ assert.equal(cards[0].title, 'Kept')
94
+ })
95
+
96
+ check('parseCards normalises unknown kinds and confidence', () => {
97
+ const { cards } = parseCards(JSON.stringify({ cards: [{ title: 'T', kind: 'gossip', confidence: 'certain' }] }))
98
+ assert.equal(cards[0].kind, 'note')
99
+ assert.equal(cards[0].confidence, 'inferred')
100
+ })
101
+
102
+ check('parseCards keeps only id-shaped supersedes and valid dates', () => {
103
+ const { cards } = parseCards(
104
+ JSON.stringify({ cards: [{ title: 'T', supersedes: ['c-20260101-aaaa', 'not-an-id', 4], valid_from: '01/11/2026' }] }),
105
+ )
106
+ assert.deepEqual(cards[0].supersedes, ['c-20260101-aaaa'])
107
+ assert.equal(cards[0].validFrom, null)
108
+ })
109
+
110
+ check('parseCards keeps a per-card project', () => {
111
+ const { cards } = parseCards(JSON.stringify({ cards: [{ title: 'T', project: ' blog-engine ' }] }))
112
+ assert.equal(cards[0].project, 'blog-engine')
113
+ })
114
+
115
+ check('parseCards reports non-JSON without throwing', () => {
116
+ const parsed = parseCards('the model rambled instead of answering')
117
+ assert.equal(parsed.cards.length, 0)
118
+ assert.match(parsed.skipped, /not JSON/)
119
+ assert.ok(parsed.raw.length > 0)
120
+ })
121
+
122
+ check('parseCards passes a skip reason through', () => {
123
+ const parsed = parseCards(JSON.stringify({ cards: [], skipped: 'nothing durable happened' }))
124
+ assert.equal(parsed.skipped, 'nothing durable happened')
125
+ })
126
+
127
+ /* ------------------------------------------------------------------- keys */
128
+
129
+ check('resolveKeyInfo prefers the configured key', () => {
130
+ const cfg = resolveConfig({ extractor: { apiKey: 'sk-configured', apiKeyEnv: 'MEMORY_TEST_KEY_A' } })
131
+ assert.deepEqual(resolveKeyInfo(cfg).source, 'config extractor.apiKey')
132
+ })
133
+
134
+ check('resolveKeyInfo falls back to the named env var', () => {
135
+ process.env.MEMORY_TEST_KEY_B = 'sk-from-env'
136
+ const cfg = resolveConfig({ extractor: { apiKeyEnv: 'MEMORY_TEST_KEY_B' } })
137
+ const found = resolveKeyInfo(cfg)
138
+ assert.equal(found.key, 'sk-from-env')
139
+ assert.equal(found.source, 'env MEMORY_TEST_KEY_B')
140
+ })
141
+
142
+ check('resolveKeyInfo reports where it looked when there is no key', () => {
143
+ const cfg = resolveConfig({ extractor: { apiKeyEnv: 'MEMORY_TEST_KEY_MISSING' } })
144
+ const found = resolveKeyInfo(cfg)
145
+ assert.equal(found.key, '')
146
+ assert.ok(found.looked.some((where) => where.includes('MEMORY_TEST_KEY_MISSING')), JSON.stringify(found.looked))
147
+ })
148
+
149
+ const store = path.join(dir, 'credentials.yaml')
150
+ await fsp.writeFile(
151
+ store,
152
+ ['version: 1', 'refs:', ' FIRST_KEY: "sk-first"', " TARGET_KEY: 'sk-target'", 'other:', ' TARGET_KEY: sk-decoy'].join('\n'),
153
+ )
154
+ check('readCredentialRef reads only the refs block, quotes stripped', () => {
155
+ assert.equal(readCredentialRef('TARGET_KEY', store), 'sk-target')
156
+ assert.equal(readCredentialRef('FIRST_KEY', store), 'sk-first')
157
+ })
158
+ check('readCredentialRef ignores names outside refs and missing files', () => {
159
+ assert.equal(readCredentialRef('NOPE', store), '')
160
+ assert.equal(readCredentialRef('TARGET_KEY', path.join(dir, 'no-such-file.yaml')), '')
161
+ assert.equal(readCredentialRef('', store), '')
162
+ })
163
+
164
+ /* ----------------------------------------------------------------- prompt */
165
+
166
+ const episode = (project, t, user, assistant) => ({ file: 'e.jsonl', line: 0, record: { project, t, user, assistant } })
167
+ const episodes = [
168
+ episode('memory-graph', '2026-11-01T09:00:00+08:00', '我们要一个记忆插件。', '好的。'),
169
+ episode('blog-engine', '2026-11-01T10:00:00+08:00', 'The generator rewrites footnotes.', 'Drop the rehype pass.'),
170
+ ]
171
+
172
+ check('buildPrompt scopes a single-project batch by that project', () => {
173
+ const messages = buildPrompt([episodes[0]], [], 'memory-graph')
174
+ assert.match(messages[1].content, /episode\(s\) from project "memory-graph"/)
175
+ assert.equal(messages[0].role, 'system')
176
+ })
177
+
178
+ check('buildPrompt names every project when a batch spans projects', () => {
179
+ const messages = buildPrompt(episodes, [], 'memory-graph')
180
+ assert.match(messages[1].content, /2 projects \(memory-graph, blog-engine\)/)
181
+ assert.match(messages[1].content, /every card must name its own project/)
182
+ })
183
+
184
+ check('buildPrompt lists existing memories of the batch projects', () => {
185
+ const cards = [
186
+ { id: 'c-20260101-aaaa', project: 'memory-graph', kind: 'decision', status: 'active', title: 'Old decision' },
187
+ { id: 'c-20260101-bbbb', project: 'unrelated', kind: 'decision', status: 'active', title: 'Other project' },
188
+ ]
189
+ const messages = buildPrompt(episodes, cards, undefined)
190
+ assert.ok(messages[1].content.includes('c-20260101-aaaa'), messages[1].content)
191
+ assert.ok(!messages[1].content.includes('c-20260101-bbbb'), 'leaked another project into the prompt')
192
+ })
193
+
194
+ check('buildPrompt omits the existing block when there is nothing to show', () => {
195
+ const messages = buildPrompt([episodes[0]], [], 'memory-graph')
196
+ assert.ok(!messages[1].content.includes('Existing memories'))
197
+ })
198
+
199
+ /* -------------------------------------------------------------- callModel */
200
+
201
+ const cfgFor = (overrides = {}) =>
202
+ resolveConfig({
203
+ vaultDir: dir,
204
+ // These cases stub `fetch`, so they exercise the HTTP transport; the harness
205
+ // transport is covered further down with a stub llm service.
206
+ extractor: { enabled: true, transport: 'http', apiKey: 'sk-test', baseUrl: 'https://example.invalid/v1', model: 'stub-1', ...overrides },
207
+ })
208
+
209
+ /** Config for the harness transport: no key and no endpoint, only a route. */
210
+ const harnessCfg = (overrides = {}) =>
211
+ resolveConfig({ vaultDir: dir, extractor: { enabled: true, model: 'deepseek-chat', ...overrides } })
212
+
213
+ await checkAsync('callModel returns content, usage and where the key came from', async () => {
214
+ stubFetch(() => modelReply([draft]))
215
+ const result = await callModel(cfgFor(), [{ role: 'user', content: 'hi' }])
216
+ assert.match(result.content, /durable thing/)
217
+ assert.equal(result.keySource, 'config extractor.apiKey')
218
+ assert.equal(result.model, 'stub-1')
219
+ })
220
+
221
+ await checkAsync('callModel asks for JSON mode on the first attempt', async () => {
222
+ stubFetch(() => modelReply([]))
223
+ await callModel(cfgFor(), [])
224
+ assert.deepEqual(calls[0].body.response_format, { type: 'json_object' })
225
+ })
226
+
227
+ await checkAsync('callModel retries without response_format when the endpoint rejects it', async () => {
228
+ stubFetch((entry, nth) =>
229
+ nth === 1
230
+ ? { status: 400, body: { error: { message: 'response_format is not supported' } } }
231
+ : modelReply([draft]),
232
+ )
233
+ const result = await callModel(cfgFor(), [])
234
+ assert.equal(calls.length, 2)
235
+ assert.equal('response_format' in calls[1].body, false)
236
+ assert.match(result.content, /durable thing/)
237
+ })
238
+
239
+ await checkAsync('callModel does not retry an unrelated 400', async () => {
240
+ stubFetch(() => ({ status: 400, body: { error: { message: 'bad model name' } } }))
241
+ await assert.rejects(() => callModel(cfgFor(), []), /HTTP 400/)
242
+ assert.equal(calls.length, 1)
243
+ })
244
+
245
+ await checkAsync('callModel reports a non-JSON response body', async () => {
246
+ stubFetch(() => ({ status: 200, body: '<html>gateway</html>' }))
247
+ await assert.rejects(() => callModel(cfgFor(), []), /non-JSON response/)
248
+ })
249
+
250
+ await checkAsync('callModel rejects an empty completion', async () => {
251
+ stubFetch(() => ({ status: 200, body: { choices: [{ message: { content: ' ' } }] } }))
252
+ await assert.rejects(() => callModel(cfgFor(), []), /empty completion/)
253
+ })
254
+
255
+ await checkAsync('callModel names the places it looked for a key', async () => {
256
+ stubFetch(() => modelReply([]))
257
+ await assert.rejects(() => callModel(cfgFor({ apiKey: '' }), []), /Looked in env MEMORY_LLM_API_KEY/)
258
+ assert.equal(calls.length, 0, 'should not have called the endpoint without a key')
259
+ })
260
+
261
+ /* ---------------------------------------------------------------- distill */
262
+
263
+ const cfg = cfgFor()
264
+ const redactor = createRedactor(cfg.privacy)
265
+ const vault = createVault(cfg, redactor)
266
+
267
+ await vault.appendEpisode({ project: 'memory-graph', t: '2026-11-01T09:00:00+08:00', user: '记忆要纯文本。', assistant: '好。' })
268
+ await vault.appendEpisode({ project: 'blog-engine', t: '2026-11-01T10:00:00+08:00', user: 'Stop rewriting footnotes.', assistant: 'Dropping the rehype pass.' })
269
+
270
+ stubFetch(() =>
271
+ modelReply([
272
+ { kind: 'decision', project: 'memory-graph', title: '记忆库用纯文本存储', body: '可 diff。', entities: ['memory-graph'] },
273
+ { kind: 'preference', project: 'blog-engine', title: 'Never rewrite the markdown body', body: 'Opaque body.', entities: ['blog-engine'] },
274
+ { kind: 'note', title: 'Card with no project at all', body: 'Falls back.', entities: [] },
275
+ ]),
276
+ )
277
+
278
+ let report
279
+ await checkAsync('distill writes one card per proposal', async () => {
280
+ report = await distill({ cfg, vault, redactor })
281
+ assert.equal(report.ok, true)
282
+ assert.equal(report.episodes, 2)
283
+ assert.equal(report.written, 3)
284
+ })
285
+
286
+ check('distill files each card under its own project', () => {
287
+ const projects = report.cards.map((card) => `${card.project}:${card.title}`).sort()
288
+ assert.deepEqual(projects, [
289
+ 'blog-engine:Card with no project at all',
290
+ 'blog-engine:Never rewrite the markdown body',
291
+ 'memory-graph:记忆库用纯文本存储',
292
+ ])
293
+ })
294
+
295
+ await checkAsync('distill falls back to the dominant project when a card forgets one', async () => {
296
+ const cards = await vault.listCards({ includeRetracted: true })
297
+ const orphan = cards.find((card) => card.title === 'Card with no project at all')
298
+ assert.equal(orphan.project, 'blog-engine', 'a 1-1 tie goes to the most recent episode')
299
+ })
300
+
301
+ await checkAsync('distill records the episode span as provenance', async () => {
302
+ const cards = await vault.listCards({ includeRetracted: true })
303
+ const card = cards.find((item) => item.title === '记忆库用纯文本存储')
304
+ assert.equal(card.source.kind, 'distill')
305
+ assert.equal(card.source.model, 'stub-1')
306
+ assert.ok(card.source.episodes.length >= 1)
307
+ })
308
+
309
+ await checkAsync('distill marks the episodes as read so the next run is a no-op', async () => {
310
+ const again = await distill({ cfg, vault, redactor })
311
+ assert.equal(again.episodes, 0)
312
+ assert.match(again.reason, /already been distilled/)
313
+ assert.equal(calls.length, 1, 'a no-op run must not call the model again')
314
+ })
315
+
316
+ await checkAsync('distill writes an audit line naming the projects and the key source', async () => {
317
+ const audit = await fsp.readFile(path.join(dir, 'audit', `${new Date().toISOString().slice(0, 7)}.log`), 'utf8')
318
+ assert.ok(audit.includes('distill'), audit)
319
+ assert.ok(audit.includes('projects=memory-graph,blog-engine'), audit)
320
+ assert.ok(audit.includes('cards=3'), audit)
321
+ })
322
+
323
+ await checkAsync('distill dry run proposes without writing', async () => {
324
+ const dryDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-dry-'))
325
+ const dryCfg = resolveConfig({ vaultDir: dryDir, extractor: { enabled: true, transport: 'http', apiKey: 'sk-test', baseUrl: 'https://example.invalid/v1' } })
326
+ const dryVault = createVault(dryCfg, createRedactor(dryCfg.privacy))
327
+ await dryVault.appendEpisode({ project: 'p', t: '2026-11-01T09:00:00+08:00', user: 'A thing.', assistant: 'Yes.' })
328
+ stubFetch(() => modelReply([{ kind: 'fact', title: 'Proposed only', body: 'b' }]))
329
+ const dry = await distill({ cfg: dryCfg, vault: dryVault, redactor: createRedactor(dryCfg.privacy), dryRun: true })
330
+ assert.equal(dry.proposed.length, 1)
331
+ assert.equal((await dryVault.listCards({ includeRetracted: true })).length, 0)
332
+ assert.equal((await dryVault.stats()).distill.lastRun, null, 'a dry run must not advance the cursor')
333
+ await fsp.rm(dryDir, { recursive: true, force: true })
334
+ })
335
+
336
+ await checkAsync('distill refuses to run while the extractor is disabled', async () => {
337
+ const offCfg = resolveConfig({ vaultDir: dir, extractor: { enabled: false } })
338
+ const off = await distill({ cfg: offCfg, vault, redactor })
339
+ assert.equal(off.ok, false)
340
+ assert.match(off.reason, /disabled/)
341
+ })
342
+
343
+ await checkAsync('distill does not write a card whose body carries a secret through untouched', async () => {
344
+ const secretDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-secret-'))
345
+ const secretCfg = resolveConfig({ vaultDir: secretDir, extractor: { enabled: true, transport: 'http', apiKey: 'sk-test', baseUrl: 'https://example.invalid/v1' } })
346
+ const secretRedactor = createRedactor(secretCfg.privacy)
347
+ const secretVault = createVault(secretCfg, secretRedactor)
348
+ const secret = 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789'
349
+ await secretVault.appendEpisode({ project: 'p', t: '2026-11-01T09:00:00+08:00', user: `My key is ${secret}.`, assistant: 'Noted.' })
350
+ stubFetch(() => modelReply([{ kind: 'fact', title: 'Key rotated', body: `The key ${secret} was rotated.`, entities: [] }]))
351
+ await distill({ cfg: secretCfg, vault: secretVault, redactor: secretRedactor })
352
+ const [card] = await secretVault.listCards({ includeRetracted: true })
353
+ assert.ok(!card.body.includes(secret), card.body)
354
+ assert.ok(card.body.includes('[REDACTED]'), card.body)
355
+ await fsp.rm(secretDir, { recursive: true, force: true })
356
+ })
357
+
358
+ /* ------------------------------------------------ harness transport (llm) */
359
+
360
+ /**
361
+ * A stand-in for the harness `llm` service: it records what the extractor
362
+ * proposed, applies the `configure` callback the way the real service does, and
363
+ * streams whatever chunks the case needs.
364
+ */
365
+ function stubLlm({ chunks, script, delayMs = 0 } = {}) {
366
+ const seen = []
367
+ let calls = 0
368
+ const reply = () => {
369
+ // `script` gives one answer per model call; `chunks` repeats for all of them.
370
+ if (Array.isArray(script)) return script[Math.min(calls, script.length - 1)]
371
+ return chunks
372
+ }
373
+ return {
374
+ seen,
375
+ get calls() {
376
+ return calls
377
+ },
378
+ llm: {
379
+ async prepareCall(config, signal, configure) {
380
+ const resolved = configure ? { ...config, ...configure(config, { reasoning: { efforts: [] } }) } : { ...config }
381
+ seen.push({ config: resolved, signal })
382
+ const answer = reply()
383
+ calls += 1
384
+ return {
385
+ config: resolved,
386
+ stream(options) {
387
+ seen.push({ options })
388
+ return (async function* () {
389
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs))
390
+ for (const chunk of answer ?? [
391
+ { type: 'text-delta', index: 0, text: '{"cards":[]}' },
392
+ { type: 'usage', usage: { totalTokens: 7 } },
393
+ { type: 'finish', reason: { kind: 'stop' } },
394
+ ]) {
395
+ yield chunk
396
+ }
397
+ })()
398
+ },
399
+ }
400
+ },
401
+ },
402
+ }
403
+ }
404
+
405
+ /** One model answer that contains these card titles. */
406
+ const cardsChunks = (...titles) => [
407
+ {
408
+ type: 'text-delta',
409
+ index: 0,
410
+ text: JSON.stringify({ cards: titles.map((title) => ({ kind: 'note', title, body: 'body' })) }),
411
+ },
412
+ { type: 'usage', usage: { totalTokens: 100 } },
413
+ { type: 'finish', reason: { kind: 'stop' } },
414
+ ]
415
+
416
+ /** One model answer that ran out of output budget. */
417
+ const truncatedChunks = [
418
+ { type: 'text-delta', index: 0, text: '{"cards":[{"kind":"note","title":"cut' },
419
+ { type: 'finish', reason: { kind: 'max-tokens' } },
420
+ ]
421
+
422
+ check('resolveRoute borrows the model selected in Settings', () => {
423
+ const route = resolveRoute(harnessCfg(), { provider: 'wb', model: 'deepseek-v4.1-flash' })
424
+ assert.equal(route.provider, 'wb')
425
+ assert.equal(route.model, 'deepseek-v4.1-flash')
426
+ assert.equal(route.effort, '')
427
+ assert.match(route.source, /Settings/)
428
+ })
429
+
430
+ check('resolveRoute keeps the thinking level of the selection', () => {
431
+ assert.equal(selectionEffort({ reasoningEffort: 'high' }), 'high')
432
+ assert.equal(selectionEffort({ reasoning: 'medium' }), 'medium')
433
+ assert.equal(selectionEffort({ reasoning: { id: 'low', name: 'Low' } }), 'low')
434
+ assert.equal(selectionEffort({}), '')
435
+ assert.equal(resolveRoute(harnessCfg(), { provider: 'p', model: 'm', reasoningEffort: 'low' }).effort, 'low')
436
+ })
437
+
438
+ check('an explicit reasoningEffort overrides the selection', () => {
439
+ const route = resolveRoute(harnessCfg({ reasoningEffort: 'high' }), { provider: 'p', model: 'm', reasoningEffort: 'low' })
440
+ assert.equal(route.effort, 'high')
441
+ })
442
+
443
+ check('resolveRoute prefers configured provider/model when useCurrentModel is off', () => {
444
+ const cfg = harnessCfg({ useCurrentModel: false, provider: 'deepseek', model: 'deepseek-chat' })
445
+ const route = resolveRoute(cfg, { provider: 'ignored', model: 'ignored' })
446
+ assert.deepEqual([route.provider, route.model], ['deepseek', 'deepseek-chat'])
447
+ assert.match(route.source, /config extractor\.provider/)
448
+ })
449
+
450
+ check('resolveRoute explains what is missing instead of guessing a route', () => {
451
+ assert.throws(
452
+ () => resolveRoute(harnessCfg({ useCurrentModel: false, provider: 'deepseek', model: '' }), { provider: 'p', model: 'm' }),
453
+ /both required/,
454
+ )
455
+ assert.throws(() => resolveRoute(harnessCfg(), undefined), /No model is selected in Settings/)
456
+ })
457
+
458
+ await checkAsync('the harness transport needs no key and names the route it used', async () => {
459
+ const { llm, seen } = stubLlm()
460
+ const result = await callModel(harnessCfg(), [{ role: 'system', content: 'S' }, { role: 'user', content: 'U' }], {
461
+ llm,
462
+ selection: { provider: 'wb', model: 'deepseek-v4.1-flash' },
463
+ })
464
+ assert.equal(result.content, '{"cards":[]}')
465
+ assert.equal(result.model, 'wb/deepseek-v4.1-flash')
466
+ assert.match(result.keySource, /Settings/)
467
+ assert.equal(seen[0].config.provider, 'wb')
468
+ assert.equal(seen[1].options.system, 'S')
469
+ assert.deepEqual(seen[1].options.messages, [{ role: 'user', content: [{ type: 'text', text: 'U' }] }])
470
+ })
471
+
472
+ await checkAsync('the harness transport passes a configured thinking level to the route', async () => {
473
+ const { llm, seen } = stubLlm()
474
+ await callModel(harnessCfg({ reasoningEffort: 'high' }), [{ role: 'user', content: 'U' }], {
475
+ llm,
476
+ selection: { provider: 'p', model: 'm' },
477
+ })
478
+ assert.equal(seen[0].config.reasoningEffort, 'high')
479
+ })
480
+
481
+ await checkAsync('the harness transport sends the output cap and leaves sampling to the route', async () => {
482
+ const { llm, seen } = stubLlm()
483
+ await callModel(harnessCfg({ maxOutputTokens: 4096 }), [{ role: 'user', content: 'U' }], {
484
+ llm,
485
+ selection: { provider: 'p', model: 'm' },
486
+ })
487
+ assert.equal(seen[0].config.maxTokens, 4096)
488
+ assert.equal('temperature' in seen[0].config, false, 'the borrowed route owns sampling')
489
+ })
490
+
491
+ await checkAsync('the harness transport reports a failed finish with its code', async () => {
492
+ const { llm } = stubLlm({
493
+ chunks: [{ type: 'finish', reason: { kind: 'error', failure: { code: 'RATE_LIMIT', message: 'slow down' } } }],
494
+ })
495
+ await assert.rejects(
496
+ () => callModel(harnessCfg(), [{ role: 'user', content: 'U' }], { llm, selection: { provider: 'p', model: 'm' } }),
497
+ /RATE_LIMIT.*slow down/s,
498
+ )
499
+ })
500
+
501
+ await checkAsync('the harness transport asks for a bigger cap when output is truncated', async () => {
502
+ const { llm } = stubLlm({ chunks: [{ type: 'text-delta', index: 0, text: '{"cards":[' }, { type: 'finish', reason: { kind: 'max-tokens' } }] })
503
+ await assert.rejects(
504
+ () => callModel(harnessCfg(), [{ role: 'user', content: 'U' }], { llm, selection: { provider: 'p', model: 'm' } }),
505
+ /maxOutputTokens/,
506
+ )
507
+ })
508
+
509
+ await checkAsync('the harness transport rejects an empty completion', async () => {
510
+ const { llm } = stubLlm({ chunks: [{ type: 'text-delta', index: 0, text: ' ' }, { type: 'finish', reason: { kind: 'stop' } }] })
511
+ await assert.rejects(
512
+ () => callModel(harnessCfg(), [{ role: 'user', content: 'U' }], { llm, selection: { provider: 'p', model: 'm' } }),
513
+ /empty completion/,
514
+ )
515
+ })
516
+
517
+ await checkAsync('a missing llm service points at the http transport instead of failing obscurely', async () => {
518
+ await assert.rejects(() => callModel(harnessCfg(), [{ role: 'user', content: 'U' }]), /no llm service.*"http"/s)
519
+ })
520
+
521
+ await checkAsync('the harness transport says who cancelled an aborted call', async () => {
522
+ const { llm } = stubLlm({ delayMs: 40, chunks: [{ type: 'finish', reason: { kind: 'aborted' } }] })
523
+ const abort = new AbortController()
524
+ setTimeout(() => abort.abort(), 5)
525
+ await assert.rejects(
526
+ () =>
527
+ callModel(harnessCfg(), [{ role: 'user', content: 'U' }], {
528
+ llm,
529
+ signal: abort.signal,
530
+ selection: { provider: 'p', model: 'm' },
531
+ }),
532
+ /p\/m was cancelled before the route answered/s,
533
+ )
534
+ })
535
+
536
+ await checkAsync('distill over the harness transport records the borrowed route as provenance', async () => {
537
+ const llmDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-harness-'))
538
+ const llmCfg = resolveConfig({ vaultDir: llmDir, extractor: { enabled: true, model: 'deepseek-chat' } })
539
+ const llmRedactor = createRedactor(llmCfg.privacy)
540
+ const llmVault = createVault(llmCfg, llmRedactor)
541
+ await llmVault.appendEpisode({ project: 'p', t: '2026-11-01T09:00:00+08:00', user: 'Use the model from Settings.', assistant: 'OK.' })
542
+ const { llm } = stubLlm({
543
+ chunks: [
544
+ { type: 'text-delta', index: 0, text: JSON.stringify({ cards: [{ kind: 'preference', title: 'Borrow the Settings model', body: 'No key of its own.', entities: ['dsh'] }] }) },
545
+ { type: 'finish', reason: { kind: 'stop' } },
546
+ ],
547
+ })
548
+ calls.length = 0
549
+ const report = await distill({ cfg: llmCfg, vault: llmVault, redactor: llmRedactor, llm, selection: { provider: 'wb', model: 'deepseek-v4.1-flash' } })
550
+ assert.equal(report.ok, true)
551
+ assert.equal(report.written, 1)
552
+ assert.equal(report.model, 'wb/deepseek-v4.1-flash')
553
+ const [card] = await llmVault.listCards({ includeRetracted: true })
554
+ assert.equal(card.source.model, 'wb/deepseek-v4.1-flash')
555
+ assert.equal(calls.length, 0, 'the harness transport must not touch fetch')
556
+ await fsp.rm(llmDir, { recursive: true, force: true })
557
+ })
558
+
559
+ // ---------------------------------------------------------------------------
560
+ // Batching: one call per batch, so the answer fits the output budget
561
+ // ---------------------------------------------------------------------------
562
+
563
+ /** A vault with `count` undistilled episodes and a harness-only extractor. */
564
+ async function backlog(count, overrides = {}) {
565
+ const vaultDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-batch-'))
566
+ const cfg = resolveConfig({ vaultDir, extractor: { enabled: true, ...overrides } })
567
+ const redactor = createRedactor(cfg.privacy)
568
+ const vault = createVault(cfg, redactor)
569
+ for (let i = 0; i < count; i += 1) {
570
+ await vault.appendEpisode({ project: 'p', t: `2026-10-${String((i % 28) + 1).padStart(2, '0')}T09:00:00+08:00`, user: `turn ${i}`, assistant: 'ok' })
571
+ }
572
+ return { cfg, redactor, vault, vaultDir }
573
+ }
574
+
575
+ check('plannedBatchSize fits the output budget and never exceeds the backlog', () => {
576
+ assert.equal(plannedBatchSize(harnessCfg({ maxOutputTokens: 3000 }), 23), 9)
577
+ assert.equal(plannedBatchSize(harnessCfg({ maxOutputTokens: 3000 }), 3), 3)
578
+ assert.equal(plannedBatchSize(harnessCfg({ maxOutputTokens: 400 }), 23), 1)
579
+ assert.equal(plannedBatchSize(harnessCfg({ maxOutputTokens: 100 }), 23), 1, 'one episode per call is the floor')
580
+ })
581
+
582
+ await checkAsync('a long backlog is distilled in batches of the planned size', async () => {
583
+ const { cfg, redactor, vault, vaultDir } = await backlog(20, { maxOutputTokens: 3000 })
584
+ const stub = stubLlm({ script: [cardsChunks('first'), cardsChunks('second'), cardsChunks('third')] })
585
+ const report = await distill({ cfg, vault, redactor, llm: stub.llm, selection: { provider: 'p', model: 'm' } })
586
+ assert.equal(report.ok, true)
587
+ assert.equal(report.episodes, 20, 'every episode is covered, in steps of nine')
588
+ assert.deepEqual(report.batches, { planned: 3, done: 3, size: 9 })
589
+ assert.equal(stub.calls, 3, 'nine, nine, two — three calls, not one forty-episode request')
590
+ assert.equal(report.written, 3)
591
+ assert.equal(report.usage.totalTokens, 300, 'usage is summed across batches')
592
+ assert.equal((await vault.readUndistilled(100)).length, 0, 'the whole backlog is marked distilled')
593
+ await fsp.rm(vaultDir, { recursive: true, force: true })
594
+ })
595
+
596
+ await checkAsync('a truncated batch is halved and retried instead of failing the run', async () => {
597
+ const { cfg, redactor, vault, vaultDir } = await backlog(4, { maxOutputTokens: 3000 })
598
+ // Four episodes fit one call; the model still runs out, so the run must ask
599
+ // for half of them rather than give up on the backlog.
600
+ const stub = stubLlm({ script: [truncatedChunks, cardsChunks('left half'), cardsChunks('right half')] })
601
+ const report = await distill({ cfg, vault, redactor, llm: stub.llm, selection: { provider: 'p', model: 'm' } })
602
+ assert.equal(report.ok, true)
603
+ assert.equal(report.episodes, 4)
604
+ assert.equal(stub.calls, 3, 'one overflow, then two halves')
605
+ assert.equal(report.written, 2)
606
+ assert.equal((await vault.readUndistilled(100)).length, 0)
607
+ await fsp.rm(vaultDir, { recursive: true, force: true })
608
+ })
609
+
610
+ await checkAsync('a single episode that overflows is reported, not retried forever', async () => {
611
+ const { cfg, redactor, vault, vaultDir } = await backlog(1, { maxOutputTokens: 3000 })
612
+ const stub = stubLlm({ chunks: truncatedChunks })
613
+ const report = await distill({ cfg, vault, redactor, llm: stub.llm, selection: { provider: 'p', model: 'm' } })
614
+ assert.equal(report.ok, false)
615
+ assert.match(report.reason, /maxOutputTokens/)
616
+ assert.equal(stub.calls, 1)
617
+ await fsp.rm(vaultDir, { recursive: true, force: true })
618
+ })
619
+
620
+ await checkAsync('a later batch failing keeps the cards the earlier ones wrote', async () => {
621
+ const { cfg, redactor, vault, vaultDir } = await backlog(10, { maxOutputTokens: 1600 })
622
+ const failing = [{ type: 'finish', reason: { kind: 'error', failure: { code: 'RATE_LIMIT', message: 'slow down' } } }]
623
+ const { llm } = stubLlm({ script: [cardsChunks('kept'), failing] })
624
+ const report = await distill({ cfg, vault, redactor, llm, selection: { provider: 'p', model: 'm' } })
625
+ assert.equal(report.ok, false, 'the run did not finish')
626
+ assert.match(report.reason, /RATE_LIMIT/)
627
+ assert.equal(report.written, 1, 'the first batch stays written')
628
+ assert.equal(report.cards.length, 1)
629
+ assert.equal(report.batches.done, 1)
630
+ const cards = await vault.listCards({ includeRetracted: true })
631
+ assert.equal(cards.length, 1)
632
+ assert.equal(cards[0].title, 'kept')
633
+ assert.equal((await vault.readUndistilled(100)).length, 5, 'only the finished batch is marked')
634
+ await fsp.rm(vaultDir, { recursive: true, force: true })
635
+ })
636
+
637
+ await checkAsync('a dry run previews one batch and says how much is left', async () => {
638
+ const { cfg, redactor, vault, vaultDir } = await backlog(20, { maxOutputTokens: 3000 })
639
+ const stub = stubLlm({ script: [cardsChunks('preview one', 'preview two')] })
640
+ const report = await distill({ cfg, vault, redactor, llm: stub.llm, selection: { provider: 'p', model: 'm' }, dryRun: true })
641
+ assert.equal(report.ok, true)
642
+ assert.equal(report.dryRun, true)
643
+ assert.equal(stub.calls, 1, 'a preview spends one call, not three')
644
+ assert.equal(report.episodes, 9)
645
+ assert.equal(report.remaining, 11)
646
+ assert.deepEqual(report.proposed.map((card) => card.title), ['preview one', 'preview two'])
647
+ assert.equal((await vault.listCards({ includeRetracted: true })).length, 0, 'nothing is written')
648
+ assert.equal((await vault.readUndistilled(100)).length, 20, 'nothing is marked distilled')
649
+ await fsp.rm(vaultDir, { recursive: true, force: true })
650
+ })
651
+
652
+ globalThis.fetch = realFetch
653
+
654
+ console.log(results.join('\n'))
655
+ if (process.exitCode) {
656
+ console.log('\nSOME CHECKS FAILED')
657
+ } else {
658
+ console.log(`\nall ${results.length} checks passed`)
659
+ await fsp.rm(dir, { recursive: true, force: true })
660
+ }