dsh-btw-plugin 0.0.0-stage → 0.5.2

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/lib/index.js ADDED
@@ -0,0 +1,829 @@
1
+ /**
2
+ * dsh-btw — host half.
3
+ *
4
+ * The plugin answers a one-off side question ("by the way") from the current
5
+ * conversation. It follows ds-bot 0.2.4's `src/host/btw.js`: snapshot the live
6
+ * session with requestHeader() + trimToolBoundary(deriveMessages()) +
7
+ * toolHistory(), make ONE `ctx.llm.stream()` call, and stream the answer back
8
+ * over a plugin-owned SSE route. No Agent is created and no Session event is
9
+ * written, so the answer never enters that conversation's memory.
10
+ *
11
+ * Routes:
12
+ * GET /btw/health — liveness + resolved config (for the client's diagnostics)
13
+ * POST /btw/context — context preview: range → {stats, dropped, cold}
14
+ * POST /btw/ask — JSON in, text/event-stream out; request admission is
15
+ * consulted through ctx.connection.requestRejection.
16
+ *
17
+ * The route is unauthenticated by the web server itself, which is why every
18
+ * request goes through the fence first: on loopback any local process could
19
+ * otherwise spend the user's model quota.
20
+ */
21
+ import { randomUUID } from 'node:crypto'
22
+
23
+ import {
24
+ DEFAULT_CONFIG,
25
+ FOCUS_MAX_CHARS,
26
+ buildBtwMessages,
27
+ buildInjectionBody,
28
+ buildSummaryPrompt,
29
+ cleanSummary,
30
+ fallbackSummary,
31
+ foldEventsToSnapshot,
32
+ mapFailure,
33
+ messageOf,
34
+ messageQuote,
35
+ normaliseDialogMessages,
36
+ normalisePriorTurns,
37
+ normaliseRange,
38
+ normaliseSummaryMeta,
39
+ pickSession,
40
+ previewContext,
41
+ snapshotOf,
42
+ sseFrame,
43
+ } from './logic.js'
44
+
45
+ export const name = 'dsh-btw'
46
+
47
+ export const VERSION = '0.5.2'
48
+
49
+ // All are real host services in DSH 0.2.1-rc.5 (see ds-bot's and
50
+ // dsh-better-sidebar's inject lists). A missing service only delays apply().
51
+ export const inject = ['webServer', 'llm', 'sessions', 'connection']
52
+
53
+ export function apply(ctx, config = {}) {
54
+ try {
55
+ const cfg = { ...DEFAULT_CONFIG, ...(config && typeof config === 'object' ? config : {}) }
56
+ if (cfg.enabled === false) return
57
+
58
+ const register = (path, handler, label) => {
59
+ try {
60
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path, handler }), label)
61
+ } catch (error) {
62
+ // A route conflict must fail only this plugin row, never host startup.
63
+ try { ctx.logger?.warn?.('dsh-btw: cannot register %s', path, error) } catch { /* no logger */ }
64
+ }
65
+ }
66
+
67
+ register('/btw/health', (req, res) => handleHealth(ctx, cfg, req, res), 'dsh-btw: /btw/health')
68
+ register('/btw/models', (req, res) => handleModels(ctx, cfg, req, res), 'dsh-btw: /btw/models')
69
+ register('/btw/context', (req, res) => handleContext(ctx, cfg, req, res), 'dsh-btw: /btw/context')
70
+ register('/btw/ask', (req, res) => handleAsk(ctx, cfg, req, res), 'dsh-btw: /btw/ask')
71
+ register('/btw/summarize', (req, res) => handleSummarize(ctx, cfg, req, res), 'dsh-btw: /btw/summarize')
72
+ register('/btw/commit', (req, res) => handleCommit(ctx, cfg, req, res), 'dsh-btw: /btw/commit')
73
+ } catch (error) {
74
+ try { ctx.logger?.warn?.('dsh-btw: activation failed', error) } catch { /* no logger */ }
75
+ }
76
+ }
77
+
78
+ /* ------------------------------------------------------------------ routes */
79
+
80
+ function handleHealth(ctx, cfg, req, res) {
81
+ if (!fence(ctx, req, res)) return
82
+ if (req.method !== 'GET') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'GET only'))
83
+ writeJson(res, 200, {
84
+ ok: true,
85
+ plugin: name,
86
+ version: VERSION,
87
+ coldRead: coldReadKind(ctx),
88
+ defaultRoute: defaultRoute(ctx),
89
+ config: {
90
+ maxParentMessages: cfg.maxParentMessages,
91
+ maxRangeMessages: cfg.maxRangeMessages,
92
+ defaultRecentMessages: cfg.defaultRecentMessages,
93
+ maxPriorTurns: cfg.maxPriorTurns,
94
+ maxSummaryChars: cfg.maxSummaryChars,
95
+ toolParity: cfg.toolParity === true,
96
+ },
97
+ })
98
+ }
99
+
100
+ /* --------------------------------------------------------------- model list */
101
+
102
+ const MODEL_CACHE_TTL_MS = 60_000
103
+ const MODEL_LIST_TIMEOUT_MS = 2_500
104
+ let modelCache = { at: 0, providers: [] }
105
+
106
+ /** GET /btw/models — provider/model catalog for the btw picker. */
107
+ async function handleModels(ctx, cfg, req, res) {
108
+ if (!fence(ctx, req, res)) return
109
+ if (req.method !== 'GET') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'GET only'))
110
+
111
+ let sessionId = ''
112
+ try {
113
+ const url = new URL(req.url || '/', 'http://localhost')
114
+ sessionId = pickString(url.searchParams.get('sessionId')) ?? ''
115
+ } catch { /* keep the global catalog */ }
116
+
117
+ const providers = await modelCatalog(ctx)
118
+ let current = null
119
+ if (sessionId) {
120
+ const session = pickSession(ctx, sessionId)
121
+ if (session) {
122
+ try {
123
+ const config = headerConfigOf({ header: session.requestHeader() })
124
+ if (pickString(config.provider) && pickString(config.model)) {
125
+ current = { provider: config.provider, model: config.model }
126
+ }
127
+ } catch { /* the session has no resolved route yet */ }
128
+ }
129
+ }
130
+ const fallback = defaultRoute(ctx)
131
+ // Catalog-driven adapters may report no models (the base adapter returns an
132
+ // empty list). Keep the two routes the harness itself knows about selectable.
133
+ ensureRoute(providers, current)
134
+ ensureRoute(providers, fallback)
135
+ writeJson(res, 200, { ok: true, providers, default: fallback, current })
136
+ }
137
+
138
+ /** Add a provider/model pair to the catalog when the adapter did not list it. */
139
+ function ensureRoute(providers, route) {
140
+ if (!route || !pickString(route.provider) || !pickString(route.model)) return
141
+ let entry = providers.find((provider) => provider.id === route.provider)
142
+ if (!entry) {
143
+ entry = { id: route.provider, name: route.provider, models: [] }
144
+ providers.push(entry)
145
+ }
146
+ if (!entry.models.some((model) => model.id === route.model)) {
147
+ entry.models.push({ id: route.model, name: route.model })
148
+ }
149
+ }
150
+
151
+ async function modelCatalog(ctx) {
152
+ const now = Date.now()
153
+ if (now - modelCache.at < MODEL_CACHE_TTL_MS && modelCache.providers.length > 0) {
154
+ return modelCache.providers
155
+ }
156
+ const llm = serviceOf(ctx, 'llm') || ctx.llm
157
+ let listed = []
158
+ try {
159
+ listed = typeof llm?.listProviders === 'function' ? llm.listProviders() : []
160
+ } catch { /* registry unavailable: return an empty catalog */ }
161
+
162
+ const providers = await Promise.all((Array.isArray(listed) ? listed : []).map(async (provider) => {
163
+ if (!provider || !pickString(provider.id)) return null
164
+ const entry = { id: provider.id, name: provider.name || provider.id, models: [] }
165
+ if (typeof llm?.listModels !== 'function') return entry
166
+ try {
167
+ // Some adapters probe the network here; never let one slow provider hang
168
+ // the whole picker (the dialog would otherwise show "读取模型…" forever).
169
+ const models = await withTimeout(llm.listModels(provider.id), MODEL_LIST_TIMEOUT_MS)
170
+ entry.models = (Array.isArray(models) ? models : [])
171
+ .filter((model) => model && pickString(model.id))
172
+ .map((model) => ({
173
+ id: model.id,
174
+ name: model.name || model.id,
175
+ ...(pickString(model.description) ? { description: model.description } : {}),
176
+ }))
177
+ } catch (error) {
178
+ entry.error = messageOf(error)
179
+ }
180
+ return entry
181
+ }))
182
+ const catalog = providers.filter(Boolean)
183
+ // Do not cache an empty catalog: the first request may race provider mounts.
184
+ if (catalog.length > 0) modelCache = { at: now, providers: catalog }
185
+ return catalog
186
+ }
187
+
188
+ function withTimeout(promise, ms) {
189
+ return new Promise((resolve, reject) => {
190
+ const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
191
+ Promise.resolve(promise).then(
192
+ (value) => { clearTimeout(timer); resolve(value) },
193
+ (error) => { clearTimeout(timer); reject(error) },
194
+ )
195
+ })
196
+ }
197
+
198
+ /** The harness-wide default model selection, when that service is present. */
199
+ function defaultRoute(ctx) {
200
+ try {
201
+ const selection = serviceOf(ctx, 'agentDefaultModel')?.currentSelection?.()
202
+ if (selection && pickString(selection.provider) && pickString(selection.model)) {
203
+ return { provider: selection.provider, model: selection.model }
204
+ }
205
+ } catch { /* optional service */ }
206
+ return null
207
+ }
208
+
209
+ /**
210
+ * Turn a client focus hint into an inlined quote. A message focus is resolved
211
+ * against the full (pre-range) snapshot, so the quote still works when the
212
+ * selected range would drop that message. Unknown ids are ignored instead of
213
+ * failing the whole ask.
214
+ */
215
+ function normaliseFocus(raw, snapshotMessages) {
216
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
217
+ if (raw.kind === 'selection') {
218
+ const text = pickString(raw.text)
219
+ if (!text || !text.trim()) return null
220
+ return { kind: 'selection', text: text.trim().slice(0, FOCUS_MAX_CHARS), role: null }
221
+ }
222
+ if (raw.kind === 'message') {
223
+ const id = pickString(raw.messageId)
224
+ if (!id || !Array.isArray(snapshotMessages)) return null
225
+ const message = snapshotMessages.find((candidate) => candidate && candidate.id === id)
226
+ if (!message) return null
227
+ const text = messageQuote(message, FOCUS_MAX_CHARS)
228
+ if (!text) return null
229
+ return { kind: 'message', text, role: pickString(message.role) ?? null }
230
+ }
231
+ return null
232
+ }
233
+
234
+ /** POST /btw/context — how much history would be sent for a given range. */
235
+ async function handleContext(ctx, cfg, req, res) {
236
+ if (!fence(ctx, req, res)) return
237
+ if (req.method !== 'POST') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'POST only'))
238
+
239
+ let payload
240
+ try {
241
+ payload = await readJsonBody(req, cfg.maxBodyBytes)
242
+ } catch (error) {
243
+ return writeJson(res, Number.isInteger(error?.statusCode) ? error.statusCode : 400,
244
+ failure('BAD_REQUEST', messageOf(error)))
245
+ }
246
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
247
+ return writeJson(res, 400, failure('BAD_REQUEST', 'JSON object body expected'))
248
+ }
249
+
250
+ const sessionId = pickString(payload.sessionId) ? payload.sessionId.trim() : ''
251
+ if (!sessionId) return writeJson(res, 400, failure('BAD_REQUEST', 'sessionId is required'))
252
+ const range = normaliseRange(payload.range, cfg)
253
+
254
+ let snapshot
255
+ try {
256
+ snapshot = await resolveSnapshot(ctx, sessionId, cfg)
257
+ } catch (error) {
258
+ const coldUnavailable = error?.code === 'SESSION_NOT_LIVE' || error?.code === 'SESSION_NOT_FOUND'
259
+ return writeJson(res, coldUnavailable ? 404 : 500,
260
+ failure(coldUnavailable ? 'SESSION_NOT_LIVE' : (error?.code || 'SNAPSHOT_FAILED'), messageOf(error)))
261
+ }
262
+
263
+ let preview
264
+ try {
265
+ preview = previewContext(snapshot.messages, range, cfg)
266
+ } catch (error) {
267
+ return writeJson(res, 500, failure('SNAPSHOT_FAILED', messageOf(error)))
268
+ }
269
+
270
+ const headerConfig = headerConfigOf(snapshot)
271
+ writeJson(res, 200, {
272
+ ok: true,
273
+ sessionId,
274
+ cold: snapshot.cold === true,
275
+ range: preview.range,
276
+ stats: preview.stats,
277
+ dropped: preview.dropped,
278
+ provider: pickString(headerConfig.provider) ?? null,
279
+ model: pickString(headerConfig.model) ?? null,
280
+ })
281
+ }
282
+
283
+ async function handleAsk(ctx, cfg, req, res) {
284
+ if (!fence(ctx, req, res)) return
285
+ if (req.method !== 'POST') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'POST only'))
286
+
287
+ let payload
288
+ try {
289
+ payload = await readJsonBody(req, cfg.maxBodyBytes)
290
+ } catch (error) {
291
+ return writeJson(res, Number.isInteger(error?.statusCode) ? error.statusCode : 400,
292
+ failure('BAD_REQUEST', messageOf(error)))
293
+ }
294
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
295
+ return writeJson(res, 400, failure('BAD_REQUEST', 'JSON object body expected'))
296
+ }
297
+
298
+ const sessionId = pickString(payload.sessionId) ? payload.sessionId.trim() : ''
299
+ const question = pickString(payload.question) ? payload.question.trim() : ''
300
+ if (!sessionId) return writeJson(res, 400, failure('BAD_REQUEST', 'sessionId is required'))
301
+ if (!question) return writeJson(res, 400, failure('BAD_REQUEST', 'question is required'))
302
+ if (question.length > cfg.maxQuestionChars) {
303
+ return writeJson(res, 400, failure('BAD_REQUEST', `question is longer than ${cfg.maxQuestionChars} characters`))
304
+ }
305
+ const range = normaliseRange(payload.range, cfg)
306
+
307
+ let snapshot
308
+ try {
309
+ snapshot = await resolveSnapshot(ctx, sessionId, cfg)
310
+ } catch (error) {
311
+ const coldUnavailable = error?.code === 'SESSION_NOT_LIVE' || error?.code === 'SESSION_NOT_FOUND'
312
+ return writeJson(res, coldUnavailable ? 404 : 500,
313
+ failure(coldUnavailable ? 'SESSION_NOT_LIVE' : (error?.code || 'SNAPSHOT_FAILED'), messageOf(error)))
314
+ }
315
+
316
+ let preview
317
+ try {
318
+ preview = previewContext(snapshot.messages, range, cfg)
319
+ } catch (error) {
320
+ return writeJson(res, 500, failure('SNAPSHOT_FAILED', messageOf(error)))
321
+ }
322
+
323
+ const focus = normaliseFocus(payload.focus, snapshot.messages)
324
+
325
+ const headerConfig = headerConfigOf(snapshot)
326
+ const provider = pickString(payload.provider) ?? pickString(headerConfig.provider)
327
+ const model = pickString(payload.model) ?? pickString(headerConfig.model)
328
+ if (!provider || !model) {
329
+ return writeJson(res, 500, failure('NO_ROUTE', 'the conversation has no resolved provider/model yet'))
330
+ }
331
+ // An explicit picker choice is validated before the stream so a typo fails
332
+ // with a readable 400 instead of a terminal NO_ADAPTER finish frame.
333
+ if (pickString(payload.provider) || pickString(payload.model)) {
334
+ try {
335
+ await ctx.llm.resolveModelInfo(provider, model)
336
+ } catch (error) {
337
+ return writeJson(res, 400, failure('BAD_MODEL', `无法使用 ${provider}/${model}:${messageOf(error)}`))
338
+ }
339
+ }
340
+
341
+ const priorTurns = normalisePriorTurns(payload.priorTurns, cfg.maxPriorTurns)
342
+ let messages
343
+ try {
344
+ messages = buildBtwMessages(preview.messages, question, priorTurns, {
345
+ fallbackRoute: { provider, model },
346
+ focus,
347
+ })
348
+ } catch (error) {
349
+ return writeJson(res, 400, failure('BAD_REQUEST', messageOf(error)))
350
+ }
351
+
352
+ const options = { ...headerConfig, provider, model, messages, sessionId }
353
+ if (pickString(payload.reasoningEffort)) options.reasoningEffort = payload.reasoningEffort
354
+ if (typeof payload.temperature === 'number' && Number.isFinite(payload.temperature)
355
+ && payload.temperature >= 0 && payload.temperature <= 2) {
356
+ options.temperature = payload.temperature
357
+ }
358
+ if (Number.isInteger(payload.maxTokens) && payload.maxTokens > 0) {
359
+ options.maxTokens = Math.min(payload.maxTokens, 32_768)
360
+ }
361
+ if (cfg.toolParity === true) {
362
+ // Parity mode (ds-bot callAside): same request shape as the conversation.
363
+ // The prompt forbids tools, and a tool-calls finish is reported as an error.
364
+ if (Array.isArray(snapshot.header?.tools)) options.tools = [...snapshot.header.tools]
365
+ if (snapshot.toolHistory !== undefined) options.toolHistory = snapshot.toolHistory
366
+ }
367
+
368
+ const abort = new AbortController()
369
+ const onClose = () => abort.abort()
370
+ res.once('close', onClose)
371
+
372
+ // Safety net: a stalled provider stream must not keep the socket open forever.
373
+ const timeoutMs = Number.isFinite(cfg.requestTimeoutMs) && cfg.requestTimeoutMs > 0
374
+ ? cfg.requestTimeoutMs
375
+ : 5 * 60 * 1000
376
+ const watchdog = setTimeout(() => abort.abort(), timeoutMs)
377
+ watchdog.unref?.()
378
+
379
+ writeSseHead(res)
380
+ // The first frame mirrors /btw/context so the badge stays truthful even if
381
+ // the user changed the range right before pressing send.
382
+ writeSse(res, 'meta', {
383
+ range: preview.range,
384
+ stats: preview.stats,
385
+ dropped: preview.dropped,
386
+ cold: snapshot.cold === true,
387
+ provider,
388
+ model,
389
+ })
390
+
391
+ let text = ''
392
+ let usage
393
+ let finish
394
+ try {
395
+ for await (const chunk of ctx.llm.stream({ ...options, signal: abort.signal })) {
396
+ if (res.writableEnded) break
397
+ switch (chunk?.type) {
398
+ case 'text-delta':
399
+ text += chunk.text
400
+ writeSse(res, 'delta', { kind: 'text', text: chunk.text })
401
+ break
402
+ case 'reasoning-delta':
403
+ writeSse(res, 'delta', { kind: 'reasoning', text: chunk.text })
404
+ break
405
+ case 'usage':
406
+ usage = chunk.usage
407
+ writeSse(res, 'usage', chunk.usage)
408
+ break
409
+ case 'finish':
410
+ finish = chunk.reason
411
+ break
412
+ default:
413
+ break
414
+ }
415
+ if (abort.signal.aborted) break
416
+ }
417
+ } catch (error) {
418
+ clearTimeout(watchdog)
419
+ res.off?.('close', onClose)
420
+ if (!res.writableEnded) {
421
+ endSse(res, 'error', abort.signal.aborted
422
+ ? { code: 'ABORTED', message: '已中止' }
423
+ : { code: 'STREAM_ERROR', message: messageOf(error) })
424
+ }
425
+ return
426
+ }
427
+ clearTimeout(watchdog)
428
+ res.off?.('close', onClose)
429
+ if (res.writableEnded) return
430
+ if (abort.signal.aborted) return endSse(res, 'error', { code: 'ABORTED', message: '已中止' })
431
+
432
+ const summary = {
433
+ range: preview.range,
434
+ stats: preview.stats,
435
+ dropped: preview.dropped,
436
+ cold: snapshot.cold === true,
437
+ }
438
+ const reason = finish && typeof finish === 'object' ? finish : { kind: 'stop' }
439
+ switch (reason.kind) {
440
+ case 'stop':
441
+ return endSse(res, 'done', { text, usage, provider, model, truncated: false, ...summary })
442
+ case 'max-tokens':
443
+ return endSse(res, 'done', { text, usage, provider, model, truncated: true, ...summary })
444
+ case 'tool-calls':
445
+ return endSse(res, 'error', {
446
+ code: 'TOOL_CALLS',
447
+ message: '模型尝试调用工具,btw 只做纯问答;请重试或换个问法',
448
+ })
449
+ case 'aborted':
450
+ return endSse(res, 'error', { code: 'ABORTED', message: '已中止' })
451
+ case 'error':
452
+ return endSse(res, 'error', mapFailure(reason.failure))
453
+ default:
454
+ return endSse(res, 'done', { text, usage, provider, model, truncated: false, ...summary })
455
+ }
456
+ }
457
+
458
+ /* ------------------------------------------------------ transfer to parent */
459
+
460
+ /**
461
+ * Compress the dialog transcript into a short record. The model call is a
462
+ * normal, non-streaming llm.stream(); any failure (or an empty answer) falls
463
+ * back to a deterministic digest so the user can still review and send it.
464
+ */
465
+ async function handleSummarize(ctx, cfg, req, res) {
466
+ if (!fence(ctx, req, res)) return
467
+ if (req.method !== 'POST') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'POST only'))
468
+
469
+ let payload
470
+ try {
471
+ payload = await readJsonBody(req, cfg.maxBodyBytes)
472
+ } catch (error) {
473
+ return writeJson(res, Number.isInteger(error?.statusCode) ? error.statusCode : 400,
474
+ failure('BAD_REQUEST', messageOf(error)))
475
+ }
476
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
477
+ return writeJson(res, 400, failure('BAD_REQUEST', 'JSON object body expected'))
478
+ }
479
+
480
+ const sessionId = pickString(payload.sessionId) ? payload.sessionId.trim() : ''
481
+ if (!sessionId) return writeJson(res, 400, failure('BAD_REQUEST', 'sessionId is required'))
482
+ const messages = normaliseDialogMessages(payload.messages)
483
+ if (messages.length === 0) {
484
+ return writeJson(res, 400, failure('BAD_REQUEST', 'messages must contain at least one user or assistant turn'))
485
+ }
486
+ const focus = payload.focus && typeof payload.focus === 'object' && pickString(payload.focus.text)
487
+ ? { text: payload.focus.text.trim().slice(0, FOCUS_MAX_CHARS) }
488
+ : null
489
+
490
+ let prompt
491
+ try {
492
+ prompt = buildSummaryPrompt(messages, { focus })
493
+ } catch (error) {
494
+ return writeJson(res, 400, failure('BAD_REQUEST', messageOf(error)))
495
+ }
496
+
497
+ let route
498
+ try {
499
+ route = await resolveSummaryRoute(ctx, payload)
500
+ } catch (error) {
501
+ const status = error?.code === 'BAD_MODEL' ? 400 : 500
502
+ return writeJson(res, status, failure(error?.code || 'NO_ROUTE', messageOf(error)))
503
+ }
504
+
505
+ const abort = new AbortController()
506
+ const timeoutMs = Number.isFinite(cfg.summaryTimeoutMs) && cfg.summaryTimeoutMs > 0
507
+ ? cfg.summaryTimeoutMs
508
+ : 60_000
509
+ const watchdog = setTimeout(() => abort.abort(), timeoutMs)
510
+ watchdog.unref?.()
511
+
512
+ let text = ''
513
+ let usage
514
+ let warning = null
515
+ try {
516
+ for await (const chunk of ctx.llm.stream({
517
+ provider: route.provider,
518
+ model: route.model,
519
+ sessionId,
520
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
521
+ temperature: 0.2,
522
+ maxTokens: 1200,
523
+ signal: abort.signal,
524
+ })) {
525
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
526
+ else if (chunk?.type === 'usage') usage = chunk.usage
527
+ }
528
+ } catch (error) {
529
+ warning = abort.signal.aborted ? '摘要生成超时,已改用本地摘要' : `摘要生成失败:${messageOf(error)}`
530
+ } finally {
531
+ clearTimeout(watchdog)
532
+ }
533
+
534
+ let summary = cleanSummary(text, 4000)
535
+ let fallback = false
536
+ if (summary === '') {
537
+ summary = fallbackSummary(messages)
538
+ fallback = true
539
+ if (!warning) warning = '模型没有返回摘要,已改用本地摘要'
540
+ }
541
+ writeJson(res, 200, {
542
+ ok: true,
543
+ summary,
544
+ fallback,
545
+ warning: warning || undefined,
546
+ provider: route.provider,
547
+ model: route.model,
548
+ usage,
549
+ })
550
+ }
551
+
552
+ async function resolveSummaryRoute(ctx, payload) {
553
+ const provider = pickString(payload.provider)
554
+ const model = pickString(payload.model)
555
+ if (provider || model) {
556
+ if (!provider || !model) throw coded(new Error('provider 和 model 需要同时提供'), 'BAD_MODEL')
557
+ try {
558
+ await ctx.llm.resolveModelInfo(provider, model)
559
+ } catch (error) {
560
+ throw coded(new Error(`无法使用 ${provider}/${model}:${messageOf(error)}`), 'BAD_MODEL')
561
+ }
562
+ return { provider, model }
563
+ }
564
+ const fallback = defaultRoute(ctx)
565
+ if (fallback) return fallback
566
+ throw coded(new Error('没有可用的默认模型,请先在 btw 里选一个模型'), 'NO_ROUTE')
567
+ }
568
+
569
+ /**
570
+ * Append the reviewed summary to the parent as a user/message with a custom
571
+ * `source.kind = 'btw'`. DSH renders non-`user` sources as a context/notice
572
+ * card and the next request's deriveMessages() picks the event up, so the
573
+ * parent agent sees it without waking a turn now.
574
+ */
575
+ async function handleCommit(ctx, cfg, req, res) {
576
+ if (!fence(ctx, req, res)) return
577
+ if (req.method !== 'POST') return writeJson(res, 405, failure('METHOD_NOT_ALLOWED', 'POST only'))
578
+
579
+ let payload
580
+ try {
581
+ payload = await readJsonBody(req, cfg.maxBodyBytes)
582
+ } catch (error) {
583
+ return writeJson(res, Number.isInteger(error?.statusCode) ? error.statusCode : 400,
584
+ failure('BAD_REQUEST', messageOf(error)))
585
+ }
586
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
587
+ return writeJson(res, 400, failure('BAD_REQUEST', 'JSON object body expected'))
588
+ }
589
+
590
+ const sessionId = pickString(payload.sessionId) ? payload.sessionId.trim() : ''
591
+ if (!sessionId) return writeJson(res, 400, failure('BAD_REQUEST', 'sessionId is required'))
592
+ const rawSummary = typeof payload.summary === 'string' ? payload.summary.trim() : ''
593
+ if (!rawSummary) return writeJson(res, 400, failure('BAD_REQUEST', 'summary is required'))
594
+ const limit = Number.isInteger(cfg.maxSummaryChars) && cfg.maxSummaryChars > 0 ? cfg.maxSummaryChars : 8000
595
+ if (rawSummary.length > limit) {
596
+ return writeJson(res, 400, failure('BAD_REQUEST', `summary is longer than ${limit} characters`))
597
+ }
598
+
599
+ const session = pickSession(ctx, sessionId)
600
+ if (!session || typeof session.append !== 'function') {
601
+ return writeJson(res, 409, failure('SESSION_NOT_LIVE', '主会话未打开,无法写入;请回到该会话后重试'))
602
+ }
603
+ let running = false
604
+ try {
605
+ running = serviceOf(ctx, 'agents')?.get?.(sessionId)?.status === 'running'
606
+ } catch { /* the agent registry is optional for this check */ }
607
+ if (running) {
608
+ return writeJson(res, 409, failure('SESSION_BUSY', '主会话正在生成回答,请等它结束再送入'))
609
+ }
610
+
611
+ const meta = normaliseSummaryMeta({ turns: payload.turns, at: payload.at, focus: payload.focus === true })
612
+ let body
613
+ try {
614
+ body = buildInjectionBody(rawSummary, meta)
615
+ } catch (error) {
616
+ return writeJson(res, 400, failure('BAD_REQUEST', messageOf(error)))
617
+ }
618
+
619
+ const message = {
620
+ id: randomUUID(),
621
+ role: 'user',
622
+ content: [{ type: 'text', text: body }],
623
+ source: { kind: 'btw', form: 'notice', at: meta.at, turns: meta.turns, focus: meta.focus },
624
+ }
625
+ let event
626
+ try {
627
+ event = session.append('user/message', message, { surfaceOp: 'append' })
628
+ } catch (error) {
629
+ return writeJson(res, 500, failure('APPEND_FAILED', `写入主会话失败:${messageOf(error)}`))
630
+ }
631
+ writeJson(res, 200, {
632
+ ok: true,
633
+ seq: Number.isFinite(event?.seq) ? event.seq : null,
634
+ turns: meta.turns,
635
+ })
636
+ }
637
+
638
+ /* --------------------------------------------------------------- snapshots */
639
+
640
+ /**
641
+ * Resolve the model-visible history for `sessionId`: live sessions first, then
642
+ * the cold path (persisted log folded into messages). Throws
643
+ * `code: 'SESSION_NOT_FOUND'` when neither source knows the id.
644
+ */
645
+ async function resolveSnapshot(ctx, sessionId, cfg) {
646
+ const session = pickSession(ctx, sessionId)
647
+ if (session) {
648
+ try {
649
+ return { ...snapshotOf(session), cold: false }
650
+ } catch (error) {
651
+ throw coded(error, 'SNAPSHOT_FAILED')
652
+ }
653
+ }
654
+ return coldSnapshot(ctx, sessionId, cfg)
655
+ }
656
+
657
+ /** What cold-read capability this composition has (for /btw/health). */
658
+ function coldReadKind(ctx) {
659
+ if (typeof serviceOf(ctx, 'sessionQuery')?.readSession === 'function') return 'sessionQuery'
660
+ if (typeof serviceOf(ctx, 'sessionPersistence')?.open === 'function') return 'sessionPersistence'
661
+ return 'none'
662
+ }
663
+
664
+ async function coldSnapshot(ctx, sessionId, cfg) {
665
+ const query = serviceOf(ctx, 'sessionQuery')
666
+ const persistence = serviceOf(ctx, 'sessionPersistence')
667
+ let events
668
+ try {
669
+ if (query && typeof query.readSession === 'function') {
670
+ const loaded = await query.readSession(sessionId)
671
+ events = loaded && Array.isArray(loaded.events) ? loaded.events : undefined
672
+ } else if (persistence && typeof persistence.open === 'function') {
673
+ const handle = await persistence.open(sessionId, 'read')
674
+ try {
675
+ const read = await handle.read()
676
+ events = read && Array.isArray(read.events) ? read.events : undefined
677
+ } finally {
678
+ try { await handle.close?.() } catch { /* closing is best effort */ }
679
+ }
680
+ } else {
681
+ throw coded(new Error('this build exposes neither ctx.sessionQuery nor ctx.sessionPersistence'), 'SESSION_NOT_LIVE')
682
+ }
683
+ } catch (error) {
684
+ if (isNotFound(error)) throw coded(new Error('session not found'), 'SESSION_NOT_FOUND')
685
+ throw coded(error, error?.code && String(error.code).startsWith('SESSION_QUERY_') ? 'SESSION_NOT_LIVE' : 'COLD_READ_FAILED')
686
+ }
687
+ if (!Array.isArray(events) || events.length === 0) throw coded(new Error('the persisted session has no events'), 'SESSION_NOT_FOUND')
688
+
689
+ try {
690
+ return foldEventsToSnapshot(events, sessionId)
691
+ } catch (error) {
692
+ throw coded(error, 'COLD_READ_FAILED')
693
+ }
694
+ }
695
+
696
+ function headerConfigOf(snapshot) {
697
+ return snapshot?.header?.config && typeof snapshot.header.config === 'object'
698
+ ? snapshot.header.config
699
+ : {}
700
+ }
701
+
702
+ function coded(error, code) {
703
+ const wrapped = error instanceof Error ? error : new Error(String(error))
704
+ if (!wrapped.code) wrapped.code = code
705
+ return wrapped
706
+ }
707
+
708
+ function isNotFound(error) {
709
+ if (!error) return false
710
+ const code = typeof error.code === 'string' ? error.code : ''
711
+ if (code.includes('NOT_FOUND')) return true
712
+ if (error.name === 'SessionPersistenceNotFoundError') return true
713
+ return /not found|no such session|absent/i.test(String(error.message || ''))
714
+ }
715
+
716
+ /* ------------------------------------------------------------------ helpers */
717
+
718
+ function pickString(value) {
719
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined
720
+ }
721
+
722
+ function failure(code, message) {
723
+ return { ok: false, error: { code, message } }
724
+ }
725
+
726
+ /** Cordis service lookup that also works for optional (non-injected) services. */
727
+ function serviceOf(ctx, name) {
728
+ if (!ctx) return null
729
+ if (typeof ctx.get === 'function') {
730
+ try {
731
+ const service = ctx.get(name)
732
+ if (service) return service
733
+ } catch { /* not mounted in this composition */ }
734
+ }
735
+ try {
736
+ return ctx[name] || null
737
+ } catch {
738
+ return null
739
+ }
740
+ }
741
+
742
+ /** Consult the host admission policy (DSH 0.2.0-rc.2+); 401/403 when rejected. */
743
+ function fence(ctx, req, res) {
744
+ let rejection
745
+ try {
746
+ rejection = typeof ctx.connection?.requestRejection === 'function'
747
+ ? ctx.connection.requestRejection(req)
748
+ : undefined
749
+ } catch {
750
+ rejection = 403
751
+ }
752
+ if (rejection === undefined || rejection === null || rejection === false) return true
753
+ writeJson(res, rejection === 401 ? 401 : 403, failure('FORBIDDEN', 'rejected by the host admission policy'))
754
+ return false
755
+ }
756
+
757
+ function writeJson(res, status, body) {
758
+ if (res.writableEnded) return
759
+ const text = JSON.stringify(body)
760
+ res.writeHead(status, {
761
+ 'content-type': 'application/json; charset=utf-8',
762
+ 'content-length': Buffer.byteLength(text),
763
+ 'cache-control': 'no-store',
764
+ })
765
+ res.end(text)
766
+ }
767
+
768
+ function writeSseHead(res) {
769
+ res.writeHead(200, {
770
+ 'content-type': 'text/event-stream; charset=utf-8',
771
+ 'cache-control': 'no-cache, no-transform',
772
+ connection: 'keep-alive',
773
+ 'x-accel-buffering': 'no',
774
+ })
775
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
776
+ }
777
+
778
+ function writeSse(res, event, data) {
779
+ if (res.writableEnded) return
780
+ try {
781
+ res.write(sseFrame(event, data))
782
+ } catch {
783
+ /* the peer went away; the close listener aborts the upstream request */
784
+ }
785
+ }
786
+
787
+ function endSse(res, event, data) {
788
+ writeSse(res, event, data)
789
+ try { res.end() } catch { /* already closed */ }
790
+ }
791
+
792
+ /** Read a JSON request body with a hard byte cap (413 on overflow). */
793
+ function readJsonBody(req, limit) {
794
+ return new Promise((resolve, reject) => {
795
+ const chunks = []
796
+ let size = 0
797
+ let failed = false
798
+ req.on('data', (chunk) => {
799
+ if (failed) return
800
+ size += chunk.length
801
+ if (size > limit) {
802
+ failed = true
803
+ const error = new Error(`request body exceeds ${limit} bytes`)
804
+ error.statusCode = 413
805
+ reject(error)
806
+ req.resume()
807
+ return
808
+ }
809
+ chunks.push(chunk)
810
+ })
811
+ req.on('end', () => {
812
+ if (failed) return
813
+ try {
814
+ const raw = Buffer.concat(chunks).toString('utf8')
815
+ resolve(raw.trim() === '' ? {} : JSON.parse(raw))
816
+ } catch {
817
+ const error = new Error('invalid JSON body')
818
+ error.statusCode = 400
819
+ reject(error)
820
+ }
821
+ })
822
+ req.on('error', (error) => {
823
+ if (!failed) {
824
+ failed = true
825
+ reject(error)
826
+ }
827
+ })
828
+ })
829
+ }