@goodandready/dsh-voice 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -149,7 +149,12 @@ The card has four blocks:
149
149
  - **Your own providers** — an OpenAI-compatible API per card: name, template,
150
150
  base URL, model, credential name. The name becomes selectable in both chains
151
151
  as soon as it is filled in.
152
- - **General** — local whisper endpoint, binary, model, autostart.
152
+ - **General** — local whisper endpoint, binary, model, autostart, beep, localOnly,
153
+ microphone, custom vocabulary, offline polish endpoint (`polishBaseUrl`/`polishModel`/`polishKeyEnv`).
154
+ - **Voice message** also has `polishSend` (polish the whole draft before sending) and
155
+ `sessionCommands` ("send", "cancel", "stop", "continue" act on the session instead of text).
156
+ - **Dictation** also has a wake word: browser recognition starts recording when speech
157
+ begins with that phrase (empty disables it).
153
158
 
154
159
  Speed matters for dictation and accuracy for messages, which is why the chains
155
160
  are separate: a sensible pair is Deepgram → Groq → local for dictation and
package/lib/client.js CHANGED
@@ -101,6 +101,17 @@ window.__ModuleLoader__.load({
101
101
  'streamChunkMs': 'Stream chunk (ms)',
102
102
  'vadAdapt': 'Adaptive silence',
103
103
  'vadAdaptHint': 'Auto-tune the silence threshold to the pace of your speech. 0 = fixed',
104
+ 'wakeWord': 'Wake word',
105
+ 'wakeWordHint': 'Browser recognition starts recording when speech begins with this phrase. Empty = off',
106
+ 'bargeIn': 'Barge-in',
107
+ 'polishSend': 'Polish whole draft before sending',
108
+ 'polishSendHint': 'Run the composed draft through the model right before sending',
109
+ 'sessionCommands': 'Voice session commands',
110
+ 'sessionCommandsHint': '"send", "cancel", "stop", "continue" act on the session instead of becoming text',
111
+ 'polishBaseUrl': 'Offline polish endpoint',
112
+ 'polishBaseUrlHint': 'OpenAI-compatible /chat/completions base URL, e.g. a local Ollama. Empty = harness model',
113
+ 'polishModel': 'Offline polish model',
114
+ 'polishKeyEnv': 'Offline polish key credential',
104
115
  'voiceCommandsLabel': 'Voice edit commands ("new line", "paragraph")',
105
116
  'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
106
117
  'messageTitle': 'Voice message',
@@ -199,6 +210,17 @@ window.__ModuleLoader__.load({
199
210
  'streamChunkMs': 'Кусок потока (мс)',
200
211
  'vadAdapt': 'Адаптивная тишина',
201
212
  'vadAdaptHint': 'Авто-подстройка порога под темп речи. 0 = фикс. поведение',
213
+ 'wakeWord': 'Слово-активатор',
214
+ 'wakeWordHint': 'Распознавание в браузере начинает запись, если речь начинается с этой фразы. Пусто = выкл',
215
+ 'bargeIn': 'Перебивание',
216
+ 'polishSend': 'Полировка всего текста перед отправкой',
217
+ 'polishSendHint': 'Прогнать весь текст через модель перед отправкой агенту',
218
+ 'sessionCommands': 'Голосовые команды сессии',
219
+ 'sessionCommandsHint': '«отправь», «отмени», «стоп», «продолжи» — действия сессии, а не текст',
220
+ 'polishBaseUrl': 'Локальный ендпоинт полировки',
221
+ 'polishBaseUrlHint': 'OpenAI-совместимый /chat/completions базовый URL, напр. локальный Ollama. Пусто = модель харнесса',
222
+ 'polishModel': 'Модель офлайн-полировки',
223
+ 'polishKeyEnv': 'Ключ офлайн-полировки',
202
224
  'voiceCommandsLabel': 'Голосовые команды («с новой строки», «абзац»)',
203
225
  'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
204
226
  'messageTitle': 'Голосовое сообщение',
@@ -319,7 +341,8 @@ window.__ModuleLoader__.load({
319
341
  if (!res.ok || !parsed || !parsed.ok) {
320
342
  throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
321
343
  }
322
- return String(parsed.text || '').trim()
344
+ if (parsed.command) return { command: parsed.command, text: '' }
345
+ return { text: String(parsed.text || '').trim(), command: null }
323
346
  }
324
347
 
325
348
  function tidyPhrase(text) {
@@ -616,7 +639,22 @@ window.__ModuleLoader__.load({
616
639
  voice.browser = startBrowserRecognition({
617
640
  lang: language,
618
641
  continuous: true,
619
- onInterim: (text) => { voice.caption = text; voice.notify() },
642
+ onInterim: (text) => {
643
+ voice.caption = text; voice.notify()
644
+ // Wake-word (#45): если interim начинается с ключевой фразы —
645
+ // обрываем прослушивание browser и уходим в обычную запись.
646
+ const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
647
+ if (ww && mode === 'dictation' && !voice.rec) {
648
+ const t = String(text || '').trim().toLowerCase()
649
+ if (t.startsWith(ww)) {
650
+ voice.browser = null
651
+ voice.caption = ''
652
+ openMic(mode)
653
+ .then((rec) => { voice.rec = rec; voice.notify() })
654
+ .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
655
+ }
656
+ }
657
+ },
620
658
  onFinal: (text) => {
621
659
  finals.push(text)
622
660
  voice.caption = ''
@@ -773,7 +811,9 @@ window.__ModuleLoader__.load({
773
811
  if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
774
812
  voice.set({ phase: 'processing' })
775
813
  try {
776
- const text = await sendAudio(blob, rec.mime, mode)
814
+ const out = await sendAudio(blob, rec.mime, mode)
815
+ if (out.command) { runSessionCommand(out.command); return }
816
+ const text = out.text || ''
777
817
  if (!text) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
778
818
  appendDraft(text)
779
819
  if (mode === 'message') {
@@ -791,8 +831,46 @@ window.__ModuleLoader__.load({
791
831
  voice.pending = null
792
832
  voice.set({ phase: 'idle' })
793
833
  const actions = voice.inputActions
794
- if (actions && typeof actions.submit === 'function') {
795
- setTimeout(() => { try { actions.submit() } catch (e) { /* композер занят */ } }, 0)
834
+ if (!actions || typeof actions.submit !== 'function') return
835
+ // Полировка всего draft перед отправкой (#46). Ошибка не блокирует.
836
+ if (voice.settings.polishSend) {
837
+ const run = async () => {
838
+ try {
839
+ const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
840
+ if (draft.trim()) {
841
+ const res = await fetch('/dsh-voice/polish', {
842
+ method: 'POST',
843
+ headers: { 'content-type': 'application/json' },
844
+ body: JSON.stringify({ text: draft }),
845
+ })
846
+ const parsed = await res.json().catch(() => null)
847
+ if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
848
+ actions.setDraft(parsed.text.trim())
849
+ }
850
+ }
851
+ } catch (e) { /* полировка best-effort */ }
852
+ }
853
+ run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* занят */ } }, 0))
854
+ return
855
+ }
856
+ setTimeout(() => { try { actions.submit() } catch (e) { /* композер занят */ } }, 0)
857
+ }
858
+
859
+ // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи».
860
+ function runSessionCommand(cmd) {
861
+ const actions = voice.inputActions
862
+ voice.set({ phase: 'idle', pending: null })
863
+ switch (cmd) {
864
+ case 'send':
865
+ case 'continue':
866
+ if (actions && typeof actions.submit === 'function') actions.submit()
867
+ break
868
+ case 'cancel':
869
+ case 'stop':
870
+ // Сбрасываем ожидание/запись; текст draft намеренно не трогаем.
871
+ break
872
+ default:
873
+ break
796
874
  }
797
875
  }
798
876
 
@@ -1010,6 +1088,10 @@ window.__ModuleLoader__.load({
1010
1088
  stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
1011
1089
  streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
1012
1090
  vadAdapt: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadAdapt) || 0,
1091
+ wakeWord: String((data && data.wakeWord) || ''),
1092
+ bargeIn: !!(data && data.bargeIn),
1093
+ polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
1094
+ sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
1013
1095
  })
1014
1096
  })
1015
1097
  .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
@@ -1221,6 +1303,14 @@ window.__ModuleLoader__.load({
1221
1303
  return () => { alive = false; off() }
1222
1304
  }, [])
1223
1305
 
1306
+ const [devices, setDevices] = React.useState([])
1307
+ React.useEffect(() => {
1308
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
1309
+ navigator.mediaDevices.enumerateDevices()
1310
+ .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
1311
+ .catch(() => {})
1312
+ }, [])
1313
+
1224
1314
  // Снимок приходит со статусом, и он важнее самого значения.
1225
1315
  // loading — ответа хоста ещё нет;
1226
1316
  // unavailable — хост ответил, но наш namespace ему пока неизвестен:
@@ -1267,6 +1357,11 @@ window.__ModuleLoader__.load({
1267
1357
  stream: !!(value && value.dictation && value.dictation.stream),
1268
1358
  streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
1269
1359
  vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
1360
+ wakeWord: String((snap && snap.wakeWord) || ''),
1361
+ bargeIn: !!(snap && snap.bargeIn),
1362
+ polishSend: !!(value && value.message && value.message.polishSend),
1363
+ sessionCommands: !!(value && value.message && value.message.sessionCommands),
1364
+ polishBaseUrl: String((snap && snap.polishBaseUrl) || ''),
1270
1365
  })
1271
1366
  }, [ready, value, snap])
1272
1367
 
@@ -1355,26 +1450,14 @@ window.__ModuleLoader__.load({
1355
1450
  t('hotkeyHint1')
1356
1451
  + t('hotkeyHint2')))
1357
1452
 
1358
- const micField = () => {
1359
- const devices = React.useState([])[0]
1360
- const setDevices = React.useState([])[1]
1361
- React.useEffect(() => {
1362
- if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
1363
- navigator.mediaDevices.enumerateDevices()
1364
- .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
1365
- .catch(() => {})
1366
- }, [])
1367
- return React.createElement('label', { className: 'dvs-field' }, t('mic'),
1453
+ const micField = () => React.createElement('label', { className: 'dvs-field' }, t('mic'),
1368
1454
  React.createElement('select', {
1369
1455
  value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1370
1456
  onChange: (e) => setTop('micDeviceId', e.target.value),
1371
1457
  },
1372
1458
  React.createElement('option', { value: '' }, t('micDefault')),
1373
1459
  devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1374
- d.label || d.deviceId.slice(0, 12)))),
1375
- )
1376
- }
1377
-
1460
+ d.label || d.deviceId.slice(0, 12)))))
1378
1461
  const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
1379
1462
  React.createElement('select', {
1380
1463
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
@@ -1413,6 +1496,11 @@ window.__ModuleLoader__.load({
1413
1496
  onChange: (e) => setIn('dictation', 'polish', e.target.checked),
1414
1497
  })),
1415
1498
  React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
1499
+ React.createElement('label', { className: 'dvs-field', title: t('wakeWordHint') }, t('wakeWord'),
1500
+ React.createElement('input', {
1501
+ type: 'text', value: String((snap && snap.wakeWord) || ''), disabled: !writable,
1502
+ onChange: (e) => setTop('wakeWord', e.target.value),
1503
+ })),
1416
1504
  React.createElement('label', { className: 'dvs-field', title: t('streamHint') }, t('stream'),
1417
1505
  React.createElement('input', {
1418
1506
  type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
@@ -1441,6 +1529,16 @@ window.__ModuleLoader__.load({
1441
1529
  type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
1442
1530
  onChange: (e) => setIn('message', 'polish', e.target.checked),
1443
1531
  })),
1532
+ React.createElement('label', { className: 'dvs-field', title: t('polishSendHint') }, t('polishSend'),
1533
+ React.createElement('input', {
1534
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
1535
+ onChange: (e) => setIn('message', 'polishSend', e.target.checked),
1536
+ })),
1537
+ React.createElement('label', { className: 'dvs-field', title: t('sessionCommandsHint') }, t('sessionCommands'),
1538
+ React.createElement('input', {
1539
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
1540
+ onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
1541
+ })),
1444
1542
  ),
1445
1543
  React.createElement('div', { className: 'dvs-block' },
1446
1544
  React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
@@ -1490,6 +1588,21 @@ window.__ModuleLoader__.load({
1490
1588
  value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
1491
1589
  onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
1492
1590
  })),
1591
+ React.createElement('label', { className: 'dvs-field', title: t('polishBaseUrlHint') }, t('polishBaseUrl'),
1592
+ React.createElement('input', {
1593
+ type: 'text', value: String((snap && snap.polishBaseUrl) || ''), disabled: !writable,
1594
+ onChange: (e) => setTop('polishBaseUrl', e.target.value),
1595
+ })),
1596
+ React.createElement('label', { className: 'dvs-field' }, t('polishModel'),
1597
+ React.createElement('input', {
1598
+ type: 'text', value: String((draft && draft.polishModel) || ''), disabled: !writable,
1599
+ onChange: (e) => setTop('polishModel', e.target.value),
1600
+ })),
1601
+ React.createElement('label', { className: 'dvs-field' }, t('polishKeyEnv'),
1602
+ React.createElement('input', {
1603
+ type: 'text', value: String((draft && draft.polishKeyEnv) || ''), disabled: !writable,
1604
+ onChange: (e) => setTop('polishKeyEnv', e.target.value),
1605
+ })),
1493
1606
  ),
1494
1607
  React.createElement('div', { className: 'dvs-row' },
1495
1608
  React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
package/lib/index.js CHANGED
@@ -83,6 +83,10 @@ export const Config = z.object({
83
83
  language: z.string().default('ru'),
84
84
  autoSendMs: z.number().default(4000)
85
85
  .description('Cancel window before the recognized text is sent to the agent.'),
86
+ sessionCommands: z.boolean().default(false)
87
+ .description('Voice session commands: a clean "send", "cancel", "stop", "continue" does not become text — it drives the composer/session.'),
88
+ polishSend: z.boolean().default(false)
89
+ .description('Polish the whole composed draft through the model right before sending, not just single phrases.'),
86
90
  }).default({}),
87
91
  hotkey: z.string().default('Control')
88
92
  .description('Hold this key anywhere in the page to record a voice message; release to send, '
@@ -120,6 +124,16 @@ export const Config = z.object({
120
124
  .description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
121
125
  voiceCommands: z.boolean().default(false)
122
126
  .description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
127
+ wakeWord: z.string().default('')
128
+ .description('Heads-free dictation: a phrase that, when recognized by the browser leg, starts a recording. Empty disables.'),
129
+ bargeIn: z.boolean().default(false)
130
+ .description('Ongoing playback or a long turn is interrupted by detected speech (browser leg).'),
131
+ polishBaseUrl: z.string().default('')
132
+ .description('Offline polish: OpenAI-compatible /chat/completions endpoint (e.g. local Ollama). Empty uses the harness model.'),
133
+ polishModel: z.string().default('')
134
+ .description('Offline polish: model id on polishBaseUrl.'),
135
+ polishKeyEnv: z.string().default('')
136
+ .description('Offline polish: credential name for the api key. Empty means no Authorization header.'),
123
137
  })
124
138
 
125
139
  const MIME_BY_EXT = {
@@ -236,18 +250,40 @@ export function apply(ctx, baseConfig) {
236
250
  return runChain(order, providers)
237
251
  }
238
252
 
239
- // Полировка транскрипта через модель харнесса (#35). Ошибка/таймаут не
240
- // блокирует: возвращаем сырой текст.
253
+ // Полировка транскрипта (#35) с поддержкой локального LLM (#47).
254
+ // Ошибка/таймаут не блокирует: возвращаем сырой текст.
241
255
  async function polishText(text, modeCfg, signal) {
242
- if (!modeCfg || modeCfg.polish !== true) return text
256
+ const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
257
+ if (!enable || !text) return text
258
+ const cfg = live()
259
+ const ask =
260
+ 'Fix the punctuation and spelling of this dictated text and split it into '
261
+ + 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
262
+ + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
263
+ + 'Reply with the polished text only:\n\n' + text
243
264
  try {
265
+ // Локальный OpenAI-совместимый эндпоинт (#47), когда задан.
266
+ if (cfg.polishBaseUrl) {
267
+ const headers = { 'content-type': 'application/json' }
268
+ if (cfg.polishKeyEnv) {
269
+ const key = await resolveKey(cfg.polishKeyEnv)
270
+ if (key) headers.authorization = 'Bearer ' + key
271
+ }
272
+ const res = await fetch(
273
+ String(cfg.polishBaseUrl).replace(/\/+$/, '') + '/chat/completions',
274
+ { method: 'POST', headers, signal, body: JSON.stringify({
275
+ model: cfg.polishModel || 'local-model',
276
+ messages: [{ role: 'user', content: ask }],
277
+ }) })
278
+ if (!res.ok) return text
279
+ const data = await res.json().catch(() => null)
280
+ const pick = data && data.choices && data.choices[0] && data.choices[0].message
281
+ && data.choices[0].message.content
282
+ return (pick && String(pick).trim()) || text
283
+ }
284
+ // Штатная модель харнесса.
244
285
  const llm = ctx.llm
245
286
  if (!llm || typeof llm.stream !== 'function') return text
246
- const ask =
247
- 'Fix the punctuation and spelling of this dictated text and split it into '
248
- + 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
249
- + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
250
- + 'Reply with the polished text only:\n\n' + text
251
287
  let acc = ''
252
288
  for await (const chunk of llm.stream({ messages: [{ role: 'user', content: ask }], signal })) {
253
289
  acc += (chunk && (chunk.text || (chunk.delta && chunk.delta.text))) || ''
@@ -257,6 +293,23 @@ export function apply(ctx, baseConfig) {
257
293
  } catch { return text }
258
294
  }
259
295
 
296
+ // Чистые голосовые команды сессии (#48). Возвращает название команды или null.
297
+ // Распознаются на сервере, чтобы не попадать в текст композера.
298
+ const SESSION_COMMANDS = [
299
+ { re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
300
+ { re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
301
+ { re: /^(стоп|stop|хватит)\s*[.!?]*$/i, cmd: 'stop' },
302
+ { re: /^(продолжи|continue|продолжай)\s*[.!?]*$/i, cmd: 'continue' },
303
+ ]
304
+ function sessionCommand(text) {
305
+ const t = String(text || '').trim().toLowerCase()
306
+ if (!t) return null
307
+ for (const { re, cmd } of SESSION_COMMANDS) {
308
+ if (re.test(t)) return cmd
309
+ }
310
+ return null
311
+ }
312
+
260
313
  ctx.effect(() => ctx.webServer.register({
261
314
  kind: 'exact',
262
315
  path: '/dsh-voice/status',
@@ -283,6 +336,7 @@ export function apply(ctx, baseConfig) {
283
336
  message: {
284
337
  chain: cfg.message.chain, language: cfg.message.language,
285
338
  autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
339
+ sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
286
340
  },
287
341
  },
288
342
  beep: cfg.beep,
@@ -290,10 +344,36 @@ export function apply(ctx, baseConfig) {
290
344
  micDeviceId: cfg.micDeviceId,
291
345
  historyLimit: cfg.historyLimit,
292
346
  voiceCommands: cfg.voiceCommands,
347
+ wakeWord: String(cfg.wakeWord || ''),
348
+ bargeIn: !!cfg.bargeIn,
349
+ polishBaseUrl: String(cfg.polishBaseUrl || ''),
293
350
  })
294
351
  },
295
352
  }), 'dsh-voice: /status route')
296
353
 
354
+ ctx.effect(() => ctx.webServer.register({
355
+ kind: 'exact',
356
+ path: '/dsh-voice/polish',
357
+ handler: async (req, res) => {
358
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
359
+ const cfg = live()
360
+ let raw
361
+ try { raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
362
+ let payload = {}
363
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* пусто */ }
364
+ const text = typeof payload.text === 'string' ? payload.text.trim() : ''
365
+ if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
366
+ const controller = new AbortController()
367
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
368
+ try {
369
+ const out = await polishText(text, { polish: true }, controller.signal)
370
+ writeJson(res, 200, { ok: true, text: out })
371
+ } catch (e) {
372
+ writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
373
+ } finally { clearTimeout(timer) }
374
+ },
375
+ }), 'dsh-voice: /polish route')
376
+
297
377
  ctx.effect(() => ctx.webServer.register({
298
378
  kind: 'exact',
299
379
  path: '/dsh-voice/transcribe',
@@ -333,6 +413,12 @@ export function apply(ctx, baseConfig) {
333
413
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
334
414
  try {
335
415
  const out = await transcribe(modeCfg, bytes, mime, controller.signal)
416
+ // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи»
417
+ // не становятся текстом, а возвращаются командой для браузера.
418
+ if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
419
+ const cmd = sessionCommand(out.text)
420
+ if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
421
+ }
336
422
  const text = await polishText(out.text, modeCfg, controller.signal)
337
423
  writeJson(res, 200, { ok: true, text, provider: out.provider, tookMs: out.tookMs })
338
424
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",