dsh-hypatia-auto-memory 0.3.0 → 0.5.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.
@@ -53,6 +53,46 @@ export const zh = {
53
53
  saveFailed: '保存失败:{message}',
54
54
  readOnly: '只读:当前上下文不可写入设置文档。',
55
55
  unsaved: '未保存',
56
+ viewMemory: '记忆',
57
+ memoryTitle: '本会话的记忆',
58
+ memoryUpdatedAt: '状态更新于 {time}',
59
+ memoryRefresh: '刷新',
60
+ memoryLoading: '正在读取记忆状态…',
61
+ memoryNoSnapshot: '插件还没有发布记忆状态。启用后完成一轮对话再回来看看。',
62
+ memoryLoadFailed: '无法读取记忆状态:{message}',
63
+ memoryUnknown: '这个会话还没有记忆记录。第一轮对话结束后会开始记录。',
64
+ memoryLogged: '对话记录',
65
+ memorySeqValue: '第 {seq} 条事件',
66
+ memoryConsolidated: '摘要整合',
67
+ memoryCaughtUp: '已结清',
68
+ memoryBehind: '较记录落后 {behind} 条',
69
+ memoryPending: '待整合内容',
70
+ memoryPendingValue: '约 {tokens} token',
71
+ memorySessionNode: '会话节点',
72
+ memorySessionNodeYes: '已建立',
73
+ memorySessionNodeNo: '未建立(宿主未提供会话摘要)',
74
+ memoryBelongTo: '已链接消息',
75
+ memoryBelongToValue: '{count} 条',
76
+ memoryDeferred: '等待会话载入的任务',
77
+ memoryFailedTitle: '失败任务',
78
+ memoryFailedItem: '{kind} 连续失败 {attempts} 次',
79
+ memoryContentTitle: '记忆内容',
80
+ memoryContentFailed: '无法读取 shelf 中的记忆内容:{message}',
81
+ memorySummariesTitle: '跨度摘要',
82
+ memoryWorkUnitsTitle: '工作单元',
83
+ memoryNoSummaries: '这个会话还没有产生摘要。整合运行后会出现在这里。',
84
+ memoryNoWorkUnits: '这个会话还没有提炼出工作单元。',
85
+ memoryCount: '显示 {shown} / 共 {total}',
86
+ memoryTier: '{level} 级',
87
+ memoryWorkUnit: '工作单元',
88
+ memoryEntryExpand: '展开',
89
+ memoryEntryCollapse: '收起',
90
+ memoryEntryEmpty: '这条条目的正文为空,或已超过展示上限。',
91
+ memoryCopyCode: '复制',
92
+ memoryCopiedCode: '已复制',
93
+ memoryFootnotes: '脚注',
94
+ memoryTruncated: '内容超出展示上限,较早的条目或过长的正文已被截断。',
95
+ memoryHint: '诊断视图:原始 msg-* 对话正文不在此显示,需要时让 Agent 通过 hypatia 检索。状态每 5 秒重读一次;「状态更新于」是宿主最近一次看到这些状态发生变化的时间(首次读取也算一次),不变说明这段时间没有新进展。两个条数都按会话日志的事件序号计(含工具调用,不是消息条数),所以它们之差就是还没总结的部分。',
56
96
  } as const satisfies Record<string, string>
57
97
 
58
98
  /** Key domain of the `hypatia-auto-memory` namespace. */
