dsh-hypatia-auto-memory 0.1.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.
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Background consolidation: turns logged conversation spans into durable
3
+ * knowledge — a span summary plus structured work-unit memories — using a
4
+ * DEDICATED model route (never the main session's loop). Runs as a queue
5
+ * executor, so it inherits durability, per-session ordering, and retry.
6
+ *
7
+ * Its own LLM calls go through ctx.llm.stream directly and never touch the
8
+ * session log, so consolidation cannot trigger itself.
9
+ *
10
+ * dsh-llm is imported lazily inside the executor so this module's pure
11
+ * helpers (transcript building, output parsing, trigger thresholds) stay
12
+ * importable without the DSH package graph.
13
+ *
14
+ * @module dsh-hypatia-auto-memory/consolidator
15
+ */
16
+
17
+ import { absolutizeDates, blocksToText, eventDate, flattenToolResult, redactSecrets } from './content-policy.js'
18
+ import { countLoggableMessages, isCompactionReplacement, isLoggableMessage } from './collector.js'
19
+ import { messageName, summaryName } from './writer.js'
20
+ import { EMPTY_PROGRESS, advanceProgress } from './progress.js'
21
+ import { TaskDeferredError } from './queue.js'
22
+
23
+ export const PLUGIN_NAME = 'dsh-hypatia-auto-memory'
24
+
25
+ /** Distinguishes permanent failures (no retry value) from transient ones. */
26
+ export class PermanentConsolidationError extends Error {
27
+ constructor(message) {
28
+ super(message)
29
+ this.name = 'PermanentConsolidationError'
30
+ this.permanent = true
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Choose one selected route per consolidation attempt in stable round-robin order.
36
+ * Empty lists do not consume a turn, so adding the first route always uses it.
37
+ */
38
+ export function createConsolidationRouteSelector() {
39
+ let next = 0
40
+ return (routes) => {
41
+ if (!Array.isArray(routes) || routes.length === 0) return undefined
42
+ const route = routes[next % routes.length]
43
+ next = (next + 1) % routes.length
44
+ return route
45
+ }
46
+ }
47
+
48
+ /**
49
+ * What a `turn/end` reason means for the consolidation trigger.
50
+ *
51
+ * `TurnEndReason` is merge-extensible — plugins can declare their own variants —
52
+ * so unknown kinds must fall through to the conservative reading rather than
53
+ * being treated as a boundary.
54
+ *
55
+ * @param {any} reason - the `reason` field of a `turn/end` event, if present.
56
+ * @returns {'boundary'|'normal'|'skip'} `boundary` = a task ended, consolidate
57
+ * eagerly; `normal` = ordinary turn, apply both thresholds; `skip` = the turn
58
+ * ended in a state whose span is not worth cutting a memory at.
59
+ */
60
+ export function classifyTurnEnd(reason) {
61
+ switch (reason?.kind) {
62
+ case 'aborted':
63
+ // Only a human interrupt marks a task boundary. A parent-, hook- or
64
+ // dispose-driven cancellation says nothing about the conversation.
65
+ return reason.reason?.kind === 'user' ? 'boundary' : 'skip'
66
+ case 'blocked':
67
+ case 'error':
68
+ case 'max-tokens':
69
+ case 'interrupted':
70
+ return 'skip'
71
+ case 'completed':
72
+ default:
73
+ return 'normal'
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Where the next consolidation span begins: the watermark, but never inside a
79
+ * fork's inherited prefix.
80
+ *
81
+ * A forked session's log opens with its parent's events (`inheritedEventCount`
82
+ * of them). The logger has always skipped that prefix; consolidation did not,
83
+ * because a fresh fork's watermark starts at 0. The result was the parent's
84
+ * conversation summarised a second time under the child's id, and — since
85
+ * message ordinals are counted from the end of the prefix — the summary's edges
86
+ * pointed at the child's own `msg-*-N` entries, marking messages summarised
87
+ * whose content the summary never saw.
88
+ *
89
+ * @param {{lastConsolidatedSeq?: number} | undefined} progressRow
90
+ * @param {{inheritedEventCount?: number} | undefined} session
91
+ * @returns {number}
92
+ */
93
+ export function consolidationStart(progressRow, session) {
94
+ return Math.max(progressRow?.lastConsolidatedSeq ?? 0, session?.inheritedEventCount ?? 0)
95
+ }
96
+
97
+ /** System directive: span summary + work-unit extraction as strict JSON. */
98
+ function consolidationInstruction(maxWorkUnits) {
99
+ return [
100
+ 'You are the memory consolidation engine of an AI coding assistant.',
101
+ 'Read the conversation transcript below and output EXACTLY one JSON object — no prose,',
102
+ 'no markdown fences — with this shape:',
103
+ '',
104
+ '{',
105
+ ' "summary": "<markdown: 3-8 terse bullets covering the request, what was done, key',
106
+ ' decisions with rationale, and pending items>",',
107
+ ' "workUnits": [',
108
+ ' {',
109
+ ' "title": "<short descriptive slug-like title>",',
110
+ ' "classification": "one-shot | correction-chain | bug-fix | design-decision | exploration",',
111
+ ' "content": "<markdown following the protocol: ## Context / ## Solution (or ## Initial',
112
+ ' Attempt / ## Why It Was Wrong / ## Correct Approach / ## Lesson for',
113
+ ' correction-chain) / ## Key Detail>",',
114
+ ' "tags": ["<topic>", "<topic>"]',
115
+ ' }',
116
+ ' ]',
117
+ '}',
118
+ '',
119
+ `Rules: capture lessons, not logs. Be specific — "use Arc<Mutex<T>>", not "use proper`,
120
+ 'synchronization". Include non-obvious details and exact identifiers. Skip trivial chat',
121
+ `(workUnits may be []). At most ${maxWorkUnits} work units — only the most valuable ones.`,
122
+ 'Write in the same language the conversation primarily uses.',
123
+ ].join('\n')
124
+ }
125
+
126
+ /**
127
+ * Compact transcript for the consolidation call: one line per actor turn, tool
128
+ * outcomes folded into a ledger tail.
129
+ *
130
+ * Two properties this function owes the rest of the pipeline:
131
+ *
132
+ * - **Every line is redacted and date-absolutized**, exactly as the logging
133
+ * path does before writing to hypatia. Consolidation ships this text to a
134
+ * remote model, so raw secrets must never reach it — and a model that sees a
135
+ * secret will happily copy it into the summary it returns.
136
+ * - **Over-budget spans drop whole entries from the TAIL, keeping the OLDEST**,
137
+ * and report how far they actually reached. The protocol batches oldest-first
138
+ * (`docs/memory.md`, "Batch order is FIFO"), and a single monotonic watermark
139
+ * cannot express "the tail is consolidated but the head is not" — so the
140
+ * caller advances only to `lastSeq` and the next trigger resumes from there.
141
+ * Character-level truncation would silently strip content the watermark then
142
+ * marked as done.
143
+ *
144
+ * @param {readonly any[]} events
145
+ * @param {{maxInputTokens: number}} config
146
+ * @param {Date} now - base for relative dates in an event that carries no
147
+ * timestamp; otherwise each line is resolved against its own event's time.
148
+ * @param {{before?: any}} [context] - the event just ahead of the span, so a
149
+ * leading compaction replacement is recognised (it restates an old result).
150
+ * @returns {{text: string, lastSeq: number, complete: boolean}} `lastSeq` is the
151
+ * seq of the last event included; `complete` is false when the budget forced
152
+ * later events out of this run.
153
+ */
154
+ export function buildTranscript(events, config, now = new Date(), { before } = {}) {
155
+ const bySeq = new Map(events.map((event) => [event.seq, event]))
156
+ // Tool results cite their call by id; the readable name lives on the call.
157
+ const toolNames = new Map()
158
+ for (const event of events) {
159
+ if (event.type === 'tool/call' && event.data?.callId !== undefined) {
160
+ toolNames.set(event.data.callId, event.data.name ?? 'tool')
161
+ }
162
+ }
163
+
164
+ const clean = (raw, event) => absolutizeDates(redactSecrets(String(raw)), eventDate(event, now)).trim()
165
+
166
+ /** @type {Array<{seq: number, line: string}>} */
167
+ const entries = []
168
+ for (const event of events) {
169
+ if (event.type === 'user/message') {
170
+ if (event.data?.source?.kind !== 'user') continue
171
+ const text = clean(blocksToText(event.data?.content ?? []), event)
172
+ if (text !== '') entries.push({ seq: event.seq, line: `U: ${text}` })
173
+ } else if (event.type === 'assistant/message') {
174
+ const text = clean(blocksToText(event.data?.message?.content ?? []), event)
175
+ if (text !== '') entries.push({ seq: event.seq, line: `A: ${text}` })
176
+ } else if (event.type === 'tool/result') {
177
+ if (isCompactionReplacement(event, bySeq, before)) continue
178
+ const callId = event.data?.message?.source?.callId
179
+ const name = toolNames.get(callId) ?? 'tool'
180
+ const ok = event.data?.error === undefined
181
+ const first = clean(flattenToolResult(event), event).split('\n')[0] ?? ''
182
+ entries.push({ seq: event.seq, line: `T: ${name} ${ok ? '✅' : '❌'} ${first}`.trimEnd() })
183
+ }
184
+ }
185
+
186
+ const budget = Math.max(1, config.maxInputTokens * 4)
187
+ if (entries.length === 0) {
188
+ return { text: '', lastSeq: -1, complete: true }
189
+ }
190
+
191
+ const kept = []
192
+ let used = 0
193
+ for (const entry of entries) {
194
+ const cost = entry.line.length + 1 // the joining newline
195
+ if (kept.length > 0 && used + cost > budget) break
196
+ // A single entry larger than the whole budget would otherwise wedge the
197
+ // watermark forever: admit it alone, truncated, so the run still advances.
198
+ kept.push(kept.length === 0 && cost > budget
199
+ ? { ...entry, line: `${entry.line.slice(0, budget)}\n[...truncated ${entry.line.length - budget} chars]` }
200
+ : entry)
201
+ used += cost
202
+ if (used >= budget) break
203
+ }
204
+
205
+ const complete = kept.length === entries.length
206
+ const text = kept.map((entry) => entry.line).join('\n')
207
+ + (complete ? '' : `\n[later content deferred to the next consolidation run: ${entries.length - kept.length} entries]`)
208
+ return { text, lastSeq: kept[kept.length - 1].seq, complete }
209
+ }
210
+
211
+ /**
212
+ * Parse and validate the model's JSON output. Throws PermanentError on any
213
+ * shape problem — a malformed answer will not heal by retrying.
214
+ */
215
+ export function parseConsolidationOutput(raw, maxWorkUnits) {
216
+ let text = String(raw).trim()
217
+ // Tolerate an accidental markdown fence without accepting prose around it.
218
+ const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
219
+ if (fence) text = fence[1].trim()
220
+ let parsed
221
+ try {
222
+ parsed = JSON.parse(text)
223
+ } catch {
224
+ throw new PermanentConsolidationError(`consolidation output is not valid JSON: ${text.slice(0, 200)}`)
225
+ }
226
+ if (typeof parsed !== 'object' || parsed === null) {
227
+ throw new PermanentConsolidationError('consolidation output is not a JSON object')
228
+ }
229
+ const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : ''
230
+ if (summary === '') {
231
+ throw new PermanentConsolidationError('consolidation output has an empty summary')
232
+ }
233
+ // `maxWorkUnits` is a soft instruction to the model, not a contract. Rejecting
234
+ // the whole answer for an extra unit threw away a usable summary AND left
235
+ // `lastConsolidatedSeq` in place, so every later trigger re-ran the same span
236
+ // — growing, never converging, burning a model call each time. Trim instead.
237
+ const rawUnits = (Array.isArray(parsed.workUnits) ? parsed.workUnits : []).slice(0, maxWorkUnits)
238
+ const workUnits = rawUnits.map((unit, index) => {
239
+ const title = typeof unit?.title === 'string' ? unit.title.trim() : ''
240
+ const content = typeof unit?.content === 'string' ? unit.content.trim() : ''
241
+ if (title === '' || content === '') {
242
+ throw new PermanentConsolidationError(`work unit #${index + 1} has an empty title or content`)
243
+ }
244
+ const tags = Array.isArray(unit.tags)
245
+ ? unit.tags.filter((t) => typeof t === 'string').slice(0, 5)
246
+ : []
247
+ const classification = typeof unit.classification === 'string' ? unit.classification : ''
248
+ return { title, content, tags, classification }
249
+ })
250
+ return { summary, workUnits }
251
+ }
252
+
253
+ /**
254
+ * @param {{
255
+ * queue: ReturnType<import('./queue.js').createQueue>,
256
+ * progress: any,
257
+ * sessions: any,
258
+ * llm: any,
259
+ * cli: ReturnType<import('./hypatia-client.js').createHypatiaClient>,
260
+ * writer: ReturnType<import('./writer.js').createWriter>,
261
+ * getConfig: () => any,
262
+ * status: import('./status.js').StatusLog,
263
+ * projectFor: (session: any) => Promise<string>,
264
+ * }} deps
265
+ */
266
+ export function createConsolidator({ queue, progress, sessions, llm, cli, writer, getConfig, status, projectFor }) {
267
+ let warnedNoRoute = false
268
+ const selectRoute = createConsolidationRouteSelector()
269
+
270
+ /**
271
+ * Turn-end trigger check. The turn threshold is a minimum interval between
272
+ * accepted consolidation tasks, not a polling cadence: once the interval
273
+ * has elapsed, every later turn re-evaluates the token/log gates until work
274
+ * is successfully enqueued.
275
+ *
276
+ * @returns {{resetTokens: boolean, advanceCheckpoint: boolean}} whether to
277
+ * zero the token counter and move the turn checkpoint to this turn. Both are
278
+ * true only after the queue has durably accepted consolidation work.
279
+ */
280
+ async function onTurnEnd(sessionId, turn, state) {
281
+ const config = getConfig()
282
+ if (config.enabled === false || config.consolidation.enabled === false) {
283
+ return { resetTokens: false, advanceCheckpoint: false }
284
+ }
285
+ const outcome = classifyTurnEnd(state.reason)
286
+ if (outcome === 'skip') {
287
+ // A blocked, failed, truncated or crash-closed turn is not a place to cut
288
+ // a memory: its span is mid-thought, and `max-tokens`/`error` in
289
+ // particular tend to repeat, which would consolidate the same broken
290
+ // stretch again and again. Retain the checkpoint so the next healthy turn
291
+ // can trigger immediately rather than waiting out the interval.
292
+ return { resetTokens: false, advanceCheckpoint: false }
293
+ }
294
+ // A user interrupt IS a task boundary — the strongest cheap signal DSH
295
+ // offers. `docs/memory-nolinear.md` puts task boundaries second only to
296
+ // topic switches, so honour it without waiting out the turn interval; the
297
+ // reduced token floor still filters out interrupts on trivial exchanges.
298
+ const boundary = outcome === 'boundary'
299
+ const elapsed = turn - (state.lastCheckTurn ?? 0)
300
+ if (!boundary && elapsed < config.consolidation.checkEveryTurns) {
301
+ return { resetTokens: false, advanceCheckpoint: false }
302
+ }
303
+ const tokenFloor = boundary
304
+ ? Math.ceil(config.consolidation.minNewTokens / 4)
305
+ : config.consolidation.minNewTokens
306
+ if ((state.pendingTokens ?? 0) < tokenFloor) {
307
+ return { resetTokens: false, advanceCheckpoint: false }
308
+ }
309
+ const current = progress.get(sessionId) ?? EMPTY_PROGRESS
310
+ // Awaited: the resolver falls back to persistence for a session the store
311
+ // will never publish again (see persisted-session.js).
312
+ const session = await sessions.get(sessionId)
313
+ if (session === undefined) {
314
+ status.warn(`consolidation deferred for ${sessionId}: session not live`)
315
+ return { resetTokens: false, advanceCheckpoint: false }
316
+ }
317
+ const fromSeq = consolidationStart(current, session)
318
+ const toSeq = Math.min(session.seq ?? 0, current.lastLoggedSeq)
319
+ if (toSeq <= fromSeq) {
320
+ return { resetTokens: false, advanceCheckpoint: false }
321
+ }
322
+ const project = await projectFor(session)
323
+ // Immediate: the thresholds already decided this span is ready. Riding the
324
+ // flush window only delayed it by `flushWindowMs` (two minutes by default),
325
+ // and a restart inside that window left the task persisted but unscheduled.
326
+ await queue.enqueue({
327
+ kind: 'consolidate',
328
+ sessionId,
329
+ fromSeq,
330
+ toSeq,
331
+ project,
332
+ immediate: true,
333
+ })
334
+ status.info(`consolidation scheduled: ${sessionId} [${fromSeq}, ${toSeq})`)
335
+ return { resetTokens: true, advanceCheckpoint: true }
336
+ }
337
+
338
+ /**
339
+ * Decide how one new work unit relates to the memories nearest it.
340
+ *
341
+ * A separate, small call rather than part of the extraction prompt: the
342
+ * candidates depend on the unit's own text, which does not exist until
343
+ * extraction has answered. It is bounded by `maxWorkUnitsPerRun` (default 3)
344
+ * and sees only titles and opening lines, so it costs a fraction of the
345
+ * extraction call it follows.
346
+ *
347
+ * The judgement is the whole point of the pass. The previous implementation
348
+ * had no model in the loop and simply asserted `extends` toward whatever came
349
+ * back first — which wrote the relationship BACKWARDS whenever the new unit
350
+ * actually contradicted the old one.
351
+ *
352
+ * @param {any} unit
353
+ * @param {readonly any[]} candidates
354
+ * @returns {Promise<{verdict: string, target: string} | undefined>}
355
+ */
356
+ async function adjudicate(unit, candidates) {
357
+ const config = getConfig()
358
+ const consolidation = config.consolidation
359
+ if (consolidation.adjudicate === false) return undefined
360
+ const route = selectRoute(consolidation.models)
361
+ if (route === undefined) return undefined
362
+
363
+ const listed = candidates.map((row, i) => {
364
+ const body = typeof row?.content?.data === 'string' ? row.content.data : ''
365
+ return `${i + 1}. ${row.name}\n${body.replace(/\s+/g, ' ').slice(0, 300)}`
366
+ }).join('\n\n')
367
+
368
+ const instruction = [
369
+ 'Decide how a NEW memory relates to EXISTING memories from the same knowledge base.',
370
+ 'Output EXACTLY one JSON object, no prose and no markdown fences:',
371
+ '{"verdict": "duplicate|refines|extends|supersedes|contradicts|unrelated", "target": "<existing entry name>"}',
372
+ '',
373
+ '- duplicate: the new memory says the same thing; it will be discarded.',
374
+ '- refines: the new memory is a more precise form of the target.',
375
+ '- extends: the new memory adds to the target without changing it.',
376
+ '- supersedes: the new memory deliberately replaces the target.',
377
+ '- contradicts: the two cannot both be true. BOTH are kept and the conflict recorded.',
378
+ '- unrelated: no meaningful relationship. Prefer this when unsure — a wrong',
379
+ ' edge is worse than a missing one.',
380
+ '',
381
+ `## New memory: ${unit.title}`,
382
+ unit.content.slice(0, 1000),
383
+ '',
384
+ '## Existing memories',
385
+ listed,
386
+ ].join('\n')
387
+
388
+ const controller = new AbortController()
389
+ const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
390
+ try {
391
+ const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
392
+ const assembler = new BlockAssembler()
393
+ for await (const chunk of llm.stream({
394
+ provider: route.provider,
395
+ model: route.model,
396
+ system: 'You produce strict JSON only. You never add prose around it.',
397
+ messages: [createUserMessage({
398
+ content: [{ type: 'text', text: instruction }],
399
+ source: { kind: 'plugin', plugin: PLUGIN_NAME },
400
+ })],
401
+ maxTokens: 200,
402
+ purpose: 'memory-adjudication',
403
+ signal: controller.signal,
404
+ })) {
405
+ assembler.push(chunk)
406
+ }
407
+ if (assembler.finish.kind !== 'stop') return undefined
408
+ const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
409
+ const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
410
+ const parsed = JSON.parse(fence ? fence[1].trim() : text)
411
+ if (typeof parsed?.verdict !== 'string') return undefined
412
+ return { verdict: parsed.verdict, target: typeof parsed.target === 'string' ? parsed.target : '' }
413
+ } finally {
414
+ clearTimeout(timer)
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Final consolidation for a session that is ending.
420
+ *
421
+ * Thresholds are deliberately not applied. They exist to pace an ongoing
422
+ * conversation, but at the end of one there is no later turn to wait for: a
423
+ * task shorter than `checkEveryTurns` turns or lighter than `minNewTokens`
424
+ * would otherwise be logged and never turned into knowledge at all — which is
425
+ * exactly the shape of a quick, self-contained fix worth remembering.
426
+ *
427
+ * @param {string} sessionId
428
+ * @param {any} session - still usable after disposal; the store has released
429
+ * it but its event log is intact.
430
+ */
431
+ async function onSessionEnd(sessionId, session) {
432
+ const config = getConfig()
433
+ if (config.enabled === false || config.consolidation.enabled === false) return false
434
+ const current = progress.get(sessionId) ?? EMPTY_PROGRESS
435
+ const fromSeq = consolidationStart(current, session)
436
+ const toSeq = Math.min(session?.seq ?? 0, current.lastLoggedSeq)
437
+ if (toSeq <= fromSeq) return false
438
+ const project = await projectFor(session)
439
+ await queue.enqueue({
440
+ kind: 'consolidate',
441
+ sessionId,
442
+ fromSeq,
443
+ toSeq,
444
+ project,
445
+ immediate: true,
446
+ })
447
+ status.info(`consolidation scheduled at session end: ${sessionId} [${fromSeq}, ${toSeq})`)
448
+ return true
449
+ }
450
+
451
+ /** Queue executor for one consolidate task. */
452
+ async function execute(task) {
453
+ const config = getConfig()
454
+ const consolidation = config.consolidation
455
+ const route = selectRoute(consolidation.models)
456
+ if (route === undefined) {
457
+ if (!warnedNoRoute) {
458
+ warnedNoRoute = true
459
+ status.warn('consolidation enabled but no model route is selected; choose one or more models in hypatia-auto-memory.consolidation.models')
460
+ }
461
+ return
462
+ }
463
+ warnedNoRoute = false
464
+ const session = await sessions.get(task.sessionId)
465
+ if (session === undefined) {
466
+ throw new TaskDeferredError(`session ${task.sessionId} is not loaded; deferred until it is`)
467
+ }
468
+ // Clamped here as well as at scheduling time: a task persisted by an older
469
+ // build may still carry a range reaching into a fork's inherited prefix.
470
+ const inherited = session.inheritedEventCount ?? 0
471
+ const fromSeq = Math.max(task.fromSeq, inherited)
472
+ if (task.toSeq <= fromSeq) {
473
+ await advanceProgress(progress, task.sessionId, (current) => ({
474
+ lastConsolidatedSeq: Math.max(current.lastConsolidatedSeq, fromSeq),
475
+ }))
476
+ return
477
+ }
478
+ const events = session.snapshotEvents(fromSeq, task.toSeq)
479
+ const now = new Date()
480
+ const before = fromSeq > 0 ? session.snapshotEvents(fromSeq - 1, fromSeq)[0] : undefined
481
+ const { text: transcript, lastSeq, complete } = buildTranscript(events, consolidation, now, { before })
482
+ if (transcript.trim() === '') {
483
+ await advanceProgress(progress, task.sessionId, () => ({ lastConsolidatedSeq: task.toSeq }))
484
+ return
485
+ }
486
+ // Only the span the transcript actually carried is consolidated. An
487
+ // over-budget run stops at `lastSeq`; the remainder keeps its place in the
488
+ // watermark and the next trigger resumes from exactly there.
489
+ const coveredToSeq = complete ? task.toSeq : lastSeq + 1
490
+
491
+ const controller = new AbortController()
492
+ const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
493
+ let assembled
494
+ try {
495
+ const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
496
+ const assembler = new BlockAssembler()
497
+ const request = createUserMessage({
498
+ content: [{ type: 'text', text: `${consolidationInstruction(consolidation.maxWorkUnitsPerRun)}\n\n<transcript>\n${transcript}\n</transcript>` }],
499
+ source: { kind: 'plugin', plugin: PLUGIN_NAME },
500
+ })
501
+ for await (const chunk of llm.stream({
502
+ provider: route.provider,
503
+ model: route.model,
504
+ system: 'You produce strict JSON only. You never add prose around it.',
505
+ messages: [request],
506
+ maxTokens: consolidation.maxOutputTokens,
507
+ purpose: 'memory-consolidation',
508
+ signal: controller.signal,
509
+ })) {
510
+ assembler.push(chunk)
511
+ }
512
+ assembled = assembler
513
+ } finally {
514
+ clearTimeout(timer)
515
+ }
516
+ const finish = assembled.finish
517
+ if (finish.kind !== 'stop') {
518
+ if (finish.kind === 'max-tokens') {
519
+ throw new PermanentConsolidationError('consolidation hit the output token cap (truncated JSON)')
520
+ }
521
+ const message = finish.kind === 'error' || finish.kind === 'aborted' ? finish.failure?.message ?? finish.kind : finish.kind
522
+ throw new Error(`consolidation model call failed: ${message}`)
523
+ }
524
+ const text = assembled.blocks()
525
+ .filter((block) => block.type === 'text')
526
+ .map((block) => block.text)
527
+ .join('\n')
528
+ const { summary, workUnits } = parseConsolidationOutput(text, consolidation.maxWorkUnitsPerRun)
529
+
530
+ const span = summaryName(task.sessionId, fromSeq, coveredToSeq)
531
+ // The entries the log executor actually wrote for this span. Both sides
532
+ // derive names the same way — same predicate, same dense ordinal counted
533
+ // from the end of the inherited prefix — so the links resolve without
534
+ // probing hypatia for each one. That only holds because `fromSeq` never
535
+ // precedes the prefix: counting from inside it would restart the ordinals
536
+ // at 0 and name the parent's messages after the child's.
537
+ let itemIndex = countLoggableMessages(session.snapshotEvents(inherited, fromSeq))
538
+ const items = []
539
+ for (const event of events) {
540
+ if (!isLoggableMessage(event)) continue
541
+ if (event.seq < coveredToSeq) items.push(messageName(task.sessionId, itemIndex))
542
+ itemIndex += 1
543
+ }
544
+ await writer.writeSummary({
545
+ sessionId: task.sessionId,
546
+ fromSeq: fromSeq,
547
+ toSeq: coveredToSeq,
548
+ markdown: summary,
549
+ project: task.project,
550
+ items,
551
+ })
552
+ const date = now.toISOString().slice(0, 10)
553
+ for (const unit of workUnits) {
554
+ const result = await writer.writeWorkUnit({
555
+ maxDistance: consolidation.dedupMaxDistance,
556
+ candidateLimit: consolidation.dedupCandidates,
557
+ title: unit.title,
558
+ content: unit.classification === ''
559
+ ? unit.content
560
+ : `${unit.content}\n\n_Classification: ${unit.classification}_`,
561
+ tags: unit.tags,
562
+ project: task.project,
563
+ derivedFrom: span,
564
+ date,
565
+ })
566
+ if (result.written) {
567
+ status.info(`work unit stored: ${result.name}`)
568
+ }
569
+ }
570
+ await advanceProgress(progress, task.sessionId, () => ({ lastConsolidatedSeq: coveredToSeq }))
571
+ // A fresh tier-1 summary may complete a batch of sixteen, so give the
572
+ // cascade a chance to run. It is its own task so a cascade failure can never
573
+ // roll back a consolidation that already succeeded.
574
+ if (consolidation.cascade?.enabled !== false) {
575
+ await queue.enqueue({
576
+ kind: 'cascade',
577
+ sessionId: task.sessionId,
578
+ fromSeq: fromSeq,
579
+ toSeq: coveredToSeq,
580
+ project: task.project,
581
+ immediate: true,
582
+ })
583
+ }
584
+ if (!complete) {
585
+ status.info(`consolidation covered [${fromSeq}, ${coveredToSeq}) of [${fromSeq}, ${task.toSeq}); remainder deferred`)
586
+ }
587
+ status.markConsolidated()
588
+ status.count('consolidations')
589
+ }
590
+
591
+ return { onTurnEnd, onSessionEnd, adjudicate, selectRoute, execute, buildTranscript, parseConsolidationOutput }
592
+ }