@wenbin_wb/dsh-bridge 1.2.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,972 @@
1
+ // dsh-bridge 平台无关的会话桥
2
+ //
3
+ // 承担所有 IM 平台共享的核心逻辑:
4
+ // - 白名单(allowFrom)与首条自动授权
5
+ // - DSH 会话生命周期(创建/切换/停止/恢复 re-attach)
6
+ // - 审批问答(approval request/response,超时自动拒绝)
7
+ // - 出站 digest 摘要 + 心跳进度
8
+ // - 命令路由(/sessions /use /new /workspaces /stop /status /help …)
9
+ // - 会话列表 / 工作区渲染(DSH 官方 API)
10
+ //
11
+ // 平台相关的部分由子类(或组合)提供:
12
+ // - sendText(text) / sendTyping(state):向当前 peer 发送
13
+ // - extractTextFrom(message):从平台消息提取文本
14
+ // - isGroupMessage(message):判断群消息
15
+ // - handlePlatformInbound(message):消息解析(返回 { senderId, text, isGroup } 或 null)
16
+ //
17
+ // 本类不直接依赖任何 IM 协议,只消费 DSH 官方服务:
18
+ // ctx.sessions / ctx.agents / ctx.approval / ctx.sessionPersistence / ctx.workspaceRegistry
19
+
20
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
+ import { randomUUID } from 'node:crypto'
22
+ import { statSync } from 'node:fs'
23
+ import { resolve, normalize } from 'node:path'
24
+
25
+ // 纯文本标记(用户偏好不用 emoji)
26
+ export const BRIDGE_MARK = {
27
+ ok: '[OK]',
28
+ err: '[错误]',
29
+ stop: '[已停止]',
30
+ idle: '[空闲]',
31
+ turn: '[新会话]',
32
+ ask: '[待确认]',
33
+ welcome: '[IM Bot]',
34
+ list: '[会话列表]',
35
+ status: '[状态]',
36
+ warn: '[注意]',
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // 出站分块(通用:按平台 maxMessageChars 硬分块 + 保留 fenced code block)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const FENCE_RE = /^```([^\n`]*)\s*$/
44
+
45
+ function normalizeMarkdownBlocks(content) {
46
+ const lines = content.split('\n')
47
+ const out = []
48
+ let blankRun = 0
49
+ let inCode = false
50
+ for (const raw of lines) {
51
+ const line = raw.replace(/\s+$/, '')
52
+ if (FENCE_RE.test(line.trim())) {
53
+ inCode = !inCode
54
+ out.push(line)
55
+ blankRun = 0
56
+ continue
57
+ }
58
+ if (inCode) {
59
+ out.push(line)
60
+ continue
61
+ }
62
+ if (!line.trim()) {
63
+ blankRun += 1
64
+ if (blankRun <= 1) out.push('')
65
+ continue
66
+ }
67
+ blankRun = 0
68
+ out.push(line)
69
+ }
70
+ return out.join('\n').trim()
71
+ }
72
+
73
+ function splitMarkdownBlocks(content) {
74
+ const blocks = []
75
+ let current = []
76
+ let inCode = false
77
+ const flush = () => {
78
+ const block = current.join('\n').trim()
79
+ if (block) blocks.push(block)
80
+ current = []
81
+ }
82
+ for (const raw of content.split('\n')) {
83
+ const line = raw.replace(/\s+$/, '')
84
+ if (FENCE_RE.test(line.trim())) {
85
+ if (!inCode && current.length) flush()
86
+ current.push(line)
87
+ inCode = !inCode
88
+ if (!inCode) flush()
89
+ continue
90
+ }
91
+ if (inCode) {
92
+ current.push(line)
93
+ continue
94
+ }
95
+ if (!line.trim()) {
96
+ flush()
97
+ continue
98
+ }
99
+ current.push(line)
100
+ }
101
+ flush()
102
+ return blocks
103
+ }
104
+
105
+ function hardSplit(text, max) {
106
+ const chunks = []
107
+ let rest = text
108
+ while (rest.length > max) {
109
+ chunks.push(rest.slice(0, max))
110
+ rest = rest.slice(max)
111
+ }
112
+ if (rest) chunks.push(rest)
113
+ return chunks
114
+ }
115
+
116
+ function packBlocks(blocks, max) {
117
+ const units = []
118
+ let current = ''
119
+ for (const block of blocks) {
120
+ const candidate = current ? `${current}\n\n${block}` : block
121
+ if (candidate.length <= max) {
122
+ current = candidate
123
+ continue
124
+ }
125
+ if (current) units.push(current)
126
+ if (block.length <= max) {
127
+ current = block
128
+ } else {
129
+ units.push(...hardSplit(block, max))
130
+ current = ''
131
+ }
132
+ }
133
+ if (current) units.push(current)
134
+ return units
135
+ }
136
+
137
+ function splitForIM(content, max = 2000) {
138
+ // 安全检查:防止畸形输入导致无限循环或崩溃
139
+ if (typeof content !== 'string' || content.length === 0) return []
140
+ if (content.length > 1_000_000) {
141
+ content = content.slice(0, 1_000_000) + '\n\n[已截断:内容过长]'
142
+ }
143
+ const normalized = normalizeMarkdownBlocks(content)
144
+ if (!normalized) return []
145
+ if (normalized.length <= max) return [normalized]
146
+ return packBlocks(splitMarkdownBlocks(normalized), max)
147
+ }
148
+
149
+ export function textOfAssistantMessage(message) {
150
+ return (message.content ?? [])
151
+ .filter((block) => block?.type === 'text')
152
+ .map((block) => block.text)
153
+ .join('\n')
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // digest 摘要
158
+ // ---------------------------------------------------------------------------
159
+
160
+ function digestLine(session) {
161
+ let turn = 0
162
+ let tools = 0
163
+ let lastTool = undefined
164
+ let inTurn = false
165
+ for (const event of session.events ?? []) {
166
+ if (event.type === 'turn/start') {
167
+ turn = event.data.turn
168
+ inTurn = true
169
+ tools = 0
170
+ lastTool = undefined
171
+ } else if (event.type === 'turn/end') {
172
+ inTurn = false
173
+ } else if (event.type === 'tool/call' && inTurn) {
174
+ tools += 1
175
+ lastTool = event.data.name
176
+ }
177
+ }
178
+ const steps = tools > 0 ? `${tools} 次工具调用` : '思考中'
179
+ const last = lastTool ? ` | 最近: ${lastTool}` : ''
180
+ return `[处理中] 第 ${turn} 轮 | ${steps}${last}`
181
+ }
182
+
183
+ function summarizeError(error) {
184
+ if (error && typeof error === 'object' && 'message' in error) {
185
+ return String(error.message).slice(0, 200)
186
+ }
187
+ return String(error).slice(0, 200)
188
+ }
189
+
190
+ function sleep(ms) {
191
+ return new Promise((resolve) => setTimeout(resolve, ms))
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // 会话桥基类
196
+ // ---------------------------------------------------------------------------
197
+
198
+ export class ConversationBridge {
199
+ /**
200
+ * @param {object} opts
201
+ * @param {object} opts.ctx Cordis 上下文
202
+ * @param {object} opts.logger 日志器
203
+ * @param {object} [opts.config] 已持久化配置(allowFrom/间隔/活动会话等)
204
+ * @param {object} opts.platform 所属 Platform 实例(提供 accountId/capabilities)—— 必需
205
+ * @param {(senderId: string) => void} [opts.onFirstSender]
206
+ * @param {(sessionId: string) => void} [opts.onActiveSessionChange]
207
+ */
208
+ constructor({ ctx, logger, config = {}, platform, onFirstSender, onActiveSessionChange } = {}) {
209
+ if (!platform) {
210
+ throw new Error('ConversationBridge requires a platform instance')
211
+ }
212
+
213
+ this.ctx = ctx
214
+ this.logger = logger
215
+ this.platform = platform
216
+ this.onFirstSender = onFirstSender
217
+ this.onActiveSessionChange = onActiveSessionChange
218
+
219
+ const maxChars = platform.capabilities?.maxMessageChars ?? 2000
220
+ this.config = {
221
+ allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
222
+ digestIntervalSec: config.digestIntervalSec ?? 300,
223
+ approvalTimeoutSec: config.approvalTimeoutSec ?? 600,
224
+ maxMessageChars: config.maxMessageChars ?? maxChars,
225
+ sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
226
+ cwd: config.cwd,
227
+ agentPreset: config.agentPreset,
228
+ agentProvider: config.agentProvider,
229
+ agentModel: config.agentModel,
230
+ }
231
+
232
+ // 从配置恢复活动会话(v0.2.1:重启后保持会话)
233
+ // 注意:不在构造函数里调用 _pickDefaultSession(),由 loadConfig 回调负责恢复,避免竞态覆盖
234
+ this.activeSessionId = config.activeSessionId ?? null
235
+ this.peerId = null
236
+ this.pending = new Map() // number -> PendingApproval
237
+ this.approvalCounter = 0
238
+ this.disposers = []
239
+
240
+ // 配置恢复状态追踪(用于防止 handleInbound 在配置加载前处理消息)
241
+ this._configRestored = false
242
+ this._restoringConfig = null
243
+
244
+ this.mark = BRIDGE_MARK
245
+ this._attachOutbound()
246
+ this._attachApprovalBridge()
247
+ }
248
+
249
+ get gatewayAccountId() {
250
+ return this.platform?.accountId ?? ''
251
+ }
252
+
253
+ activeSession() {
254
+ if (!this.activeSessionId) return undefined
255
+ return this.ctx.sessions?.get(this.activeSessionId)
256
+ }
257
+
258
+ activeAgent() {
259
+ if (!this.activeSessionId) return undefined
260
+ return this.ctx.agents?.get(this.activeSessionId)
261
+ }
262
+
263
+ ownsAgent(agent) {
264
+ return this.activeSessionId !== null && agent?.session?.id === this.activeSessionId
265
+ }
266
+
267
+ isAllowed(senderId) {
268
+ if (!Array.isArray(this.config.allowFrom)) return false
269
+ if (this.config.allowFrom.length === 0) return false
270
+ return this.config.allowFrom.includes(senderId)
271
+ }
272
+
273
+ setActiveSession(session) {
274
+ this.activeSessionId = session.id
275
+ try { this.onActiveSessionChange?.(session.id) } catch { /* 持久化失败不致命 */ }
276
+ }
277
+
278
+ // 仅按 ID 设置活动会话(持久化会话可能没有内存 session 对象),
279
+ // 发消息时通过 re-attach 逻辑拉起 agent。
280
+ setActiveSessionById(id) {
281
+ if (!id) return
282
+ this.activeSessionId = id
283
+ try { this.onActiveSessionChange?.(id) } catch { /* 持久化失败不致命 */ }
284
+ }
285
+
286
+ async _pickDefaultSession() {
287
+ const sessions = await listSessions(this)
288
+ if (sessions.length > 0) this.setActiveSessionById(sessions[0].id)
289
+ }
290
+
291
+ async createSession(prompt, cwdOverride) {
292
+ // 使用 DSH 原生格式 session-${uuid},与 ctx.sessions 持久化系统兼容
293
+ const sessionId = `session-${randomUUID()}`
294
+ try {
295
+ const cwd = cwdOverride || this.config.cwd || process.cwd()
296
+ // 校验指定目录存在且是目录,防止路径遍历
297
+ if (cwdOverride) {
298
+ const validation = await validateWorkspacePath(this, cwdOverride)
299
+ if (!validation.valid) {
300
+ await this.sendText(validation.error)
301
+ return
302
+ }
303
+ }
304
+ const meta = {
305
+ cwd,
306
+ agentPreset: this.config.agentPreset || 'routing-suite',
307
+ }
308
+ const agentOptions = {}
309
+ if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
310
+ if (this.config.agentModel) agentOptions.model = this.config.agentModel
311
+ if (!agentOptions.provider || !agentOptions.model) {
312
+ try {
313
+ const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
314
+ if (def) {
315
+ if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
316
+ if (!agentOptions.model && def.model) agentOptions.model = def.model
317
+ }
318
+ } catch { /* 默认模型服务不可用则忽略,交由 DSH 自行处理 */ }
319
+ }
320
+ // 注意:不预创建 ctx.sessions——agents.create 会自己 prepare+enter session
321
+ const handle = await this.ctx.agents.create({ sessionId, meta, agentOptions })
322
+ this.setActiveSession(handle.agent.session)
323
+ if (prompt) {
324
+ handle.agent.followup(createUserMessage({
325
+ content: [{ type: 'text', text: prompt }],
326
+ source: { kind: 'user' },
327
+ }))
328
+ }
329
+ await this.sendText(`${this.mark.turn} 已创建会话 ${handle.agent.session.id}${cwdOverride ? `(工作区: ${cwdOverride})` : ''}${prompt ? '' : '(发消息即可开始)'}`)
330
+ } catch (error) {
331
+ await this.sendText(`${this.mark.err} 创建会话失败: ${error instanceof Error ? error.message : String(error)}`)
332
+ }
333
+ }
334
+
335
+ // ---- 审批 ----
336
+
337
+ nextApprovalNumber() {
338
+ this.approvalCounter += 1
339
+ return this.approvalCounter
340
+ }
341
+
342
+ registerApproval(number, approval) {
343
+ this.pending.set(number, approval)
344
+ }
345
+
346
+ clearApproval(number) {
347
+ const entry = this.pending.get(number)
348
+ if (entry) {
349
+ clearTimeout(entry.timer)
350
+ this.pending.delete(number)
351
+ }
352
+ }
353
+
354
+ // 取消并拒绝审批(用于 dispose 清理)
355
+ cancelApproval(number) {
356
+ const entry = this.pending.get(number)
357
+ if (entry) {
358
+ clearTimeout(entry.timer)
359
+ this.pending.delete(number)
360
+ entry.resolve('rejected') // 触发 Promise,防止泄漏
361
+ }
362
+ }
363
+
364
+ resolveApproval(text) {
365
+ const entries = [...this.pending.entries()]
366
+ if (entries.length === 0) return false
367
+ let outcome
368
+ if (text === '/yes') outcome = 'allowed-once'
369
+ else if (text === '/no') outcome = 'rejected'
370
+ if (outcome) {
371
+ const [number, entry] = entries[entries.length - 1]
372
+ // 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
373
+ if (!this.pending.has(number)) return false
374
+ this.clearApproval(number)
375
+ entry.resolve(outcome)
376
+ return true
377
+ }
378
+ if ((text === '1' || text === '2') && entries.length === 1) {
379
+ const [number, entry] = entries[0]
380
+ // 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
381
+ if (!this.pending.has(number)) return false
382
+ this.clearApproval(number)
383
+ entry.resolve(text === '1' ? 'allowed-once' : 'rejected')
384
+ return true
385
+ }
386
+ return false
387
+ }
388
+
389
+ dispose() {
390
+ for (const disposer of this.disposers) {
391
+ try { disposer() } catch { /* 忽略 */ }
392
+ }
393
+ this.disposers = []
394
+ for (const number of [...this.pending.keys()]) this.cancelApproval(number)
395
+ // 清理所有引用,防止内存泄漏
396
+ this.peerId = null
397
+ this.activeSessionId = null
398
+ this._restoringConfig = null
399
+ }
400
+
401
+ // ---- 入站核心(平台无关)----
402
+ //
403
+ // 子类解析出平台消息后调用本方法:
404
+ // await bridge.handleInbound({ senderId, text, isGroup })
405
+ //
406
+ // 返回:
407
+ // 'ignored' 消息被忽略(未授权/群消息/空消息)
408
+ // 'routed' 消息已路由到 agent
409
+ async handleInbound({ senderId, text, isGroup = false }) {
410
+ // 等待配置恢复完成(防止启动时竞态)
411
+ if (this._restoringConfig) {
412
+ await this._restoringConfig
413
+ }
414
+
415
+ const sender = String(senderId ?? '').trim()
416
+ if (!sender) return 'ignored'
417
+
418
+ if (!this.isAllowed(sender)) {
419
+ // 扫码即自动加入:白名单为空时,首个发消息的真实用户自动纳入白名单。
420
+ // 这是"登录后第一条消息即完成授权"的一步到位体验。
421
+ if (this.config.allowFrom.length === 0 && !isGroup) {
422
+ if (text?.trim()) {
423
+ this.config.allowFrom = [sender]
424
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto-approved first sender ${sender} into allowlist (onboarding)`)
425
+ try {
426
+ await this.onFirstSender?.(sender)
427
+ } catch (err) {
428
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
429
+ }
430
+ } else {
431
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
432
+ }
433
+ }
434
+
435
+ // 如果仍未通过白名单,拒绝处理(防止绕过白名单)
436
+ if (!this.isAllowed(sender)) {
437
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
438
+ return 'ignored'
439
+ }
440
+ }
441
+
442
+ if (isGroup) {
443
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (v0.1: no group support)`)
444
+ return 'ignored'
445
+ }
446
+
447
+ const fullText = text?.trim() ?? ''
448
+ if (!fullText) {
449
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore empty message from ${sender}`)
450
+ return 'ignored'
451
+ }
452
+
453
+ this.peerId = sender
454
+
455
+ if (await routeCommand(this, fullText)) return 'routed'
456
+
457
+ let agent = this.activeAgent()
458
+ if (!agent && this.activeSessionId) {
459
+ // agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
460
+ try {
461
+ const agentOptions = {}
462
+ if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
463
+ if (this.config.agentModel) agentOptions.model = this.config.agentModel
464
+ if (!agentOptions.provider || !agentOptions.model) {
465
+ try {
466
+ const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
467
+ if (def) {
468
+ if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
469
+ if (!agentOptions.model && def.model) agentOptions.model = def.model
470
+ }
471
+ } catch { /* ignore */ }
472
+ }
473
+
474
+ // 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
475
+ // 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
476
+ let persisted = false
477
+ try {
478
+ const headers = await this.ctx.sessionPersistence?.list?.()
479
+ persisted = Array.isArray(headers) && headers.some((h) => h?.id === this.activeSessionId)
480
+ } catch { /* 读取失败则按未持久化处理 */ }
481
+
482
+ let handle
483
+ if (persisted) {
484
+ handle = await this.ctx.agents.resume({
485
+ resumeSessionId: this.activeSessionId,
486
+ agentOptions,
487
+ })
488
+ } else {
489
+ // 读取持久化会话的 cwd 做 fallback(新建会话时用)
490
+ let sessionCwd = this.config.cwd || process.cwd()
491
+ const meta = {
492
+ cwd: sessionCwd,
493
+ agentPreset: this.config.agentPreset || 'routing-suite',
494
+ }
495
+ handle = await this.ctx.agents.create({
496
+ sessionId: this.activeSessionId,
497
+ meta,
498
+ agentOptions,
499
+ })
500
+ }
501
+ agent = handle.agent
502
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${this.activeSessionId} (${persisted ? 'resume' : 'create'})`)
503
+ } catch (err) {
504
+ const reason = err instanceof Error ? err.message : String(err)
505
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
506
+ // 清除失效的 activeSessionId,避免用户误以为还在旧会话中
507
+ this.activeSessionId = null
508
+ await this.sendText(`${this.mark.err} 恢复会话失败: ${reason}。发送 /new <提示词> 新建一个会话。`)
509
+ }
510
+ }
511
+ if (!agent) {
512
+ await this.sendText(`${this.mark.idle} 没有活动会话。发送 /new <提示词> 开始一个新会话,或 /sessions 查看已有会话。`)
513
+ return 'routed'
514
+ }
515
+
516
+ const messageValue = createUserMessage({
517
+ content: [{ type: 'text', text: fullText }],
518
+ source: { kind: 'user' },
519
+ })
520
+ agent.followup(messageValue)
521
+ await this.sendTyping(1).catch(() => {})
522
+ return 'routed'
523
+ }
524
+
525
+ // ---- 发送(子类必须实现)----
526
+
527
+ /** 向当前 peer 发送文本(自动分块 + typing 指示)。 */
528
+ async sendText(text) {
529
+ const peer = this.peerId
530
+ if (!peer) return
531
+ const chunks = splitForIM(text, this.config.maxMessageChars)
532
+ if (chunks.length === 0) return
533
+ await this.sendTyping(1).catch(() => {})
534
+ try {
535
+ for (let i = 0; i < chunks.length; i++) {
536
+ const result = await this.platform.sendText(peer, chunks[i])
537
+ if (result && result.success === false) {
538
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] outbound chunk ${i + 1}/${chunks.length} failed: ${result.error}`)
539
+ break
540
+ }
541
+ if (i < chunks.length - 1 && this.config.sendChunkDelayMs > 0) {
542
+ await sleep(this.config.sendChunkDelayMs)
543
+ }
544
+ }
545
+ } finally {
546
+ await this.sendTyping(2).catch(() => {})
547
+ }
548
+ }
549
+
550
+ /** 发送 typing 状态(1=开始,2=停止)。子类可覆盖。 */
551
+ async sendTyping(state) {
552
+ if (!this.platform?.sendTyping || this.peerId == null) return
553
+ return this.platform.sendTyping(this.peerId, state)
554
+ }
555
+
556
+ // ---- 出站事件绑定 ----
557
+
558
+ _attachOutbound() {
559
+ const digestState = new Map()
560
+ const stopHeartbeat = (state) => {
561
+ if (state.heartbeat) {
562
+ clearInterval(state.heartbeat)
563
+ state.heartbeat = undefined
564
+ }
565
+ }
566
+ const startHeartbeat = (session, state) => {
567
+ stopHeartbeat(state)
568
+ if (this.config.digestIntervalSec <= 0) return
569
+ state.heartbeat = setInterval(() => {
570
+ // 心跳时同时刷新 typing 状态(微信 typing 只维持 15 秒)
571
+ if (this.peerId) this.sendTyping(1).catch(() => {})
572
+ void this.sendText(digestLine(session))
573
+ }, this.config.digestIntervalSec * 1000)
574
+ if (typeof state.heartbeat.unref === 'function') state.heartbeat.unref()
575
+ }
576
+ const onEvent = (session, event) => {
577
+ if (session.id !== this.activeSessionId) return
578
+ const state = digestState.get(session.id) ?? { startedTurns: new Set() }
579
+ digestState.set(session.id, state)
580
+
581
+ if (event.type === 'turn/start') {
582
+ const turn = event.data.turn
583
+ if (!state.startedTurns.has(turn)) {
584
+ state.startedTurns.add(turn)
585
+ // 不发送"[OK] 收到,开始处理…",改用 typing 指示 + 心跳进度。
586
+ if (this.peerId) this.sendTyping(1).catch(() => {})
587
+ }
588
+ startHeartbeat(session, state)
589
+ return
590
+ }
591
+ if (event.type === 'assistant/message') {
592
+ const text = textOfAssistantMessage(event.data.message)
593
+ if (text.trim()) void this.sendText(text)
594
+ return
595
+ }
596
+ if (event.type === 'turn/end') {
597
+ stopHeartbeat(state)
598
+ if (this.peerId) this.sendTyping(2).catch(() => {})
599
+ const reason = event.data.reason
600
+ if (reason.kind === 'error') {
601
+ void this.sendText(`${this.mark.err} 处理出错: ${summarizeError(reason.error)}`)
602
+ } else if (reason.kind === 'aborted') {
603
+ void this.sendText(`${this.mark.stop} 已停止`)
604
+ } else if (reason.kind === 'max-tokens') {
605
+ void this.sendText(`${this.mark.warn} 达到输出上限,本轮已截断`)
606
+ }
607
+ return
608
+ }
609
+ }
610
+ const listener = (session, event) => onEvent(session, event)
611
+ const disposer = this.ctx.on('session/event', listener)
612
+ this.disposers.push(() => {
613
+ for (const state of digestState.values()) stopHeartbeat(state)
614
+ disposer()
615
+ })
616
+ }
617
+
618
+ // ---- 审批桥 ----
619
+
620
+ _attachApprovalBridge() {
621
+ const listener = async (req, next) => {
622
+ if (!this.ownsAgent(req.agent)) return next()
623
+ const peer = this.peerId
624
+ if (!peer) return next()
625
+
626
+ const number = this.nextApprovalNumber()
627
+ const timeoutSec = this.config.approvalTimeoutSec
628
+ const prompt = [
629
+ `${this.mark.ask} #${number} 需要你的确认`,
630
+ `工具: ${req.toolName}`,
631
+ ...(req.reason ? [`原因: ${req.reason}`] : []),
632
+ `回复 /yes 同意,/no 拒绝(仅一条待确认时也可回复 1/2)`,
633
+ `${Math.max(1, Math.round(timeoutSec / 60))} 分钟内未回复将自动拒绝。`,
634
+ ].join('\n')
635
+
636
+ void this.sendText(prompt)
637
+
638
+ let timeoutFired = false
639
+ const outcome = await new Promise((resolve) => {
640
+ const timer = setTimeout(() => {
641
+ timeoutFired = true
642
+ this.clearApproval(number)
643
+ resolve('rejected')
644
+ }, timeoutSec * 1000)
645
+ if (typeof timer.unref === 'function') timer.unref()
646
+ this.registerApproval(number, { number, request: req, resolve, timer })
647
+ })
648
+
649
+ // 仅在非超时路径发送确认消息(超时时 resolve 已经发生在 timer 回调)
650
+ if (!timeoutFired) {
651
+ const label = outcome === 'allowed-once' ? `${this.mark.ok} 已同意` : outcome === 'rejected' ? `${this.mark.err} 已拒绝` : `[${outcome}]`
652
+ void this.sendText(`${label}(#${number})`)
653
+ }
654
+ return outcome
655
+ }
656
+ const disposer = this.ctx.on('approval/request', listener)
657
+ this.disposers.push(disposer)
658
+ }
659
+ }
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // 辅助
663
+ // ---------------------------------------------------------------------------
664
+
665
+ function sessionLabel(session) {
666
+ for (const event of session.events ?? []) {
667
+ if (event.type === 'user/message') {
668
+ const blocks = event.data.content ?? []
669
+ const text = blocks
670
+ .filter((block) => block.type === 'text')
671
+ .map((block) => block.text ?? '')
672
+ .join(' ')
673
+ .trim()
674
+ if (text) return text.length > 24 ? `${text.slice(0, 24)}…` : text
675
+ }
676
+ }
677
+ return '(空会话)'
678
+ }
679
+
680
+ // 校验工作区路径(防止路径遍历攻击)
681
+ async function validateWorkspacePath(node, sel) {
682
+ const normalized = normalize(resolve(sel))
683
+ const workspaces = await listWorkspaces(node)
684
+ const allowedPaths = workspaces.map(w => normalize(resolve(w.path)))
685
+
686
+ if (!allowedPaths.includes(normalized)) {
687
+ const wsDisplay = allowedPaths.slice(0, 5).join('\n ')
688
+ const more = allowedPaths.length > 5 ? `\n ... 等 ${allowedPaths.length} 个工作区` : ''
689
+ return {
690
+ valid: false,
691
+ error: `${node.mark.err} 路径不在已注册工作区内: ${normalized}\n\n` +
692
+ `可用工作区:\n ${wsDisplay}${more}\n\n` +
693
+ `提示:使用 /workspaces 查看完整列表`
694
+ }
695
+ }
696
+
697
+ // 校验目录存在
698
+ let ok = false
699
+ try { ok = statSync(normalized).isDirectory() } catch { ok = false }
700
+ if (!ok) {
701
+ return {
702
+ valid: false,
703
+ error: `${node.mark.err} 工作区目录不存在: ${normalized}`
704
+ }
705
+ }
706
+
707
+ return { valid: true, path: normalized }
708
+ }
709
+
710
+ // 从事件日志折叠会话标题(本地实现,等价 DSH foldSessionTitle):优先取最后的
711
+ // session/title 事件(DSH 生成的会话名),否则回退到第一条用户消息文本。
712
+ function foldTitle(events) {
713
+ const evts = events ?? []
714
+ for (let i = evts.length - 1; i >= 0; i--) {
715
+ const e = evts[i]
716
+ if (e && e.type === 'session/title' && e.data?.title) return String(e.data.title)
717
+ }
718
+ return null
719
+ }
720
+
721
+ // 列出会话:使用 DSH 官方 API(ctx.sessions + sessionPersistence),与 web 端一致。
722
+ // 屏蔽已归档会话。返回 [{ id, createdAt, events?, seq?, cwd?, title? }],按时间倒序。
723
+ async function listSessions(node) {
724
+ // 归档会话 ID 集合
725
+ let archived = new Set()
726
+ try { archived = new Set(node.ctx.workspaceRegistry?.archivedSessionIds ?? []) } catch { /* ignore */ }
727
+ const live = [...(node.ctx.sessions?.list() ?? [])].filter((s) => !archived.has(s.id))
728
+ const liveIds = new Set(live.map((s) => s.id))
729
+ // 内存活跃会话(带完整 events/title)
730
+ const liveMapped = live.map((s) => {
731
+ try {
732
+ const title = foldTitle(s.events ?? [])
733
+ if (title) return { id: s.id, createdAt: s.header?.createdAt ?? 0, events: s.events, seq: s.seq, cwd: s.header?.cwd, title }
734
+ } catch { /* ignore */ }
735
+ return { id: s.id, createdAt: s.header?.createdAt ?? 0, events: s.events, seq: s.seq, cwd: s.header?.cwd }
736
+ })
737
+ // 持久化会话(含 cwd,与 web 端过滤一致)
738
+ let cold = []
739
+ try {
740
+ const headers = await node.ctx.sessionPersistence?.list?.()
741
+ if (Array.isArray(headers)) {
742
+ const coldHeaders = headers.filter((h) => h && h.id && !liveIds.has(h.id) && h.cwd !== undefined && !archived.has(h.id))
743
+ // 分批加载以避免并发过载(每批 10 个)
744
+ const BATCH_SIZE = 10
745
+ for (let i = 0; i < coldHeaders.length; i += BATCH_SIZE) {
746
+ const batch = coldHeaders.slice(i, i + BATCH_SIZE)
747
+ const batchResults = await Promise.all(batch.map(async (h) => {
748
+ let title
749
+ try {
750
+ const insp = await node.ctx.sessionPersistence.load(h.id)
751
+ title = foldTitle(insp.events ?? []) ?? undefined
752
+ } catch { /* 标题提取失败则只用 id */ }
753
+ return { id: h.id, createdAt: h.createdAt ?? 0, events: undefined, seq: 0, cwd: h.cwd, title }
754
+ }))
755
+ cold.push(...batchResults)
756
+ }
757
+ }
758
+ } catch { /* 持久化服务不可用时仅返回内存会话 */ }
759
+ return [...liveMapped, ...cold].sort((a, b) => b.createdAt - a.createdAt || b.seq - a.seq)
760
+ }
761
+
762
+ // 列出可用工作区:使用 DSH 官方 workspaceRegistry。返回 [{title, path}]。
763
+ async function listWorkspaces(node) {
764
+ try {
765
+ const list = await node.ctx.workspaceRegistry?.list?.() ?? []
766
+ const out = []
767
+ for (const ws of list) {
768
+ if (ws && ws.path) out.push({ title: ws.title ?? ws.path, path: ws.path })
769
+ }
770
+ return out.sort((a, b) => String(a.path).localeCompare(String(b.path)))
771
+ } catch {
772
+ return []
773
+ }
774
+ }
775
+
776
+ async function routeCommand(node, text) {
777
+ const trimmed = text.trim()
778
+ if (!trimmed.startsWith('/')) return false
779
+
780
+ if (trimmed === '/yes' || trimmed === '/no' || /^[12]$/.test(trimmed)) {
781
+ if (node.resolveApproval(trimmed)) return true
782
+ }
783
+
784
+ const [command, ...rest] = trimmed.slice(1).split(/\s+/)
785
+ switch (command) {
786
+ case 'help':
787
+ await node.sendText(helpText())
788
+ return true
789
+ case 'sessions':
790
+ await node.sendText(await renderSessions(node))
791
+ return true
792
+ case 'use': {
793
+ const index = Number(rest[0])
794
+ const sessions = sessionsInDisplayOrder(await listSessions(node))
795
+ if (!Number.isInteger(index) || index < 1 || index > sessions.length) {
796
+ await node.sendText(`${node.mark.err} 无效编号。可用: 1–${sessions.length}(/sessions 查看列表)`)
797
+ return true
798
+ }
799
+ const session = sessions[index - 1]
800
+ node.setActiveSessionById(session.id)
801
+ await node.sendText(`${node.mark.ok} 已切换到会话 #${index}(${session.id})`)
802
+ return true
803
+ }
804
+ case 'workspaces': {
805
+ const workspaces = await listWorkspaces(node)
806
+ if (workspaces.length === 0) {
807
+ await node.sendText(`${node.mark.list} 没有可用的工作区。使用 /new <提示词> @<路径> 指定一个目录。`)
808
+ return true
809
+ }
810
+ const lines = workspaces.map((w, i) => {
811
+ const name = w.title && w.title !== w.path ? `**${w.title}** · \`${w.path}\`` : `\`${w.path}\``
812
+ return `${i + 1}. ${name}`
813
+ })
814
+ await node.sendText(`${node.mark.list}\n**可用工作区**(/new <提示词> @N 选择)\n\n${lines.join('\n')}`)
815
+ return true
816
+ }
817
+ case 'new': {
818
+ // 解析尾部 @N 或 @路径 作为工作区 cwd
819
+ const args = rest.join(' ').trim()
820
+ let cwd
821
+ let prompt = args
822
+ const atMatch = args.match(/\s+@(\S+)$/)
823
+ if (atMatch) {
824
+ prompt = args.slice(0, atMatch.index).trim()
825
+ const sel = atMatch[1]
826
+ const workspaces = await listWorkspaces(node)
827
+ if (/^\d+$/.test(sel)) {
828
+ const idx = Number(sel)
829
+ const ws = workspaces[idx - 1]
830
+ if (ws) cwd = ws.path
831
+ else { await node.sendText(`${node.mark.err} 无效工作区编号 ${sel}。用 /workspaces 查看。`); return true }
832
+ } else {
833
+ // 直接指定路径时,规范化并校验(必须完全匹配已注册工作区)
834
+ const validation = await validateWorkspacePath(node, sel)
835
+ if (!validation.valid) {
836
+ await node.sendText(validation.error)
837
+ return true
838
+ }
839
+ cwd = validation.path
840
+ }
841
+ }
842
+ await node.createSession(prompt, cwd)
843
+ return true
844
+ }
845
+ case 'stop': {
846
+ const agent = node.activeAgent()
847
+ if (!agent) {
848
+ await node.sendText(`${node.mark.err} 没有活动的 agent`)
849
+ } else {
850
+ agent.cancel({ kind: 'user' })
851
+ await node.sendText(`${node.mark.stop} 已请求停止`)
852
+ }
853
+ return true
854
+ }
855
+ case 'status': {
856
+ const agent = node.activeAgent()
857
+ const session = node.activeSession()
858
+ if (!session) {
859
+ await node.sendText(`${node.mark.idle} 没有活动会话。发送 /new <提示词> 开始,或 /sessions 查看已有会话。`)
860
+ return true
861
+ }
862
+ const status = agent?.status ?? 'idle'
863
+ const lastTurn = [...(session.events ?? [])].reverse().find((e) => e.type === 'turn/end')
864
+ const reason = lastTurn ? describeTurnEnd(lastTurn.data.reason) : '尚未运行'
865
+ await node.sendText(`${node.mark.status}\n会话: ${session.id}\nagent: ${status}\n事件: ${session.seq} 条\n最近: ${reason}`)
866
+ return true
867
+ }
868
+ case 'start': // 别名:首次扫码自动开始一个会话
869
+ await node.createSession('')
870
+ return true
871
+ default:
872
+ await node.sendText(`${node.mark.err} 未知命令 /${command}\n${helpText()}`)
873
+ return true
874
+ }
875
+ }
876
+
877
+ function describeTurnEnd(reason) {
878
+ switch (reason.kind) {
879
+ case 'completed': return '[完成]'
880
+ case 'error': return '[出错]'
881
+ case 'aborted': return '[已停止]'
882
+ case 'blocked': return '[已阻塞]'
883
+ case 'max-tokens': return '[输出截断]'
884
+ case 'interrupted': return '[中断]'
885
+ default: return reason.kind
886
+ }
887
+ }
888
+
889
+ async function renderSessions(node) {
890
+ const all = await listSessions(node)
891
+ if (all.length === 0) return `${node.mark.list} 没有会话。发送 /new <提示词> 开始。`
892
+ // 按工作区(真实 cwd)分组;无 cwd 的归入 '(未指定)'
893
+ const groups = new Map()
894
+ for (const s of all) {
895
+ const key = s.cwd || '(未指定)'
896
+ if (!groups.has(key)) groups.set(key, [])
897
+ groups.get(key).push(s)
898
+ }
899
+ const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
900
+ const parts = [`**会话列表** · 共 ${all.length} 个(/use N 切换)`, '']
901
+ let idx = 0
902
+ for (const [cwd, sessions] of sortedGroups) {
903
+ parts.push(`## ${cwd}`)
904
+ for (const session of sessions.slice(0, 20)) {
905
+ idx += 1
906
+ const active = session.id === node.activeSessionId ? ' **← 当前**' : ''
907
+ const title = session.title || (session.events ? sessionLabel(session) : '')
908
+ const label = title || fmtSessionId(session.id)
909
+ const when = session.createdAt ? fmtTime(session.createdAt) : ''
910
+ parts.push(`${idx}. \`${label}\``)
911
+ parts.push(` ${session.id} · ${when}${active}`)
912
+ }
913
+ if (sessions.length > 20) parts.push(` …该工作区共 ${sessions.length} 个`)
914
+ }
915
+ if (all.length > 50) parts.push('', `…共 ${all.length} 个会话,仅显示前若干`)
916
+ return `${node.mark.list}\n${parts.join('\n')}`
917
+ }
918
+
919
+ // 与 renderSessions 完全一致的显示顺序:按工作区字母序分组、组内保持 listSessions 顺序。
920
+ // /use N 用这个数组索引,保证显示的编号 N 与切换的会话一一对应。
921
+ function sessionsInDisplayOrder(all) {
922
+ const groups = new Map()
923
+ for (const s of all) {
924
+ const key = s.cwd || '(未指定)'
925
+ if (!groups.has(key)) groups.set(key, [])
926
+ groups.get(key).push(s)
927
+ }
928
+ const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
929
+ return sortedGroups.flatMap(([, sessions]) => sessions)
930
+ }
931
+
932
+ // 时间戳 → 可读时间
933
+ function fmtTime(ms) {
934
+ try {
935
+ const d = new Date(ms)
936
+ const p = (n) => String(n).padStart(2, '0')
937
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
938
+ } catch { return '' }
939
+ }
940
+
941
+ // 持久化会话没有加载 events,用时间戳作为可读标识
942
+ function fmtSessionId(id) {
943
+ const m = /^session-([0-9a-f]{8})/.exec(id ?? '')
944
+ return m ? `session-${m[1]}…` : (id ?? '')
945
+ }
946
+
947
+ function helpText() {
948
+ return [
949
+ `${BRIDGE_MARK.welcome} 命令`,
950
+ '/sessions — 列出会话(按工作区分组)',
951
+ '/use N — 切换到会话 N',
952
+ '/workspaces — 列出可用工作区',
953
+ '/new <提示词> — 新建会话并开始(当前工作区)',
954
+ '/new <提示词> @路径 — 在指定目录新建会话',
955
+ '/new <提示词> @N — 用编号选择工作区(/workspaces)',
956
+ '/stop — 停止当前任务',
957
+ '/status — 查看状态',
958
+ '/yes /no 或 1/2 — 回应权限请求',
959
+ '/help — 本帮助',
960
+ ].join('\n')
961
+ }
962
+
963
+ // 导出,便于测试与复用
964
+ export const conversationBridgeHelpers = {
965
+ splitForIM,
966
+ digestLine,
967
+ textOfAssistantMessage,
968
+ sessionsInDisplayOrder,
969
+ listSessions,
970
+ listWorkspaces,
971
+ BRIDGE_MARK,
972
+ }