dsh-speak 1.5.0 → 1.7.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.
@@ -1,63 +1,63 @@
1
- // speech-hook.js — DSH web adapter: auto voice-announce the final assistant reply
1
+ // speech-hook.js — DSH web adapter: voice-announce assistant activity
2
2
  // ==============================================================================
3
- // Listens to the session event stream (session/event), watches for
4
- // assistant/message append events, extracts the final reply text, and hands it to
5
- // the speech engine (engine/speak.ps1 on Windows, engine/speak.sh on macOS)
6
- // through a hidden, non-blocking child process.
3
+ // Listens to the session event stream (session/event), extracts the final reply
4
+ // text, and hands it to the speech engine (engine/speak.ps1 on Windows,
5
+ // engine/speak.sh on macOS) through a hidden, non-blocking child process.
7
6
  //
8
- // Trigger semantics:
9
- // * only events with a `text` block are announced (reasoning / tool_use blocks
10
- // are skipped)
11
- // * a tool/call to `ask_user_question` announces the parsed question
12
- // (title + single/multi + options); other tool calls cancel the pending
13
- // announcement (that round's assistant text is process narration)
14
- // * `approval/asked` is announced immediately (reason with the fixed English
15
- // template prefix stripped, or a fixed prompt)
16
- // * a final reply with no following tool/call is announced after a throttle
17
- // delay (merges multi-step messages from the same reply)
7
+ // Since 1.7.0 this is the merged host of the original dsh-speak behavior and
8
+ // victorwads' PR #2 (turn-level replay + host FIFO speech queue + WebSocket
9
+ // state sync + native Speak settings page):
10
+ //
11
+ // * a host-owned FIFO speech queue: only one native speech process runs at a
12
+ // time; queued items continue automatically when the current one finishes
13
+ // * every eligible item (final reply, approvals, questions, optional events,
14
+ // manual replay) is enqueued, so the WebSocket state (which message is
15
+ // speaking, queue length) is always truthful even for automatic replies
16
+ // * `queueAllMessages` (default off) switches between two automatic modes:
17
+ // - off (default): final replies are throttled/merged as before, plus the
18
+ // optional event announcements; tool calls cancel pending narration
19
+ // - on: every assistant/message is enqueued immediately as it arrives
20
+ // * a `/dsh-speak/control` POST route (play/stop/status) and a
21
+ // `/dsh-speak/ws` WebSocket publish the authoritative speech state
22
+ // * a `dsh-speak` settings namespace via installSettingsSection; schema
23
+ // defaults → patch config → UI user layer
24
+ // * `enabled` master switch: when off, nothing is ever enqueued (no sound)
18
25
  //
19
- // Registration: add an insert entry in ~/.dsh/profiles/web/cordis.patch.yml —
20
- // - insert:
21
- // - id: speech-hook
22
- // name: 'dsh-speak' # npm package (preferred)
23
- // name: 'file:///C:/Users/<your-username>/.../speech-hook.js' # repo/file install (replace <your-username>)
24
- // (run adapters/dsh/install.ps1 to do this automatically for the file install)
26
+ // Trigger semantics:
27
+ // * assistant/message with a `text` block is announced (reasoning / tool_use
28
+ // blocks are skipped)
29
+ // * a tool/call to `ask_user_question` announces the parsed question; other
30
+ // tool calls cancel the pending throttled announcement (default mode)
31
+ // * `approval/asked` is announced immediately (reason, else a fixed prompt)
32
+ // * optional events (turn/end, command/done, goal/change, tool/result errors,
33
+ // todo/write) are announced when their toggle is on (default off)
25
34
  //
26
- // Configuration — prefer the profile patch `config` block (see docs/CUSTOMIZATION.md):
27
- // - insert:
28
- // - id: speech-hook
29
- // name: 'dsh-speak'
30
- // config:
31
- // throttleMs: 1500 # merge delay before announcing (ms)
32
- // engine: '' # engine path override; '' = auto-resolve
33
- // announceApprovals: true # speak approval requests
34
- // announceQuestions: true # speak ask_user_question content
35
- // stripApprovalPrefix: true # strip "escalate sandbox to ...: " prefix
36
- // longTextMode: message # message | heading (speak largest md heading)
37
- // maxChars: 300 # engine per-utterance ceiling
38
- // volume: 50 # Windows only
39
- // rate: 0 # 0 = engine default (Windows SAPI scale / macOS wpm)
35
+ // Configuration — prefer the Web UI (Settings dsh-speak settings) or the
36
+ // profile patch `config` block (see README.md). All keys resolve as
37
+ // schema default → patch config → UI user layer.
40
38
  'use strict'
39
+
41
40
  const { spawn } = require('child_process')
41
+ const { createRequire } = require('module')
42
+ const { WebSocketServer, WebSocket } = require('ws')
42
43
  const fs = require('fs')
