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.
- package/LICENSE +21 -0
- package/README.md +454 -0
- package/cordis.patch.yml +20 -0
- package/lib/client.js +1081 -0
- package/lib/client.js.map +1 -0
- package/package.json +90 -0
- package/skills/hypatia/SKILL.md +585 -0
- package/skills/hypatia-dream/SKILL.md +246 -0
- package/skills/hypatia-dream/evals/evals.json +92 -0
- package/skills/hypatia-memory/SKILL.md +175 -0
- package/src/auto-approve.js +133 -0
- package/src/cascade.js +190 -0
- package/src/client/SettingsCard.module.css +420 -0
- package/src/client/SettingsCard.tsx +700 -0
- package/src/client/card-form.ts +450 -0
- package/src/client/consolidation-models.ts +69 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.tsx +106 -0
- package/src/client/locales.ts +111 -0
- package/src/client/shelves.ts +88 -0
- package/src/client/slot-contract.ts +17 -0
- package/src/collector.js +596 -0
- package/src/config.js +225 -0
- package/src/consolidator.js +592 -0
- package/src/content-policy.js +301 -0
- package/src/housekeeping.js +197 -0
- package/src/hypatia-cli.js +294 -0
- package/src/hypatia-client.js +72 -0
- package/src/hypatia-mcp.js +477 -0
- package/src/index.js +418 -0
- package/src/persisted-session.js +99 -0
- package/src/progress.js +56 -0
- package/src/queue.js +466 -0
- package/src/recall.js +148 -0
- package/src/shelf.js +180 -0
- package/src/skills.js +166 -0
- package/src/state.js +87 -0
- package/src/status.js +53 -0
- package/src/writer.js +320 -0
|
@@ -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
|
+
}
|