dsh-hypatia-auto-memory 0.2.0 → 0.4.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 +29 -14
- package/README.zh-CN.md +430 -0
- package/lib/client.js +33 -33
- package/package.json +1 -1
- package/src/cascade.js +29 -4
- package/src/collector.js +27 -1
- package/src/config.js +5 -1
- package/src/consolidator.js +165 -40
- package/src/content-policy.js +5 -1
- package/src/index.js +17 -0
- package/src/model-log.js +246 -0
package/src/cascade.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { createHash } from 'node:crypto'
|
|
23
23
|
|
|
24
24
|
import { PLUGIN_NAME } from './consolidator.js'
|
|
25
|
+
import { NULL_MODEL_LOG } from './model-log.js'
|
|
25
26
|
|
|
26
27
|
/** Tag marking the tier an entry belongs to. */
|
|
27
28
|
export function levelTag(level) {
|
|
@@ -80,9 +81,10 @@ function archiveInstruction(level, count) {
|
|
|
80
81
|
* selectRoute: (routes: any) => any,
|
|
81
82
|
* getConfig: () => any,
|
|
82
83
|
* status: import('./status.js').StatusLog,
|
|
84
|
+
* modelLog?: ReturnType<import('./model-log.js').createModelLog>,
|
|
83
85
|
* }} deps
|
|
84
86
|
*/
|
|
85
|
-
export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
|
|
87
|
+
export function createCascade({ cli, llm, selectRoute, getConfig, status, modelLog = NULL_MODEL_LOG }) {
|
|
86
88
|
/**
|
|
87
89
|
* Ask the model to archive one batch.
|
|
88
90
|
* @returns {Promise<{title: string, summary: string} | undefined>}
|
|
@@ -93,8 +95,11 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
|
|
|
93
95
|
return `### ${i + 1}. ${row.name}\n${data}`
|
|
94
96
|
}).join('\n\n')
|
|
95
97
|
|
|
98
|
+
const attempt = modelLog.begin('memory-cascade', route)
|
|
96
99
|
const controller = new AbortController()
|
|
97
100
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
101
|
+
let outcome = 'incomplete'
|
|
102
|
+
let detail = ''
|
|
98
103
|
try {
|
|
99
104
|
const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
|
|
100
105
|
const assembler = new BlockAssembler()
|
|
@@ -112,18 +117,38 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
|
|
|
112
117
|
})) {
|
|
113
118
|
assembler.push(chunk)
|
|
114
119
|
}
|
|
115
|
-
if (assembler.finish.kind !== 'stop')
|
|
120
|
+
if (assembler.finish.kind !== 'stop') {
|
|
121
|
+
detail = String(assembler.finish.kind)
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
116
124
|
const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
|
|
117
125
|
const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
|
|
118
|
-
|
|
126
|
+
// Its own catch: a parse failure is an unusable reply, not a failed call,
|
|
127
|
+
// and Node's parse error quotes the reply's opening characters.
|
|
128
|
+
let parsed
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(fence ? fence[1].trim() : text)
|
|
131
|
+
} catch {
|
|
132
|
+
detail = 'unparseable reply'
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
119
135
|
const summary = typeof parsed?.summary === 'string' ? parsed.summary.trim() : ''
|
|
120
|
-
if (summary === '')
|
|
136
|
+
if (summary === '') {
|
|
137
|
+
detail = 'no summary'
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
121
140
|
const title = typeof parsed?.title === 'string' && parsed.title.trim() !== ''
|
|
122
141
|
? parsed.title.trim()
|
|
123
142
|
: `Archive tier ${level}`
|
|
143
|
+
outcome = 'ok'
|
|
124
144
|
return { title, summary }
|
|
145
|
+
} catch (error) {
|
|
146
|
+
outcome = 'error'
|
|
147
|
+
detail = error instanceof Error ? error.message : String(error)
|
|
148
|
+
throw error
|
|
125
149
|
} finally {
|
|
126
150
|
clearTimeout(timer)
|
|
151
|
+
void attempt.finish(outcome, detail)
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
154
|
|
package/src/collector.js
CHANGED
|
@@ -59,6 +59,26 @@ const RECORDED_TYPES = new Set([
|
|
|
59
59
|
'tool/result',
|
|
60
60
|
])
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Whether an assistant/message event carries anything worth logging on its own.
|
|
64
|
+
*
|
|
65
|
+
* A turn that only issued tool calls (no text, no reasoning, no interruption
|
|
66
|
+
* marker) adds nothing to the conversation record — the tool outcomes already
|
|
67
|
+
* live in the session log if needed. Skipping these avoids msg-* entries whose
|
|
68
|
+
* entire body would be `(tool calls only)` or an empty placeholder.
|
|
69
|
+
*
|
|
70
|
+
* @param {any} event
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
function hasAssistantContent(event) {
|
|
74
|
+
const blocks = event.data?.message?.content ?? []
|
|
75
|
+
for (const block of blocks) {
|
|
76
|
+
if (block.type === 'text' && String(block.text ?? '').trim() !== '') return true
|
|
77
|
+
if (block.type === 'image') return true
|
|
78
|
+
}
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
/**
|
|
63
83
|
* Whether this event becomes one `msg-*` entry.
|
|
64
84
|
*
|
|
@@ -66,11 +86,17 @@ const RECORDED_TYPES = new Set([
|
|
|
66
86
|
* output) are excluded: they are not things the human said, and re-logging them
|
|
67
87
|
* would let memory amplify into itself.
|
|
68
88
|
*
|
|
89
|
+
* Assistant turns whose only content is tool calls are also excluded: they have
|
|
90
|
+
* no substantive message to remember.
|
|
91
|
+
*
|
|
69
92
|
* @param {any} event
|
|
70
93
|
* @returns {boolean}
|
|
71
94
|
*/
|
|
72
95
|
export function isLoggableMessage(event) {
|
|
73
|
-
if (event?.type === 'assistant/message')
|
|
96
|
+
if (event?.type === 'assistant/message') {
|
|
97
|
+
const interrupted = event.data?.interrupted === true
|
|
98
|
+
return interrupted || hasAssistantContent(event)
|
|
99
|
+
}
|
|
74
100
|
return event?.type === 'user/message' && event.data?.source?.kind === 'user'
|
|
75
101
|
}
|
|
76
102
|
|
package/src/config.js
CHANGED
|
@@ -35,7 +35,11 @@ export const DEFAULTS = {
|
|
|
35
35
|
// User messages were never capped, so a pasted log went into hypatia whole.
|
|
36
36
|
// Generous, because what the user typed is the part most worth keeping.
|
|
37
37
|
maxUserChars: 32000,
|
|
38
|
-
|
|
38
|
+
// Tool-call ledgers (name / count / success / duration) are intentionally
|
|
39
|
+
// off by default: they still reveal which tools were used and when, and the
|
|
40
|
+
// user has asked to avoid writing any tool-call-derived data to hypatia.
|
|
41
|
+
// Set this to true to restore the previous behavior.
|
|
42
|
+
toolLedger: false,
|
|
39
43
|
},
|
|
40
44
|
consolidation: {
|
|
41
45
|
enabled: true,
|
package/src/consolidator.js
CHANGED
|
@@ -14,14 +14,37 @@
|
|
|
14
14
|
* @module dsh-hypatia-auto-memory/consolidator
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { absolutizeDates, blocksToText, eventDate,
|
|
17
|
+
import { absolutizeDates, blocksToText, eventDate, redactSecrets } from './content-policy.js'
|
|
18
18
|
import { countLoggableMessages, isCompactionReplacement, isLoggableMessage } from './collector.js'
|
|
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) {
|
|
@@ -178,8 +201,11 @@ export function buildTranscript(events, config, now = new Date(), { before } = {
|
|
|
178
201
|
const callId = event.data?.message?.source?.callId
|
|
179
202
|
const name = toolNames.get(callId) ?? 'tool'
|
|
180
203
|
const ok = event.data?.error === undefined
|
|
181
|
-
|
|
182
|
-
|
|
204
|
+
// Tool outputs are intentionally kept out of the consolidation transcript.
|
|
205
|
+
// The model only sees that a tool was called and whether it succeeded;
|
|
206
|
+
// the actual result text never reaches the remote model or the summaries
|
|
207
|
+
// and work units derived from it.
|
|
208
|
+
entries.push({ seq: event.seq, line: `T: ${name} ${ok ? '✅' : '❌'}` })
|
|
183
209
|
}
|
|
184
210
|
}
|
|
185
211
|
|
|
@@ -260,13 +286,46 @@ export function parseConsolidationOutput(raw, maxWorkUnits) {
|
|
|
260
286
|
* writer: ReturnType<import('./writer.js').createWriter>,
|
|
261
287
|
* getConfig: () => any,
|
|
262
288
|
* status: import('./status.js').StatusLog,
|
|
289
|
+
* modelLog?: ReturnType<import('./model-log.js').createModelLog>,
|
|
263
290
|
* projectFor: (session: any) => Promise<string>,
|
|
264
291
|
* }} deps
|
|
265
292
|
*/
|
|
266
|
-
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 }) {
|
|
267
294
|
let warnedNoRoute = false
|
|
268
295
|
const selectRoute = createConsolidationRouteSelector()
|
|
269
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
|
+
|
|
270
329
|
/**
|
|
271
330
|
* Turn-end trigger check. The turn threshold is a minimum interval between
|
|
272
331
|
* accepted consolidation tasks, not a polling cadence: once the interval
|
|
@@ -385,9 +444,14 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
385
444
|
listed,
|
|
386
445
|
].join('\n')
|
|
387
446
|
|
|
447
|
+
const attempt = modelLog.begin('memory-adjudication', route)
|
|
388
448
|
const controller = new AbortController()
|
|
389
449
|
const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
|
|
450
|
+
let outcome = 'incomplete'
|
|
451
|
+
let detail = ''
|
|
390
452
|
try {
|
|
453
|
+
// Asked for only when the route advertises it; see `canDisableThinking`.
|
|
454
|
+
const skipThinking = await canDisableThinking(route, controller.signal)
|
|
391
455
|
const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
|
|
392
456
|
const assembler = new BlockAssembler()
|
|
393
457
|
for await (const chunk of llm.stream({
|
|
@@ -398,20 +462,41 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
398
462
|
content: [{ type: 'text', text: instruction }],
|
|
399
463
|
source: { kind: 'plugin', plugin: PLUGIN_NAME },
|
|
400
464
|
})],
|
|
401
|
-
maxTokens:
|
|
465
|
+
maxTokens: ADJUDICATION_MAX_TOKENS,
|
|
466
|
+
...skipThinking ? { reasoningEffort: ADJUDICATION_REASONING_EFFORT } : {},
|
|
402
467
|
purpose: 'memory-adjudication',
|
|
403
468
|
signal: controller.signal,
|
|
404
469
|
})) {
|
|
405
470
|
assembler.push(chunk)
|
|
406
471
|
}
|
|
407
|
-
if (assembler.finish.kind !== 'stop')
|
|
472
|
+
if (assembler.finish.kind !== 'stop') {
|
|
473
|
+
detail = String(assembler.finish.kind)
|
|
474
|
+
return undefined
|
|
475
|
+
}
|
|
408
476
|
const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
|
|
409
477
|
const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
|
|
410
|
-
|
|
411
|
-
|
|
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'
|
|
412
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
|
|
413
497
|
} finally {
|
|
414
498
|
clearTimeout(timer)
|
|
499
|
+
void attempt.finish(outcome, detail)
|
|
415
500
|
}
|
|
416
501
|
}
|
|
417
502
|
|
|
@@ -488,44 +573,84 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
488
573
|
// watermark and the next trigger resumes from exactly there.
|
|
489
574
|
const coveredToSeq = complete ? task.toSeq : lastSeq + 1
|
|
490
575
|
|
|
576
|
+
const attempt = modelLog.begin('memory-consolidation', route)
|
|
491
577
|
const controller = new AbortController()
|
|
492
578
|
const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
|
|
493
|
-
let
|
|
579
|
+
let outcome = 'incomplete'
|
|
580
|
+
let detail = ''
|
|
581
|
+
let parsedOutput
|
|
494
582
|
try {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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)
|
|
511
605
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
520
640
|
}
|
|
521
|
-
|
|
522
|
-
|
|
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)
|
|
523
652
|
}
|
|
524
|
-
const
|
|
525
|
-
.filter((block) => block.type === 'text')
|
|
526
|
-
.map((block) => block.text)
|
|
527
|
-
.join('\n')
|
|
528
|
-
const { summary, workUnits } = parseConsolidationOutput(text, consolidation.maxWorkUnitsPerRun)
|
|
653
|
+
const { summary, workUnits } = parsedOutput
|
|
529
654
|
|
|
530
655
|
const span = summaryName(task.sessionId, fromSeq, coveredToSeq)
|
|
531
656
|
// The entries the log executor actually wrote for this span. Both sides
|
package/src/content-policy.js
CHANGED
|
@@ -277,7 +277,11 @@ export function blocksToText(blocks) {
|
|
|
277
277
|
for (const block of blocks ?? []) {
|
|
278
278
|
if (block.type === 'text') parts.push(block.text)
|
|
279
279
|
else if (block.type === 'image') parts.push('[image]')
|
|
280
|
-
|
|
280
|
+
// Thinking / reasoning blocks are internal monologue, not something the
|
|
281
|
+
// assistant said to the user. Including them created `[reasoning]`
|
|
282
|
+
// placeholder entries with no substantive content; skip them so only the
|
|
283
|
+
// final text / image output reaches the log.
|
|
284
|
+
else if (block.type === 'thinking' || block.type === 'reasoning') continue
|
|
281
285
|
else if (block.type === 'tool-result') parts.push(blocksToText(block.content ?? []))
|
|
282
286
|
// A tool-call block is the model asking for a tool, not something it said.
|
|
283
287
|
// Rendered as `[tool-call]` it filled the content of half the stored
|
package/src/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { TaskDeferredError, createQueue } from './queue.js'
|
|
|
37
37
|
import { countLoggableMessages, createCollector, formatSpan } from './collector.js'
|
|
38
38
|
import { createCascade } from './cascade.js'
|
|
39
39
|
import { createConsolidator, PLUGIN_NAME } from './consolidator.js'
|
|
40
|
+
import { openModelLog } from './model-log.js'
|
|
40
41
|
import { createRecall } from './recall.js'
|
|
41
42
|
import { createPersistedSessions } from './persisted-session.js'
|
|
42
43
|
import { createAutoApprove } from './auto-approve.js'
|
|
@@ -93,6 +94,20 @@ function applyCollect(ctx, cordisConfig) {
|
|
|
93
94
|
progress: shelfTable(openedState.progress, shelf),
|
|
94
95
|
tasks: shelfTable(openedState.tasks, shelf),
|
|
95
96
|
}
|
|
97
|
+
// Which model a background attempt ran on is otherwise unknowable from
|
|
98
|
+
// outside the process: the cursor that picks it is process-local, the host
|
|
99
|
+
// logger scrolls away, and the usage ledger cannot see plugin calls. Kept in
|
|
100
|
+
// its own domain so a diagnostics row can never reject the open of the
|
|
101
|
+
// watermarks (see model-log.js). Not shelf-scoped: the route list is config,
|
|
102
|
+
// and the question is about this process, not this shelf.
|
|
103
|
+
const openedModelLog = await openModelLog(ctx, { status })
|
|
104
|
+
if (disposed) {
|
|
105
|
+
// Same race as the state domain above: if the fiber went away while this
|
|
106
|
+
// was opening, its disposer may never run, so close the handle here.
|
|
107
|
+
await openedModelLog.domain?.close().catch(() => {})
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const modelLog = openedModelLog.modelLog
|
|
96
111
|
let announcedShelf = shelf
|
|
97
112
|
configHandle.onChange((value) => {
|
|
98
113
|
if (value.shelf === announcedShelf) return
|
|
@@ -364,6 +379,7 @@ function applyCollect(ctx, cordisConfig) {
|
|
|
364
379
|
writer,
|
|
365
380
|
getConfig: configHandle.get,
|
|
366
381
|
status,
|
|
382
|
+
modelLog,
|
|
367
383
|
projectFor: collector.projectFor,
|
|
368
384
|
})
|
|
369
385
|
queue.registerExecutor('consolidate', consolidator.execute)
|
|
@@ -376,6 +392,7 @@ function applyCollect(ctx, cordisConfig) {
|
|
|
376
392
|
selectRoute: consolidator.selectRoute,
|
|
377
393
|
getConfig: configHandle.get,
|
|
378
394
|
status,
|
|
395
|
+
modelLog,
|
|
379
396
|
})
|
|
380
397
|
queue.registerExecutor('cascade', cascade.execute)
|
|
381
398
|
shared.consolidator = consolidator
|