dsh-superinput 0.0.0-stage → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,718 @@
1
+ /* 由 scripts/build-suite.mjs 从 dsh-followup/index.js 机械生成;勿手改,重跑脚本再生成。 */
2
+ /**
3
+ * dsh-followup — 每轮对话结束后的智能跟进推荐(DeepSeek Harness / dsh)
4
+ *
5
+ * Trae「智能跟进推荐」的同款交互:助手回复落定后,用一次轻量模型调用
6
+ * 生成 2-3 条短跟进建议,缓存在内存里;web 端在输入框上方浮出可点击
7
+ * 的 chip,点击把建议填进草稿(不自动发送)。
8
+ *
9
+ * 工作原理(服务端半):
10
+ * 主通道(0.2.3 起,借鉴 dsh-cost-meter 的链尾监听模式):
11
+ * - ctx.on('llm/stream', listener, { global: true }):llm 服务的瀑布事件,
12
+ * 每次 ctx.llm.stream() 都会经过。监听器拿到下游流后包一层旁路观察
13
+ * (逐 chunk 透传,不改流协议),累积 text-delta 得到助手回复;
14
+ * options.sessionId(GenerateOptions.sessionId,dsh-reference types.ts)
15
+ * 关联会话,options.messages 里最后一个真实用户消息作为本轮输入。
16
+ * { global: true } 让监听器收到隔离 llm 作用域(记忆插件等)的流。
17
+ * AsyncLocalStorage 深度标记排除两类重复捕获:包装路由插件在自己
18
+ * stream() 体内再发起的上游调用(pull 下游时打标记),以及本插件
19
+ * 自己发起的建议生成调用(发起时打标记)。
20
+ * 回落通道:
21
+ * - 客户端回合落定后 POST /generate {sessionId};服务端优先用 llm/stream
22
+ * 实时捕获的缓存(10 分钟内),未命中才解压 session.v4.jsonl.zstd 直读
23
+ * (日志驱动,0.2.0 引入,对新架构进程分裂免疫)
24
+ * 旧架构事件监听(agent/pre-step / assistant-stream / status)保留:
25
+ * 收不到事件的宿主上它们是死代码,旧版本 dsh 上仍可用。
26
+ *
27
+ * 已核对的 dsh API(deepseek-ai/deepseek-harness,对照 dsh-reference):
28
+ * - ctx.on('llm/stream', (options, next) => AsyncIterable, { global: true })
29
+ * 监听器契约:必须调用 next() 推进瀑布并返回(包装的)流;嵌套/排除
30
+ * 时原样返回 next() 的下游
31
+ * - GenerateOptions.sessionId?: Branded<'SessionId'>(packages/llm/llm/src/types.ts)
32
+ * - ctx.on('agent/status', ({ agent, status }) => ...) // idle ⇄ running
33
+ * - ctx.on('agent/pre-step', async ({ agent, messages }, next) => PreStepDecision)
34
+ * - ctx.on('agent/assistant-stream', ({ agent, frame }) => ...)
35
+ * frame = { type: 'start' | 'chunk' | 'end', chunk?: StreamChunk, ... }
36
+ * 文本块 chunk = { type: 'text-delta', text }
37
+ * - ctx.llm.stream({ provider, model, messages, maxTokens, signal })
38
+ * → AsyncIterable<{ type: 'text-delta', text } | { type: 'finish', reason }>
39
+ * - agent.options.provider / agent.options.model:会话当前的路由
40
+ * - ctx.get('agentDefaultModel')?.currentSelection?.() → { provider, model }
41
+ * - webServer.register({ kind: 'prefix', path, handler }, label)
42
+ */
43
+
44
+ import Schema from '@deepseek-ai/schemastery'
45
+ import { AsyncLocalStorage } from 'node:async_hooks'
46
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
47
+ import { appendFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
48
+ import { homedir } from 'node:os'
49
+ import { dirname, join } from 'node:path'
50
+
51
+ const name = 'followup'
52
+
53
+ const DIAG_LOG = join(homedir(), '.dsh', 'followup-debug.log')
54
+ const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
55
+ const PROFILES_ROOT = join(homedir(), '.dsh', 'profiles')
56
+ const SUGGEST_FILE = join(homedir(), '.dsh', 'followup-suggestions.json')
57
+
58
+ /** 每环节一行落盘:事件是否到、文本抓没抓到、生成成没成,一次复现即可定位断点。 */
59
+ const report = async (line) => {
60
+ try {
61
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
62
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
63
+ } catch { /* ignore */ }
64
+ }
65
+
66
+ /* ------------------------------------------------------------------ */
67
+ /* 会话日志直读(新架构进程分裂后 agent/* 事件到不了插件进程, */
68
+ /* 0.2.0 起改为日志驱动:解压 session.v4.jsonl.zstd 取最后一轮文本) */
69
+ /* ------------------------------------------------------------------ */
70
+
71
+ /** 多帧 zstd(追加写每帧独立):按魔数切段逐帧解压,坏帧跳过。 */
72
+ async function decompressLog(file) {
73
+ const zlib = await import('node:zlib')
74
+ if (typeof zlib.zstdDecompressSync !== 'function') {
75
+ throw new Error('node zlib 无 zstd 支持(需 Node ≥23.8)')
76
+ }
77
+ const buf = await readFile(file)
78
+ const MAGIC = 0x28
79
+ const idxs = [0]
80
+ for (let i = 4; i <= buf.length - 4; i += 1) {
81
+ if (buf[i] === MAGIC && buf[i + 1] === 0xB5 && buf[i + 2] === 0x2F && buf[i + 3] === 0xFD) idxs.push(i)
82
+ }
83
+ let out = ''
84
+ for (let k = 0; k < idxs.length; k += 1) {
85
+ try {
86
+ out += zlib.zstdDecompressSync(buf.subarray(idxs[k], idxs[k + 1] ?? buf.length)).toString('utf8')
87
+ } catch { /* 截断/坏帧跳过 */ }
88
+ }
89
+ return out
90
+ }
91
+
92
+ /** 找会话日志文件:目录名可能是裸 uuid 或 session-<uuid>。 */
93
+ async function findSessionFile(sessionId) {
94
+ const bare = String(sessionId).replace(/^session-/, '')
95
+ let workspaces = []
96
+ try { workspaces = await readdir(SESSIONS_ROOT, { withFileTypes: true }) } catch { return null }
97
+ for (const ws of workspaces) {
98
+ if (!ws.isDirectory()) continue
99
+ for (const name of [sessionId, `session-${bare}`, bare]) {
100
+ const file = join(SESSIONS_ROOT, ws.name, name, 'session.v4.jsonl.zstd')
101
+ try { await readFile(file); return file } catch { /* 尝试下一个 */ }
102
+ }
103
+ }
104
+ return null
105
+ }
106
+
107
+ /** 从持久日志提取最后一轮:最后一个真实用户消息 + 其后最后的助手文本。 */
108
+ async function lastTurnFromLog(sessionId) {
109
+ const file = await findSessionFile(sessionId)
110
+ if (!file) return null
111
+ const text = await decompressLog(file)
112
+ const events = []
113
+ for (const line of text.split('\n')) {
114
+ if (line.length === 0) continue
115
+ try { events.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
116
+ }
117
+ const isRealUser = (e) => e?.type === 'user/message'
118
+ && e.data?.source?.kind === 'user'
119
+ && Array.isArray(e.data.content) && e.data.content.length === 1
120
+ && e.data.content[0]?.type === 'text' && typeof e.data.content[0].text === 'string'
121
+ const isAssistant = (e) => e?.type === 'assistant/message'
122
+ let assistantText = null
123
+ let userText = null
124
+ for (let i = events.length - 1; i >= 0; i -= 1) {
125
+ const e = events[i]
126
+ if (assistantText === null && isAssistant(e)) {
127
+ const blocks = e.data?.message?.content
128
+ if (Array.isArray(blocks)) {
129
+ const joined = blocks.filter((b) => b?.type === 'text' && typeof b.text === 'string')
130
+ .map((b) => b.text).join('')
131
+ if (joined.trim().length > 0) assistantText = joined
132
+ }
133
+ continue
134
+ }
135
+ if (assistantText !== null && isRealUser(e)) {
136
+ userText = e.data.content[0].text
137
+ break
138
+ }
139
+ }
140
+ if (userText === null || assistantText === null) return null
141
+ return { userText: userText.trim(), assistantText: assistantText.trim() }
142
+ }
143
+
144
+ /** 从 profile 用户补丁层读 agent-default-model 的 provider/model(兜底路由)。 */
145
+ async function patchDefaultRoute() {
146
+ let profiles = []
147
+ try { profiles = await readdir(PROFILES_ROOT, { withFileTypes: true }) } catch { return null }
148
+ for (const p of profiles) {
149
+ if (!p.isDirectory()) continue
150
+ try {
151
+ const raw = await readFile(join(PROFILES_ROOT, p.name, 'cordis.patch.yml'), 'utf8')
152
+ const m = raw.match(/id:\s*agent-default-model[\s\S]{0,300}?provider:\s*(\S+)[\s\S]{0,120}?model:\s*(\S+)/)
153
+ if (m) return { provider: m[1].replace(/^['"]|['"]$/g, ''), model: m[2].replace(/^['"]|['"]$/g, '') }
154
+ } catch { /* 下一个 profile */ }
155
+ }
156
+ return null
157
+ }
158
+
159
+ export const inject = ['llm']
160
+
161
+ export const Config = Schema.object({
162
+ /** 总开关:关闭后不监听任何事件,也不注册 web 端点。 */
163
+ enabled: Schema.boolean().default(true).description('是否启用智能跟进推荐'),
164
+ /** 留空则跟随会话当前模型(agent.options)。 */
165
+ provider: Schema.string().description('生成建议使用的 provider(留空跟随会话)'),
166
+ /** 建议配一个快模型:这条调用在每轮回复结束后立刻发生。 */
167
+ model: Schema.string().description('生成建议使用的模型(留空跟随会话,建议快模型)'),
168
+ /** 单次生成超时;超时即放弃本轮建议。 */
169
+ timeoutMs: Schema.number().default(12000).description('生成超时(毫秒)'),
170
+ /** 回合落定后等多久再生成:缓冲多步回合的中间 idle。 */
171
+ idleDelayMs: Schema.number().default(1200).description('回合结束后的缓冲时间(毫秒)'),
172
+ /** 生成几条建议(最多 3)。 */
173
+ maxSuggestions: Schema.number().default(2).description('最多生成几条建议(1-2)'),
174
+ /** 上下文裁剪:超出截断,控制这条调用的 token 开销。 */
175
+ userChars: Schema.number().default(400).description('用户消息最多带多少字符'),
176
+ assistantChars: Schema.number().default(1200).description('助手回复最多带多少字符'),
177
+ /** 助手回复短于此不生成:「好的」「完成」这类没有可跟进的实质内容。 */
178
+ minAssistantChars: Schema.number().default(30).description('助手回复最小长度(字符)'),
179
+ })
180
+
181
+ /* ------------------------------------------------------------------ */
182
+ /* 提示词与解析 */
183
+ /* ------------------------------------------------------------------ */
184
+
185
+ function clampTail(text, limit) {
186
+ const clean = typeof text === 'string' ? text.trim() : ''
187
+ if (clean.length <= limit) return clean
188
+ return `…${clean.slice(-limit)}`
189
+ }
190
+
191
+ function buildPrompt(userText, assistantText, count, config) {
192
+ return [
193
+ `You generate follow-up suggestions for an AI chat session (DeepSeek Harness). Based on the exchange below, propose ${count} short follow-up prompts the user would most plausibly send next.`,
194
+ '',
195
+ 'Rules:',
196
+ '- Continue the conversation: refine, extend, verify or apply the assistant\'s last answer. Never generic filler like "还有吗" / "继续" / "tell me more".',
197
+ '- One imperative sentence each: 8~24 Chinese characters or 6~16 English words. No trailing period, no quotes, no numbering, no emoji.',
198
+ '- They must be sendable as-is: self-contained requests, not fragments.',
199
+ '- Write in the same language as the exchange.',
200
+ '- Output STRICT JSON only: an array of strings, nothing else. Example: ["第一条建议","第二条建议"]',
201
+ '',
202
+ '[User]',
203
+ clampTail(userText, config.userChars),
204
+ '',
205
+ '[Assistant]',
206
+ clampTail(assistantText, config.assistantChars),
207
+ ].join('\n')
208
+ }
209
+
210
+ /** 从模型输出里抠 JSON 数组;抠不出就返回空数组,宁缺毋滥。 */
211
+ function parseSuggestions(text, count) {
212
+ const start = text.indexOf('[')
213
+ const end = text.lastIndexOf(']')
214
+ if (start < 0 || end <= start) return []
215
+ let parsed
216
+ try {
217
+ parsed = JSON.parse(text.slice(start, end + 1))
218
+ } catch {
219
+ return []
220
+ }
221
+ if (!Array.isArray(parsed)) return []
222
+ return parsed
223
+ .filter((item) => typeof item === 'string')
224
+ .map((item) => item.trim().replace(/^[-*•\d.)、\s]+/, '').slice(0, 60))
225
+ .filter((item) => item.length >= 2)
226
+ .slice(0, count)
227
+ }
228
+
229
+ /* ------------------------------------------------------------------ */
230
+ /* 插件入口 */
231
+ /* ------------------------------------------------------------------ */
232
+
233
+ export function apply(ctx, config) {
234
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
235
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
236
+ const live = { ...config }
237
+ if (!live.enabled) return
238
+
239
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
240
+ const SETTINGS_KEYS = ['maxSuggestions', 'idleDelayMs', 'timeoutMs', 'minAssistantChars', 'userChars', 'assistantChars']
241
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-followup', 'settings.json')
242
+ try {
243
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
244
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
245
+ } catch { /* 无存档:全部跟随配置 */ }
246
+ let settingsTimer = null
247
+ const persistSettings = () => {
248
+ if (settingsTimer) return
249
+ settingsTimer = setTimeout(() => {
250
+ settingsTimer = null
251
+ try {
252
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
253
+ const out = {}
254
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
255
+ const tmp = `${SETTINGS_PATH}.tmp`
256
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
257
+ renameSync(tmp, SETTINGS_PATH)
258
+ } catch { /* ignore */ }
259
+ }, 400)
260
+ }
261
+
262
+ void report(`server boot v0.3.3 enabled=true minAssistantChars=${live.minAssistantChars} idleDelayMs=${live.idleDelayMs} route=${live.provider || ''}/${live.model || ''}(follow-session)`)
263
+
264
+ /** 每个 agent 的对话尾部累积:userText 来自 pre-step,assistantText 来自流帧。 */
265
+ const buffers = new WeakMap()
266
+ const seenFrameTypes = new Set()
267
+ let bufCounter = 0
268
+ const agentConstructorName = (agent) => (agent === undefined || agent === null
269
+ ? 'none'
270
+ : `${agent.constructor?.name ?? typeof agent}`)
271
+ const bufferOf = (agent) => {
272
+ let buf = buffers.get(agent)
273
+ if (buf === undefined) {
274
+ buf = { id: ++bufCounter, userText: '', assistantText: '', controller: null }
275
+ buffers.set(agent, buf)
276
+ void report(`buffer new #${buf.id} for agent=${agentConstructorName(agent)}`)
277
+ }
278
+ return buf
279
+ }
280
+
281
+ /** 供 web 端拉取的最近一份建议;seq 递增,客户端凭它去重。 */
282
+ let latest = { seq: 0, suggestions: [], at: 0 }
283
+ /** 最近一次生成的上下文,供「换一批」重生成。 */
284
+ let lastContext = null
285
+ let seqCounter = 0
286
+ let pendingTimer = 0
287
+
288
+ /* -- 建议持久化(0.2.4):重启后打开会话还能拿到最近一轮的建议 ----- */
289
+ /** sessionId → { seq, suggestions, at },boot 时读回,成功生成时写入。 */
290
+ let persisted = {}
291
+ try {
292
+ persisted = JSON.parse(readFileSync(SUGGEST_FILE, 'utf8'))
293
+ if (!persisted || typeof persisted !== 'object') persisted = {}
294
+ for (const entry of Object.values(persisted)) {
295
+ if (Number.isInteger(entry?.seq) && entry.seq > seqCounter) seqCounter = entry.seq
296
+ }
297
+ say('info', `restored ${Object.keys(persisted).length} persisted suggestion set(s)`)
298
+ } catch { /* 首次或损坏:空表开始 */ }
299
+
300
+ function persistSuggestions(sessionId, seq, suggestions) {
301
+ if (!sessionId || !Array.isArray(suggestions) || suggestions.length === 0) return
302
+ persisted[sessionId] = { seq, suggestions, at: Date.now() }
303
+ const keys = Object.keys(persisted)
304
+ if (keys.length > 24) {
305
+ let oldest = null
306
+ let oldestAt = Infinity
307
+ for (const key of keys) {
308
+ if (persisted[key].at < oldestAt) { oldestAt = persisted[key].at; oldest = key }
309
+ }
310
+ if (oldest !== null) delete persisted[oldest]
311
+ }
312
+ void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
313
+ .catch(() => { /* 持久化失败不影响主流程 */ })
314
+ }
315
+
316
+ /** Trae 逻辑:建议属于「每轮响应」——本轮没有新建议就清掉该会话的旧建议。 */
317
+ function clearSuggestions(sessionId) {
318
+ if (!sessionId || !(sessionId in persisted)) return
319
+ delete persisted[sessionId]
320
+ void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
321
+ .catch(() => { /* ignore */ })
322
+ }
323
+
324
+ const say = (level, message) => {
325
+ try {
326
+ console[level === 'error' ? 'error' : 'info'](`[dsh-followup] ${message}`)
327
+ } catch { /* ignore */ }
328
+ }
329
+
330
+ function singleTextOf(message) {
331
+ if (!message || message.role !== 'user') return null
332
+ // dsh 内部还有 goal / session-reference / notice 等来源的 user 消息,
333
+ // 不是用户在对话框里输入的内容,不进上下文。
334
+ if (message.source?.kind !== 'user') return null
335
+ if (!Array.isArray(message.content) || message.content.length !== 1) return null
336
+ const block = message.content[0]
337
+ return block && block.type === 'text' && typeof block.text === 'string' ? block.text : null
338
+ }
339
+
340
+ /** llm/stream 捕获用:请求 messages 里最后一个真实用户文本。 */
341
+ function singleTextOfLastUser(messages) {
342
+ if (!Array.isArray(messages)) return null
343
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
344
+ const text = singleTextOf(messages[i])
345
+ if (text !== null) return text
346
+ }
347
+ return null
348
+ }
349
+
350
+ function defaultRoute() {
351
+ const defaults = ctx.get('agentDefaultModel')
352
+ const selection = defaults?.currentSelection?.()
353
+ return selection?.provider && selection?.model
354
+ ? { provider: selection.provider, model: selection.model }
355
+ : null
356
+ }
357
+
358
+ function configRoute() {
359
+ return live.provider && live.model ? { provider: live.provider, model: live.model } : null
360
+ }
361
+
362
+ function resolveRoute(agent) {
363
+ const provider = agent?.options?.provider
364
+ const model = agent?.options?.model
365
+ return configRoute()
366
+ ?? (provider && model ? { provider, model } : null)
367
+ ?? defaultRoute()
368
+ }
369
+
370
+ function cancelPending(buf, reason) {
371
+ if (buf.controller) {
372
+ try { buf.controller.abort(new Error(reason)) } catch { /* ignore */ }
373
+ buf.controller = null
374
+ }
375
+ }
376
+
377
+ /* -- 事件监听:拼出对话尾部(旧架构回落通道) ---------------------- */
378
+ /* 新架构进程分裂下这些 agent/* 事件到不了插件进程(0.1.x 实证零事件),
379
+ 保留是为了旧版本 dsh 仍然可用;新架构走上面的 llm/stream 捕获 + 日志回落。 */
380
+
381
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
382
+ const buf = bufferOf(agent)
383
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
384
+ const text = singleTextOf(messages[i])
385
+ if (text !== null) { buf.userText = text; break }
386
+ }
387
+ buf.assistantText = ''
388
+ // 用户已经开始说下一句:上一份没生成的建议直接作废。
389
+ cancelPending(buf, 'superseded by next user message')
390
+ void report(`pre-step buf#${buf.id} agent=${agentConstructorName(agent)} msgs=${messages.length} userLen=${(buf.userText ?? '').length} userHead=${JSON.stringify((buf.userText ?? '').slice(0, 40))}`)
391
+ return next()
392
+ })
393
+
394
+ ctx.on('agent/assistant-stream', ({ agent, frame }) => {
395
+ const buf = bufferOf(agent)
396
+ if (frame?.type && !seenFrameTypes.has(frame.type)) {
397
+ seenFrameTypes.add(frame.type)
398
+ void report(`stream frame-type first-seen: ${frame.type} agent=${agentConstructorName(agent)}`)
399
+ }
400
+ if (frame?.type === 'start') { buf.assistantText = ''; return }
401
+ const chunk = frame?.type === 'chunk' ? frame.chunk : null
402
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
403
+ buf.assistantText += chunk.text
404
+ // 超长回复只保留尾部,控制内存。
405
+ if (buf.assistantText.length > 20000) buf.assistantText = buf.assistantText.slice(-10000)
406
+ return
407
+ }
408
+ if (frame?.type === 'end') {
409
+ void report(`stream end buf#${buf.id} agent=${agentConstructorName(agent)} assistantLen=${(buf.assistantText ?? '').length}`)
410
+ }
411
+ })
412
+
413
+ ctx.on('agent/status', ({ agent, status }) => {
414
+ const buf = bufferOf(agent)
415
+ if (status !== 'idle') {
416
+ // 回合重新运转(工具链下一步):撤销缓冲中的生成计划。
417
+ cancelPending(buf, 'agent running again')
418
+ if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = 0 }
419
+ return
420
+ }
421
+ const userText = (buf.userText ?? '').trim()
422
+ const assistantText = (buf.assistantText ?? '').trim()
423
+ if (!userText || assistantText.length < live.minAssistantChars) {
424
+ void report(`idle skip buf#${buf.id} agent=${agentConstructorName(agent)} userLen=${userText.length} assistantLen=${assistantText.length} (need user>0 & assistant>=${live.minAssistantChars})`)
425
+ return
426
+ }
427
+ // 缓冲片刻:多步回合的步骤之间也会闪现 idle,期间回到 running 就作废。
428
+ if (pendingTimer) clearTimeout(pendingTimer)
429
+ pendingTimer = setTimeout(() => {
430
+ pendingTimer = 0
431
+ void generateFor(agent, buf, userText, assistantText)
432
+ }, Math.max(200, live.idleDelayMs))
433
+ })
434
+
435
+ /* -- 主通道:llm/stream 瀑布旁路捕获(0.2.3,借鉴 dsh-cost-meter) -- */
436
+
437
+ /** 深度标记:存在即「本插件已知会、不需要再捕获」的 llm 流。 */
438
+ const llmDepth = new AsyncLocalStorage()
439
+
440
+ /** 实时捕获的最近一轮:sessionId → { userText, assistantText, at }。 */
441
+ const turnCache = new Map()
442
+ const TURN_CACHE_MAX = 24
443
+ const TURN_CACHE_TTL_MS = 10 * 60_000
444
+
445
+ function cachedTurnOf(sessionId) {
446
+ const hit = turnCache.get(sessionId)
447
+ if (!hit) return null
448
+ if (Date.now() - hit.at > TURN_CACHE_TTL_MS) { turnCache.delete(sessionId); return null }
449
+ return hit
450
+ }
451
+
452
+ function rememberTurn(sessionId, userText, assistantText) {
453
+ if (!sessionId || !userText || !assistantText || assistantText.trim().length === 0) return
454
+ const prev = turnCache.get(sessionId)
455
+ // 同一轮的多步工具链 / 重试续流 userText 相同:助手文本追加累积,
456
+ // 让「最终答案」落在缓存尾部(生成时 clampTail 取尾部正好命中)。
457
+ const merged = prev && prev.userText === userText
458
+ ? `${prev.assistantText}\n\n${assistantText}`
459
+ : assistantText
460
+ turnCache.set(sessionId, { userText, assistantText: merged.slice(-20000), at: Date.now() })
461
+ if (turnCache.size > TURN_CACHE_MAX) {
462
+ let oldestKey = null
463
+ let oldestAt = Infinity
464
+ for (const [key, value] of turnCache) {
465
+ if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key }
466
+ }
467
+ if (oldestKey !== null) turnCache.delete(oldestKey)
468
+ }
469
+ }
470
+
471
+ // llm/stream 捕获是否活着是 0.2.3 的关键真机问题(新架构进程分裂下
472
+ // 事件归属未实证),首帧与每次入缓存都留痕,一次对话即可从日志判定。
473
+ let llmStreamArmed = false
474
+
475
+ ctx.on('llm/stream', (options, next) => {
476
+ // 自己发起的建议生成调用 / 下游包装路由的嵌套调用:原样透传。
477
+ if (llmDepth.getStore() !== undefined) return next()
478
+ const downstream = next()
479
+ if (!llmStreamArmed) {
480
+ llmStreamArmed = true
481
+ void report('llm/stream first fire: capture channel is ALIVE in this process')
482
+ }
483
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
484
+ const userText = (singleTextOfLastUser(options?.messages) ?? '').trim()
485
+ let assistantText = ''
486
+ let downstreamIter = null
487
+ let completed = false
488
+ return (async function* followupObserve() {
489
+ try {
490
+ downstreamIter = downstream[Symbol.asyncIterator]()
491
+ for (;;) {
492
+ // 深度标记内拉取:包装路由适配器在消费上下文里再发起的
493
+ // ctx.llm.stream() 继承标记,内层监听器判定为已处理、透传。
494
+ const result = await llmDepth.run(1, () => downstreamIter.next())
495
+ if (result.done) break
496
+ const chunk = result.value
497
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
498
+ assistantText += chunk.text
499
+ if (assistantText.length > 20000) assistantText = assistantText.slice(-10000)
500
+ }
501
+ yield chunk
502
+ }
503
+ completed = true
504
+ } finally {
505
+ // 消费方提前中断(用户改下一句、错误终止)也要收尾;流失败时
506
+ // assistantText 不完整,通常过不了 minAssistantChars,无害。
507
+ if (!completed) {
508
+ try { await downstreamIter?.return?.() } catch { /* ignore */ }
509
+ }
510
+ try {
511
+ rememberTurn(sessionId, userText, assistantText)
512
+ void report(`llm/stream captured session=${sessionId ? sessionId.slice(0, 50) : '(none)'} userLen=${userText.length} assistantLen=${assistantText.length} ${!completed ? '(interrupted)' : ''}`)
513
+ } catch { /* 缓存失败绝不影响主对话流 */ }
514
+ }
515
+ })()
516
+ }, { global: true })
517
+
518
+ /* -- 生成 --------------------------------------------------------- */
519
+
520
+ async function runGeneration(llm, route, userText, assistantText, signal) {
521
+ const count = Math.min(2, Math.max(1, Math.round(live.maxSuggestions)))
522
+ // 深度标记内发起:本次生成调用经过 llm/stream 瀑布时,监听器读到
523
+ // 标记即透传——建议生成的 prompt 不会被误缓存为对话轮。
524
+ // reasoningEffort low + maxTokens 2000:真机 04:50 实测 profile 默认
525
+ // high 推理把 600 上限全部烧光(raw len=0 → 无建议渲染)。
526
+ // 给 dsh-cost 打来源标记:建议生成的费用单独归类,不混进对话。
527
+ globalThis.__dshPluginCall = { source: 'followup', at: Date.now() }
528
+
529
+ const iterator = llmDepth.run(1, () => llm.stream({
530
+ provider: route.provider,
531
+ model: route.model,
532
+ reasoningEffort: 'low',
533
+ messages: [{ role: 'user', content: [{ type: 'text', text: buildPrompt(userText, assistantText, count, config) }] }],
534
+ maxTokens: 2000,
535
+ signal,
536
+ })[Symbol.asyncIterator]())
537
+ let text = ''
538
+ try {
539
+ for (;;) {
540
+ const { done, value } = await iterator.next()
541
+ if (done) break
542
+ if (value?.type === 'text-delta' && typeof value.text === 'string') text += value.text
543
+ else if (value?.type === 'finish' && value.reason && (value.reason.kind === 'error' || value.reason.kind === 'aborted')) {
544
+ throw new Error(`followup llm ${value.reason.kind}`)
545
+ }
546
+ }
547
+ } catch (err) {
548
+ // 中断底层流,失败后不在后台继续烧 token。
549
+ try {
550
+ const closing = iterator.return?.()
551
+ if (closing && typeof closing.catch === 'function') closing.catch(() => {})
552
+ } catch { /* ignore */ }
553
+ throw err
554
+ }
555
+ void report(`raw output len=${text.length}: ${JSON.stringify(text.slice(0, 300))}`)
556
+ return parseSuggestions(text, count)
557
+ }
558
+
559
+ async function generateFor(agent, buf, userText, assistantText) {
560
+ const route = resolveRoute(agent)
561
+ if (!route) {
562
+ say('info', 'no model route available, skip')
563
+ void report('generate skip: no model route (config unset, agent.options empty, agentDefaultModel missing)')
564
+ return
565
+ }
566
+ const controller = new AbortController()
567
+ buf.controller = controller
568
+ const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
569
+ // 根作用域没声明 llm inject 时属性访问直接抛(真机 07:41 实证);
570
+ // 捕住降级——cache/日志路径不受影响。
571
+ let eventLlm = null
572
+ try { eventLlm = ctx.llm } catch { eventLlm = null }
573
+ if (eventLlm === null) {
574
+ void report('event-path skip: llm not injectable in this scope (cache path covers it)')
575
+ return
576
+ }
577
+ try {
578
+ void report(`generate start route=${route.provider}/${route.model} userLen=${userText.length} assistantLen=${assistantText.length}`)
579
+ const suggestions = await runGeneration(eventLlm, route, userText, assistantText, controller.signal)
580
+ if (suggestions.length) {
581
+ lastContext = { route, userText, assistantText }
582
+ latest = { seq: ++seqCounter, suggestions, at: Date.now() }
583
+ let eventSessionId = ''
584
+ try { eventSessionId = agent?.session?.id ?? '' } catch { eventSessionId = '' }
585
+ persistSuggestions(eventSessionId, latest.seq, suggestions)
586
+ say('info', `turn ${latest.seq}: ${suggestions.length} suggestions`)
587
+ void report(`generate ok seq=${latest.seq} count=${suggestions.length} ${JSON.stringify(suggestions)}`)
588
+ } else {
589
+ void report('generate empty: model returned no parseable suggestions')
590
+ }
591
+ } catch (err) {
592
+ if (!/aborted|superseded|timeout/i.test(String(err?.message ?? err))) {
593
+ say('error', `generate failed: ${err?.message ?? err}`)
594
+ void report(`generate failed: ${String(err?.message ?? err).slice(0, 300)}`)
595
+ } else {
596
+ void report(`generate aborted: ${String(err?.message ?? err).slice(0, 120)}`)
597
+ }
598
+ } finally {
599
+ clearTimeout(timer)
600
+ if (buf.controller === controller) buf.controller = null
601
+ }
602
+ }
603
+
604
+ /* -- web 端点 ------------------------------------------------------ */
605
+
606
+ // 可选注入:CLI / TUI 等没有 webServer 的宿主照常加载,只是没有 chips。
607
+ // llm 必须在此作用域声明——cordis 的属性访问按作用域校验 inject,
608
+ // 根作用域的静态 inject 不传导进嵌套作用域(真机实测报
609
+ // cannot get property "llm" without inject)。
610
+ ctx.inject(['webServer', 'llm'], (webCtx) => {
611
+ webCtx.effect(() => webCtx.webServer.register({
612
+ kind: 'prefix',
613
+ path: '/api/dsh-followup',
614
+ handler: async (req, res) => {
615
+ const send = (status, body) => {
616
+ res.statusCode = status
617
+ res.setHeader('content-type', 'application/json')
618
+ res.end(JSON.stringify(body))
619
+ }
620
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-followup/, '')
621
+ if (path === '/settings' && req.method === 'GET') {
622
+ send(200, { ...live })
623
+ return
624
+ }
625
+ // 运行时改参数:白名单字段,provider/model 不在其中(改了要重建路由)。
626
+ if (path === '/settings' && req.method === 'POST') {
627
+ let raw = ''
628
+ for await (const chunk of req) raw += chunk
629
+ let body
630
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
631
+ const numeric = { maxSuggestions: [1, 3], idleDelayMs: [200, 10000], timeoutMs: [1000, 120000], minAssistantChars: [0, 500], userChars: [100, 4000], assistantChars: [100, 8000] }
632
+ for (const [key, [min, max]] of Object.entries(numeric)) {
633
+ if (body[key] === undefined) continue
634
+ const value = Number(body[key])
635
+ if (!Number.isFinite(value)) continue
636
+ live[key] = Math.min(max, Math.max(min, Math.round(value)))
637
+ }
638
+ persistSettings()
639
+ send(200, { ...live })
640
+ return
641
+ }
642
+
643
+
644
+ // 客户端在每次「生成中 → 空闲」后拉取;seq 相同就静默忽略。
645
+ // Trae 逻辑:建议随会话持久化、无时间过期,只被下一轮替换或清除。
646
+ if (path === '/latest' && req.method === 'GET') {
647
+ const qs = new URL(req.url ?? '', 'http://local').searchParams
648
+ const wantSession = qs.get('sessionId') ?? ''
649
+ const entry = wantSession ? persisted[wantSession] : undefined
650
+ if (entry && Array.isArray(entry.suggestions) && entry.suggestions.length > 0) {
651
+ send(200, entry)
652
+ return
653
+ }
654
+ send(200, latest)
655
+ return
656
+ }
657
+
658
+ /**
659
+ * 生成建议(0.2.3 双通道):优先用 llm/stream 实时捕获的对话轮
660
+ * (零文件 IO、无解析时延),未命中才回落日志驱动(0.2.0 引入,
661
+ * 解压 session.v4.jsonl.zstd 直读)。
662
+ */
663
+ if ((path === '/generate' || path === '/refresh') && req.method === 'POST') {
664
+ let body = ''
665
+ for await (const chunk of req) body += chunk
666
+ let sessionId = ''
667
+ try { sessionId = JSON.parse(body).sessionId ?? '' } catch { sessionId = '' }
668
+ if (!sessionId) { send(400, { error: 'missing sessionId' }); return }
669
+ const cached = cachedTurnOf(sessionId)
670
+ const turn = cached
671
+ ? { userText: cached.userText, assistantText: cached.assistantText }
672
+ : await lastTurnFromLog(sessionId)
673
+ if (!turn) {
674
+ void report(`generate(${cached ? 'cache' : 'log'}) ${path}: no usable turn in session ${String(sessionId).slice(0, 50)}`)
675
+ // Trae 逻辑:建议属于每轮响应——本轮拿不到可跟进内容即清掉旧建议
676
+ clearSuggestions(String(sessionId))
677
+ send(409, { error: 'no-turn' })
678
+ return
679
+ }
680
+ const route = configRoute() ?? defaultRoute() ?? await patchDefaultRoute()
681
+ if (!route) {
682
+ void report(`generate(${cached ? 'cache' : 'log'}): no route (config/agentDefaultModel/patch all missing)`)
683
+ send(503, { error: 'no-route' })
684
+ return
685
+ }
686
+ const source = cached ? 'cache' : 'log'
687
+ const controller = new AbortController()
688
+ const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
689
+ try {
690
+ void report(`generate(${source}) start route=${route.provider}/${route.model} userLen=${turn.userText.length} assistantLen=${turn.assistantText.length}`)
691
+ const suggestions = await runGeneration(webCtx.llm, route, turn.userText, turn.assistantText, controller.signal)
692
+ if (!suggestions.length) {
693
+ void report(`generate(${source}) empty`)
694
+ // 同上:本轮生成失败/为空,旧建议不再属于最新一轮
695
+ clearSuggestions(String(sessionId))
696
+ send(502, { error: 'generate-failed' })
697
+ return
698
+ }
699
+ lastContext = { route, ...turn }
700
+ latest = { seq: ++seqCounter, suggestions, at: Date.now() }
701
+ persistSuggestions(String(sessionId), latest.seq, suggestions)
702
+ void report(`generate(${source}) ok seq=${latest.seq} ${JSON.stringify(suggestions)}`)
703
+ send(200, latest)
704
+ } catch (err) {
705
+ void report(`generate(${source}) failed: ${String(err?.message ?? err).slice(0, 200)}`)
706
+ send(502, { error: 'generate-failed' })
707
+ } finally {
708
+ clearTimeout(timer)
709
+ }
710
+ return
711
+ }
712
+
713
+ send(404, { error: 'not-found' })
714
+ },
715
+ }), 'dsh-followup: /api/dsh-followup/*')
716
+ })
717
+ }
718
+