@@ -108,4 +148,44 @@ export const en = {
108
148
  saveFailed: 'Save failed: {message}',
109
149
  readOnly: 'Read-only: settings document is not writable in this context.',
110
150
  unsaved: 'Unsaved',
151
+ viewMemory: 'Memory',
152
+ memoryTitle: 'This session\'s memory',
153
+ memoryUpdatedAt: 'State updated {time}',
154
+ memoryRefresh: 'Refresh',
155
+ memoryLoading: 'Reading memory status…',
156
+ memoryNoSnapshot: 'The plugin has not published a status snapshot yet. Enable it, finish a turn, and come back.',
157
+ memoryLoadFailed: 'Unable to read memory status: {message}',
158
+ memoryUnknown: 'This session has no memory record yet. Logging starts once the first turn ends.',
159
+ memoryLogged: 'Conversation log',
160
+ memorySeqValue: 'event {seq}',
161
+ memoryConsolidated: 'Summarisation',
162
+ memoryCaughtUp: 'caught up',
163
+ memoryBehind: '{behind} events behind the log',
164
+ memoryPending: 'Waiting to summarise',
165
+ memoryPendingValue: '~{tokens} tokens',
166
+ memorySessionNode: 'Session node',
167
+ memorySessionNodeYes: 'created',
168
+ memorySessionNodeNo: 'not created (the Host supplied no session summary)',
169
+ memoryBelongTo: 'Linked messages',
170
+ memoryBelongToValue: '{count}',
171
+ memoryDeferred: 'Tasks waiting for the session to load',
172
+ memoryFailedTitle: 'Failed tasks',
173
+ memoryFailedItem: '{kind} failed {attempts} times',
174
+ memoryContentTitle: 'Memory content',
175
+ memoryContentFailed: 'Unable to read memory content from the shelf: {message}',
176
+ memorySummariesTitle: 'Span summaries',
177
+ memoryWorkUnitsTitle: 'Work units',
178
+ memoryNoSummaries: 'This session has produced no summary yet. They appear here once consolidation runs.',
179
+ memoryNoWorkUnits: 'This session has yielded no work units yet.',
180
+ memoryCount: 'showing {shown} of {total}',
181
+ memoryTier: 'tier {level}',
182
+ memoryWorkUnit: 'work unit',
183
+ memoryEntryExpand: 'Expand',
184
+ memoryEntryCollapse: 'Collapse',
185
+ memoryEntryEmpty: 'This entry has no body, or it exceeded the display cap.',
186
+ memoryCopyCode: 'Copy',
187
+ memoryCopiedCode: 'Copied',
188
+ memoryFootnotes: 'Footnotes',
189
+ memoryTruncated: 'Content exceeded the display cap: earlier entries or over-long bodies were cut.',
190
+ memoryHint: 'Diagnostic view: raw msg-* conversation text is not shown here — ask the agent to retrieve it through hypatia. Status is re-read every 5 seconds; the timestamp is when the Host last saw that state change (the first read counts as one), so an unchanged one means nothing new happened. Both counts index the session log\'s event sequence (tool calls included, not messages), so their difference is what is left to summarise.',
111
191
  } satisfies Record<AutoMemoryLocaleKey, string>
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The Memory tab's read side: one same-origin request per refresh.
3
+ *
4
+ * The Host half serves it from `/api/dsh-hypatia-auto-memory/session`
5
+ * (see `src/memory-api.js`), which is parameterized by session and authenticates
6
+ * itself — unlike a settings namespace, it can answer for the one session the
7
+ * tab is bound to instead of shipping every session's state to the browser.
8
+ *
9
+ * Zero runtime imports beyond `fetch`, so the parsing half is unit-tested under
10
+ * `node:test` like `shelves.ts`.
11
+ */
12
+
13
+ /** Route prefix the Host claims; the tab appends `/session`. */
14
+ export const MEMORY_API_PREFIX = '/api/dsh-hypatia-auto-memory'
15
+
16
+ /** One task that exhausted its attempts. */
17
+ export interface FailedMemoryTask {
18
+ kind: string
19
+ sessionId: string
20
+ attempts: number
21
+ error: string
22
+ }
23
+
24
+ /** What the Host derived for one session out of the state tables. */
25
+ export interface SessionMemoryStatus {
26
+ sessionId: string
27
+ /** False when the Host has no row and no task for this session yet. */
28
+ known: boolean
29
+ loggedSeq: number
30
+ consolidatedSeq: number
31
+ /** True when consolidation has covered everything logged. */
32
+ caughtUp: boolean
33
+ pendingTokens: number
34
+ sessionNode: boolean
35
+ belongTo: number
36
+ deferred: number
37
+ failed: number
38
+ failedTasks: FailedMemoryTask[]
39
+ error: string
40
+ }
41
+
42
+ /** One distilled entry: a span summary or a work unit. */
43
+ export interface MemoryEntry {
44
+ name: string
45
+ /** Markdown body, already capped by the Host. */
46
+ markdown: string
47
+ /** Archive tier, summaries only; 1 condenses messages. */
48
+ level: number
49
+ createdAt: string
50
+ }
51
+
52
+ /**
53
+ * The shelf half of a response.
54
+ *
55
+ * Kept as one object rather than spread across the payload so "the Host did not
56
+ * read the shelf" is representable: the five-second poll asks for status only,
57
+ * and a reader that cannot tell that apart from "the shelf is empty" wipes the
58
+ * content it already showed.
59
+ */
60
+ export interface MemoryContent {
61
+ summaries: MemoryEntry[]
62
+ workUnits: MemoryEntry[]
63
+ /** How many the shelf holds, before the response caps. */
64
+ summaryCount: number
65
+ workUnitCount: number
66
+ /** True when a cap dropped entries or cut a body short. */
67
+ truncated: boolean
68
+ /** Non-empty when the shelf could not be read; the status half is still valid. */
69
+ error: string
70
+ }
71
+
72
+ /** One response from the Host. */
73
+ export interface MemoryPayload {
74
+ session: SessionMemoryStatus
75
+ /** Epoch milliseconds the Host last saw this session's status change. */
76
+ updatedAt: number
77
+ /** Absent when this response carried status only. */
78
+ content: MemoryContent | undefined
79
+ }
80
+
81
+ /** Read one session's memory. `content` also asks the Host to read the shelf. */
82
+ export type FetchMemory = (sessionId: string, options: { content: boolean }) => Promise<MemoryPayload>
83
+
84
+ /**
85
+ * The URL one refresh reads.
86
+ * @param sessionId - the session the tab is bound to.
87
+ * @param content - whether to ask the Host to read the shelf as well.
88
+ * @returns a same-origin path with its query.
89
+ */
90
+ export function memoryUrl(sessionId: string, content: boolean): string {
91
+ const query = new URLSearchParams({ session: sessionId })
92
+ if (content) query.set('content', '1')
93
+ return `${MEMORY_API_PREFIX}/session?${query.toString()}`
94
+ }
95
+
96
+ /** Fetch one session's memory from the Host. */
97
+ export const fetchMemory: FetchMemory = async (sessionId, { content }) => {
98
+ const response = await fetch(memoryUrl(sessionId, content), {
99
+ method: 'GET',
100
+ credentials: 'same-origin',
101
+ headers: { accept: 'application/json' },
102
+ })
103
+ const body: unknown = await response.json().catch(() => undefined)
104
+ if (!response.ok) {
105
+ const message = isRecord(body) && typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`
106
+ throw new Error(message)
107
+ }
108
+ const payload = parseMemoryPayload(body, sessionId)
109
+ if (payload === undefined) throw new Error('the Host returned an unreadable memory payload')
110
+ return payload
111
+ }
112
+
113
+ /**
114
+ * Session-log events that are recorded but not yet consolidated.
115
+ *
116
+ * Both watermarks index the same log, so their difference is exactly the work
117
+ * consolidation still owes. Clamped at zero: a consolidation run may cover a
118
+ * range the logging watermark has not caught up to yet, which is not a backlog.
119
+ *
120
+ * @param session - the session half of a payload.
121
+ * @returns the gap in session-log events.
122
+ */
123
+ export function consolidationGap(session: SessionMemoryStatus): number {
124
+ return Math.max(0, session.loggedSeq - session.consolidatedSeq)
125
+ }
126
+
127
+ function isRecord(value: unknown): value is Record<string, unknown> {
128
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
129
+ }
130
+
131
+ function num(value: unknown): number {
132
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
133
+ }
134
+
135
+ function str(value: unknown): string {
136
+ return typeof value === 'string' ? value : ''
137
+ }
138
+
139
+ function parseFailedTasks(value: unknown): FailedMemoryTask[] {
140
+ if (!Array.isArray(value)) return []
141
+ return value.flatMap((entry): FailedMemoryTask[] => isRecord(entry)
142
+ ? [{
143
+ kind: str(entry.kind),
144
+ sessionId: str(entry.sessionId),
145
+ attempts: num(entry.attempts),
146
+ error: str(entry.error),
147
+ }]
148
+ : [])
149
+ }
150
+
151
+ function parseEntries(value: unknown): MemoryEntry[] {
152
+ if (!Array.isArray(value)) return []
153
+ return value.flatMap((entry): MemoryEntry[] => {
154
+ if (!isRecord(entry)) return []
155
+ const name = str(entry.name)
156
+ if (name === '') return []
157
+ return [{
158
+ name,
159
+ markdown: str(entry.markdown),
160
+ level: num(entry.level) || 1,
161
+ createdAt: str(entry.createdAt),
162
+ }]
163
+ })
164
+ }
165
+
166
+ /**
167
+ * Read the Host's answer into the tab's shape, defensively.
168
+ *
169
+ * A malformed field falls back to its zero value rather than reaching the DOM;
170
+ * a payload with no usable `session` is rejected outright, because rendering a
171
+ * panel of zeros for a session the Host never answered about would be a lie.
172
+ *
173
+ * A status-only answer parses to `content: undefined` — never to empty lists,
174
+ * which is what made the five-second poll erase the shelf content. The Host also
175
+ * states which half it sent (`content`), and that flag is what this prefers; the
176
+ * key sniffing beside it keeps a bundle built after the flag from misreading a
177
+ * Host that predates it, so the fix does not need a profile restart to land.
178
+ *
179
+ * @param value - the parsed response body.
180
+ * @param sessionId - the session that was asked for.
181
+ * @returns the payload, or undefined when the body is not one.
182
+ */
183
+ export function parseMemoryPayload(value: unknown, sessionId: string): MemoryPayload | undefined {
184
+ if (!isRecord(value) || !isRecord(value.session)) return undefined
185
+ const session = value.session
186
+ const carried = value.content === true
187
+ || Object.hasOwn(value, 'summaries')
188
+ || Object.hasOwn(value, 'contentError')
189
+ return {
190
+ session: {
191
+ sessionId: str(session.sessionId) || sessionId,
192
+ known: session.known === true,
193
+ loggedSeq: num(session.loggedSeq),
194
+ consolidatedSeq: num(session.consolidatedSeq),
195
+ caughtUp: session.caughtUp === true,
196
+ pendingTokens: num(session.pendingTokens),
197
+ sessionNode: session.sessionNode === true,
198
+ belongTo: num(session.belongTo),
199
+ deferred: num(session.deferred),
200
+ failed: num(session.failed),
201
+ failedTasks: parseFailedTasks(session.failedTasks),
202
+ error: str(session.error),
203
+ },
204
+ updatedAt: num(value.updatedAt),
205
+ content: carried
206
+ ? {
207
+ summaries: parseEntries(value.summaries),
208
+ workUnits: parseEntries(value.workUnits),
209
+ summaryCount: num(value.summaryCount),
210
+ workUnitCount: num(value.workUnitCount),
211
+ truncated: value.truncated === true,
212
+ error: str(value.contentError),
213
+ }
214
+ : undefined,
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Fold a new answer onto the one on screen.
220
+ *
221
+ * Status is always the newest. Content is only replaced by an answer that
222
+ * carried content, so the poll cannot erase it. An answer whose shelf read
223
+ * failed keeps the content already shown — entries, counts and the truncation
224
+ * flag together, so the header still describes the list under it — and records
225
+ * the error beside it, because a transient hypatia failure should not blank
226
+ * what the reader was looking at.
227
+ *
228
+ * @param previous - the payload on screen, if any.
229
+ * @param next - the answer just parsed.
230
+ * @returns the payload to render.
231
+ */
232
+ export function mergeMemory(
233
+ previous: MemoryPayload | undefined,
234
+ next: MemoryPayload,
235
+ ): MemoryPayload {
236
+ if (next.content === undefined) return { ...next, content: previous?.content }
237
+ // A failed read keeps whatever was on screen — the entries AND the counts that
238
+ // describe them — and replaces only the reason. The guard must not consult the
239
+ // previous error: on the SECOND consecutive failure it is already set, and
240
+ // testing for it fell through to the zeroed answer, blanking the panel the
241
+ // first failure had deliberately preserved.
242
+ if (next.content.error !== '' && previous?.content !== undefined) {
243
+ return { ...next, content: { ...previous.content, error: next.content.error } }
244
+ }
245
+ return next
246
+ }
@@ -19,9 +19,32 @@ import { countLoggableMessages, isCompactionReplacement, isLoggableMessage } fro
19
19
  import { messageName, summaryName } from './writer.js'
20
20
  import { EMPTY_PROGRESS, advanceProgress } from './progress.js'
21
21
  import { TaskDeferredError } from './queue.js'
22
+ import { NULL_MODEL_LOG } from './model-log.js'
22
23
 
23
24
  export const PLUGIN_NAME = 'dsh-hypatia-auto-memory'
24
25
 
26
+ /**
27
+ * Output budget for one adjudication.
28
+ *
29
+ * It is not 200 — the value it carried for as long as the call existed — because
30
+ * a thinking-enabled route spends the SAME cap on its reasoning and its answer.
31
+ * `deepseek-*` resolves an omitted `reasoningEffort` to `high` (the
32
+ * `llm-deepseek` profile default) and puts roughly half its output tokens into
33
+ * reasoning, so a 200-token adjudication ended with `max-tokens` before the
34
+ * verdict was ever emitted: every other work unit was silently stored with no
35
+ * relationship at all. `ADJUDICATION_REASONING_EFFORT` below usually removes
36
+ * the need for the headroom, but a route that cannot honour `off` — or a
37
+ * provider that ignores the control — still has to fit thinking plus a verdict.
38
+ */
39
+ export const ADJUDICATION_MAX_TOKENS = 1024
40
+
41
+ /**
42
+ * What the adjudication call asks for on a route that advertises it. A verdict
43
+ * is a six-way classification of two short texts: thinking there spends the
44
+ * output budget and the latency for nothing.
45
+ */
46
+ export const ADJUDICATION_REASONING_EFFORT = 'off'
47
+
25
48
  /** Distinguishes permanent failures (no retry value) from transient ones. */
26
49
  export class PermanentConsolidationError extends Error {
27
50
  constructor(message) {
@@ -263,13 +286,46 @@ export function parseConsolidationOutput(raw, maxWorkUnits) {
263
286
  * writer: ReturnType<import('./writer.js').createWriter>,
264
287
  * getConfig: () => any,
265
288
  * status: import('./status.js').StatusLog,
289
+ * modelLog?: ReturnType<import('./model-log.js').createModelLog>,
266
290
  * projectFor: (session: any) => Promise<string>,
267
291
  * }} deps
268
292
  */
269
- export function createConsolidator({ queue, progress, sessions, llm, cli, writer, getConfig, status, projectFor }) {
293
+ export function createConsolidator({ queue, progress, sessions, llm, cli, writer, getConfig, status, modelLog = NULL_MODEL_LOG, projectFor }) {
270
294
  let warnedNoRoute = false
271
295
  const selectRoute = createConsolidationRouteSelector()
272
296
 
297
+ /**
298
+ * Whether a route may be asked for {@link ADJUDICATION_REASONING_EFFORT},
299
+ * memoized per route for this instance.
300
+ *
301
+ * The core rejects a request whose effort the model does not advertise
302
+ * (`UNSUPPORTED_REASONING_EFFORT`, `llm`'s `resolveCallWithInfo`), so the
303
+ * control only follows a positive capability answer. A route that cannot be
304
+ * inspected — an adapter that throws, or the `llm` double a unit test hands
305
+ * in — simply gets no effort, which is what every call did before this.
306
+ * Capability does not change while an instance lives; a profile reload
307
+ * rebuilds the instance and the cache with it.
308
+ *
309
+ * @param {{provider: string, model: string}} route
310
+ * @param {AbortSignal} signal - the same signal the call carries, so a
311
+ * lookup cannot outlive the timeout it is part of.
312
+ */
313
+ const thinkingOff = new Map()
314
+ async function canDisableThinking(route, signal) {
315
+ const key = `${route.provider}\0${route.model}`
316
+ if (thinkingOff.has(key)) return thinkingOff.get(key)
317
+ let supported = false
318
+ try {
319
+ const info = await llm.resolveModelInfo?.(route.provider, route.model, signal)
320
+ supported = Array.isArray(info?.reasoning?.efforts)
321
+ && info.reasoning.efforts.some((effort) => effort?.id === ADJUDICATION_REASONING_EFFORT)
322
+ } catch (error) {
323
+ status.warn(`reasoning capability lookup failed for ${key.replace('\0', '/')}: ${String(error)}`)
324
+ }
325
+ thinkingOff.set(key, supported)
326
+ return supported
327
+ }
328
+
273
329
  /**
274
330
  * Turn-end trigger check. The turn threshold is a minimum interval between
275
331
  * accepted consolidation tasks, not a polling cadence: once the interval
@@ -388,9 +444,14 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
388
444
  listed,
389
445
  ].join('\n')
390
446
 
447
+ const attempt = modelLog.begin('memory-adjudication', route)
391
448
  const controller = new AbortController()
392
449
  const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
450
+ let outcome = 'incomplete'
451
+ let detail = ''
393
452
  try {
453
+ // Asked for only when the route advertises it; see `canDisableThinking`.
454
+ const skipThinking = await canDisableThinking(route, controller.signal)
394
455
  const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
395
456
  const assembler = new BlockAssembler()
396
457
  for await (const chunk of llm.stream({
@@ -401,20 +462,41 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
401
462
  content: [{ type: 'text', text: instruction }],
402
463
  source: { kind: 'plugin', plugin: PLUGIN_NAME },
403
464
  })],
404
- maxTokens: 200,
465
+ maxTokens: ADJUDICATION_MAX_TOKENS,
466
+ ...skipThinking ? { reasoningEffort: ADJUDICATION_REASONING_EFFORT } : {},
405
467
  purpose: 'memory-adjudication',
406
468
  signal: controller.signal,
407
469
  })) {
408
470
  assembler.push(chunk)
409
471
  }
410
- if (assembler.finish.kind !== 'stop') return undefined
472
+ if (assembler.finish.kind !== 'stop') {
473
+ detail = String(assembler.finish.kind)
474
+ return undefined
475
+ }
411
476
  const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
412
477
  const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
413
- const parsed = JSON.parse(fence ? fence[1].trim() : text)
414
- if (typeof parsed?.verdict !== 'string') return undefined
478
+ // Its own catch: a parse failure is an unusable reply, not a failed call,
479
+ // and Node's parse error quotes the reply's opening characters.
480
+ let parsed
481
+ try {
482
+ parsed = JSON.parse(fence ? fence[1].trim() : text)
483
+ } catch {
484
+ detail = 'unparseable reply'
485
+ return undefined
486
+ }
487
+ if (typeof parsed?.verdict !== 'string') {
488
+ detail = 'no verdict'
489
+ return undefined
490
+ }
491
+ outcome = 'ok'
415
492
  return { verdict: parsed.verdict, target: typeof parsed.target === 'string' ? parsed.target : '' }
493
+ } catch (error) {
494
+ outcome = 'error'
495
+ detail = error instanceof Error ? error.message : String(error)
496
+ throw error
416
497
  } finally {
417
498
  clearTimeout(timer)
499
+ void attempt.finish(outcome, detail)
418
500
  }
419
501
  }
420
502
 
@@ -491,44 +573,84 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
491
573
  // watermark and the next trigger resumes from exactly there.
492
574
  const coveredToSeq = complete ? task.toSeq : lastSeq + 1
493
575
 
576
+ const attempt = modelLog.begin('memory-consolidation', route)
494
577
  const controller = new AbortController()
495
578
  const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
496
- let assembled
579
+ let outcome = 'incomplete'
580
+ let detail = ''
581
+ let parsedOutput
497
582
  try {
498
- const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
499
- const assembler = new BlockAssembler()
500
- const request = createUserMessage({
501
- content: [{ type: 'text', text: `${consolidationInstruction(consolidation.maxWorkUnitsPerRun)}\n\n<transcript>\n${transcript}\n</transcript>` }],
502
- source: { kind: 'plugin', plugin: PLUGIN_NAME },
503
- })
504
- for await (const chunk of llm.stream({
505
- provider: route.provider,
506
- model: route.model,
507
- system: 'You produce strict JSON only. You never add prose around it.',
508
- messages: [request],
509
- maxTokens: consolidation.maxOutputTokens,
510
- purpose: 'memory-consolidation',
511
- signal: controller.signal,
512
- })) {
513
- assembler.push(chunk)
583
+ let assembled
584
+ try {
585
+ const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
586
+ const assembler = new BlockAssembler()
587
+ const request = createUserMessage({
588
+ content: [{ type: 'text', text: `${consolidationInstruction(consolidation.maxWorkUnitsPerRun)}\n\n<transcript>\n${transcript}\n</transcript>` }],
589
+ source: { kind: 'plugin', plugin: PLUGIN_NAME },
590
+ })
591
+ for await (const chunk of llm.stream({
592
+ provider: route.provider,
593
+ model: route.model,
594
+ system: 'You produce strict JSON only. You never add prose around it.',
595
+ messages: [request],
596
+ maxTokens: consolidation.maxOutputTokens,
597
+ purpose: 'memory-consolidation',
598
+ signal: controller.signal,
599
+ })) {
600
+ assembler.push(chunk)
601
+ }
602
+ assembled = assembler
603
+ } finally {
604
+ clearTimeout(timer)
514
605
  }
515
- assembled = assembler
516
- } finally {
517
- clearTimeout(timer)
518
- }
519
- const finish = assembled.finish
520
- if (finish.kind !== 'stop') {
521
- if (finish.kind === 'max-tokens') {
522
- throw new PermanentConsolidationError('consolidation hit the output token cap (truncated JSON)')
606
+ // The attempt stays open through parsing: a stream that ended with `stop`
607
+ // but yields no usable summary is `incomplete`, not `ok` — the cursor was
608
+ // consumed, nothing was stored, and a table that said otherwise would
609
+ // send a reader looking for a summary that does not exist. The throws are
610
+ // re-issued unchanged, so recording never changes which error the queue
611
+ // sees.
612
+ const finish = assembled.finish
613
+ if (finish.kind !== 'stop') {
614
+ // The thrown message is computed exactly as it was before this attempt
615
+ // was recorded: `error` AND `aborted` carry the provider's reason, and
616
+ // losing it for `aborted` would replace a timeout with a bare
617
+ // 'aborted' in the task row. `detail` uses the same message; `finish`
618
+ // caps it.
619
+ const message = finish.kind === 'error' || finish.kind === 'aborted'
620
+ ? finish.failure?.message ?? finish.kind
621
+ : finish.kind
622
+ if (finish.kind === 'error') outcome = 'error'
623
+ detail = String(message)
624
+ throw finish.kind === 'max-tokens'
625
+ ? new PermanentConsolidationError('consolidation hit the output token cap (truncated JSON)')
626
+ : new Error(`consolidation model call failed: ${message}`)
627
+ }
628
+ const text = assembled.blocks()
629
+ .filter((block) => block.type === 'text')
630
+ .map((block) => block.text)
631
+ .join('\n')
632
+ try {
633
+ parsedOutput = parseConsolidationOutput(text, consolidation.maxWorkUnitsPerRun)
634
+ } catch (parseError) {
635
+ // A parse failure is an unusable reply, not a failed call, and its
636
+ // message quotes the reply — so it never becomes `detail`. See the
637
+ // `detail` contract in model-log.js.
638
+ detail = 'unusable output'
639
+ throw parseError
523
640
  }
524
- const message = finish.kind === 'error' || finish.kind === 'aborted' ? finish.failure?.message ?? finish.kind : finish.kind
525
- throw new Error(`consolidation model call failed: ${message}`)
641
+ outcome = 'ok'
642
+ } catch (error) {
643
+ // Only a throw that arrived with nothing classified is a failed call
644
+ // rather than an unusable reply.
645
+ if (outcome === 'incomplete' && detail === '') {
646
+ outcome = 'error'
647
+ detail = error instanceof Error ? error.message : String(error)
648
+ }
649
+ throw error
650
+ } finally {
651
+ void attempt.finish(outcome, detail)
526
652
  }
527
- const text = assembled.blocks()
528
- .filter((block) => block.type === 'text')
529
- .map((block) => block.text)
530
- .join('\n')
531
- const { summary, workUnits } = parseConsolidationOutput(text, consolidation.maxWorkUnitsPerRun)
653
+ const { summary, workUnits } = parsedOutput
532
654
 
533
655
  const span = summaryName(task.sessionId, fromSeq, coveredToSeq)
534
656
  // The entries the log executor actually wrote for this span. Both sides