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/logic.js ADDED
@@ -0,0 +1,628 @@
1
+ /**
2
+ * dsh-btw — pure host-side helpers.
3
+ *
4
+ * Everything here is deliberately free of DSH imports so it can be unit-tested
5
+ * with plain `node --test`. The plugin's host half (lib/index.js) only wires
6
+ * these helpers to Cordis services.
7
+ *
8
+ * The snapshot/model-call shape follows ds-bot 0.2.4 (`src/host/btw.js`):
9
+ * requestHeader() + trimToolBoundary(deriveMessages()) + toolHistory(), then a
10
+ * single ctx.llm.stream() call. Nothing is appended to the parent session.
11
+ */
12
+
13
+ export const DEFAULT_CONFIG = {
14
+ enabled: true,
15
+ /** Hard cap on the parent-snapshot messages sent to the model (oldest first are dropped). */
16
+ maxParentMessages: 200,
17
+ /** Ceiling for the explicit "all" / "no tool output" ranges (a model window is still model-specific). */
18
+ maxRangeMessages: 1000,
19
+ /** Default for the "最近 N 条" range. */
20
+ defaultRecentMessages: 40,
21
+ /** How many earlier dialog turns the client may send back with a follow-up question. */
22
+ maxPriorTurns: 20,
23
+ maxQuestionChars: 8000,
24
+ maxBodyBytes: 1024 * 1024,
25
+ /** Abort an ask that runs longer than this (ms); guards against a hung provider. */
26
+ requestTimeoutMs: 5 * 60 * 1000,
27
+ /** Abort a summary call that runs longer than this (ms). */
28
+ summaryTimeoutMs: 60 * 1000,
29
+ /** Hard cap for the (possibly user-edited) summary written into the parent. */
30
+ maxSummaryChars: 8000,
31
+ /** true → mirror ds-bot's parity mode: pass header.tools + toolHistory. */
32
+ toolParity: false,
33
+ }
34
+
35
+ /**
36
+ * Drop a trailing assistant tool-call whose tool/result has not been logged yet.
37
+ * Copied from ds-bot/src/host/btw.js:92-104; a frozen DSH history must never be
38
+ * mutated, so a shortened array is returned when trimming happens.
39
+ *
40
+ * @param {ReadonlyArray<any>} messages
41
+ * @returns {ReadonlyArray<any>}
42
+ */
43
+ export function trimToolBoundary(messages) {
44
+ if (!Array.isArray(messages) || messages.length === 0) return messages
45
+ let index = messages.length
46
+ while (index > 0 && messages[index - 1]?.role === 'tool') index -= 1
47
+ if (index === 0 || index === messages.length) return messages
48
+ const previous = messages[index - 1]
49
+ const calls = (Array.isArray(previous?.content) ? previous.content : [])
50
+ .filter(block => block && block.type === 'tool-call')
51
+ .map(block => block.id)
52
+ if (calls.length === 0) return messages
53
+ const results = new Set(messages.slice(index).map(message => message?.toolCallId))
54
+ return calls.every(id => results.has(id)) ? messages : messages.slice(0, index - 1)
55
+ }
56
+
57
+ /**
58
+ * Snapshot the model-visible context of a live session.
59
+ * requestHeader()/toolHistory() are Session methods found in DSH 0.2.1-rc.5
60
+ * (dsh-session/lib/index.js:1612 and :1643) but not documented in its README,
61
+ * hence the feature detection. When they are missing the caller still gets the
62
+ * derived messages, so a plain (route-less) request is possible.
63
+ *
64
+ * @param {{ id?: string, requestHeader?: () => any, deriveMessages: () => any[], toolHistory?: () => any }} session
65
+ */
66
+ export function snapshotOf(session) {
67
+ if (!session || typeof session.deriveMessages !== 'function') {
68
+ throw new Error('session does not expose deriveMessages()')
69
+ }
70
+ const header = typeof session.requestHeader === 'function' ? session.requestHeader() : undefined
71
+ const messages = trimToolBoundary(session.deriveMessages())
72
+ const toolHistory = typeof session.toolHistory === 'function' ? session.toolHistory() : undefined
73
+ return { header, messages, toolHistory, sessionId: session.id }
74
+ }
75
+
76
+ export const BTW_PROMPT = [
77
+ 'ds-btw/aside',
78
+ "Below is a read-only snapshot of the user's current conversation. It is reference background, not your current task.",
79
+ 'Answer the question from that snapshot when it suffices; otherwise say exactly what is missing.',
80
+ 'Do not call any tool.',
81
+ 'Nothing you say here is written back into that conversation.',
82
+ 'Never claim to have done anything.',
83
+ ].join('\n')
84
+
85
+ export const BTW_RETRY = 'Do not call any tool. Answer in plain text only.'
86
+
87
+ /** Build the trailing user prompt (boundary text + optional retry reminder + question). */
88
+ export function buildBtwPrompt(question, retry = false) {
89
+ return [BTW_PROMPT, ...(retry ? [BTW_RETRY] : []), String(question)].join('\n\n')
90
+ }
91
+
92
+ function textContent(text) {
93
+ return [{ type: 'text', text: String(text) }]
94
+ }
95
+
96
+ /**
97
+ * One earlier dialog turn → one request message. Assistant messages carry a
98
+ * `source` tag because provider adapters read it (ds-bot/src/host/side.js:35-40).
99
+ *
100
+ * @param {{ role: string, text: string, provider?: string, model?: string }} turn
101
+ * @param {{ provider?: string, model?: string }} [fallbackRoute]
102
+ */
103
+ export function turnToMessage(turn, fallbackRoute = {}) {
104
+ if (!turn || typeof turn.text !== 'string' || turn.text.length === 0) {
105
+ throw new Error('prior turn needs a non-empty text')
106
+ }
107
+ if (turn.role === 'user') return { role: 'user', content: textContent(turn.text) }
108
+ if (turn.role !== 'assistant') throw new Error(`unsupported prior-turn role: ${String(turn.role)}`)
109
+ const provider = typeof turn.provider === 'string' && turn.provider ? turn.provider : fallbackRoute.provider
110
+ const model = typeof turn.model === 'string' && turn.model ? turn.model : fallbackRoute.model
111
+ const message = { role: 'assistant', content: textContent(turn.text) }
112
+ if (provider && model) message.source = { kind: 'model', provider, model }
113
+ return message
114
+ }
115
+
116
+ /**
117
+ * Validate and cap the dialog history sent by the browser. The host never
118
+ * trusts it beyond this shape: only user/assistant text turns are accepted.
119
+ *
120
+ * @param {unknown} raw
121
+ * @param {number} [max]
122
+ */
123
+ export function normalisePriorTurns(raw, max = DEFAULT_CONFIG.maxPriorTurns) {
124
+ if (!Array.isArray(raw)) return []
125
+ const turns = []
126
+ for (const value of raw) {
127
+ if (!value || typeof value !== 'object') continue
128
+ if (value.role !== 'user' && value.role !== 'assistant') continue
129
+ if (typeof value.text !== 'string' || value.text.trim() === '') continue
130
+ turns.push({
131
+ role: value.role,
132
+ text: String(value.text).slice(0, 32_000),
133
+ ...(typeof value.provider === 'string' && value.provider ? { provider: value.provider } : {}),
134
+ ...(typeof value.model === 'string' && value.model ? { model: value.model } : {}),
135
+ })
136
+ }
137
+ // Keep the most recent turns: older dialog history is the least relevant.
138
+ return max > 0 && turns.length > max ? turns.slice(-max) : turns
139
+ }
140
+
141
+ /**
142
+ * Assemble the exact request: parent snapshot, earlier dialog pairs, then the
143
+ * new question. `snapshotMessages` is expected to have been through
144
+ * trimToolBoundary already (snapshotOf does that). `options.focus` (from a
145
+ * per-message action or a text selection) is quoted ahead of the question.
146
+ */
147
+ export function buildBtwMessages(snapshotMessages, question, priorTurns = [], options = {}) {
148
+ const { retry = false, fallbackRoute = {}, focus = null } = options
149
+ if (typeof question !== 'string' || question.trim() === '') {
150
+ throw new Error('question must be a non-empty string')
151
+ }
152
+ const extra = priorTurns.map(turn => turnToMessage(turn, fallbackRoute))
153
+ const preamble = buildFocusPreamble(focus)
154
+ const prompt = buildBtwPrompt(question.trim(), retry)
155
+ return [
156
+ ...snapshotMessages,
157
+ ...extra,
158
+ { role: 'user', content: textContent(preamble ? `${preamble}\n\n${prompt}` : prompt) },
159
+ ]
160
+ }
161
+
162
+ /** Longest quote accepted from a selection or a focused message. */
163
+ export const FOCUS_MAX_CHARS = 2000
164
+
165
+ /** Pull plain text out of a message for use as a quoted excerpt. */
166
+ export function messageQuote(message, max = FOCUS_MAX_CHARS) {
167
+ if (!message || !Array.isArray(message.content)) return ''
168
+ const parts = []
169
+ for (const block of message.content) {
170
+ if (block && block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
171
+ parts.push(block.text)
172
+ }
173
+ }
174
+ return parts.join('\n').trim().slice(0, max)
175
+ }
176
+
177
+ /**
178
+ * Turn a `{ kind, text, role }` focus into a prompt preamble. `kind` is either
179
+ * `message` (a specific finalized message) or `selection` (text the user picked).
180
+ */
181
+ export function buildFocusPreamble(focus) {
182
+ if (!focus || typeof focus !== 'object') return ''
183
+ const text = typeof focus.text === 'string' ? focus.text.trim() : ''
184
+ if (text === '') return ''
185
+ const label = focus.kind === 'message'
186
+ ? `会话中的一条 ${focus.role || 'assistant'} 消息`
187
+ : '用户选中的文本'
188
+ return [
189
+ `[btw] 用户正在针对${label}提问,引用如下:`,
190
+ '"""',
191
+ text.slice(0, FOCUS_MAX_CHARS),
192
+ '"""',
193
+ '请优先解释或回答与此段内容相关的问题;如果会话快照不足以回答,请明确说明。',
194
+ ].join('\n')
195
+ }
196
+
197
+ /**
198
+ * Keep the newest `max` parent messages, never starting on an orphan `tool`
199
+ * result. The parent snapshot prefix stays byte-identical to the main request
200
+ * until the cut point, which keeps provider KV-cache reuse plausible.
201
+ */
202
+ export function pruneMessages(messages, max = DEFAULT_CONFIG.maxParentMessages) {
203
+ if (!Array.isArray(messages)) return []
204
+ if (messages.length <= max) return messages
205
+ let start = messages.length - max
206
+ while (start < messages.length && messages[start]?.role === 'tool') start += 1
207
+ return messages.slice(start)
208
+ }
209
+
210
+ /* ------------------------------------------------------- context ranges (B) */
211
+
212
+ /** Range modes accepted from the browser; `auto` = the host's own default cap. */
213
+ export const RANGE_MODES = ['auto', 'all', 'recent', 'no-tools']
214
+
215
+ export const RANGE_LABELS = {
216
+ auto: '自动',
217
+ all: '全部',
218
+ recent: '最近 N 条',
219
+ 'no-tools': '不含工具输出',
220
+ }
221
+
222
+ /** Validate the browser's range request and fill defaults. */
223
+ export function normaliseRange(raw, defaults = DEFAULT_CONFIG) {
224
+ const fallbackRecent = defaults.defaultRecentMessages > 0 ? defaults.defaultRecentMessages : 40
225
+ if (!raw || typeof raw !== 'object') return { mode: 'auto', recentMessages: fallbackRecent }
226
+ const mode = RANGE_MODES.includes(raw.mode) ? raw.mode : 'auto'
227
+ let recentMessages = Number.isFinite(raw.recentMessages) ? Math.round(raw.recentMessages) : fallbackRecent
228
+ recentMessages = Math.min(Math.max(recentMessages, 2), 500)
229
+ return { mode, recentMessages }
230
+ }
231
+
232
+ /**
233
+ * Drop tool traffic from a model-visible history: tool/result messages and the
234
+ * assistant `tool-call` blocks are removed together, so the remaining request
235
+ * never contains a call without its result (or the reverse). Assistant messages
236
+ * that carried only tool calls disappear.
237
+ */
238
+ export function stripToolMessages(messages) {
239
+ if (!Array.isArray(messages)) return []
240
+ const out = []
241
+ for (const message of messages) {
242
+ if (!message || typeof message !== 'object') continue
243
+ if (message.role === 'tool') continue
244
+ if (message.role === 'assistant' && Array.isArray(message.content)) {
245
+ const content = message.content.filter(block => !(block && block.type === 'tool-call'))
246
+ if (content.length === 0) continue
247
+ out.push(content.length === message.content.length ? message : { ...message, content })
248
+ continue
249
+ }
250
+ out.push(message)
251
+ }
252
+ return out
253
+ }
254
+
255
+ /**
256
+ * Apply the requested range to a snapshot. Returns the messages to send plus
257
+ * how many were cut, so the UI can show "已裁剪 N 条" instead of silently
258
+ * shortening the context.
259
+ */
260
+ export function applyRange(messages, range, config = DEFAULT_CONFIG) {
261
+ const source = Array.isArray(messages) ? messages : []
262
+ const normalised = normaliseRange(range, config)
263
+ let ranged
264
+ switch (normalised.mode) {
265
+ case 'recent':
266
+ ranged = pruneMessages(source, normalised.recentMessages)
267
+ break
268
+ case 'no-tools':
269
+ ranged = pruneMessages(stripToolMessages(source), capOf(config, 'maxRangeMessages', 1000))
270
+ break
271
+ case 'all':
272
+ ranged = pruneMessages(source, capOf(config, 'maxRangeMessages', 1000))
273
+ break
274
+ case 'auto':
275
+ default:
276
+ ranged = pruneMessages(source, capOf(config, 'maxParentMessages', 200))
277
+ break
278
+ }
279
+ return { messages: ranged, dropped: Math.max(0, source.length - ranged.length), range: normalised }
280
+ }
281
+
282
+ function capOf(config, key, fallback) {
283
+ const value = config && Number.isFinite(config[key]) ? config[key] : fallback
284
+ return value > 0 ? Math.round(value) : fallback
285
+ }
286
+
287
+ /* ------------------------------------------------ snapshot statistics (B) */
288
+
289
+ const CJK_PATTERN = /[\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g
290
+
291
+ /**
292
+ * Rough token estimate (the host does not expose a tokenizer to plugins).
293
+ * CJK characters count ~1 token each, other words ~1.3, plus a tiny per-message
294
+ * overhead in {@link messagesStats}. Only used for the UI's context badge.
295
+ */
296
+ export function estimateTokens(text) {
297
+ const value = typeof text === 'string' ? text : String(text ?? '')
298
+ if (value === '') return 0
299
+ const cjk = (value.match(CJK_PATTERN) || []).length
300
+ const rest = value.replace(CJK_PATTERN, ' ').trim()
301
+ const words = rest === '' ? 0 : rest.split(/\s+/).length
302
+ return Math.ceil(cjk + words * 1.3)
303
+ }
304
+
305
+ function messageText(message) {
306
+ if (!message || !Array.isArray(message.content)) return ''
307
+ let text = ''
308
+ for (const block of message.content) {
309
+ if (block && block.type === 'text' && typeof block.text === 'string') text += (text ? '\n' : '') + block.text
310
+ else if (block && block.type === 'tool-call' && typeof block.arguments === 'string') text += block.arguments
311
+ }
312
+ return text
313
+ }
314
+
315
+ /** `{ messages, chars, tokens }` for one model-visible history. */
316
+ export function messagesStats(messages) {
317
+ const list = Array.isArray(messages) ? messages : []
318
+ let chars = 0
319
+ let tokens = 0
320
+ for (const message of list) {
321
+ const text = messageText(message)
322
+ chars += text.length
323
+ tokens += 4 + estimateTokens(text)
324
+ }
325
+ return { messages: list.length, chars, tokens }
326
+ }
327
+
328
+ /**
329
+ * Snapshot → range → stats, in the exact order the ask route uses. The context
330
+ * preview endpoint and `/btw/ask` share this so the badge and the real request
331
+ * can never disagree.
332
+ */
333
+ export function previewContext(snapshotMessages, range, config = DEFAULT_CONFIG) {
334
+ const applied = applyRange(snapshotMessages, range, config)
335
+ return {
336
+ messages: applied.messages,
337
+ dropped: applied.dropped,
338
+ range: applied.range,
339
+ stats: messagesStats(applied.messages),
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Surface-eligible event types (dsh-session/lib/index.js:SURFACE_EVENT_TYPES).
345
+ * Only these derive an LLM message; everything else is log-only.
346
+ */
347
+ const SURFACE_EVENT_TYPES = new Set([
348
+ 'system/message',
349
+ 'developer/message',
350
+ 'user/message',
351
+ 'assistant/message',
352
+ 'tool/result',
353
+ ])
354
+
355
+ /** Fold `request/header` events the way dsh-session's foldRequestHeader does. */
356
+ function foldRequestHeaderOf(events) {
357
+ let header
358
+ for (const event of events) {
359
+ if (event && event.type === 'request/header' && event.data && typeof event.data.header === 'object') {
360
+ header = canonicalHeaderOf(event.data.header)
361
+ }
362
+ }
363
+ return header
364
+ }
365
+
366
+ function canonicalHeaderOf(header) {
367
+ const adapterDefaults = header.adapterDefaults
368
+ return {
369
+ config: header.config,
370
+ ...(adapterDefaults && (adapterDefaults.reasoningEffort === true || adapterDefaults.maxTokens === true)
371
+ ? { adapterDefaults }
372
+ : {}),
373
+ ...(Array.isArray(header.tools) && header.tools.length > 0 ? { tools: header.tools } : {}),
374
+ }
375
+ }
376
+
377
+ /** dsh-session's deriveEventMessage, without the projection map (see below). */
378
+ function deriveEventMessageOf(event) {
379
+ if (!event || typeof event !== 'object') return null
380
+ switch (event.type) {
381
+ case 'user/message':
382
+ return event.data || null
383
+ case 'system/message':
384
+ case 'developer/message':
385
+ case 'assistant/message': {
386
+ const message = event.data && event.data.message
387
+ if (!message || !Array.isArray(message.content) || message.content.length === 0) return null
388
+ return message
389
+ }
390
+ case 'tool/result':
391
+ return (event.data && event.data.message) || null
392
+ default:
393
+ return null
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Fold a persisted raw event log into a btw snapshot (cold sessions).
399
+ *
400
+ * Mirrors `foldSurface`/`deriveEventMessage` from `@deepseek-ai/dsh-session`
401
+ * without importing it (a plugin's dynamic import resolves against its own
402
+ * filesystem position, not the host's package table). Message *projections*
403
+ * are intentionally not applied: they are display-level transforms registered
404
+ * by other plugins; the request keeps the session's own committed content.
405
+ *
406
+ * @param {ReadonlyArray<any>} events contiguous log records (with `seq`)
407
+ * @param {string} sessionId
408
+ */
409
+ export function foldEventsToSnapshot(events, sessionId) {
410
+ if (!Array.isArray(events) || events.length === 0) throw new Error('the persisted session has no events')
411
+
412
+ const nodes = []
413
+ for (const event of events) {
414
+ if (!event || typeof event.type !== 'string' || !Number.isSafeInteger(event.seq)) continue
415
+ if (!SURFACE_EVENT_TYPES.has(event.type)) continue
416
+ const op = event.surfaceOp
417
+ if (op && typeof op === 'object' && op.op === 'replace') {
418
+ // Replacement ranges always point at events already on the surface.
419
+ const startIndex = nodes.indexOf(op.startSeq)
420
+ const endIndex = nodes.indexOf(op.endSeq)
421
+ if (startIndex >= 0 && endIndex >= startIndex) nodes.splice(startIndex, endIndex - startIndex + 1)
422
+ }
423
+ nodes.push(event.seq)
424
+ }
425
+
426
+ const bySeq = new Map()
427
+ for (const event of events) {
428
+ if (event && Number.isSafeInteger(event.seq)) bySeq.set(event.seq, event)
429
+ }
430
+ const messages = []
431
+ for (const seq of nodes) {
432
+ const message = deriveEventMessageOf(bySeq.get(seq))
433
+ if (message) messages.push(message)
434
+ }
435
+
436
+ return {
437
+ header: foldRequestHeaderOf(events),
438
+ messages: trimToolBoundary(messages),
439
+ toolHistory: undefined,
440
+ sessionId,
441
+ cold: true,
442
+ }
443
+ }
444
+
445
+ /** One SSE frame. `data` is JSON-encoded; the client parser splits on a blank line. */
446
+ export function sseFrame(event, data) {
447
+ return `event: ${event}\ndata: ${JSON.stringify(data ?? {})}\n\n`
448
+ }
449
+
450
+ const FAILURE_MESSAGES = {
451
+ NO_ADAPTER: '没有可用的模型适配器(provider/model 未注册)',
452
+ MISSING_CREDENTIAL: '模型凭据缺失',
453
+ INVALID_CREDENTIAL: '模型凭据无效',
454
+ AUTH: '模型鉴权失败',
455
+ RATE_LIMIT: '触发限流,请稍后再试',
456
+ CONTEXT_WINDOW_EXCEEDED: '上下文超出该模型窗口上限',
457
+ QUOTA: '模型额度不足',
458
+ ACCOUNT_QUOTA: '账户额度不足',
459
+ ABORTED: '已中止',
460
+ }
461
+
462
+ /** Normalise an LlmFailure (finish.reason.failure) into an SSE error payload. */
463
+ export function mapFailure(failure) {
464
+ const code = typeof failure?.code === 'string' && failure.code ? failure.code : 'LLM_ERROR'
465
+ const message = FAILURE_MESSAGES[code]
466
+ ?? (typeof failure?.message === 'string' && failure.message ? failure.message : '模型请求失败')
467
+ return { code, message }
468
+ }
469
+
470
+ /** Best-effort human text for arbitrary thrown values (used by host handlers). */
471
+ export function messageOf(error) {
472
+ if (error && typeof error === 'object' && typeof error.message === 'string' && error.message) return error.message
473
+ return String(error)
474
+ }
475
+
476
+ /**
477
+ * Resolve the live session for a browser-requested id. Sessions owned by the
478
+ * AgentRegistry are exposed through ctx.sessions; the agents fallback covers
479
+ * builds where the session store and the registry are not the same face.
480
+ */
481
+ export function pickSession(ctx, sessionId) {
482
+ const direct = ctx.sessions?.get?.(sessionId)
483
+ if (direct) return direct
484
+ return ctx.agents?.get?.(sessionId)?.session
485
+ }
486
+
487
+ /* ------------------------------------------------------------ "送入主会话" */
488
+
489
+ /** Target length of the compressed record; the model is told to stay under it. */
490
+ export const SUMMARY_MAX_CHARS = 900
491
+ /** Transcript budget for the summarizer prompt (older turns are dropped first). */
492
+ export const SUMMARY_INPUT_CHARS = 12_000
493
+
494
+ export const SUMMARY_PROMPT = [
495
+ '你是一个「旁问记录压缩器」。用户在主对话之外,针对主对话里不清楚的地方向另一个模型问了几个问题;下面是这段旁问的问答记录。',
496
+ '请把它压缩成一条给主对话 agent 看的记录,帮助它理解用户关心什么、得到了什么结论。',
497
+ '',
498
+ '要求:',
499
+ '1. 只保留能帮助理解主对话的信息:用户问了什么、结论、关键事实与数字、涉及的文件/路径/命令/API、仍未解决的点。',
500
+ '2. 不编造;记录里没有的写「未提及」,不确定的写「不确定」。',
501
+ '3. 不写寒暄、不重复完整推导;不要新增建议或行动。',
502
+ '4. 用中文 Markdown,最多 900 字,按以下结构输出(没有内容的小节省略):',
503
+ '## 我问了什么',
504
+ '## 结论',
505
+ '## 关键细节',
506
+ '## 仍未解决',
507
+ ].join('\n')
508
+
509
+ /** Keep only renderable user/assistant turns from a client-supplied transcript. */
510
+ export function normaliseDialogMessages(raw) {
511
+ if (!Array.isArray(raw)) return []
512
+ const cleaned = []
513
+ for (const entry of raw) {
514
+ if (!entry || typeof entry !== 'object') continue
515
+ const role = entry.role === 'user' ? 'user' : entry.role === 'assistant' ? 'assistant' : null
516
+ if (!role) continue
517
+ const text = typeof entry.text === 'string' ? entry.text.trim() : ''
518
+ if (text === '') continue
519
+ cleaned.push({ role, text })
520
+ }
521
+ return cleaned
522
+ }
523
+
524
+ function summaryChunks(messages) {
525
+ const chunks = []
526
+ let turn = 0
527
+ for (const entry of messages) {
528
+ if (entry.role === 'user') {
529
+ turn += 1
530
+ chunks.push(`### 第 ${turn} 轮\n用户问:${entry.text}`)
531
+ } else {
532
+ chunks.push(`旁问回答:${entry.text}`)
533
+ }
534
+ }
535
+ return chunks
536
+ }
537
+
538
+ /**
539
+ * Build the summarizer input. The newest turns survive a transcript that is
540
+ * over budget; the prompt says how many messages were dropped.
541
+ */
542
+ export function buildSummaryPrompt(messages, options = {}) {
543
+ const list = normaliseDialogMessages(messages)
544
+ if (list.length === 0) throw new Error('没有可压缩的旁问对话')
545
+ const focusText = options.focus && typeof options.focus.text === 'string'
546
+ ? options.focus.text.trim().slice(0, 1000)
547
+ : ''
548
+ const chunks = summaryChunks(list)
549
+
550
+ let omitted = 0
551
+ let body = chunks.join('\n\n')
552
+ while (body.length > SUMMARY_INPUT_CHARS && chunks.length > 1) {
553
+ chunks.shift()
554
+ omitted += 1
555
+ body = chunks.join('\n\n')
556
+ }
557
+ if (body.length > SUMMARY_INPUT_CHARS) body = body.slice(0, SUMMARY_INPUT_CHARS)
558
+
559
+ const parts = []
560
+ if (focusText) parts.push(`[背景引用] 用户在主会话中选中了这段文字:「${focusText}」`)
561
+ if (omitted > 0) parts.push(`(较早的 ${omitted} 条旁问消息因过长已省略)`)
562
+ parts.push(body)
563
+ return `${SUMMARY_PROMPT}\n\n---\n\n${parts.join('\n\n')}`
564
+ }
565
+
566
+ /** Normalise a model-produced (or user-edited) summary: no fences, no runs of blank lines. */
567
+ export function cleanSummary(raw, max = 4000) {
568
+ let text = typeof raw === 'string' ? raw.trim() : ''
569
+ if (text.startsWith('```')) {
570
+ text = text.replace(/^```[a-zA-Z]*\s*\n?/, '').replace(/\n?```\s*$/, '').trim()
571
+ }
572
+ text = text.replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
573
+ if (text.length > max) text = `${text.slice(0, max - 1).trimEnd()}…`
574
+ return text
575
+ }
576
+
577
+ /**
578
+ * Deterministic fallback when the summarizer model fails: keep every question
579
+ * and the opening of each answer, so the parent still gets the gist.
580
+ */
581
+ export function fallbackSummary(messages, max = 700) {
582
+ const list = normaliseDialogMessages(messages)
583
+ const lines = []
584
+ for (const entry of list) {
585
+ if (entry.role === 'user') {
586
+ lines.push(`- 问:${entry.text.length > 160 ? `${entry.text.slice(0, 160)}…` : entry.text}`)
587
+ } else {
588
+ const first = entry.text.split(/\n{2,}/)[0]
589
+ lines.push(` 答:${first.length > 180 ? `${first.slice(0, 180)}…` : first}`)
590
+ }
591
+ }
592
+ return cleanSummary(lines.join('\n'), max)
593
+ }
594
+
595
+ function stampOf(value) {
596
+ const date = value instanceof Date ? value : new Date(typeof value === 'string' ? value : Date.now())
597
+ const safe = Number.isNaN(date.getTime()) ? new Date() : date
598
+ const pad = (n) => String(n).padStart(2, '0')
599
+ return `${safe.getFullYear()}-${pad(safe.getMonth() + 1)}-${pad(safe.getDate())} ${pad(safe.getHours())}:${pad(safe.getMinutes())}`
600
+ }
601
+
602
+ /** Validate the client-supplied commit metadata; bad fields fall back to neutral values. */
603
+ export function normaliseSummaryMeta(raw) {
604
+ const source = raw && typeof raw === 'object' ? raw : {}
605
+ const turns = Number.isInteger(source.turns) && source.turns > 0 ? Math.min(source.turns, 999) : 0
606
+ const at = typeof source.at === 'string' && !Number.isNaN(Date.parse(source.at)) ? source.at : new Date().toISOString()
607
+ return { turns, at, focus: source.focus === true }
608
+ }
609
+
610
+ /**
611
+ * The text written into the parent. It is wrapped so the parent agent reads it
612
+ * as background, not as a fresh instruction from the user.
613
+ */
614
+ export function buildInjectionBody(summary, meta = {}) {
615
+ const text = cleanSummary(summary, 8000)
616
+ if (text === '') throw new Error('摘要为空')
617
+ const info = normaliseSummaryMeta(meta)
618
+ return [
619
+ `【btw 旁问记录 · ${stampOf(info.at)}】`,
620
+ '以下是我在主会话之外就一些不清楚的地方做的旁问,压缩记录如下,供你理解上下文;',
621
+ '这不是新的用户指令,无需逐条回复。',
622
+ '',
623
+ text,
624
+ '',
625
+ `—— btw 插件写入${info.turns > 0 ? ` · 共 ${info.turns} 轮旁问` : ''}`,
626
+ ].join('\n')
627
+ }
628
+
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "dsh-btw-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.2",
4
+ "private": false,
5
+ "description": "btw - one-off context side questions for a DSH session: a temporary dialog that inherits the current session context; answers stay in the dialog unless the user reviews a compressed summary and sends it into the session.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/yanzoro926/dsh-btw.git"
9
+ },
10
+ "homepage": "https://github.com/yanzoro926/dsh-btw#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/yanzoro926/dsh-btw/issues"
13
+ },
14
+ "type": "module",
15
+ "main": "lib/index.js",
16
+ "files": [
17
+ "lib",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "exports": {
23
+ ".": "./lib/index.js",
24
+ "./client": "./lib/client.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "scripts": {
28
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/logic.js",
29
+ "test": "node --test test/",
30
+ "test:direct": "node test/logic.test.mjs && node test/host-smoke.test.mjs && node test/client-smoke.test.mjs"
31
+ },
32
+ "keywords": [
33
+ "dsh",
34
+ "deepseek-harness",
35
+ "plugin",
36
+ "btw",
37
+ "side-question"
38
+ ],
39
+ "engines": {
40
+ "node": ">=22.5"
41
+ },
42
+ "license": "MIT",
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web"
49
+ }
50
+ }
51
+ }