43
44
  const os = require('os')
44
45
  const path = require('path')
45
46
 
46
- // diagnostic log (for troubleshooting; safe to remove once stable)
47
47
  const LOG = path.join(os.tmpdir(), 'dsh-speech-hook.log')
48
48
  function log(...args) {
49
- try {
50
- fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${args.join(' ')}\n`)
51
- } catch (e) { /* ignore */ }
49
+ try { fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${args.join(' ')}\n`) } catch (e) { /* ignore */ }
52
50
  }
53
51
 
54
52
  const ENGINE_NAME = process.platform === 'darwin' ? 'speak.sh' : 'speak.ps1'
53
+ // Settings namespace of this plugin (lowercase kebab-case; must match the
54
+ // browser card's namespace in client/client.js).
55
+ const SETTINGS_NS = 'dsh-speak'
55
56
 
56
57
  /**
57
58
  * Locate the engine script:
58
59
  * 1. explicit override (config `engine`)
59
- * 2. <this package>/engine/<speak.ps1|speak.sh> — works both when running from
60
- * a repo checkout and when installed into a profile's node_modules
60
+ * 2. <this package>/engine/<speak.ps1|speak.sh> — repo checkout or profile install
61
61
  * 3. legacy file-copy location (~/.dsh/hooks/<speak.ps1|speak.sh>)
62
62
  */
