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/CHANGELOG.md +97 -0
- package/LICENSE +21 -0
- package/README.md +205 -2
- package/cordis.patch.yml +14 -0
- package/lib/client.js +1980 -0
- package/lib/index.js +829 -0
- package/lib/logic.js +628 -0
- package/package.json +49 -4
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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|