@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.
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.en.md +466 -0
- package/README.md +459 -0
- package/client.js +1953 -0
- package/cordis.patch.yml +13 -0
- package/docs/auto-settings.png +0 -0
- package/docs/distill-button.png +0 -0
- package/docs/graph-dark.png +0 -0
- package/docs/graph-light.png +0 -0
- package/docs/session-tab-cards.png +0 -0
- package/docs/session-tab-open.png +0 -0
- package/docs/session-tab.png +0 -0
- package/docs/vault-panel.png +0 -0
- package/eval/REPORT.md +437 -0
- package/eval/questions.json +277 -0
- package/eval/run.mjs +460 -0
- package/eval/seed.mjs +489 -0
- package/icon.svg +26 -0
- package/index.js +243 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -0
- package/src/auto.js +194 -0
- package/src/capture.js +237 -0
- package/src/config.js +280 -0
- package/src/distillapi.js +30 -0
- package/src/distillrun.js +106 -0
- package/src/extract.js +660 -0
- package/src/graph.js +1582 -0
- package/src/graphdata.js +475 -0
- package/src/http.js +32 -0
- package/src/pageapi.js +40 -0
- package/src/privacy.js +78 -0
- package/src/schema.js +323 -0
- package/src/search.js +317 -0
- package/src/tools.js +534 -0
- package/src/vault.js +888 -0
- package/test/auto.mjs +295 -0
- package/test/capture.mjs +264 -0
- package/test/client.mjs +1035 -0
- package/test/distillapi.mjs +212 -0
- package/test/extract.mjs +660 -0
- package/test/graphdata.mjs +295 -0
- package/test/live-supersede.mjs +78 -0
- package/test/live.mjs +156 -0
- package/test/page.mjs +116 -0
- package/test/schema.mjs +256 -0
- package/test/smoke.mjs +428 -0
- 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
|
+
}
|