@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
package/src/extract.js ADDED
@@ -0,0 +1,660 @@
1
+ /**
2
+ * Distillation: raw episodes -> typed memory cards.
3
+ *
4
+ * This is the only place that calls a model, and it is always explicit: the
5
+ * agent runs it through the `memory_distill` tool, or a scheduled job does.
6
+ * Capture itself never calls a model, so nothing is lost while the extractor
7
+ * is unconfigured.
8
+ */
9
+ import fs from 'node:fs'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+ import { CARD_KINDS, CONFIDENCE } from './vault.js'
13
+
14
+ const SYSTEM_PROMPT = `You distill conversation transcripts into durable long-term memory.
15
+
16
+ Extract only what would still matter weeks later. Ignore chatter, tool mechanics,
17
+ restatements of the same fact, and anything already captured.
18
+
19
+ Card kinds:
20
+ - decision: a choice that was made, plus why. Include the alternative that was rejected when it is visible.
21
+ - fact: a stable property of the world, the project, or the system. Not a transient state.
22
+ - preference: how the user wants things done, in their own terms. If the user said how they want
23
+ something to work, that is a preference even when a decision was made in the same breath.
24
+ - open_question: something unresolved, with what would settle it.
25
+ - note: anything else durable worth keeping.
26
+
27
+ Rules:
28
+ 1. Each card must be self-contained: a reader with no other context must understand it.
29
+ 2. Language: write each card in the language of the user message it came from, never translated.
30
+ A Chinese user message produces a Chinese title and a Chinese body, even when the assistant
31
+ answered in English or the surrounding transcript is mostly English.
32
+ 3. A card title is one short sentence, no trailing period.
33
+ 4. A body is 1-4 sentences: what, why, and what it replaced. No preamble.
34
+ 5. entities: the concrete things involved — projects, files, tools, services, people, concepts.
35
+ Short canonical names, reuse the same name every time. Prefer 1-4 entities.
36
+ 6. confidence: "user_stated" if the user said it, "verified" if a tool result or the world confirmed it,
37
+ "inferred" if you concluded it yourself.
38
+ 7. If a card makes an existing memory wrong or obsolete, put that memory's exact id (from the
39
+ "existing memories" list) in supersedes. Do not supersede a card that is merely related.
40
+ 8. project: set it to the project named in the header of the episode the card came from. Never
41
+ copy the project of another episode in the same batch.
42
+ 9. valid_from: the date of the episode, YYYY-MM-DD.
43
+ 10. A transcript often contains mid-course corrections ("no, I meant", "不是", "改成", "其实").
44
+ Record the final state, never the corrected-away one; when the reversal matters, say what it replaced.
45
+ 11. If nothing durable happened, return {"cards":[]} and say why in "skipped".
46
+
47
+ Use the kind that fits, and do not collapse everything into one kind: a batch of
48
+ distinct events usually yields a mix.
49
+
50
+ Return JSON only, no prose and no code fences:
51
+ {"cards":[{"kind":"decision","project":"...","title":"...","body":"...","entities":["..."],"confidence":"user_stated","supersedes":["c-..."],"valid_from":"YYYY-MM-DD"}],"skipped":"..."}`
52
+
53
+ function fmtEpisode(entry, index) {
54
+ const { record } = entry
55
+ const lines = [`#${index + 1} [${record.t}] project=${record.project}${record.turn != null ? ` turn=${record.turn}` : ''}`]
56
+ if (record.user) lines.push(`USER: ${record.user}`)
57
+ if (record.assistant) lines.push(`ASSISTANT: ${record.assistant}`)
58
+ if (record.tools) lines.push(`TOOLS_USED: ${record.tools}`)
59
+ return lines.join('\n')
60
+ }
61
+
62
+ function existingBlock(cards, projects) {
63
+ const scoped = new Set(projects)
64
+ const relevant = cards
65
+ .filter((card) => card.status === 'active')
66
+ .filter((card) => scoped.size === 0 || scoped.has(card.project))
67
+ .slice(-60)
68
+ if (relevant.length === 0) return ''
69
+ return [
70
+ '',
71
+ 'Existing memories for these projects (use these exact ids in supersedes when one becomes wrong):',
72
+ ...relevant.map((card) => `- ${card.id} · ${card.project} · ${card.kind} · ${card.title}`),
73
+ ].join('\n')
74
+ }
75
+
76
+ export function buildPrompt(episodes, existingCards, project) {
77
+ const transcript = episodes.map(fmtEpisode).join('\n\n')
78
+ const projects = [...new Set([project, ...episodes.map((entry) => entry.record.project)].filter(Boolean))]
79
+ const scope =
80
+ projects.length === 1
81
+ ? `Distill these ${episodes.length} episode(s) from project "${projects[0]}".`
82
+ : `Distill these ${episodes.length} episode(s). They come from ${projects.length} projects ` +
83
+ `(${projects.join(', ')}); every card must name its own project in "project".`
84
+ const user = [
85
+ scope,
86
+ existingBlock(existingCards, projects),
87
+ '',
88
+ 'Transcript:',
89
+ '<transcript>',
90
+ transcript,
91
+ '</transcript>',
92
+ ].join('\n')
93
+ return [
94
+ { role: 'system', content: SYSTEM_PROMPT },
95
+ { role: 'user', content: user },
96
+ ]
97
+ }
98
+
99
+ function extractJson(text) {
100
+ const source = String(text ?? '').trim()
101
+ if (source === '') return null
102
+
103
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(source)
104
+ const candidates = []
105
+ if (fenced) candidates.push(fenced[1].trim())
106
+ candidates.push(source)
107
+
108
+ const firstBrace = source.indexOf('{')
109
+ const lastBrace = source.lastIndexOf('}')
110
+ if (firstBrace !== -1 && lastBrace > firstBrace) candidates.push(source.slice(firstBrace, lastBrace + 1))
111
+
112
+ for (const candidate of candidates) {
113
+ try {
114
+ return JSON.parse(candidate)
115
+ } catch {
116
+ // try the next shape
117
+ }
118
+ }
119
+ return null
120
+ }
121
+
122
+ export function parseCards(text) {
123
+ const parsed = extractJson(text)
124
+ if (parsed === null) return { cards: [], skipped: 'model output was not JSON', raw: String(text ?? '').slice(0, 600) }
125
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.cards) ? parsed.cards : []
126
+ const cards = []
127
+ for (const raw of list) {
128
+ if (!raw || typeof raw !== 'object') continue
129
+ const title = String(raw.title ?? '').trim()
130
+ if (title === '') continue
131
+ const kind = CARD_KINDS.includes(raw.kind) ? raw.kind : 'note'
132
+ const confidence = CONFIDENCE.includes(raw.confidence) ? raw.confidence : 'inferred'
133
+ cards.push({
134
+ kind,
135
+ title: title.slice(0, 160),
136
+ body: String(raw.body ?? '').trim().slice(0, 2000),
137
+ entities: Array.isArray(raw.entities)
138
+ ? raw.entities.filter((e) => typeof e === 'string' && e.trim() !== '').slice(0, 8)
139
+ : [],
140
+ confidence,
141
+ project: typeof raw.project === 'string' && raw.project.trim() !== '' ? raw.project.trim() : null,
142
+ supersedes: Array.isArray(raw.supersedes)
143
+ ? raw.supersedes.filter((s) => typeof s === 'string' && /^c-/.test(s)).slice(0, 8)
144
+ : [],
145
+ validFrom: typeof raw.valid_from === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.valid_from) ? raw.valid_from : null,
146
+ })
147
+ }
148
+ return { cards, skipped: typeof parsed.skipped === 'string' ? parsed.skipped : null }
149
+ }
150
+
151
+ /**
152
+ * Where the harness keeps its own named secrets, if it has one. DeepSeek Harness
153
+ * stores per-provider keys in `<DSH_HOME>/.credentials.yaml` under `refs:` and
154
+ * resolves them internally, so they never appear in `process.env`. Reading the
155
+ * one name the user configured makes the extractor work without asking anyone to
156
+ * duplicate a key into a shell environment.
157
+ */
158
+ function credentialStorePath() {
159
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
160
+ return path.join(home, '.credentials.yaml')
161
+ }
162
+
163
+ /**
164
+ * Read one named secret out of the credential store. Only the requested name is
165
+ * looked at; the value is returned and never logged, audited or stored.
166
+ */
167
+ export function readCredentialRef(name, file = credentialStorePath()) {
168
+ if (!name) return ''
169
+ let text
170
+ try {
171
+ text = fs.readFileSync(file, 'utf8')
172
+ } catch {
173
+ return ''
174
+ }
175
+ let inRefs = false
176
+ for (const line of text.split('\n')) {
177
+ if (/^refs:\s*$/.test(line)) {
178
+ inRefs = true
179
+ continue
180
+ }
181
+ if (inRefs && /^\S/.test(line)) inRefs = false
182
+ if (!inRefs) continue
183
+ const match = /^\s+([A-Za-z0-9_.-]+)\s*:\s*(.+?)\s*$/.exec(line)
184
+ if (match && match[1] === name) return match[2].replace(/^['"]|['"]$/g, '')
185
+ }
186
+ return ''
187
+ }
188
+
189
+ /**
190
+ * @returns {{key: string, source: string, looked: string[]}} where the key came
191
+ * from, described in words safe to show a user and to put in a report.
192
+ */
193
+ export function resolveKeyInfo(cfg) {
194
+ const { apiKey, apiKeyEnv } = cfg.extractor
195
+ const store = credentialStorePath()
196
+ const looked = []
197
+ if (apiKey) return { key: apiKey, source: 'config extractor.apiKey', looked }
198
+ if (apiKeyEnv && process.env[apiKeyEnv]) return { key: process.env[apiKeyEnv], source: `env ${apiKeyEnv}`, looked }
199
+ if (apiKeyEnv) {
200
+ looked.push(`env ${apiKeyEnv}`, `${store} refs.${apiKeyEnv}`)
201
+ const stored = readCredentialRef(apiKeyEnv)
202
+ if (stored) return { key: stored, source: `credential store refs.${apiKeyEnv}`, looked }
203
+ }
204
+ return { key: '', source: '', looked }
205
+ }
206
+
207
+ function resolveKey(cfg) {
208
+ return resolveKeyInfo(cfg).key
209
+ }
210
+
211
+ /**
212
+ * The thinking level of a Settings model selection. The harness stores it as
213
+ * `reasoningEffort` (an opaque id); older shapes nested it under `reasoning` as
214
+ * a string or `{id, name}` record, so all three are accepted.
215
+ */
216
+ export function selectionEffort(selection) {
217
+ const direct = selection?.reasoningEffort
218
+ if (typeof direct === 'string') return direct
219
+ const value = selection?.reasoning
220
+ if (typeof value === 'string') return value
221
+ if (value && typeof value === 'object' && typeof value.id === 'string') return value.id
222
+ return ''
223
+ }
224
+
225
+ /**
226
+ * The two harness services a harness-transport distillation borrows: the `llm`
227
+ * caller, and whatever model 设置 → 模型 has selected right now.
228
+ *
229
+ * Both are optional on purpose. A profile without them still runs the plugin —
230
+ * the http transport needs neither, and the harness transport reports what is
231
+ * missing instead of throwing. Resolved at call time, so switching models in
232
+ * Settings takes effect on the next run.
233
+ */
234
+ export function extractorServices(ctx) {
235
+ try {
236
+ const llm = ctx?.get?.('llm', false) ?? undefined
237
+ const selection = ctx?.get?.('agentDefaultModel', false)?.currentSelection?.() ?? undefined
238
+ return { llm, selection }
239
+ } catch {
240
+ return { llm: undefined, selection: undefined }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * The route one harness-transport call takes: the provider/model pair chosen in
246
+ * this plugin's settings, or the model currently selected in Settings → Models.
247
+ * Reusing that selection is the whole point of the harness transport: the route,
248
+ * its key and its base URL already exist, so the extractor holds no secret.
249
+ * @returns {{provider: string, model: string, effort: string, source: string}}
250
+ */
251
+ export function resolveRoute(cfg, selection) {
252
+ const { useCurrentModel, provider, model, reasoningEffort } = cfg.extractor
253
+ if (!useCurrentModel) {
254
+ if (!provider || !model) {
255
+ throw new Error(
256
+ 'extractor.useCurrentModel is off, so extractor.provider and extractor.model are both required.',
257
+ )
258
+ }
259
+ return { provider, model, effort: reasoningEffort, source: 'config extractor.provider/model' }
260
+ }
261
+ if (!selection?.provider || !selection?.model) {
262
+ throw new Error(
263
+ 'No model is selected in Settings → Models, so the extractor has no route to borrow. ' +
264
+ 'Pick a model there, or set extractor.provider and extractor.model and turn extractor.useCurrentModel off.',
265
+ )
266
+ }
267
+ return {
268
+ provider: selection.provider,
269
+ model: selection.model,
270
+ // An explicit setting wins, so a big thinking budget can be reserved for
271
+ // distillation without changing the model the conversation itself uses.
272
+ effort: reasoningEffort || selectionEffort(selection),
273
+ source: 'Settings → Models selection',
274
+ }
275
+ }
276
+
277
+ /** Split OpenAI-shaped messages into the harness's system prompt plus one user input. */
278
+ function splitMessages(messages) {
279
+ const join = (list) => list.map((message) => message.content).join('\n\n')
280
+ return {
281
+ system: join(messages.filter((message) => message.role === 'system')),
282
+ input: join(messages.filter((message) => message.role !== 'system')),
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Call a route the Settings page already owns, through the harness LLM service.
288
+ *
289
+ * The service resolves the adapter, the base URL and the credential, so a
290
+ * custom provider added in Settings works here with no configuration at all, and
291
+ * the key never passes through this plugin.
292
+ */
293
+ async function callHarnessModel(cfg, messages, { signal, llm, selection } = {}) {
294
+ if (!llm || typeof llm.prepareCall !== 'function') {
295
+ throw new Error(
296
+ 'This harness build exposes no llm service to plugins, so set extractor.transport = "http" to post to an endpoint directly.',
297
+ )
298
+ }
299
+ const route = resolveRoute(cfg, selection)
300
+ const { system, input } = splitMessages(messages)
301
+ const controller = new AbortController()
302
+ const timer = setTimeout(() => controller.abort(), cfg.extractor.timeoutMs)
303
+ const onAbort = () => controller.abort()
304
+ signal?.addEventListener?.('abort', onAbort, { once: true })
305
+
306
+ try {
307
+ const { effort } = route
308
+ // Sampling is left alone: the borrowed route owns it, and some providers
309
+ // reject an explicit temperature when they are in a thinking mode.
310
+ const controls = {
311
+ maxTokens: cfg.extractor.maxOutputTokens,
312
+ ...(effort === '' ? {} : { reasoningEffort: effort }),
313
+ }
314
+ // prepareCall validates the controls against this exact route before any
315
+ // I/O, so an effort the route does not offer fails here with its own error
316
+ // instead of halfway through a paid call.
317
+ const call = await llm.prepareCall(
318
+ { provider: route.provider, model: route.model, ...controls },
319
+ controller.signal,
320
+ (proposed) => ({ ...proposed, ...controls }),
321
+ )
322
+ const options = {
323
+ ...call.config,
324
+ messages: [{ role: 'user', content: [{ type: 'text', text: input }] }],
325
+ system,
326
+ signal: controller.signal,
327
+ }
328
+ let text = ''
329
+ let usage = null
330
+ let finish = null
331
+ for await (const chunk of call.stream(options)) {
332
+ if (chunk?.type === 'text-delta') text += chunk.text
333
+ else if (chunk?.type === 'usage') usage = chunk.usage ?? null
334
+ else if (chunk?.type === 'finish') finish = chunk.reason ?? null
335
+ }
336
+ const kind = finish?.kind
337
+ if (kind === 'error' || kind === 'aborted') {
338
+ const failure = finish.failure ?? {}
339
+ if (controller.signal.aborted) {
340
+ // Either the caller cancelled us or our own timer fired; say which,
341
+ // because the fix is different (wait longer vs. run less work).
342
+ const why = signal?.aborted
343
+ ? 'was cancelled before the route answered'
344
+ : `timed out after ${cfg.extractor.timeoutMs} ms`
345
+ throw new Error(
346
+ `Extractor call to ${route.provider}/${route.model} ${why}; raise extractor.timeoutMs or distill fewer episodes per run.`,
347
+ )
348
+ }
349
+ throw new Error(
350
+ `Extractor call failed on ${route.provider}/${route.model} (${failure.code ?? kind}): ${failure.message ?? 'the route ended the stream without a reason'}`,
351
+ )
352
+ }
353
+ if (kind === 'tool-calls') {
354
+ throw new Error('Extractor answered with a tool call instead of JSON. Try a different model for distillation.')
355
+ }
356
+ if (kind === 'max-tokens') {
357
+ throw truncated(
358
+ `Extractor output reached maxOutputTokens (${cfg.extractor.maxOutputTokens}); raise it or distill fewer episodes per run.`,
359
+ )
360
+ }
361
+ if (text.trim() === '') throw new Error('Extractor returned an empty completion.')
362
+ return {
363
+ content: text,
364
+ usage,
365
+ model: `${route.provider}/${route.model}`,
366
+ keySource: `${route.source}${effort === '' ? '' : ` · effort ${effort}`}`,
367
+ }
368
+ } finally {
369
+ clearTimeout(timer)
370
+ signal?.removeEventListener?.('abort', onAbort)
371
+ }
372
+ }
373
+
374
+ /** Post to an OpenAI-compatible endpoint with this plugin's own key. */
375
+ export async function callHttpModel(cfg, messages, { signal } = {}) {
376
+ const { key, source, looked } = resolveKeyInfo(cfg)
377
+ if (key === '') {
378
+ const where = looked.length > 0 ? looked.join(' or ') : 'config extractor.apiKey'
379
+ throw new Error(
380
+ `No API key for the extractor. Looked in ${where}. Set the env var, add the name to the harness credential store, or set config extractor.apiKey.`,
381
+ )
382
+ }
383
+ const url = `${cfg.extractor.baseUrl}${cfg.extractor.path}`
384
+ const controller = new AbortController()
385
+ const timer = setTimeout(() => controller.abort(), cfg.extractor.timeoutMs)
386
+ const onAbort = () => controller.abort()
387
+ signal?.addEventListener?.('abort', onAbort, { once: true })
388
+
389
+ const body = { model: cfg.extractor.model, messages, temperature: cfg.extractor.temperature, max_tokens: cfg.extractor.maxOutputTokens, stream: false }
390
+
391
+ const post = (extra) =>
392
+ fetch(url, {
393
+ method: 'POST',
394
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
395
+ body: JSON.stringify({ ...body, ...extra }),
396
+ signal: controller.signal,
397
+ })
398
+
399
+ try {
400
+ // Ask for strict JSON, but not every OpenAI-compatible endpoint implements
401
+ // response_format: a proxy that rejects the field gets one retry without it.
402
+ let response = await post({ response_format: { type: 'json_object' } })
403
+ let text = await response.text()
404
+ if (response.status === 400 && /response_format|json_object/i.test(text)) {
405
+ response = await post({})
406
+ text = await response.text()
407
+ }
408
+ if (!response.ok) {
409
+ throw new Error(`Extractor HTTP ${response.status} from ${url}: ${text.slice(0, 400)}`)
410
+ }
411
+ let data
412
+ try {
413
+ data = JSON.parse(text)
414
+ } catch {
415
+ throw new Error(`Extractor returned non-JSON response: ${text.slice(0, 300)}`)
416
+ }
417
+ const content =
418
+ data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? data?.output_text ?? ''
419
+ if (data?.choices?.[0]?.finish_reason === 'length') {
420
+ // Same situation as the harness route's max-tokens finish: the answer is
421
+ // cut off mid-JSON, so ask for less instead of blaming the parser.
422
+ throw truncated(
423
+ `Extractor output reached maxOutputTokens (${cfg.extractor.maxOutputTokens}); raise it or distill fewer episodes per run.`,
424
+ )
425
+ }
426
+ if (typeof content !== 'string' || content.trim() === '') {
427
+ throw new Error('Extractor returned an empty completion.')
428
+ }
429
+ return { content, usage: data?.usage ?? null, model: data?.model ?? cfg.extractor.model, keySource: source }
430
+ } finally {
431
+ clearTimeout(timer)
432
+ signal?.removeEventListener?.('abort', onAbort)
433
+ }
434
+ }
435
+
436
+ /**
437
+ * One model call for distillation, over the configured transport.
438
+ * @param {object} cfg resolved plugin config
439
+ * @param {Array<{role: string, content: string}>} messages prompt built by {@link buildPrompt}
440
+ * @param {{signal?: AbortSignal, llm?: object, selection?: object}} options harness
441
+ * service handle and the current Settings model selection, both used by the
442
+ * harness transport and ignored by the HTTP one
443
+ */
444
+ export async function callModel(cfg, messages, { signal, llm, selection } = {}) {
445
+ return cfg.extractor.transport === 'http'
446
+ ? callHttpModel(cfg, messages, { signal })
447
+ : callHarnessModel(cfg, messages, { signal, llm, selection })
448
+ }
449
+
450
+ /**
451
+ * The project a batch belongs to when a card does not say: the most frequent
452
+ * project among the episodes, with the most recent one winning a tie.
453
+ */
454
+ function dominantProject(entries) {
455
+ const counts = new Map()
456
+ let best = null
457
+ let bestCount = 0
458
+ for (const entry of entries) {
459
+ const project = entry.record?.project
460
+ if (!project) continue
461
+ const count = (counts.get(project) ?? 0) + 1
462
+ counts.set(project, count)
463
+ if (count >= bestCount) {
464
+ best = project
465
+ bestCount = count
466
+ }
467
+ }
468
+ return best
469
+ }
470
+
471
+ /** Stable code for "the model ran out of output budget part-way through". */
472
+ const TRUNCATED = 'EXTRACTOR_TRUNCATED'
473
+
474
+ /** Rough output cost of one episode: a card, sometimes two. */
475
+ const OUTPUT_TOKENS_PER_EPISODE = 320
476
+
477
+ function truncated(message) {
478
+ const error = new Error(message)
479
+ error.code = TRUNCATED
480
+ return error
481
+ }
482
+
483
+ /** How many episodes to send in one call so the answer fits the output budget. */
484
+ export function plannedBatchSize(cfg, total) {
485
+ const budget = Math.floor((cfg.extractor.maxOutputTokens || 0) / OUTPUT_TOKENS_PER_EPISODE)
486
+ const fit = Number.isFinite(budget) && budget > 0 ? budget : 1
487
+ return Math.max(1, Math.min(total, fit))
488
+ }
489
+
490
+ /**
491
+ * Read undistilled episodes, extract cards, persist them.
492
+ * @returns {Promise<object>} a report the tool renders verbatim
493
+ */
494
+ export async function distill({ cfg, vault, redactor, project, dryRun = false, signal, llm, selection }) {
495
+ if (!cfg.extractor.enabled) {
496
+ return {
497
+ ok: false,
498
+ reason:
499
+ 'Extractor is disabled. Set config extractor.enabled = true; the default transport reuses the model selected in Settings → Models.',
500
+ }
501
+ }
502
+
503
+ const entries = await vault.readUndistilled(cfg.extractor.maxEpisodesPerRun)
504
+ if (entries.length === 0) {
505
+ return { ok: true, episodes: 0, written: 0, reason: 'Every episode has already been distilled.' }
506
+ }
507
+
508
+ const known = await vault.listCards({ includeRetracted: true })
509
+ // One run can span several projects. The batch is scoped by the projects the
510
+ // episodes actually name, and each card is filed under its own project; the
511
+ // dominant project is only the fallback when a card forgets to say.
512
+ const batchProjects = [...new Set(entries.map((entry) => entry.record.project).filter(Boolean))]
513
+ const dominant = dominantProject(entries) || cfg.defaultProject
514
+
515
+ // One call per batch, not per run: 40 episodes would ask for more cards than
516
+ // any sane maxOutputTokens allows, and a truncated answer loses the lot.
517
+ const size = plannedBatchSize(cfg, entries.length)
518
+ const planned = Math.ceil(entries.length / size)
519
+
520
+ const run = { calls: 0, usage: null, model: null, keySource: null }
521
+ const skippedNotes = []
522
+
523
+ /** Ask for one batch, halving and retrying when the output budget ran out. */
524
+ const ask = async (chunk) => {
525
+ const projects = [...new Set(chunk.map((entry) => entry.record.project).filter(Boolean))]
526
+ const scoped = project || (projects.length === 1 ? projects[0] : dominant)
527
+ const context = [...known, ...written.map((card) => card.card).filter(Boolean)]
528
+ let answer
529
+ try {
530
+ answer = await callModel(cfg, buildPrompt(chunk, context, scoped), { signal, llm, selection })
531
+ } catch (error) {
532
+ // A single episode that still overflows is a real failure; with more than
533
+ // one, the honest fix is to ask for less at a time rather than to give up.
534
+ if (chunk.length > 1 && error?.code === TRUNCATED) {
535
+ const half = Math.ceil(chunk.length / 2)
536
+ return [...(await ask(chunk.slice(0, half))), ...(await ask(chunk.slice(half)))]
537
+ }
538
+ throw error
539
+ }
540
+ run.calls += 1
541
+ run.model ??= answer.model
542
+ run.keySource ??= answer.keySource
543
+ run.usage = sumUsage(run.usage, answer.usage)
544
+ return [{ chunk, answer }]
545
+ }
546
+
547
+ const written = []
548
+ const proposed = []
549
+ const queue = [...entries]
550
+ const report = () => ({
551
+ episodes: dryRun ? Math.min(size, entries.length) : entries.length - queue.length,
552
+ remaining: queue.length,
553
+ batches: { planned, done: run.calls, size },
554
+ skipped: skippedNotes.length === 0 ? null : skippedNotes.join('; ').slice(0, 400),
555
+ usage: run.usage,
556
+ model: run.model,
557
+ keySource: run.keySource,
558
+ })
559
+
560
+ while (queue.length > 0) {
561
+ const chunk = queue.splice(0, size)
562
+ let answers
563
+ try {
564
+ answers = await ask(chunk)
565
+ } catch (error) {
566
+ // Keep whatever already landed: a later batch failing does not undo it.
567
+ return {
568
+ ok: false,
569
+ reason: error.message,
570
+ written: written.length,
571
+ cards: written,
572
+ projects: [...new Set(written.map((card) => card.project))],
573
+ ...report(),
574
+ }
575
+ }
576
+
577
+ for (const { chunk: part, answer } of answers) {
578
+ const parsed = parseCards(answer.content)
579
+ if (parsed.skipped) skippedNotes.push(parsed.skipped)
580
+ if (dryRun) {
581
+ proposed.push(...parsed.cards)
582
+ continue
583
+ }
584
+ const allowed = new Set([...part.map((entry) => entry.record.project), cfg.defaultProject])
585
+ const partProjects = [...new Set(part.map((entry) => entry.record.project).filter(Boolean))]
586
+ const fallback = project || (partProjects.length === 1 ? partProjects[0] : dominant)
587
+ for (const draft of parsed.cards) {
588
+ const title = redactor.redact(draft.title).text
589
+ const body = redactor.redact(draft.body).text
590
+ const entities = draft.entities.map((entity) => redactor.redact(entity).text)
591
+ const cardProject =
592
+ project || (draft.project && allowed.has(draft.project) ? draft.project : null) || fallback
593
+ const result = await vault.saveCard({
594
+ ...draft,
595
+ title,
596
+ body,
597
+ entities,
598
+ project: cardProject,
599
+ source: {
600
+ kind: 'distill',
601
+ episodes: part.map((entry) => `${entry.file}#${entry.line}`).slice(0, 50),
602
+ model: answer.model,
603
+ },
604
+ })
605
+ written.push({
606
+ id: result.card.id,
607
+ kind: result.card.kind,
608
+ project: result.card.project,
609
+ title: result.card.title,
610
+ deduped: result.deduped,
611
+ supersedes: draft.supersedes,
612
+ card: result.card,
613
+ })
614
+ }
615
+ // Mark per batch: a run that dies half-way should not redo the batches
616
+ // that already landed.
617
+ await vault.markDistilled(part)
618
+ }
619
+ // A dry run is a preview of one batch; batching the rest would spend the
620
+ // user's tokens to say the same thing.
621
+ if (dryRun) break
622
+ }
623
+
624
+ if (dryRun) {
625
+ return {
626
+ ok: true,
627
+ dryRun: true,
628
+ projects: batchProjects,
629
+ proposed,
630
+ ...report(),
631
+ }
632
+ }
633
+
634
+ await vault.regenerateReadme()
635
+ await vault.regenerateGraph?.()
636
+ await vault.audit(
637
+ 'distill',
638
+ `projects=${batchProjects.join(',') || dominant} episodes=${entries.length} cards=${written.length} batches=${run.calls} model=${run.model} via=${cfg.extractor.transport} route=${run.keySource}`,
639
+ )
640
+
641
+ return {
642
+ ok: true,
643
+ written: written.length,
644
+ cards: written.map(({ card, ...rest }) => rest),
645
+ projects: [...new Set(written.map((card) => card.project))],
646
+ vault: cfg.vaultDir,
647
+ ...report(),
648
+ }
649
+ }
650
+
651
+ /** Add two usage reports without assuming any single field is present. */
652
+ function sumUsage(total, next) {
653
+ if (!next) return total
654
+ if (!total) return { ...next }
655
+ const out = { ...total }
656
+ for (const [key, value] of Object.entries(next)) {
657
+ if (typeof value === 'number') out[key] = (typeof out[key] === 'number' ? out[key] : 0) + value
658
+ }
659
+ return out
660
+ }