63
63
  function resolveEngine(override) {
@@ -67,147 +67,493 @@ function resolveEngine(override) {
67
67
  return path.join(os.homedir(), '.dsh', 'hooks', ENGINE_NAME)
68
68
  }
69
69
 
70
+ // Platform-aware defaults: macOS `say` has no per-utterance ceiling, so
71
+ // `maxChars` defaults to 0 (unlimited) there; Windows keeps the safe 300.
72
+ const DEFAULT_MAX_CHARS = process.platform === 'darwin' ? 0 : 300
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Settings namespace (best-effort; see installSettingsSection in dsh-settings)
76
+ // ---------------------------------------------------------------------------
77
+ // The schema mirrors every config key. Values resolve as:
78
+ // schema default → patch `config` (base) → user settings layer (the UI).
79
+ const SCHEMA_DEFAULTS = {
80
+ enabled: true,
81
+ automaticSpeech: true,
82
+ cleanMarkdownFormatting: true,
83
+ readInlineCode: true,
84
+ codeBlocks: 'smart',
85
+ codeBlockMaxChars: 300,
86
+ codeBlockReplacementText: 'You can see the code in our history.',
87
+ queueAllMessages: false,
88
+ throttleMs: 1500,
89
+ replayFullRead: false,
90
+ engine: '',
91
+ announceApprovals: true,
92
+ announceQuestions: true,
93
+ stripApprovalPrefix: true,
94
+ questionGapMs: 2000,
95
+ longTextMode: 'message',
96
+ longTextMessage: '本次播报内容较长,请自行阅读。',
97
+ maxChars: DEFAULT_MAX_CHARS,
98
+ volume: 50,
99
+ rate: 0,
100
+ announceTurnEnd: false,
101
+ announceCommandDone: false,
102
+ announceGoalChange: false,
103
+ announceToolErrors: false,
104
+ announceTodoWrite: false,
105
+ }
106
+
107
+ /**
108
+ * Resolve the raw settings value into the mutable `cfg` the queue reads.
109
+ * Kept as a pure function so both the initial apply and settings onChange use
110
+ * the same normalization (engine re-resolution, platform maxChars default).
111
+ */
112
+ function resolveConfig(value) {
113
+ value = value || {}
114
+ return {
115
+ enabled: value.enabled !== false,
116
+ automaticSpeech: value.automaticSpeech !== false,
117
+ cleanMarkdownFormatting: value.cleanMarkdownFormatting !== false,
118
+ readInlineCode: value.readInlineCode !== false,
119
+ codeBlocks: ['all', 'smart', 'replace'].includes(value.codeBlocks) ? value.codeBlocks : 'smart',
120
+ codeBlockMaxChars: Number(value.codeBlockMaxChars != null ? value.codeBlockMaxChars : 300),
121
+ codeBlockReplacementText: String(value.codeBlockReplacementText || 'You can see the code in our history.'),
122
+ queueAllMessages: value.queueAllMessages === true,
123
+ throttleMs: Number(value.throttleMs != null ? value.throttleMs : 1500) || 1500,
124
+ replayFullRead: value.replayFullRead === true,
125
+ engine: resolveEngine(value.engine || ''),
126
+ announceApprovals: value.announceApprovals !== false,
127
+ announceQuestions: value.announceQuestions !== false,
128
+ stripApprovalPrefix: value.stripApprovalPrefix !== false,
129
+ questionGapMs: Math.max(0, Number(value.questionGapMs != null ? value.questionGapMs : 2000)) || 0,
130
+ longTextMode: value.longTextMode === 'heading' ? 'heading' : 'message',
131
+ longTextMessage: String(value.longTextMessage || SCHEMA_DEFAULTS.longTextMessage),
132
+ maxChars: Number(value.maxChars != null ? value.maxChars : DEFAULT_MAX_CHARS) || 0,
133
+ volume: Number(value.volume != null ? value.volume : 50) || 50,
134
+ rate: Number(value.rate != null ? value.rate : 0) || 0,
135
+ announceTurnEnd: value.announceTurnEnd === true,
136
+ announceCommandDone: value.announceCommandDone === true,
137
+ announceGoalChange: value.announceGoalChange === true,
138
+ announceToolErrors: value.announceToolErrors === true,
139
+ announceTodoWrite: value.announceTodoWrite === true,
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Build the settings schema + entry for installSettingsSection. Best-effort:
145
+ * any failure (missing peer packages) returns null and the plugin keeps the
146
+ * patch config. The registration itself happens on a timer tick in apply.
147
+ */
148
+ function buildSettingsNamespace(ctx, patch) {
149
+ try {
150
+ const profileRequire = createRequire(ctx.baseUrl || __filename)
151
+ const z = profileRequire('@deepseek-ai/schemastery')
152
+ const schema = z.object({
153
+ enabled: z.boolean().default(true),
154
+ automaticSpeech: z.boolean().default(true),
155
+ cleanMarkdownFormatting: z.boolean().default(true),
156
+ readInlineCode: z.boolean().default(true),
157
+ codeBlocks: z.union(['all', 'smart', 'replace']).default('smart'),
158
+ codeBlockMaxChars: z.natural().default(300),
159
+ codeBlockReplacementText: z.string().default('You can see the code in our history.'),
160
+ queueAllMessages: z.boolean().default(false),
161
+ throttleMs: z.natural().default(1500),
162
+ replayFullRead: z.boolean().default(false),
163
+ engine: z.string().default(''),
164
+ announceApprovals: z.boolean().default(true),
165
+ announceQuestions: z.boolean().default(true),
166
+ stripApprovalPrefix: z.boolean().default(true),
167
+ questionGapMs: z.natural().default(2000),
168
+ longTextMode: z.union(['message', 'heading']).default('message'),
169
+ longTextMessage: z.string().default('本次播报内容较长,请自行阅读。'),
170
+ maxChars: z.natural().default(DEFAULT_MAX_CHARS),
171
+ volume: z.natural().default(50),
172
+ rate: z.number().default(0),
173
+ announceTurnEnd: z.boolean().default(false),
174
+ announceCommandDone: z.boolean().default(false),
175
+ announceGoalChange: z.boolean().default(false),
176
+ announceToolErrors: z.boolean().default(false),
177
+ announceTodoWrite: z.boolean().default(false),
178
+ })
179
+ return { schema, entry: { ...SCHEMA_DEFAULTS, ...(patch || {}) } }
180
+ } catch (e) {
181
+ log('settings 依赖不可用,跳过 settings namespace 注册:', e && e.message)
182
+ return null
183
+ }
184
+ }
185
+
70
186
  module.exports = {
71
187
  apply(ctx, config) {
72
188
  config = config || {}
73
- // resolved settings: config > default
74
- const cfg = {
75
- throttleMs: Number(config.throttleMs != null ? config.throttleMs : 1500) || 1500,
76
- engine: resolveEngine(config.engine || ''),
77
- announceApprovals: config.announceApprovals !== false,
78
- announceQuestions: config.announceQuestions !== false,
79
- stripApprovalPrefix: config.stripApprovalPrefix !== false,
80
- longTextMode: config.longTextMode || 'message',
81
- maxChars: Number(config.maxChars != null ? config.maxChars : 300) || 300,
82
- volume: Number(config.volume != null ? config.volume : 50) || 50,
83
- rate: Number(config.rate != null ? config.rate : 0) || 0,
84
- }
85
- log('plugin apply 执行(加载成功); engine=', cfg.engine, '; throttle=', cfg.throttleMs,
86
- '; longTextMode=', cfg.longTextMode, '; maxChars=', cfg.maxChars)
189
+ let cfg = resolveConfig(config)
87
190
 
88
- let timer = null
89
- let pendingText = ''
191
+ // Register the settings namespace on a timer tick so apply never blocks;
192
+ // cfg is replaced wholesale on settings changes.
193
+ ctx.inject(['timer'], timerCtx => {
194
+ timerCtx.timer.timeout(() => {
195
+ const prepared = buildSettingsNamespace(ctx, config)
196
+ if (!prepared) return
197
+ let settingsModule
198
+ try {
199
+ const profileRequire = createRequire(ctx.baseUrl || __filename)
200
+ settingsModule = profileRequire('@deepseek-ai/dsh-settings')
201
+ } catch (e) {
202
+ log('dsh-settings 不可用,跳过 settings namespace 注册:', e && e.message)
203
+ return
204
+ }
205
+ // Keep a live source getter: installSettingsSection passes the resolved
206
+ // scope thunk to setSource, and onChange must re-derive cfg from it
207
+ // (installSettingsSection only calls setSource on attach/detach).
208
+ let settingsSource = () => prepared.entry
209
+ settingsModule.installSettingsSection(ctx, settingsModule.settingsNamespace(SETTINGS_NS), prepared.schema, prepared.entry, {
210
+ setSource: source => { settingsSource = source; cfg = resolveConfig(source()) },
211
+ onChange: () => {
212
+ try { cfg = resolveConfig(settingsSource()) } catch (e) { log('settings 变更应用失败:', e && e.message) }
213
+ log('settings 变更已应用; cfg=', JSON.stringify(cfg))
214
+ },
215
+ })
216
+ }, 0)
217
+ })
90
218
 
91
- /** cancel a pending announcement (called when a tool-call round arrives) */
92
- function cancelPending() {
93
- if (timer) { clearTimeout(timer); timer = null }
94
- pendingText = ''
219
+ // ---- host-owned FIFO speech queue + WebSocket state sync (PR #2) ----
220
+ let activeSpeech = null
221
+ let speechToken = 0
222
+ let replacement = null
223
+ /** 队列项播完后的停顿定时器(多问题提问之间的间隔) */
224
+ let gapTimer = null
225
+ const speechQueue = []
226
+ const speechSockets = new Set()
227
+ const speechWss = new WebSocketServer({ noServer: true })
228
+
229
+ function state() {
230
+ const item = activeSpeech && activeSpeech.item
231
+ return {
232
+ type: 'speech-state',
233
+ speaking: item !== undefined && item !== null,
234
+ sessionId: item ? item.sessionId : null,
235
+ turn: item ? item.turn : null,
236
+ messageId: item ? item.messageId : null,
237
+ source: item ? item.source : null,
238
+ queueLength: speechQueue.length,
239
+ }
240
+ }
241
+ function publishState() {
242
+ const payload = JSON.stringify(state())
243
+ for (const socket of speechSockets) {
244
+ if (socket.readyState === WebSocket.OPEN) {
245
+ try { socket.send(payload) } catch (e) { speechSockets.delete(socket) }
246
+ }
247
+ }
95
248
  }
249
+ function removeTemp(tmp) { try { fs.unlinkSync(tmp) } catch (e) { /* already removed */ } }
96
250
 
97
- function speak(text) {
98
- log('speak 调用, 文本长度:', text ? text.length : 0)
99
- if (!text || !text.trim()) return
251
+ function startOne(item) {
252
+ // master switch: nothing is ever spoken while disabled
253
+ if (!cfg.enabled) { log('总开关关闭,跳过播报(文本长度:', item.text.length, ')'); return false }
254
+ if (!item || !item.text.trim() || activeSpeech) return false
100
255
  const tmp = path.join(os.tmpdir(), `dsh-speech-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`)
101
- try {
102
- fs.writeFileSync(tmp, text, 'utf8')
103
- } catch (e) {
104
- log('写临时文件失败:', e.message)
105
- return
106
- }
107
- let ps
256
+ try { fs.writeFileSync(tmp, item.text, 'utf8') } catch (e) { log('write temp failed:', e.message); return false }
257
+ log('speech start', item.source, item.sessionId || '-', item.turn == null ? '-' : item.turn, item.messageId || '-', item.text.slice(0, 80))
258
+ let child
259
+ // 手动重播完整朗读:replayFullRead 打开时,重播跳过 heading 截断完整朗读
260
+ const fullRead = item.manual === true && cfg.replayFullRead === true
108
261
  if (process.platform === 'darwin') {
109
- // macOS: run the say-based engine through bash
110
- const args = ['-f', tmp, '-m', String(cfg.maxChars), '-M', cfg.longTextMode]
262
+ const args = ['-f', tmp, '-m', String(cfg.maxChars), '-M', cfg.longTextMode, '-l', cfg.longTextMessage, '-C', cfg.cleanMarkdownFormatting ? '1' : '0', '-I', cfg.readInlineCode ? '1' : '0', '-B', cfg.codeBlocks, '-K', String(cfg.codeBlockMaxChars), '-R', cfg.codeBlockReplacementText]
111
263
  if (cfg.rate > 0) args.push('-r', String(cfg.rate))
112
- ps = spawn('/bin/bash', [cfg.engine].concat(args), { stdio: 'ignore' })
113
- log('spawn bash (macOS engine) 已发起:', args.join(' '))
264
+ if (fullRead) args.push('-F')
265
+ child = spawn('/bin/bash', [cfg.engine].concat(args), { detached: true, stdio: 'ignore' })
114
266
  } else {
115
- ps = spawn('powershell.exe',
116
- ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cfg.engine,
117
- '-File', tmp,
118
- '-Volume', String(cfg.volume),
119
- '-Rate', String(cfg.rate > 0 ? cfg.rate : 1),
120
- '-MaxChars', String(cfg.maxChars),
121
- '-LongTextMode', cfg.longTextMode],
122
- { windowsHide: true, stdio: 'ignore' })
123
- log('spawn powershell 已发起')
267
+ child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cfg.engine, '-File', tmp, '-Volume', String(cfg.volume), '-Rate', String(cfg.rate > 0 ? cfg.rate : 1), '-MaxChars', String(cfg.maxChars), '-LongTextMode', cfg.longTextMode, '-LongTextMessage', cfg.longTextMessage, '-CleanMarkdownFormatting', cfg.cleanMarkdownFormatting ? '1' : '0', '-ReadInlineCode', cfg.readInlineCode ? '1' : '0', '-CodeBlocks', cfg.codeBlocks, '-CodeBlockMaxChars', String(cfg.codeBlockMaxChars), '-CodeBlockReplacementText', cfg.codeBlockReplacementText, '-FullRead', fullRead ? '1' : '0'], { windowsHide: true, stdio: 'ignore' })
268
+ }
269
+ const token = ++speechToken
270
+ activeSpeech = { process: child, tmp, token, item, gapMs: item.gapMs || 0 }
271
+ publishState()
272
+ const settle = () => {
273
+ removeTemp(tmp)
274
+ if (!activeSpeech || activeSpeech.token !== token) return
275
+ const gap = activeSpeech.gapMs || 0
276
+ activeSpeech = null
277
+ publishState()
278
+ const proceed = () => {
279
+ gapTimer = null
280
+ if (replacement) {
281
+ const next = replacement
282
+ replacement = null
283
+ startOne(next)
284
+ } else {
285
+ startNext()
286
+ }
287
+ }
288
+ // 队列项之间可配置停顿(如多个提问之间留 2 秒)
289
+ if (gap > 0) {
290
+ gapTimer = setTimeout(proceed, gap)
291
+ } else {
292
+ proceed()
293
+ }
294
+ }
295
+ child.once('exit', settle)
296
+ child.once('error', settle)
297
+ return true
298
+ }
299
+ function startNext() {
300
+ if (activeSpeech || replacement) return
301
+ const item = speechQueue.shift()
302
+ if (!item) { publishState(); return }
303
+ publishState()
304
+ startOne(item)
305
+ }
306
+ function enqueue(item) {
307
+ if (!item || !item.text || !item.text.trim()) return
308
+ speechQueue.push(item)
309
+ publishState()
310
+ startNext()
311
+ }
312
+ function stopActive() {
313
+ const active = activeSpeech
314
+ if (!active) return false
315
+ try {
316
+ if (process.platform === 'darwin' && active.process.pid) process.kill(-active.process.pid, 'SIGTERM')
317
+ else active.process.kill()
318
+ } catch (e) { log('stop speech failed:', e.message) }
319
+ return true
320
+ }
321
+ function clearAndStop() {
322
+ if (gapTimer) { clearTimeout(gapTimer); gapTimer = null }
323
+ speechQueue.length = 0
324
+ publishState()
325
+ return stopActive()
326
+ }
327
+ function replaceWith(item) {
328
+ if (gapTimer) { clearTimeout(gapTimer); gapTimer = null }
329
+ speechQueue.length = 0
330
+ replacement = item
331
+ publishState()
332
+ if (!activeSpeech) {
333
+ const next = replacement
334
+ replacement = null
335
+ startOne(next)
336
+ return
337
+ }
338
+ stopActive()
339
+ }
340
+ function visibleText(message) {
341
+ if (!message) return ''
342
+ if (typeof message.content === 'string') return message.content
343
+ if (!Array.isArray(message.content)) return ''
344
+ return message.content.filter(block => block && block.type === 'text' && typeof block.text === 'string').map(block => block.text).join('')
345
+ }
346
+ function hostItem(source, session, event, text, messageId) {
347
+ const sessionValue = session && (session.id != null ? session.id : session.sessionId)
348
+ return {
349
+ source,
350
+ sessionId: sessionValue != null ? String(sessionValue) : null,
351
+ turn: event && event.data && Number.isFinite(event.data.turn) ? event.data.turn : null,
352
+ messageId: messageId == null ? null : String(messageId),
353
+ text,
124
354
  }
125
- ps.on('exit', (code) => { log('播报进程退出 code=', code); try { fs.unlinkSync(tmp) } catch (e) { /* 清理 */ } })
126
- ps.on('error', (e) => { log('播报进程 error:', e.message); try { fs.unlinkSync(tmp) } catch (e2) { /* 清理 */ } })
355
+ }
356
+
357
+ // ---- WebSocket + control route (PR #2) ----
358
+ ctx.inject(['webServer'], webCtx => {
359
+ webCtx.effect(() => webCtx.webServer.registerUpgrade({
360
+ path: '/dsh-speak/ws',
361
+ handler: (req, socket, head) => speechWss.handleUpgrade(req, socket, head, client => {
362
+ speechSockets.add(client)
363
+ client.once('close', () => speechSockets.delete(client))
364
+ client.once('error', () => speechSockets.delete(client))
365
+ try { client.send(JSON.stringify(state())) } catch (e) { speechSockets.delete(client) }
366
+ }),
367
+ }), 'dsh-speak speech-state websocket')
368
+ webCtx.effect(() => webCtx.webServer.register({
369
+ kind: 'exact', path: '/dsh-speak/control', handler: async (req, res) => {
370
+ const reply = (status, value) => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(value)) }
371
+ if (req.method !== 'POST' || !String(req.headers['content-type'] || '').startsWith('application/json')) { reply(405, { error: 'POST application/json required' }); return }
372
+ try {
373
+ const chunks = []; let size = 0
374
+ for await (const chunk of req) { size += chunk.length; if (size > 1024 * 1024) throw new Error('request too large'); chunks.push(chunk) }
375
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
376
+ if (body.action === 'status') { reply(200, state()); return }
377
+ if (body.action === 'stop') {
378
+ replacement = null
379
+ clearAndStop()
380
+ reply(200, state())
381
+ return
382
+ }
383
+ if (body.action !== 'play' || typeof body.text !== 'string' || !body.text.trim()) { reply(400, { error: 'invalid control request' }); return }
384
+ replaceWith({ source: 'manual', manual: true, sessionId: body.sessionId == null ? null : String(body.sessionId), turn: Number.isFinite(body.turn) ? body.turn : null, messageId: body.messageId == null ? null : String(body.messageId), text: body.text })
385
+ reply(200, state())
386
+ } catch (e) { reply(e.message === 'request too large' ? 413 : 400, { error: e.message }) }
387
+ },
388
+ }), 'dsh-speak replay control route')
389
+ })
390
+
391
+ ctx.effect(() => () => {
392
+ replacement = null
393
+ clearAndStop()
394
+ for (const socket of speechSockets) { try { socket.close() } catch (e) { /* closed */ } }
395
+ speechSockets.clear()
396
+ try { speechWss.close() } catch (e) { /* closed */ }
397
+ }, 'dsh-speak speech cleanup')
398
+
399
+ // ---- session event handling ----
400
+ let timer = null
401
+ let pendingText = ''
402
+ /** 当前回合内最后一条助手消息文本(turn/end 兜底播报用) */
403
+ let lastText = ''
404
+ /** 已通过节流播报过的文本(防止 turn/end 兜底重复播报) */
405
+ let lastSpokenText = ''
406
+ /** 最后一条助手消息 id(兜底播报时带上) */
407
+ let lastMessageId = null
408
+ /** cancel a pending throttled announcement (default mode, tool-call round) */
409
+ function cancelPending() {
410
+ if (timer) { clearTimeout(timer); timer = null }
411
+ pendingText = ''
127
412
  }
128
413
 
129
414
  ctx.on('session/event', (session, event) => {
130
415
  try {
131
416
  const type = event && event.type
132
- // noise filter: assistant/chunk (streaming chunks) is not recorded
133
417
  if (type !== 'assistant/chunk') {
134
418
  log('事件 type=', type, 'surfaceOp=', event && event.surfaceOp, 'seq=', event && event.seq)
135
419
  }
136
- // tool-call round: a call to ask_user_question announces the parsed
137
- // question (title + mode + options); any other tool call cancels the
138
- // pending announcement (that round's assistant text is narration)
420
+ // 新回合开始:清空上一回合的兜底状态,避免跨回合残留
421
+ if (type === 'turn/start') {
422
+ cancelPending()
423
+ lastText = ''
424
+ lastSpokenText = ''
425
+ lastMessageId = null
426
+ return
427
+ }
428
+ // tool-call round: ask_user_question announces the parsed question; any
429
+ // other tool call cancels the pending throttled narration
139
430
  if (type === 'tool/call') {
140
- const toolName = event.data && event.data.name
141
- if (toolName === 'ask_user_question' && cfg.announceQuestions) {
142
- let spoken = ''
431
+ if (event.data && event.data.name === 'ask_user_question' && cfg.announceQuestions) {
432
+ let items = []
143
433
  try {
144
- const args = JSON.parse((event.data && event.data.arguments) || '{}')
145
- const qs = Array.isArray(args.questions) ? args.questions : []
146
- spoken = qs.map((q) => {
147
- const mode = q.multi_select ? '多选' : '单选'
148
- const labels = Array.isArray(q.options)
149
- ? q.options.map((o) => o.label).filter(Boolean).join('')
150
- : ''
151
- return (q.question || '') + '(' + mode + ')' + (labels ? ',选项:' + labels : '')
152
- }).filter(Boolean).join('')
153
- } catch (e) { /* arguments 解析失败则回退原逻辑 */ }
154
- if (spoken) {
434
+ const args = JSON.parse(event.data.arguments || '{}')
435
+ const questions = Array.isArray(args.questions) ? args.questions : []
436
+ // 每个问题单独入队播报:带"问题N"序号(多问题时)与"选项N"序号
437
+ // (序号用数字,与 UI 的自动编号一致;中文 TTS 自然读成"一/二/三")
438
+ items = questions.map((question, qi) => {
439
+ const mode = question.multi_select ? '多选' : '单选'
440
+ const opts = Array.isArray(question.options) ? question.options : []
441
+ const optText = opts.map((option, oi) => {
442
+ const label = option && option.label ? String(option.label) : ''
443
+ return label ? `选项${oi + 1},${label}` : ''
444
+ }).filter(Boolean).join(';')
445
+ const head = questions.length > 1 ? `问题${qi + 1},` : ''
446
+ // question 文案已含"单选/多选"字样时不再追加模式后缀,避免重复
447
+ const modeSuffix = /单选|多选/.test(question.question || '') ? '' : `(${mode})`
448
+ const body = [question.question || '', modeSuffix, optText ? ',' + optText : ''].join('')
449
+ return (head + body).trim()
450
+ }).filter(Boolean)
451
+ } catch (e) { /* ignore malformed arguments */ }
452
+ if (items.length > 0) {
155
453
  cancelPending()
156
- log('提问播报:', spoken.slice(0, 120))
157
- speak(spoken)
158
- } else {
159
- log('提问工具调用(ask_user_question)— 保留待播报文本')
454
+ // 问题已单独播报,标记当前最后文本为已播,避免 turn/end 兜底重复
455
+ lastSpokenText = lastText
456
+ // 多条问题按 FIFO 串行播报,之间停顿 cfg.questionGapMs(默认 2 秒)
457
+ const gap = cfg.questionGapMs > 0 && items.length > 1 ? cfg.questionGapMs : 0
458
+ items.forEach((itemText, i) => {
459
+ const item = hostItem('question', session, event, itemText, null)
460
+ item.gapMs = i < items.length - 1 ? gap : 0
461
+ enqueue(item)
462
+ })
160
463
  }
161
464
  return
162
465
  }
163
466
  cancelPending()
164
467
  return
165
468
  }
166
- // approval requested: announce it right away (time-sensitive), using
167
- // the approval reason if present
469
+ // approval requested: announce right away
168
470
  if (type === 'approval/asked' && cfg.announceApprovals) {
169
471
  cancelPending()
170
- let reason = (event.data && event.data.reason) || ''
171
- if (cfg.stripApprovalPrefix) {
172
- // strip the fixed English template prefix (e.g. "escalate sandbox
173
- // to danger-full-access: "), keep the human explanation
174
- reason = reason.replace(/^escalate sandbox to danger-full-access\s*:\s*/i, '').trim()
472
+ let reason = String((event.data && event.data.reason) || '')
473
+ if (cfg.stripApprovalPrefix) reason = reason.replace(/^escalate sandbox to danger-full-access\s*:\s*/i, '').trim()
474
+ enqueue(hostItem('approval', session, event, reason || '需要你的审批,请查看界面。', null))
475
+ return
476
+ }
477
+ // 回合结束:兜底播报最终回复(被工具调用取消的节流文本在此补播,
478
+ // 已播过的不重复),随后按需播报"第 N 轮对话完成"可选事件
479
+ if (type === 'turn/end') {
480
+ if (cfg.automaticSpeech && !cfg.queueAllMessages && lastText && lastText !== lastSpokenText) {
481
+ const itemText = lastText
482
+ const itemMessageId = lastMessageId
483
+ cancelPending()
484
+ lastText = ''
485
+ lastSpokenText = itemText
486
+ enqueue(hostItem('automatic', session, event, itemText, itemMessageId))
487
+ }
488
+ if (!cfg.announceTurnEnd) return
489
+ const data = event.data
490
+ const prefix = data && data.turn != null ? `第 ${data.turn} 轮对话` : '本轮对话'
491
+ const kind = data && data.reason && data.reason.kind
492
+ const text = ({ completed: prefix + '完成', aborted: prefix + '中断', interrupted: prefix + '中断', blocked: prefix + '被阻塞', error: prefix + '异常结束', 'max-tokens': prefix + '异常结束' })[kind] || prefix + '结束'
493
+ enqueue(hostItem('turn/end', session, event, text, null))
494
+ return
495
+ }
496
+ if (type === 'command/done' && cfg.announceCommandDone) {
497
+ enqueue(hostItem('command/done', session, event, (event.data && event.data.kind) === 'error' ? '命令执行失败' : '命令执行完成', null))
498
+ return
499
+ }
500
+ if (type === 'goal/change' && cfg.announceGoalChange) {
501
+ const data = event.data
502
+ const objective = data && data.goal && data.goal.objective
503
+ const label = ({ create: '已创建目标', edit: '目标已更新', complete: '目标已完成', pause: '目标已暂停', resume: '目标已恢复', block: '目标已阻塞', clear: '目标已清除' })[data && data.operation] || '目标状态变化'
504
+ const text = objective && ['create', 'edit', 'complete'].includes(data.operation) ? `${label}:${objective.replace(/\s+/g, ' ').trim().slice(0, 40)}` : label
505
+ enqueue(hostItem('goal/change', session, event, text, null))
506
+ return
507
+ }
508
+ if (type === 'tool/result' && cfg.announceToolErrors) {
509
+ const data = event.data
510
+ const err = data && data.error
511
+ // 真实错误标记:error 字段(name/code)或 message 内容块 isError === true
512
+ // (pwsh 等工具失败时没有 error 字段,错误文本在 isError 内容块里)
513
+ const errText = (Array.isArray(data && data.message && data.message.content) ? data.message.content : [])
514
+ .filter(block => block && block.isError === true)
515
+ .map(block => block.text || block.code || '').filter(Boolean).join(' ')
516
+ if (err || errText) {
517
+ const detail = (errText || (err && err.code) || (err && err.name) || '').replace(/\s+/g, ' ').trim().slice(0, 60)
518
+ // 纯英文错误详情(PowerShell 固定模板 / 技术 code)对中文用户可读性差,
519
+ // 播报时截掉,只保留含中文的详情(如"文件不存在")
520
+ const readable = /[\u4e00-\u9fff]/.test(detail) ? `:${detail}` : ''
521
+ enqueue(hostItem('tool/result', session, event, `工具调用出错${readable}`, null))
175
522
  }
176
- const text = reason || '需要你的审批,请查看界面。'
177
- log('审批请求,播报:', text.slice(0, 60))
178
- speak(text)
523
+ return
524
+ }
525
+ if (type === 'todo/write' && cfg.announceTodoWrite) {
526
+ const todos = Array.isArray(event.data && event.data.todos) ? event.data.todos : []
527
+ const done = todos.filter(t => t && t.status === 'completed').length
528
+ enqueue(hostItem('todo/write', session, event, `待办已更新:${done}/${todos.length} 完成`, null))
179
529
  return
180
530
  }
181
531
  if (!event || type !== 'assistant/message') return
182
532
  if (event.surfaceOp && event.surfaceOp !== 'append') return
183
- // the message object lives at event.data.message (event.data wraps { turn, step, message })
184
- const msg = event.data && (event.data.message || event.data)
185
- if (!msg) return
186
- let text = ''
187
- const c = msg.content
188
- if (typeof c === 'string') {
189
- text = c
190
- } else if (Array.isArray(c)) {
191
- // only text blocks: reasoning / tool_use blocks are not announced
192
- text = c
193
- .filter(b => b && b.type === 'text' && typeof b.text === 'string')
194
- .map(b => b.text)
195
- .join('')
196
- }
533
+ const message = event.data && (event.data.message || event.data)
534
+ const text = visibleText(message)
197
535
  if (!text.trim()) return
198
- log('缓存待播报文本长度:', text.length, '前 60:', text.slice(0, 60))
536
+
537
+ // queueAllMessages mode (PR #2): enqueue every assistant message now
538
+ if (cfg.queueAllMessages && cfg.automaticSpeech) {
539
+ enqueue(hostItem('automatic', session, event, text, message && message.id))
540
+ return
541
+ }
542
+ // default mode: throttle/merge the final reply; a tool/call cancels it,
543
+ // and turn/end 兜底补播 lastText(见上方 turn/end 分支)
544
+ cancelPending()
199
545
  pendingText = text
200
- if (timer) clearTimeout(timer)
201
- // throttle: merge multi-step messages of one reply; a tool/call in
202
- // between cancels the announcement
546
+ lastText = text
547
+ lastMessageId = message && message.id ? String(message.id) : null
203
548
  timer = setTimeout(() => {
204
- speak(pendingText)
549
+ if (!pendingText) return
550
+ const itemText = pendingText
205
551
  pendingText = ''
206
552
  timer = null
553
+ lastSpokenText = itemText
554
+ enqueue(hostItem('automatic', session, event, itemText, lastMessageId))
207
555
  }, cfg.throttleMs)
208
- } catch (e) {
209
- log('事件处理异常:', e.message)
210
- }
556
+ } catch (e) { log('session event speech error:', e.message) }
211
557
  })
212
558
  },
213
559
  }