@falling-ts/dsh-force-compact 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,818 @@
1
+ /**
2
+ * dsh-force-compact's own one-shot summarization, modeled on the official
3
+ * `compaction-basic` summarizer. Builds a compaction directive as the final user
4
+ * message after the replayed region (so the provider's KV cache is reused),
5
+ * streams it through `ctx.llm`, and returns the condensed checkpoint.
6
+ *
7
+ * This is a pre-commit preview + shrink gate: the durable surface mutation is
8
+ * delegated to the `compaction` service's `compactRegion` (read live via
9
+ * `ctx.get('compaction')`), which is the authoritative summarizer. See
10
+ * `engine/checkpoint.js` for the orchestration.
11
+ *
12
+ * @module @falling-ts/dsh-force-compact/summarizer
13
+ */
14
+
15
+ import { guardFn } from '../core/crashnet.js'
16
+
17
+ /** Tag opening the structured summary block inside a landed checkpoint node.
18
+ * (The matching close tag is the symmetric `</compacted-summary>`; it is kept
19
+ * as a literal where emitted rather than a second constant, since the open tag
20
+ * is the sole anchor referenced elsewhere — by the prior-checkpoint rule in
21
+ * `COMPACTION_INSTRUCTION`.) */
22
+ export const SUMMARY_OPEN_TAG = '<compacted-summary>'
23
+
24
+ /** Closing counterpart of {@link SUMMARY_OPEN_TAG}; wraps the structured
25
+ * summary body inside a landed checkpoint (aligned with the official
26
+ * `compaction-basic` `frameSummary`, which emits both tags around the body). */
27
+ export const SUMMARY_CLOSE_TAG = '</compacted-summary>'
28
+
29
+ /**
30
+ * The compaction directive, delivered as the FINAL user message after the
31
+ * replayed conversation rather than as a distinct summarizer system prompt.
32
+ * Keeping the conversation's own system prompt, tools, and message prefix in
33
+ * front of it makes the auxiliary call a genuine prefix of the last routed
34
+ * request, so the provider's KV cache is reused instead of invalidated.
35
+ */
36
+ export const COMPACTION_INSTRUCTION = [
37
+ 'You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.',
38
+ '',
39
+ 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
40
+ '',
41
+ '## Primary Request and Intent',
42
+ "- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
43
+ '',
44
+ '## Key Technical Concepts',
45
+ '- [technologies, frameworks, patterns, and conventions in play]',
46
+ '',
47
+ '## Files and Code',
48
+ '- [exact path: why it matters, key changes or snippets]',
49
+ '',
50
+ '## Errors and Fixes',
51
+ '- [error: how it was resolved, plus any related user feedback]',
52
+ '',
53
+ '## Pending Jobs',
54
+ '- [explicitly requested work not yet completed]',
55
+ '',
56
+ '## Current Work',
57
+ '- [precisely what was in progress at this checkpoint]',
58
+ '',
59
+ '## Next Step',
60
+ '- [the single next action, directly in line with the most recent request, or "(none)"]',
61
+ '',
62
+ '## Critical Context',
63
+ '- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
64
+ '',
65
+ 'Rules:',
66
+ '- Write concise engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.',
67
+ '- Capture user feedback and explicit instructions faithfully, especially corrections.',
68
+ '- Do NOT mention this summarization request or that the context was compacted.',
69
+ '- Output only the checkpoint text: do not call any tool or take any other action.',
70
+ `- If the conversation already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
71
+ ].join('\n')
72
+
73
+ /** Framing that makes the replacement user message established context. */
74
+ export const CHECKPOINT_PREAMBLE =
75
+ 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
76
+
77
+ /**
78
+ * Frame summarized content blocks for landing as a `user/message` — aligned with
79
+ * the official `compaction-basic` `frameSummary` (a BLOCK-LEVEL wrap, NOT a
80
+ * text splice): the first block carries the preamble and the OPEN
81
+ * `<compacted-summary>` tag, the summary's own text blocks pass through VERBATIM
82
+ * (preserving their exact content), and a trailing block carries the CLOSE tag.
83
+ * Wrapping as discrete blocks (rather than joining into one string) keeps each
84
+ * fragment individually addressable and matches the durable event shape exactly.
85
+ * So a later compression recognizing the `<compacted-summary>` anchor applies the
86
+ * instruction's "merge, don't copy forward" rule correctly.
87
+ * @param {Array<{type:'text', text:string}>} textBlocks the condensed checkpoint blocks.
88
+ * @returns {Array<{type:'text', text:string}>} the framed node blocks.
89
+ */
90
+ export function frameSummary(textBlocks) {
91
+ const blocks = Array.isArray(textBlocks) ? textBlocks.filter(b => b && b.type === 'text' && typeof b.text === 'string') : []
92
+ return [
93
+ { type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
94
+ ...blocks,
95
+ { type: 'text', text: SUMMARY_CLOSE_TAG },
96
+ ]
97
+ }
98
+
99
+ /**
100
+ * The replayed conversation surface the summarizer condenses. Reproducing the
101
+ * last routed request's system prompt, tools, and leading messages verbatim
102
+ * lets the auxiliary call reuse the provider's warm prefix cache; the trailing
103
+ * compaction instruction is then the only novel input.
104
+ */
105
+ /**
106
+ * @typedef {{ system?: string, tools?: ReadonlyArray<object>, messages: Array<object> }} SummarizationInput
107
+ * Replayed prefix for the summarization call. `system` and `tools` come from
108
+ * the session's latest request header (prefix-cache alignment); `messages` is
109
+ * the shadowed region in surface order.
110
+ */
111
+
112
+ /**
113
+ * @typedef {{ provider: string, model: string }|undefined} Target
114
+ */
115
+
116
+ /**
117
+ * Run the cache-reusing `ctx.llm.stream()` summarization call.
118
+ *
119
+ * Aligned with the official `compaction-basic` summarizer (single-source-of-
120
+ * truth pattern):
121
+ * 1. target resolution order: **configured** (`config.summarizationProvider` /
122
+ * `config.summarizationModel`, optional) → **latest routed header**
123
+ * (`agent.session.requestHeader().config`) → **agent.options**
124
+ * (`provider` / `model`). The first candidate with BOTH fields wins.
125
+ * 2. The replayed prefix (system + tools from the request header, plus the
126
+ * shadowed-region messages) is passed VERBATIM so the auxiliary call is a
127
+ * genuine prefix of the last routed request — the provider's warm KV
128
+ * cache is reused instead of invalidated. The compaction instruction is
129
+ * then the only novel input.
130
+ * 3. `purpose: 'compaction'` tags the call for adapter-side routing policy.
131
+ * 4. All chunk kinds are accumulated; a terminal `finish` of `error` /
132
+ * `aborted` / `max-tokens` throws a typed error (caller closes the
133
+ * transaction via `closeWithError`); image output is refused (image
134
+ * content can never safely become a checkpoint).
135
+ * 5. Usage is surfaced when the provider reports it.
136
+ *
137
+ * @param {import('@deepseek-ai/cordis').Context} ctx
138
+ * @param {Readonly<object>} config backend config (may carry `maxSummaryTokens`
139
+ * and the optional `summarizationProvider` / `summarizationModel` override pair)
140
+ * @param {import('@deepseek-ai/dsh-agent').Agent} agent provides the session
141
+ * (routed-header lookup) and fallback target.
142
+ * @param {SummarizationInput} input replayed prefix + region messages.
143
+ * @param {AbortSignal} [signal]
144
+ * @param {{ reasoningEffort?: 'off' | 'low' | 'high' | 'max', maxTokens?: number }} [extra]
145
+ * optional generation overrides: `reasoningEffort` maps to the LLM
146
+ * adapter's thinking toggle; `maxTokens` OVERRIDES the `config.maxSummaryTokens`
147
+ * base value when present (callers route through the `settings.maxSummaryTokens`
148
+ * runtime knob without changing the static default).
149
+ * @returns {Promise<object>} NEVER rejects. A discriminated result object the
150
+ * caller branches on by `status`:
151
+ * • `{ status: 'ok', summary, provider, model, maxTokens?, usage? }` —
152
+ * `summary` is the condensed text-only checkpoint blocks.
153
+ * • `{ status: 'no-target' }` / `{ status: 'no-llm' }` — the call was never
154
+ * made; caller silently skips (nothing to cool down).
155
+ * • `{ status: '<failure>', reason: string }` — the call was made but no
156
+ * usable summary resulted. Failure labels: `not-iterable`, `no-finish`,
157
+ * `provider-error`, `aborted`, `truncated-empty`, `image-content`,
158
+ * `empty-text`. Caller arms the per-session cooldown and closes the
159
+ * transaction with `error`.
160
+ * No throw path exists: a malformed chunk/finish/object degrades to a
161
+ * labeled failure, so a bad provider response can never surface a TypeError
162
+ * nor trap the idle path in an uncaught-exception retry loop.
163
+ */
164
+ // Internal body of `summarize` — routed through the crash-net wrapper. The
165
+ // documented contract is "NEVER THROWS", but a genuinely novel throw shape
166
+ // (e.g. a `JSON.stringify` on a cycle, an exotic iterator) escapes into the
167
+ // crash net, leaving a durable trace and propagating the original value
168
+ // unchanged (existing callers keep their semantics).
169
+ async function __summarizeBody(ctx, config, agent, input, signal, extra) {
170
+ // NEVER THROWS. Always resolves to a structured result the caller branches on
171
+ // by `status`:
172
+ // { status: 'ok', summary[], provider, model, maxTokens?, usage? }
173
+ // { status: 'no-target' } — no resolvable provider/model (call never made)
174
+ // { status: 'no-llm' } — ctx has no `llm.stream` service (call never made)
175
+ // { status: '<failure>', reason: string } — the call was MADE but produced no usable
176
+ // summary. Failures:
177
+ // 'not-iterable' (returned stream not an async iterable),
178
+ // 'no-finish' (stream consumed but delivered no terminal finish chunk),
179
+ // 'provider-error' (terminal finish kind:'error'),
180
+ // 'aborted' (terminal finish kind:'aborted'),
181
+ // 'truncated-empty' (kind:'max-tokens' with no text),
182
+ // 'image-content' (image blocks present — unsafe as a checkpoint),
183
+ // 'empty-text' (terminated successfully but emitted no text).
184
+ // The caller (builtin.js runTransaction) maps 'ok' → commit; 'no-target'/
185
+ // 'no-llm' → silent skip (nothing to cool down); any other status → arm the
186
+ // per-session failure cooldown + close the transaction with `error`. No throw
187
+ // path exists, so a malformed chunk/finish/object can never propagate a
188
+ // TypeError and the idle path can never loop on an uncaught exception.
189
+ const target = resolveTarget(config, agent)
190
+ if (target === undefined) return { status: 'no-target' }
191
+
192
+ const llm = ctx.get('llm')
193
+ if (llm === undefined || typeof llm.stream !== 'function') return { status: 'no-llm' }
194
+
195
+ // Backwards-compatible call signature: a bare `messages` array (the old
196
+ // 4-arg form) is treated as an input with no system/tools prefix. New
197
+ // callers pass `{ messages, system?, tools? }`.
198
+ const normalized = Array.isArray(input) ? { messages: input } : input
199
+ const regionMessages = normalized.messages || []
200
+
201
+ const request = [
202
+ ...regionMessages,
203
+ { role: 'user', content: [{ type: 'text', text: COMPACTION_INSTRUCTION }], source: { kind: 'plugin', plugin: 'force-compact' } },
204
+ ]
205
+ const options = {
206
+ provider: target.provider,
207
+ model: target.model,
208
+ messages: request,
209
+ maxTokens: config.maxSummaryTokens,
210
+ }
211
+ // Prefix-cache alignment: feed the conversation's own system prompt and tool
212
+ // schemas into the auxiliary call so the provider's warm KV cache for the
213
+ // last routed request is REUSED rather than invalidated. Absent headers
214
+ // (system-less session or a tool-free request) omit the field entirely.
215
+ if (normalized.system !== undefined && typeof normalized.system === 'string' && normalized.system.length > 0) {
216
+ options.system = normalized.system
217
+ }
218
+ if (Array.isArray(normalized.tools) && normalized.tools.length > 0) {
219
+ options.tools = [...normalized.tools]
220
+ }
221
+ // The force-compact "disable thinking" setting maps to a per-request
222
+ // `reasoningEffort: 'off'`, which the adapter turns into
223
+ // `thinking: { type: 'disabled' }` (provider thinking off for the call).
224
+ if (extra !== undefined && typeof extra.reasoningEffort === 'string') {
225
+ options.reasoningEffort = extra.reasoningEffort
226
+ }
227
+ // Callers may override the static `config.maxSummaryTokens` via the
228
+ // `extra.maxTokens` knob (e.g., to honor the `settings.maxSummaryTokens`
229
+ // runtime setting without changing the compile-time default). Only apply
230
+ // when the caller supplied a positive numeric override — otherwise keep the
231
+ // static `config` value.
232
+ if (extra !== undefined && Number.isFinite(extra.maxTokens) && extra.maxTokens > 0) {
233
+ options.maxTokens = extra.maxTokens
234
+ }
235
+ if (signal !== undefined) options.signal = signal
236
+ const session = agent.session
237
+ if (session !== undefined && session !== null && typeof session.id === 'string') {
238
+ options.sessionId = session.id
239
+ }
240
+ // This one-shot call IS the compaction: tag it with the closed-union
241
+ // `purpose` the LLM service understands (adapters may map it to
242
+ // purpose-specific generation policy). The agent's free-form purpose string
243
+ // is NOT a valid `GenerateOptions.purpose` value.
244
+ options.purpose = 'compaction'
245
+
246
+ // Assemble ALL chunk kinds (text + reasoning + images). Reasoning deltas are
247
+ // dropped later by `extractTextOnly`; a terminal finish decides whether the
248
+ // call succeeded. Accumulating the full stream mirrors the official
249
+ // `compaction-basic` `BlockAssembler` shape — the difference is that this
250
+ // plugin does not depend on `@deepseek-ai/dsh-llm` symbols (it ships as
251
+ // plain JS outside the DSH workspace), so the assembly logic is inlined here
252
+ // against the documented `StreamChunk` shape.
253
+ // Bind the stream ONCE (rather than inline in the collectChunks call) so the
254
+ // missing-finish diagnostic below can describe the ACTUAL object we were given
255
+ // — distinguishing "not async-iterable (swapped by a waterfall listener)" from
256
+ // "iterated cleanly but never delivered a finish chunk".
257
+ const stream = llm.stream(options)
258
+ // `collectChunks` performs the up-front async-iterability assertion and, if the
259
+ // returned value is NOT a real async iterable (a `llm/stream` waterfall
260
+ // listener swapped it for a Promise/plain object), it RESOLVES to
261
+ // `{ _rejected: true, _rejectReason, ... }` rather than throwing. Any other
262
+ // shape is treated as a degenerate collection (zero chunks, no finish) below.
263
+ // We therefore wrap in try/catch as belt-and-braces: even a stray iteration
264
+ // error degrades to a labeled failure instead of escaping `summarize`.
265
+ let collected
266
+ try {
267
+ collected = await collectChunks(stream, signal)
268
+ } catch (err) {
269
+ // `for await` threw mid-iteration (generator fault, network reset, a
270
+ // poisoned composed stream, …). Record it and fall through to the shared
271
+ // failure handling — never let it escape this function.
272
+ collected = {
273
+ blocks: [], text: '', hasImage: false, finish: undefined, usage: undefined,
274
+ _chunkCount: 0, _rejected: true,
275
+ _rejectReason: (err && err.message) ? err.message : String(err),
276
+ }
277
+ }
278
+ if (!collected || typeof collected !== 'object') {
279
+ collected = { blocks: [], text: '', hasImage: false, finish: undefined, usage: undefined, _chunkCount: 0, _rejected: true, _rejectReason: 'collectChunks returned a non-object' }
280
+ }
281
+ if (collected._rejected) {
282
+ // The stream value was not a usable async iterable (see collectChunks).
283
+ return { status: 'not-iterable', reason: 'llm.stream() did not return an async iterable: ' + (collected._rejectReason || describeStream(stream)) }
284
+ }
285
+
286
+ // ---- Defensive read of the terminal `finish` ---------------------------
287
+ // We do NOT trust that `finish` is a well-formed object. Every property is
288
+ // read behind an explicit validity guard; any anomaly degrades to a labeled
289
+ // failure instead of throwing.
290
+ const finish = collected.finish
291
+ const finishIsObject = finish !== null && typeof finish === 'object'
292
+ const finishKind = finishIsObject ? (typeof finish.kind === 'string' ? finish.kind : undefined) : undefined
293
+
294
+ // No terminal finish chunk (or the chunk carried no recognizable `kind`):
295
+ // the stream stopped without telling us it completed. Report the observed
296
+ // facts and give up — never assume success.
297
+ if (finish === undefined) {
298
+ const n = (typeof collected._chunkCount === 'number') ? collected._chunkCount : '?'
299
+ // DISAMBIGUATE the two sub-classes of a missing terminator: a stream that
300
+ // observed a trailing `usage` chunk reached a NORMAL end (and would have
301
+ // been synthesized into a `stop` by `collectChunks` — so landing here
302
+ // MEANS it died mid-way before any usage), versus a silent/hung stream
303
+ // that yielded chunks but never signalled completion at all. The two point
304
+ // at entirely different upstream causes.
305
+ const explicit = collected._explicitFinish
306
+ const suffix = explicit === undefined
307
+ ? ''
308
+ : (explicit
309
+ ? `; an explicit finish WAS observed earlier but a later phase lost it — investigate the composed stream chain`
310
+ : `; no usage chunk preceded the end either — the stream simply went silent`)
311
+ return {
312
+ status: 'no-finish',
313
+ reason: `stream ended without a terminal finish chunk (collected ${n} chunks; stream was ${describeStream(stream)}${suffix})`,
314
+ }
315
+ }
316
+ if (!finishIsObject || finishKind === undefined) {
317
+ // A `finish` chunk was seen but its payload was not the expected
318
+ // `FinishReason` object (e.g. `chunk.reason` was undefined/null or a
319
+ // primitive). Treat as an incomplete termination.
320
+ return {
321
+ status: 'no-finish',
322
+ reason: `terminal finish chunk lacked a valid kind (finish rendered as ${renderFinish(finish)})`,
323
+ }
324
+ }
325
+
326
+ // Official `FinishReason.kind` closed union (upstream types.ts):
327
+ // 'stop' | 'tool-calls' | 'max-tokens' | 'aborted' | 'error'.
328
+ if (finishKind === 'error') {
329
+ // TEMPORARY CRASH-HARNESS PROBE: the recurring terminal failure
330
+ // `provider failure: Cannot read properties of undefined (reading 'kind')`
331
+ // (code UNKNOWN) tells us SOMEWHERE inside the composed stream chain a
332
+ // harness/middleware reader dereferenced `undefined.kind`. To finally root-
333
+ // cause it, dump EVERYTHING observable about the failure fact on the single
334
+ // branch where such a crash lands. Self-limiting: writes at most one line
335
+ // per errored call, logging never propagates, removable once root-caused.
336
+ try {
337
+ const f = readProp(finish, 'failure')
338
+ const stackTop = (() => {
339
+ const st = f && typeof f.stack === 'string' ? f.stack : (new Error('probe-no-stack-on-failure')).stack
340
+ // Keep the frames INSIDE the harness/adapter (skip this probe's own
341
+ // frames): drop lines mentioning this file, keep the rest, max 6.
342
+ const frames = st.split('\n').filter(line => !line.includes('summarizer.js'))
343
+ return frames.slice(0, 6).map(line => line.trim()).join(' <- ')
344
+ })()
345
+ const causeChain = []
346
+ let cursor = f && f.cause
347
+ for (let depth = 0; depth < 4 && cursor !== undefined && cursor !== null; depth++) {
348
+ causeChain.push(typeof cursor === 'object'
349
+ ? { ctor: (cursor.constructor && cursor.constructor.name) || '?', message: cursor.message, name: cursor.name, code: cursor.code }
350
+ : { primitive: cursor })
351
+ cursor = (typeof cursor === 'object') ? cursor.cause : undefined
352
+ }
353
+ const failJson = (() => {
354
+ try { return JSON.stringify({ kind: finish.kind, msg: f && f.message, code: f && f.code }).slice(0, 400) } catch { return '<unserializable>' }
355
+ })()
356
+ console.log(`[force-compact] CRASH-HARNESS: failureFact=${JSON.stringify(f && typeof f === 'object' ? { ctor: f.constructor && f.constructor.name, protoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(f)).slice(0, 12), ownKeys: Object.keys(f).slice(0, 12) } : f, (k, v) => (k === 'stack' ? '<omitted>' : v)) } `
357
+ + `failJson=${failJson} causeChain=${JSON.stringify(causeChain).slice(0, 600)} `
358
+ + `stackInsideHarness=[${stackTop}] optionsShape={provider:${options.provider},model:${options.model},msgs:${options.messages.length},tools:${options.tools !== undefined ? options.tools.length : 'absent'},system:${typeof options.system},purpose:${options.purpose},effort:${options.reasoningEffort}}`)
359
+ } catch { /* the probe itself must never mask the original outcome */ }
360
+ return { status: 'provider-error', reason: 'provider failure: ' + (failureText(readProp(finish, 'failure')) || 'unknown provider error') }
361
+ }
362
+ if (finishKind === 'aborted') {
363
+ return { status: 'aborted', reason: 'aborted during generation: ' + (failureText(readProp(finish, 'failure')) || '(caller abort)') }
364
+ }
365
+ if (finishKind === 'max-tokens') {
366
+ // Truncation at the token cap. Empty → useless (report); non-empty → a
367
+ // partial summary we ACCEPT, letting the downstream shrink gate arbitrate.
368
+ if (measuredLength(safeText(collected.text)) === 0) {
369
+ return { status: 'truncated-empty', reason: 'truncated at the token cap with no output' }
370
+ }
371
+ // fall through to extraction (partial accept)
372
+ }
373
+ // `stop` / `tool-calls` / any unrecognized kind are treated as a normal
374
+ // termination — fall through to extracting whatever text was produced. An
375
+ // unrecognized `kind` is NOT fatal: we still surface any text and let the
376
+ // shrink gate decide usefulness.
377
+
378
+ // ---- Text extraction --------------------------------------------------
379
+ // `collected.blocks` is expected to be an array; a missing/non-array value
380
+ // simply yields an empty extraction (handled by the empty-text branch).
381
+ const extracted = extractTextOnly(Array.isArray(collected.blocks) ? collected.blocks : [])
382
+
383
+ // Image output can never safely become a checkpoint user-message.
384
+ if (collected.hasImage === true) {
385
+ return { status: 'image-content', reason: 'output contained image content — refusing to land as a checkpoint' }
386
+ }
387
+
388
+ const usableText = extracted.some(b => b != null && typeof b === 'object' && typeof b.text === 'string' && b.text.trim().length > 0)
389
+ if (!usableText) {
390
+ return { status: 'empty-text', reason: 'produced no usable text' }
391
+ }
392
+
393
+ const result = {
394
+ status: 'ok',
395
+ summary: extracted,
396
+ provider: target.provider,
397
+ model: target.model,
398
+ maxTokens: options.maxTokens,
399
+ }
400
+ // Surface provider-reported usage when the adapter carried it; callers can
401
+ // record it alongside `compaction/summary` for observability.
402
+ const usage = collected.usage
403
+ if (usage !== undefined && usage !== null) result.usage = usage
404
+ return result
405
+ }
406
+
407
+ /**
408
+ * Read a single property defensively. Never throws regardless of `obj` shape.
409
+ * Returns `undefined` for non-objects or missing/invalid values. Used wherever
410
+ * we read into a structure we do not control (chunk payloads, finish reasons).
411
+ * @param {*} obj
412
+ * @param {string} key
413
+ * @returns {*}
414
+ */
415
+ function readProp(obj, key) {
416
+ try {
417
+ if (obj === null || typeof obj !== 'object') return undefined
418
+ return obj[key]
419
+ } catch {
420
+ return undefined
421
+ }
422
+ }
423
+
424
+ /** Coerce a possibly-missing text field to a safe string for measurement. */
425
+ function safeText(value) {
426
+ return typeof value === 'string' ? value : ''
427
+ }
428
+
429
+ /** Render a possibly-malformed `finish` value for diagnostics without throwing. */
430
+ function renderFinish(finish) {
431
+ try {
432
+ if (finish === undefined) return 'undefined'
433
+ if (finish === null) return 'null'
434
+ if (typeof finish !== 'object') return typeof finish
435
+ const k = typeof finish.kind === 'string' ? `kind='${finish.kind}'` : 'kind=<missing>'
436
+ return `an object (${k})`
437
+ } catch {
438
+ return '<undescribable>'
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Resolve the provider/model for the summarization call, in priority order
444
+ * (mirroring the official summarizer's three-tier target resolution):
445
+ *
446
+ * 1. CONFIGURED target: `config.summarizationProvider` /
447
+ * `config.summarizationModel` — an operator-declared override (both
448
+ * fields must be non-empty strings to count as a target).
449
+ * 2. Latest ROUTED HEADER: `agent.session.requestHeader().config` — the
450
+ * session's most recent routed request; the model the conversation is
451
+ * actually running on.
452
+ * 3. AGENT OPTIONS: `agent.options.provider` / `agent.options.model` —
453
+ * the Agent's configured fallback.
454
+ *
455
+ * @param {Readonly<object>} config
456
+ * @param {import('@deepseek-ai/dsh-agent').Agent} agent
457
+ * @returns {Target}
458
+ */
459
+ function resolveTarget(config, agent) {
460
+ const cfgProvider = typeof config.summarizationProvider === 'string' ? config.summarizationProvider : ''
461
+ const cfgModel = typeof config.summarizationModel === 'string' ? config.summarizationModel : ''
462
+ if (cfgProvider.length > 0 && cfgModel.length > 0) {
463
+ return { provider: cfgProvider, model: cfgModel }
464
+ }
465
+ const session = agent.session
466
+ if (session !== undefined && typeof session.requestHeader === 'function') {
467
+ try {
468
+ const header = session.requestHeader()
469
+ const hconfig = header && header.config
470
+ if (hconfig !== undefined
471
+ && typeof hconfig.provider === 'string' && hconfig.provider.length > 0
472
+ && typeof hconfig.model === 'string' && hconfig.model.length > 0) {
473
+ return { provider: hconfig.provider, model: hconfig.model }
474
+ }
475
+ } catch {
476
+ // requestHeader is best-effort: a malformed header folds to undefined;
477
+ // fall through to the agent-options fallback below.
478
+ }
479
+ }
480
+ const opts = agent.options || {}
481
+ if (typeof opts.provider === 'string' && opts.provider.length > 0
482
+ && typeof opts.model === 'string' && opts.model.length > 0) {
483
+ return { provider: opts.provider, model: opts.model }
484
+ }
485
+ return undefined
486
+ }
487
+
488
+ /** Public entry — wrapped by the universal crash net. */
489
+ export const summarize = guardFn('summarizer.summarize', __summarizeBody)
490
+
491
+ /**
492
+ * Read the session's latest request header and project the prefix-cache
493
+ * alignment fields (`system` prompt + `tools` schemas) out of it. Used by
494
+ * callers that want to reproduce the last routed request's verbatim prefix so
495
+ * the auxiliary summarization call hits the provider's warm KV cache.
496
+ *
497
+ * NEVER throws: any receiver shape (including a header whose `config` is
498
+ * absent) degrades gracefully to `{ system: undefined, tools: undefined }`.
499
+ *
500
+ * @param {import('@deepseek-ai/dsh-agent').Session|undefined} session
501
+ * @returns {{ system?: string, tools?: object[] }}
502
+ */
503
+ export function headerPrefix(session) {
504
+ const result = {}
505
+ if (session === undefined || session === null || typeof session.requestHeader !== 'function') return result
506
+ let header
507
+ try {
508
+ header = session.requestHeader()
509
+ } catch {
510
+ return result
511
+ }
512
+ if (header === undefined || header === null) return result
513
+ if (typeof header.system === 'string' && header.system.length > 0) result.system = header.system
514
+ if (Array.isArray(header.tools) && header.tools.length > 0) result.tools = [...header.tools]
515
+ return result
516
+ }
517
+
518
+ /**
519
+ * Collect EVERY chunk kind from the stream into a small accumulator that
520
+ * mimics the official `BlockAssembler`: ordered content blocks, a boolean
521
+ * `hasImage` flag, the terminal `finish` fact, and the provider-reported
522
+ * `usage` (when the adapter carried it).
523
+ *
524
+ * Inline against the documented `StreamChunk` shape — this plugin ships as
525
+ * plain JS outside the DSH workspace and must not import `@deepseek-ai/dsh-llm`
526
+ * symbols (they are not resolvable at plugin load time).
527
+ *
528
+ * `finish` is stored AS-IS as the raw `FinishReason` object (`{ kind,
529
+ * failure? }`) — the same thing the official `BlockAssembler` retains — so the
530
+ * caller switches on `finish.kind` and reads `finish.failure` per protocol.
531
+ *
532
+ * @param {AsyncIterable<object>} stream
533
+ * @param {AbortSignal} [signal]
534
+ * @param {{recordOnEmpty?: boolean}} [opts]
535
+ * @returns {Promise<{ blocks: Array, text: string, hasImage: boolean, finish: object|undefined, usage: object|undefined }> }
536
+ */
537
+ async function collectChunks(stream, signal, opts) {
538
+ // UP-FRONT ITERABILITY CHECK: `ctx.llm.stream()` is contractually an
539
+ // `AsyncIterable<StreamChunk>` (direct `for await`, no outer await /
540
+ // `.values()`). But `llm.stream` COMPOSES every `llm/stream` waterfall
541
+ // listener, and a listener that `return`s a Promise or a plain (non-
542
+ // async-iterable) object poisons the composition — turning the stream into
543
+ // exactly the unstable "sometimes throws not-async-iterable, sometimes
544
+ // yields nothing" we observed. Detect it HERE with a precise, actionable
545
+ // message naming the offending constructor, instead of letting a confusing
546
+ // `yield* (intermediate value)…` TypeError leak out later.
547
+ const asyncIterFn = (stream && typeof stream === 'object')
548
+ ? (stream[Symbol.asyncIterator]?.bind(stream))
549
+ : undefined
550
+ if (typeof asyncIterFn !== 'function') {
551
+ // The returned value is NOT a usable async iterable (a `llm/stream`
552
+ // waterfall listener swapped the generator for a Promise/plain object, or
553
+ // the value was undefined/null). Rather than THROWING — which would bubble
554
+ // an opaque `yield*`-style TypeError far up the stack — RESOLVE to a
555
+ // marked failure result so the caller can report it as a labeled
556
+ // `not-iterable` summarization failure and recover normally. No partial
557
+ // result is possible (nothing could be consumed), so nothing is salvaged.
558
+ const detail = describeStream(stream)
559
+ return {
560
+ blocks: [], text: '', hasImage: false, finish: undefined, usage: undefined,
561
+ _chunkCount: 0, _rejected: true,
562
+ _rejectReason: `did NOT return an async iterable (got ${detail}); a llm/stream waterfall listener likely replaced the generator with another object`,
563
+ }
564
+ }
565
+ const blocks = []
566
+ let text = ''
567
+ let hasImage = false
568
+ let finish
569
+ let usage
570
+
571
+ // Accumulators keyed by BLOCK INDEX, mirroring the official `BlockAssembler`:
572
+ // a `block-start` opens a slot; `*-delta`s fill it; `block-end` closes it.
573
+ // When no `block-start` precedes a delta (some adapters omit it), we lazily
574
+ // open the slot on first delta using the chunk's own index. This keeps streamed
575
+ // output and the final assembled blocks in agreement regardless of whether the
576
+ // adapter emits explicit delimiters.
577
+ const partials = new Map()
578
+ const order = []
579
+
580
+ const ensure = (index, blockType) => {
581
+ let p = partials.get(index)
582
+ if (!p) {
583
+ p = { blockType, text: '', toolCallId: undefined, toolCallName: '', toolCallArgs: '' }
584
+ partials.set(index, p)
585
+ order.push(index)
586
+ }
587
+ return p
588
+ }
589
+
590
+ const finalizeSlot = (index) => {
591
+ const p = partials.get(index)
592
+ if (!p) return
593
+ if (p.assembled) return // block-end already settled it
594
+ if (p.blockType === 'text') {
595
+ blocks.push({ type: 'text', text: p.text })
596
+ text += p.text
597
+ } else if (p.blockType === 'reasoning') {
598
+ if (p.text !== '') blocks.push({ type: 'reasoning', text: p.text })
599
+ } else if (p.blockType === 'tool-call') {
600
+ blocks.push({
601
+ type: 'tool-call',
602
+ toolCallId: p.toolCallId,
603
+ name: p.toolCallName,
604
+ arguments: p.toolCallArgs,
605
+ })
606
+ }
607
+ p.assembled = true
608
+ }
609
+
610
+ const flushOpenSlots = () => {
611
+ // Close any slot that received content but never got a `block-end`
612
+ // (lenient tail-handling). Only slots that actually hold data matter.
613
+ for (const idx of order) {
614
+ const p = partials.get(idx)
615
+ if (p && !p.assembled && (p.text !== '' || p.toolCallArgs !== '')) finalizeSlot(idx)
616
+ }
617
+ }
618
+
619
+ // TEMPORARY CHUNK-SHAPE PROBE: when a stream finishes yet produced NO text
620
+ // blocks, record the raw shape (type + keys + first 160 chars) of the FIRST
621
+ // few chunks. Self-limiting to ≤3 chunks so it cannot spam on a long stream.
622
+ // Guarded: logging never propagates.
623
+ let probeBuf = []
624
+ const recording = Boolean(opts && opts.recordOnEmpty)
625
+ let chunkCount = 0
626
+ // Tracks whether a REAL terminal `finish` chunk was observed (vs. the
627
+ // end-of-stream synthesis below). Consumed by the caller to keep its
628
+ // diagnostics honest: "clean natural termination with no explicit finish
629
+ // marker" vs. "explicit finish marker present" is a materially different
630
+ // failure class for debugging.
631
+ let sawExplicitFinish = false
632
+
633
+ for await (const rawChunk of stream) {
634
+ if (signal !== undefined && signal.aborted) break
635
+ // Defensive: a malformed stream may yield null/undefined/non-object items.
636
+ // Count them (so the "consumed N chunks" diagnostic stays accurate) but skip
637
+ // their processing — reading `.type` on a non-object would throw.
638
+ const chunk = (rawChunk !== null && typeof rawChunk === 'object') ? rawChunk : undefined
639
+ chunkCount++
640
+ if (chunk === undefined) continue
641
+ if (recording && probeBuf.length < 3 && chunkCount <= 3) {
642
+ // Best-effort shape capture; a non-serializable chunk must not abort the
643
+ // collection, so stringify is guarded and falls back to a placeholder.
644
+ let sample
645
+ try { sample = JSON.stringify(chunk).slice(0, 160) } catch { sample = '<unserializable>' }
646
+ probeBuf.push({ type: chunk.type, keys: Object.keys(chunk).join(','), sample })
647
+ }
648
+ switch (chunk.type) {
649
+ case 'block-start':
650
+ ensure(chunk.index, chunk.blockType)
651
+ break
652
+ case 'text-delta': {
653
+ const p = ensure(chunk.index, 'text')
654
+ if (!p.assembled) p.text += typeof chunk.text === 'string' ? chunk.text : ''
655
+ break
656
+ }
657
+ case 'reasoning-delta': {
658
+ const p = ensure(chunk.index, 'reasoning')
659
+ if (!p.assembled) p.text += typeof chunk.text === 'string' ? chunk.text : ''
660
+ break
661
+ }
662
+ case 'tool-call-delta': {
663
+ const p = ensure(chunk.index, 'tool-call')
664
+ if (!p.assembled) {
665
+ if (chunk.id !== undefined) p.toolCallId = chunk.id
666
+ if (chunk.name) p.toolCallName = chunk.name
667
+ if (typeof chunk.argumentsDelta === 'string') p.toolCallArgs += chunk.argumentsDelta
668
+ }
669
+ break
670
+ }
671
+ case 'block-end': {
672
+ // Authoritative settlement: the adapter hands the COMPLETE block. Take
673
+ // it verbatim rather than trusting accumulated deltas.
674
+ const b = chunk.block
675
+ if (b && b.type) {
676
+ if (b.type === 'text' && typeof b.text === 'string') text += b.text
677
+ if (b.type === 'image' || b.mediaType !== undefined) hasImage = true
678
+ blocks.push(b)
679
+ }
680
+ const p = ensure(chunk.index, b && b.type ? b.type : 'text')
681
+ p.assembled = true
682
+ break
683
+ }
684
+ case 'usage':
685
+ // THE STREAM CHARTER IS THE SOURCE OF TRUTH FOR TERMINATION (the
686
+ // protocol's invariant spec tests pin chunk-order violations like
687
+ // "usage after terminal finish", implying a well-formed stream always
688
+ // ends with a `finish` chunk). BUT `collectChunks` ALSO accepts streams
689
+ // that terminate WITHOUT one (its `no-finish` branch), so treat the
690
+ // END-OF-STREAM AS A TERMINAL FACT too: if the provider (typically a
691
+ // proxy that relays the upstream's `finish_reason`/`usage` verbatim)
692
+ // delivers a `usage` chunk and then simply CLOSES — the exact
693
+ // observed live symptom of an all-reasoning summary arriving as
694
+ // `reasoning-delta`s followed by `usage` and then silence — record it
695
+ // as a NORMAL completion rather than a protocol violation. This
696
+ // converts what used to be classified `no-finish` (→ arming the
697
+ // failure cooldown and burning the locked bracket) into a chance for
698
+ // the TEXT-EXTRACTION layer to salvage whatever usable output the
699
+ // stream DID produce. A genuine hung stream (never reaching the end,
700
+ // hence never seeing a `usage` chunk either) still surfaces as
701
+ // `no-finish`.
702
+ usage = chunk.usage
703
+ break
704
+ // `response-metadata`: OPENAI-compatible adapters emit this near the end
705
+ // of a response to carry provider-level facts (request id, completion
706
+ // details, …). It is neither block nor terminal information — count and
707
+ // ignore it explicitly so the default branch never masks a future
708
+ // chunk-kind addition that we silently drop.
709
+ case 'response-metadata':
710
+ break
711
+ case 'finish':
712
+ // The RAW `finish` chunk carries `reason` — a `FinishReason`
713
+ // `{ kind, failure? }` — directly (NOT a nested re-wrap). Store it as-is
714
+ // so the caller can read `finish.kind` / `finish.failure` per the
715
+ // protocol, matching the official `BlockAssembler` (`_finish =
716
+ // chunk.reason`). Previously this branch re-wrapped into a synthetic
717
+ // `{kind, reason, failure}` shape, losing the discriminator and making
718
+ // every terminal finish classify as SUCCESS → spurious "no usable text".
719
+ finish = chunk.reason
720
+ sawExplicitFinish = true
721
+ break
722
+ case 'reasoning-chunks':
723
+ // Legacy/aggregate reasoning variant (not in the core protocol but seen
724
+ // on some routes): fold each element into a reasoning block.
725
+ if (Array.isArray(chunk.chunks)) {
726
+ for (const c of chunk.chunks) {
727
+ if (typeof c === 'string') blocks.push({ type: 'reasoning', text: c })
728
+ else if (c && typeof c.text === 'string') blocks.push({ type: 'reasoning', text: c.text })
729
+ }
730
+ }
731
+ break
732
+ default:
733
+ break
734
+ }
735
+ }
736
+ flushOpenSlots()
737
+ // SYNTHESIZED TERMINATION (see the `usage` case): a stream that ran to the
738
+ // very end — evidenced by an OBSERVED trailing `usage` chunk — yet never
739
+ // delivered an explicit `finish` chunk terminated NORMALLY (a provider or
740
+ // relay chose to close without the marker, typically right after handing
741
+ // over usage). Record it as a clean `stop` so the text-extraction layer
742
+ // gets a chance to salvage real output, instead of the caller classifying
743
+ // the run as a protocol-violating `no-finish` and arming the failure
744
+ // cooldown on top. The explicit `note` marks the distinction for
745
+ // diagnostics without lying about where the terminator came from. A truly
746
+ // HUNG stream (never reaching the end, hence never observing a `usage`
747
+ // chunk either) still falls through as a bona-fide `no-finish`.
748
+ if (finish === undefined && usage !== undefined) {
749
+ finish = { kind: 'stop', note: 'synthesized: stream closed after a usage chunk without an explicit finish chunk' }
750
+ }
751
+ // CHUNK-SHAPE PROBE emission: only when the caller asked to record AND this
752
+ // run yielded zero text blocks — i.e. the exact failure signature ("produced
753
+ // no usable text"). Fully self-contained and guarded: NOTHING in this block
754
+ // may throw out of `collectChunks`, so serialization is try/catch'd and the
755
+ // whole emission is a no-op on any anomaly.
756
+ // NOTE: the bulk CHUNK-SHAPE emission that used to live here was REMOVED —
757
+ // it fired on every no-text run with byte-identical payloads (the upstream
758
+ // error is deterministic) and flooded the dev-server log. The raw chunk
759
+ // shapes are STILL recorded into `probeBuf`; a targeted probe can be
760
+ // re-enabled via `recordOnEmpty` should a genuinely NEW failure shape ever
761
+ // appear. The persistent `CRASH-HARNESS` line emitted on terminal
762
+ // `kind:'error'` finishes (in `summarize`) now carries the discriminating
763
+ // detail instead.
764
+ // `_chunkCount` is exposed (underscore-prefixed, internal) purely so a
765
+ // missing-finish diagnostic can distinguish "consumed N chunks but never a
766
+ // terminal finish" from "consumed ZERO chunks (silent/lazy/no-op stream)";
767
+ // `_explicitFinish` additionally lets the caller tell a SYNTHESIZED
768
+ // terminator apart from a real terminal finish chunk when rendering its
769
+ // diagnostics.
770
+ return { blocks, text, hasImage, finish, usage, _chunkCount: chunkCount, _explicitFinish: sawExplicitFinish }
771
+ }
772
+
773
+ /**
774
+ * Render a terse, human-readable description of whatever object `stream` actually
775
+ * is — used ONLY in error paths to identify who replaced the expected async
776
+ * iterable. Never throws: any inspection failure degrades to a generic tag.
777
+ * @param {*} stream the object passed where an `AsyncIterable` was expected.
778
+ * @returns {string} e.g. `a Promise (constructor Promise)`, `a Generator`, `undefined`, `an array of length 3`.
779
+ */
780
+ function describeStream(stream) {
781
+ try {
782
+ if (stream === undefined) return 'undefined'
783
+ if (stream === null) return 'null'
784
+ const ctor = (stream.constructor && stream.constructor.name) || typeof stream
785
+ if (ctor === 'Promise') return 'a Promise (constructor Promise) — a listener likely wrapped the stream in an async fn'
786
+ if (Array.isArray(stream)) return `an array of length ${stream.length} — a listener likely returned a pre-materialized chunk array`
787
+ const hasAsyncIter = typeof stream[Symbol.asyncIterator] === 'function'
788
+ const hasSyncIter = typeof stream[Symbol.iterator] === 'function'
789
+ const keys = Object.prototype.toString.call(stream)
790
+ return `constructor=${ctor} ${keys} asyncIterable=${hasAsyncIter} syncIterable=${hasSyncIter}`
791
+ } catch {
792
+ return `<undescribable object (${typeof stream})>`
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Filter the assembled content blocks down to TEXT ONLY and refuse anything
798
+ * that is structurally unsafe to land as a checkpoint (images). Reasoning
799
+ * blocks are dropped intentionally: they are collapsible UI regions, never
800
+ * durable checkpoint content.
801
+ * @param {Array} blocks
802
+ * @returns {Array<{type:'text', text:string}>}
803
+ */
804
+ function extractTextOnly(blocks) {
805
+ return (blocks || []).filter(b => b && b.type === 'text' && typeof b.text === 'string')
806
+ }
807
+
808
+ /** Coerce a provider failure fact to a short human-readable message. */
809
+ function failureText(failure) {
810
+ if (failure === undefined || failure === null) return ''
811
+ if (typeof failure === 'string') return failure
812
+ return failure.message || failure.description || ''
813
+ }
814
+
815
+ /** Length of the concatenated text blocks (0 when none). */
816
+ function measuredLength(text) {
817
+ return typeof text === 'string' ? text.length : 0
818
+ }