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.
- package/README.md +68 -7
- package/README.zh-CN.md +54 -9
- package/lib/client.js +720 -40
- package/lib/client.js.map +1 -1
- package/package.json +3 -2
- package/src/cascade.js +29 -4
- package/src/client/MemoryView.module.css +312 -0
- package/src/client/MemoryView.tsx +335 -0
- package/src/client/index.tsx +32 -2
- package/src/client/locales.ts +80 -0
- package/src/client/memory-client.ts +246 -0
- package/src/consolidator.js +159 -37
- package/src/index.js +74 -6
- package/src/memory-api.js +557 -0
- package/src/memory-status.js +167 -0
- package/src/model-log.js +246 -0
package/src/client/locales.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/consolidator.js
CHANGED
|
@@ -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:
|
|
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')
|
|
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
|
-
|
|
414
|
-
|
|
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
|
|
579
|
+
let outcome = 'incomplete'
|
|
580
|
+
let detail = ''
|
|
581
|
+
let parsedOutput
|
|
497
582
|
try {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
|
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
|