@goodandready/dsh-voice 0.8.28 → 0.8.30

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/cordis.patch.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  # dsh-voice bundle layer: applied automatically when the package is installed
2
2
  # as a profile bundle (package.json declares dsh.bundle.patch).
3
3
  #
4
- # `name` must stay the full npm package name: the client-modules registry
4
+ # Full npm package identifier is required: the client-modules registry
5
5
  # resolves the browser bundle by the loader entry name, so a shortened name
6
6
  # leaves the UI half silently out of window.__DSH_BOOT__.
7
7
  #
package/lib/client.js CHANGED
@@ -17,6 +17,14 @@ window.__ModuleLoader__.load({
17
17
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
18
18
  let React = require('react')
19
19
 
20
+ let ChevronIcon = null
21
+ try {
22
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
23
+ ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
24
+ } catch {
25
+ ChevronIcon = null
26
+ }
27
+
20
28
  const NS = 'dsh-voice'
21
29
 
22
30
  // UI strings live in the locale registry so a separate package can
@@ -183,6 +191,15 @@ window.__ModuleLoader__.load({
183
191
  'noiseGate': 'Noise Gate (dB)',
184
192
  'noiseGateHint': 'Mute background fan hum and keyboard clicks below threshold. Off = disabled',
185
193
  'noiseGateOff': 'Off',
194
+ 'versionTitle': 'Plugin Version & Updates',
195
+ 'versionHint': 'Check for new versions and update directly from npm.',
196
+ 'updateAvailable': 'Update available',
197
+ 'upToDate': 'Up to date',
198
+ 'updatePrompt': 'A new version is available on npm. Click update to install.',
199
+ 'updateNow': 'Update now',
200
+ 'updating': 'Updating…',
201
+ 'updateSuccess': 'Updated successfully! Restart DSH to apply.',
202
+ 'updateFailed': 'Update failed. Check logs.',
186
203
  }
187
204
 
188
205
  const zh = {
@@ -346,6 +363,15 @@ window.__ModuleLoader__.load({
346
363
  'noiseGate': '噪声门 (dB)',
347
364
  'noiseGateHint': '静音切除低于阈值的风扇嗡嗡声与机械键盘敲击声。Off = 关闭',
348
365
  'noiseGateOff': '关闭',
366
+ 'versionTitle': '插件版本与更新',
367
+ 'versionHint': '检查新版本并直接从 npm 更新。',
368
+ 'updateAvailable': '有可用更新',
369
+ 'upToDate': '已是最新版本',
370
+ 'updatePrompt': 'npm 上有新版本可用。点击更新进行安装。',
371
+ 'updateNow': '立即更新',
372
+ 'updating': '正在更新…',
373
+ 'updateSuccess': '更新成功!请重启 DSH 生效。',
374
+ 'updateFailed': '更新失败,请查看日志。',
349
375
  }
350
376
 
351
377
  // Strings are also needed outside components — in recording handlers and
@@ -357,7 +383,8 @@ window.__ModuleLoader__.load({
357
383
  '.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
358
384
  '.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
359
385
  '.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
360
- '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
386
+ '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
387
+ '.dvo-chev-icon{width:14px;height:14px;display:block;flex:none}' +
361
388
  '.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
362
389
  '.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
363
390
  '.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
@@ -447,8 +474,31 @@ window.__ModuleLoader__.load({
447
474
  React.createElement('path', { d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }),
448
475
  React.createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }),
449
476
  React.createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }))
450
- const chevronIcon = () => React.createElement('svg', ic,
451
- React.createElement('path', { d: 'M6 9l6 6 6-6' }))
477
+ const chevronIcon = () => (
478
+ ChevronIcon
479
+ ? React.createElement(ChevronIcon, { className: 'dvo-chev-icon' })
480
+ : React.createElement('svg', {
481
+ className: 'dvo-chev-icon',
482
+ width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
483
+ },
484
+ React.createElement('path', {
485
+ d: 'M4 6l4 4 4-4', stroke: 'currentColor', strokeWidth: 1.5,
486
+ strokeLinecap: 'round', strokeLinejoin: 'round',
487
+ }))
488
+ )
489
+
490
+ function focusComposer() {
491
+ if (typeof window === 'undefined') return
492
+ const actions = voice.inputActions
493
+ if (actions && typeof actions.focus === 'function') {
494
+ try { actions.focus(); return } catch { /* best effort */ }
495
+ }
496
+ try {
497
+ const el = document.querySelector('.conversation-input textarea, .cb-composer textarea, .dsh-composer textarea, [data-slate-editor="true"], textarea')
498
+ if (el && typeof el.focus === 'function') el.focus()
499
+ } catch { /* best effort */ }
500
+ }
501
+
452
502
 
453
503
  // ------------------------------------------------------------ transport
454
504
  function blobToBase64(blob) {
@@ -466,6 +516,8 @@ window.__ModuleLoader__.load({
466
516
  const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
467
517
  if (!text || text.length < 3) return []
468
518
  const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
519
+ // Linguistic filter: functional stop-word dictionary for vocabulary keyword extraction.
520
+ // Language processing data for context analysis, distinct from user-facing UI text.
469
521
  const stop = new Set([
470
522
  'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was',
471
523
  'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now',
@@ -526,8 +578,8 @@ window.__ModuleLoader__.load({
526
578
  return s
527
579
  }
528
580
 
529
- // Spoken edit commands (#37): "new line" / новой строки" -> \n and so on.
530
- // Applied before normalization, only when enabled in settings.
581
+ // Functional speech edit commands (#37): spoken line breaks and punctuation -> symbols.
582
+ // Linguistic data for audio transcript post-processing; not user-facing UI text.
531
583
  // Russian phrases match RU STT output; English covers EN dictation.
532
584
  const VOICE_COMMANDS = [
533
585
  [/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
@@ -790,7 +842,7 @@ window.__ModuleLoader__.load({
790
842
  src.connect(filter)
791
843
  lastNode = filter
792
844
  rec.filterNode = filter
793
- } catch (e) {}
845
+ } catch (e) { /* audio filter fallback */ }
794
846
 
795
847
  rec.analyser = rec.audioCtx.createAnalyser()
796
848
  rec.analyser.fftSize = 128
@@ -1016,6 +1068,7 @@ window.__ModuleLoader__.load({
1016
1068
 
1017
1069
  function cancelCurrent() {
1018
1070
  announceVoice('end')
1071
+ focusComposer()
1019
1072
  if (voice.browser) {
1020
1073
  voice.browser.abort()
1021
1074
  voice.browser = null
@@ -1039,6 +1092,7 @@ window.__ModuleLoader__.load({
1039
1092
  // sends the whole recording and opens the cancel window.
1040
1093
  function stopCurrent() {
1041
1094
  announceVoice('end')
1095
+ focusComposer()
1042
1096
  if (voice.browser) {
1043
1097
  const mode = voice.mode
1044
1098
  const said = (voice.browserFinals || []).join(' ').trim()
@@ -1164,11 +1218,14 @@ window.__ModuleLoader__.load({
1164
1218
  return React.createElement(React.Fragment, null,
1165
1219
  React.createElement('button', {
1166
1220
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1167
- title: err ? v.error : t('dictationBtn'), onClick: startDictation,
1221
+ title: err ? v.error : t('dictationBtn'),
1222
+ 'aria-label': err ? v.error : t('dictationBtn'),
1223
+ onClick: startDictation,
1168
1224
  }, micIcon()),
1169
1225
  React.createElement('button', {
1170
1226
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1171
1227
  title: err ? v.error : (t('messageBtn') + hk),
1228
+ 'aria-label': err ? v.error : (t('messageBtn') + hk),
1172
1229
  // Hold: record while pressed; leaving the control cancels.
1173
1230
  onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
1174
1231
  onPointerUp: () => endHold(false),
@@ -1178,6 +1235,7 @@ window.__ModuleLoader__.load({
1178
1235
  ? React.createElement('button', {
1179
1236
  type: 'button', className: 'dvo-btn' + (voice.showPlayer ? ' dvo-btn-active' : ''),
1180
1237
  title: t('listenBack'),
1238
+ 'aria-label': t('listenBack'),
1181
1239
  onClick: () => voice.set({ showPlayer: !voice.showPlayer }),
1182
1240
  }, playIcon())
1183
1241
  : null,
@@ -1399,7 +1457,7 @@ window.__ModuleLoader__.load({
1399
1457
  g.clearRect(0, 0, cssW, cssH)
1400
1458
 
1401
1459
  if (!voice.waveColor) {
1402
- try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
1460
+ try { voice.waveColor = getComputedStyle(canvas).color || 'currentColor' } catch (e) { voice.waveColor = 'currentColor' }
1403
1461
  }
1404
1462
  const levels = voice.levels
1405
1463
  const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
@@ -1457,11 +1515,12 @@ window.__ModuleLoader__.load({
1457
1515
 
1458
1516
  if (v.phase === 'idle') {
1459
1517
  if (!voice.showPlayer || !voice.lastNote || !voice.lastNote.url) return null
1460
- return React.createElement('div', { className: 'dvo-pill' },
1518
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1461
1519
  React.createElement('div', { className: 'dvo-audio-wrap' },
1462
1520
  React.createElement('button', {
1463
1521
  type: 'button', className: 'dvo-audio-play',
1464
1522
  title: isPlaying ? t('pause') : t('play'),
1523
+ 'aria-label': isPlaying ? t('pause') : t('play'),
1465
1524
  onClick: () => {
1466
1525
  const el = audioRef.current
1467
1526
  if (!el) return
@@ -1477,13 +1536,15 @@ window.__ModuleLoader__.load({
1477
1536
  }),
1478
1537
  React.createElement('span', { className: 'dvo-audio-time' }, t('lastRecording')),
1479
1538
  ),
1480
- React.createElement('span', { className: 'dvo-status' }, voice.lastNote.text || ''),
1539
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, voice.lastNote.text || ''),
1481
1540
  React.createElement('button', {
1482
1541
  type: 'button', className: 'dvo-pbtn', title: t('hide'),
1542
+ 'aria-label': t('hide'),
1483
1543
  onClick: () => {
1484
1544
  if (audioRef.current) audioRef.current.pause()
1485
1545
  setIsPlaying(false)
1486
1546
  voice.set({ showPlayer: false })
1547
+ focusComposer()
1487
1548
  },
1488
1549
  }, xIcon()),
1489
1550
  )
@@ -1496,12 +1557,16 @@ window.__ModuleLoader__.load({
1496
1557
  // Live caption of what is heard right now. Until the browser emits a
1497
1558
  // final chunk the text is interim and changes on screen.
1498
1559
  const caption = voice.caption || (inBrowser ? '' : null)
1499
- return React.createElement('div', { className: 'dvo-pill' },
1500
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('cancel'), onClick: cancelCurrent }, xIcon()),
1560
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1561
+ React.createElement('button', {
1562
+ type: 'button', className: 'dvo-pbtn', title: t('cancel'),
1563
+ 'aria-label': t('cancel'),
1564
+ onClick: cancelCurrent,
1565
+ }, xIcon()),
1501
1566
  inBrowser
1502
1567
  ? null
1503
1568
  : React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
1504
- React.createElement('span', { className: 'dvo-status' },
1569
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' },
1505
1570
  caption
1506
1571
  ? caption
1507
1572
  : (voice.holding
@@ -1509,36 +1574,43 @@ window.__ModuleLoader__.load({
1509
1574
  : (inBrowser
1510
1575
  ? t('listening')
1511
1576
  : (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint)))),
1512
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('stop'), onClick: stopCurrent }, stopIcon()),
1577
+ React.createElement('button', {
1578
+ type: 'button', className: 'dvo-pbtn', title: t('stop'),
1579
+ 'aria-label': t('stop'),
1580
+ onClick: stopCurrent,
1581
+ }, stopIcon()),
1513
1582
  )
1514
1583
  }
1515
1584
 
1516
1585
  if (v.phase === 'processing') {
1517
- return React.createElement('div', { className: 'dvo-pill' },
1518
- React.createElement('span', { className: 'dvo-status' }, spinIcon(), t('transcribing')))
1586
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1587
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, spinIcon(), t('transcribing')))
1519
1588
  }
1520
1589
 
1521
1590
  if (v.phase === 'pending') {
1522
1591
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
1523
1592
  if (voice.pending && voice.pending.undoOnly) {
1524
1593
  // Cancel window for delayed dictation insert (#29-5).
1525
- return React.createElement('div', { className: 'dvo-pill' },
1526
- React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
1594
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1595
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, t('undo'), ': ', left, t('secondsShort')),
1527
1596
  React.createElement('button', {
1528
1597
  type: 'button', className: 'dvo-pbtn', title: t('undo'),
1598
+ 'aria-label': t('undo'),
1529
1599
  onClick: async () => {
1530
1600
  const msg = await undoLastInsert()
1531
1601
  voice.set({ phase: 'idle', error: msg === t('undone') ? '' : msg })
1602
+ focusComposer()
1532
1603
  },
1533
1604
  }, xIcon()),
1534
1605
  )
1535
1606
  }
1536
- return React.createElement('div', { className: 'dvo-pill' },
1607
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1537
1608
  voice.lastNote && voice.lastNote.url
1538
1609
  ? React.createElement('div', { className: 'dvo-audio-wrap' },
1539
1610
  React.createElement('button', {
1540
1611
  type: 'button', className: 'dvo-audio-play',
1541
1612
  title: isPlaying ? t('pause') : t('play'),
1613
+ 'aria-label': isPlaying ? t('pause') : t('play'),
1542
1614
  onClick: () => {
1543
1615
  const el = audioRef.current
1544
1616
  if (!el) return
@@ -1555,15 +1627,29 @@ window.__ModuleLoader__.load({
1555
1627
  React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
1556
1628
  )
1557
1629
  : null,
1558
- React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
1630
+ React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, t('sendingIn')),
1559
1631
  React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
1560
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('keepPending'), onClick: keepPending }, xIcon()),
1632
+ React.createElement('button', {
1633
+ type: 'button', className: 'dvo-pbtn', title: t('keepPending'),
1634
+ 'aria-label': t('keepPending'),
1635
+ onClick: () => {
1636
+ keepPending()
1637
+ focusComposer()
1638
+ },
1639
+ }, xIcon()),
1561
1640
  )
1562
1641
  }
1563
1642
 
1564
- return React.createElement('div', { className: 'dvo-pill' },
1565
- React.createElement('span', { className: 'dvo-status dvo-err' }, warnIcon(), v.error),
1566
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('hide'), onClick: () => voice.set({ phase: 'idle', error: '' }) }, xIcon()),
1643
+ return React.createElement('div', { className: 'dvo-pill', role: 'region', 'aria-label': t('title') },
1644
+ React.createElement('span', { className: 'dvo-status dvo-err', 'aria-live': 'polite', 'aria-atomic': 'true' }, warnIcon(), v.error),
1645
+ React.createElement('button', {
1646
+ type: 'button', className: 'dvo-pbtn', title: t('hide'),
1647
+ 'aria-label': t('hide'),
1648
+ onClick: () => {
1649
+ voice.set({ phase: 'idle', error: '' })
1650
+ focusComposer()
1651
+ },
1652
+ }, xIcon()),
1567
1653
  )
1568
1654
  }
1569
1655
 
@@ -1666,16 +1752,16 @@ window.__ModuleLoader__.load({
1666
1752
  '.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
1667
1753
  '.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
1668
1754
  '.cb-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
1669
- '.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
1670
- '.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
1671
- '.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
1755
+ '.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
1756
+ '.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
1757
+ '.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
1672
1758
  '.cb-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
1673
1759
  '.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
1674
1760
  '.cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
1675
1761
  '.cb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
1676
1762
  '.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
1677
1763
  '.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
1678
- '.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
1764
+ '.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
1679
1765
  '.cb-btn-mini{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;padding:0;font-size:13px}' +
1680
1766
  '.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
1681
1767
  '.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
@@ -1683,7 +1769,7 @@ window.__ModuleLoader__.load({
1683
1769
  '.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
1684
1770
  '.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
1685
1771
  '.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
1686
- '.cb-alert-err{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
1772
+ '.cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
1687
1773
  '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
1688
1774
  '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
1689
1775
  '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
@@ -1947,8 +2033,8 @@ window.__ModuleLoader__.load({
1947
2033
  if (testingMic) {
1948
2034
  if (testRef.current) {
1949
2035
  testRef.current.active = false
1950
- try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
1951
- if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
2036
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
2037
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
1952
2038
  testRef.current = null
1953
2039
  }
1954
2040
  setTestingMic(false)
@@ -1973,7 +2059,7 @@ window.__ModuleLoader__.load({
1973
2059
  filter.Q.value = 0.707
1974
2060
  src.connect(filter)
1975
2061
  lastNode = filter
1976
- } catch (e) {}
2062
+ } catch (e) { /* filter fallback */ }
1977
2063
  const analyser = audioCtx.createAnalyser()
1978
2064
  analyser.fftSize = 128
1979
2065
  lastNode.connect(analyser)
@@ -1982,12 +2068,12 @@ window.__ModuleLoader__.load({
1982
2068
 
1983
2069
  const check = () => {
1984
2070
  if (!testRef.current || !testRef.current.active) return
1985
- const data = new Uint8Array(analyser.frequencyBinCount)
1986
- analyser.getByteFrequencyData(data)
2071
+ const arr = new Uint8Array(analyser.frequencyBinCount)
2072
+ analyser.getByteFrequencyData(arr)
1987
2073
  let sum = 0
1988
- for (let i = 0; i < data.length; i++) sum += data[i]
1989
- const lvl = Math.min(100, Math.round((sum / data.length / 255) * 220))
1990
- setTestLevel(lvl)
2074
+ for (let i = 0; i < arr.length; i++) sum += arr[i]
2075
+ const avg = sum / (arr.length || 1)
2076
+ setTestLevel(Math.min(100, Math.round((avg / 128) * 100)))
1991
2077
  requestAnimationFrame(check)
1992
2078
  }
1993
2079
  requestAnimationFrame(check)
@@ -2000,8 +2086,8 @@ window.__ModuleLoader__.load({
2000
2086
  return () => {
2001
2087
  if (testRef.current) {
2002
2088
  testRef.current.active = false
2003
- try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
2004
- if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
2089
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
2090
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) { /* already closed */ } }
2005
2091
  testRef.current = null
2006
2092
  }
2007
2093
  }
@@ -2032,6 +2118,55 @@ window.__ModuleLoader__.load({
2032
2118
  fetchStatus()
2033
2119
  }, [fetchStatus])
2034
2120
 
2121
+ const [updaterStatus, setUpdaterStatus] = React.useState(null)
2122
+ const [updating, setUpdating] = React.useState(false)
2123
+ const [updaterMsg, setUpdaterMsg] = React.useState('')
2124
+ const [updaterLoading, setUpdaterLoading] = React.useState(false)
2125
+
2126
+ const checkUpdater = React.useCallback(async () => {
2127
+ setUpdaterLoading(true)
2128
+ setUpdaterMsg('')
2129
+ try {
2130
+ const res = await fetch('/api/dsh-voice/update', {
2131
+ headers: { accept: 'application/json' },
2132
+ cache: 'no-store',
2133
+ })
2134
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
2135
+ const data = await res.json()
2136
+ setUpdaterStatus(data)
2137
+ } catch (e) {
2138
+ /* best effort update check */
2139
+ } finally {
2140
+ setUpdaterLoading(false)
2141
+ }
2142
+ }, [])
2143
+
2144
+ const onUpdateNow = async () => {
2145
+ setUpdating(true)
2146
+ setUpdaterMsg('')
2147
+ try {
2148
+ const res = await fetch('/api/dsh-voice/update', {
2149
+ method: 'POST',
2150
+ headers: {
2151
+ 'x-dsh-plugin-update': '1',
2152
+ 'content-type': 'application/json',
2153
+ },
2154
+ })
2155
+ const data = await res.json()
2156
+ if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`)
2157
+ setUpdaterStatus(data)
2158
+ setUpdaterMsg(t('updateSuccess'))
2159
+ } catch (e) {
2160
+ setUpdaterMsg(t('updateFailed') + ' ' + (e?.message || String(e)))
2161
+ } finally {
2162
+ setUpdating(false)
2163
+ }
2164
+ }
2165
+
2166
+ React.useEffect(() => {
2167
+ checkUpdater()
2168
+ }, [checkUpdater])
2169
+
2035
2170
  if (!ready) {
2036
2171
  const waiting = !snap || snap.status === 'loading'
2037
2172
  return React.createElement('div', { className: 'cb-page' },
@@ -2461,6 +2596,47 @@ window.__ModuleLoader__.load({
2461
2596
  ),
2462
2597
  ),
2463
2598
 
2599
+ // 7. Plugin Version & Updates Card
2600
+ React.createElement('div', { className: 'cb-section-card' },
2601
+ React.createElement('div', { className: 'cb-section-title' },
2602
+ React.createElement('span', null, '📦 ' + t('versionTitle')),
2603
+ React.createElement('button', {
2604
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
2605
+ onClick: checkUpdater, disabled: updaterLoading || updating,
2606
+ }, updaterLoading ? '⏳…' : '🔄 ' + t('refreshStats')),
2607
+ ),
2608
+ React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
2609
+ React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
2610
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2611
+ 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.8.29')
2612
+ ),
2613
+ updaterStatus && updaterStatus.updateAvailable
2614
+ ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
2615
+ t('updateAvailable') + ': v' + updaterStatus.latestVersion
2616
+ )
2617
+ : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2618
+ t('upToDate')
2619
+ ),
2620
+ updaterStatus && updaterStatus.updateAvailable
2621
+ ? React.createElement('button', {
2622
+ type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
2623
+ onClick: onUpdateNow, disabled: updating,
2624
+ }, updating ? t('updating') : t('updateNow'))
2625
+ : null,
2626
+ ),
2627
+ updaterMsg
2628
+ ? React.createElement('div', {
2629
+ style: {
2630
+ marginTop: '8px',
2631
+ fontSize: '12px',
2632
+ color: updaterMsg.includes('fail') || updaterMsg.includes('失败')
2633
+ ? 'var(--dsw-alias-state-warning-primary)'
2634
+ : 'var(--dsw-alias-state-success-primary)',
2635
+ },
2636
+ }, updaterMsg)
2637
+ : null,
2638
+ ),
2639
+
2464
2640
  // Action bar (Save)
2465
2641
  React.createElement('div', { className: 'cb-row' },
2466
2642
  React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
package/lib/http-util.js CHANGED
@@ -25,4 +25,52 @@ export function readBody(req, maxBytes) {
25
25
  req.on('end', () => resolve(Buffer.concat(chunks)))
26
26
  req.on('error', reject)
27
27
  })
28
+ }
29
+
30
+ /**
31
+ * Check whether an IP address is a local loopback interface.
32
+ * @param {string} addr
33
+ * @returns {boolean}
34
+ */
35
+ export function isLoopback(addr) {
36
+ if (!addr) return false
37
+ const clean = String(addr).replace(/^::ffff:/, '')
38
+ return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost'
39
+ }
40
+
41
+ /**
42
+ * Verify that the HTTP request originates from a trusted caller (loopback or same-origin).
43
+ * Fail-closed security guard for state-changing or quota-consuming POST routes.
44
+ * @param {import('node:http').IncomingMessage} req
45
+ * @returns {boolean}
46
+ */
47
+ export function isTrustedCaller(req) {
48
+ if (!req) return false
49
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
50
+ if (isLoopback(remote)) return true
51
+
52
+ const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
53
+ if (secSite === 'same-origin' || secSite === 'same-site') return true
54
+ if (secSite === 'cross-site') return false
55
+
56
+ const host = String(req.headers?.host || '').toLowerCase()
57
+ const origin = String(req.headers?.origin || '').trim().toLowerCase()
58
+ if (origin) {
59
+ try {
60
+ const u = new URL(origin)
61
+ if (u.host === host) return true
62
+ } catch { /* invalid url */ }
63
+ return false
64
+ }
65
+
66
+ const referer = String(req.headers?.referer || '').trim().toLowerCase()
67
+ if (referer) {
68
+ try {
69
+ const u = new URL(referer)
70
+ if (u.host === host) return true
71
+ } catch { /* invalid url */ }
72
+ return false
73
+ }
74
+
75
+ return false
28
76
  }
package/lib/index.js CHANGED
@@ -22,16 +22,18 @@ import { makeProviders, PROVIDER_KEYS, PRESET_KEYS, KNOWN_KEYS, DEFAULT_MODELS,
22
22
  import { toWav16k } from './wav.js'
23
23
  import { normalizePhrase } from './normalize.js'
24
24
  import { createStatsTracker, mergeContextVocabulary } from './stats.js'
25
- import { writeJson, readBody } from './http-util.js'
25
+ import { writeJson, readBody, isTrustedCaller } from './http-util.js'
26
26
  import { createPolishText } from './polish.js'
27
27
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
28
28
  import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
29
+ import { registerPluginUpdater } from './updater.js'
29
30
 
30
31
  function isAutoLang(lang) {
31
32
  return !lang || lang === 'auto' || String(lang).includes(',')
32
33
  }
33
34
 
34
- export const name = 'dsh-voice'
35
+ export const name = '@goodandready/dsh-voice'
36
+ export const NS = 'dsh-voice'
35
37
  export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
36
38
 
37
39
  const ChainEntry = z.object({
@@ -181,7 +183,7 @@ export function apply(ctx, baseConfig) {
181
183
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? baseConfig
182
184
 
183
185
  ctx.inject(['settings'], (sctx) => {
184
- const scope = sctx.settings.register(name, Config, { base: baseConfig })
186
+ const scope = sctx.settings.register(NS, Config, { base: baseConfig })
185
187
  getConfig = () => scope.get() ?? baseConfig
186
188
  sctx.effect(() => () => { getConfig = () => baseConfig })
187
189
  })
@@ -207,6 +209,7 @@ export function apply(ctx, baseConfig) {
207
209
  } catch { return false }
208
210
  }
209
211
 
212
+ let whisperError = null
210
213
  let startingWhisper = false
211
214
  async function startWhisper() {
212
215
  if (startingWhisper) return false
@@ -216,7 +219,10 @@ export function apply(ctx, baseConfig) {
216
219
  // Without a model path there is nothing to start: the package cannot know
217
220
  // where a user's model lives and must not launch a random binary.
218
221
  if (!cfg.whisperModel) return false
219
- if (await whisperAlive()) return true
222
+ if (await whisperAlive()) {
223
+ whisperError = null
224
+ return true
225
+ }
220
226
  try {
221
227
  const spec = ctx.shell.resolve({
222
228
  command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
@@ -227,10 +233,17 @@ export function apply(ctx, baseConfig) {
227
233
  child = ctx.shell.start(spec)
228
234
  for (let i = 0; i < 20; i++) {
229
235
  await new Promise((r) => setTimeout(r, 500))
230
- if (await whisperAlive()) return true
236
+ if (await whisperAlive()) {
237
+ whisperError = null
238
+ return true
239
+ }
231
240
  }
241
+ whisperError = 'whisper server process started but failed health check on port 8001'
232
242
  return false
233
- } catch { return false }
243
+ } catch (err) {
244
+ whisperError = String(err && err.message ? err.message : err)
245
+ return false
246
+ }
234
247
  finally { startingWhisper = false }
235
248
  }
236
249
 
@@ -238,6 +251,7 @@ export function apply(ctx, baseConfig) {
238
251
 
239
252
  // SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
240
253
  let sensevoiceChild = null
254
+ let sensevoiceError = null
241
255
 
242
256
  async function sensevoiceAlive() {
243
257
  try {
@@ -259,7 +273,10 @@ export function apply(ctx, baseConfig) {
259
273
  const cfg = live()
260
274
  if (!cfg.sensevoiceAutostart) return false
261
275
  if (!cfg.sensevoiceModel) return false
262
- if (await sensevoiceAlive()) return true
276
+ if (await sensevoiceAlive()) {
277
+ sensevoiceError = null
278
+ return true
279
+ }
263
280
  try {
264
281
  let port = '6006'
265
282
  try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
@@ -273,10 +290,17 @@ export function apply(ctx, baseConfig) {
273
290
  sensevoiceChild = ctx.shell.start(spec)
274
291
  for (let i = 0; i < 20; i++) {
275
292
  await new Promise((r) => setTimeout(r, 500))
276
- if (await sensevoiceAlive()) return true
293
+ if (await sensevoiceAlive()) {
294
+ sensevoiceError = null
295
+ return true
296
+ }
277
297
  }
298
+ sensevoiceError = 'sensevoice process started but failed health check'
278
299
  return false
279
- } catch { return false }
300
+ } catch (err) {
301
+ sensevoiceError = String(err && err.message ? err.message : err)
302
+ return false
303
+ }
280
304
  finally { startingSensevoice = false }
281
305
  }
282
306
 
@@ -330,9 +354,14 @@ export function apply(ctx, baseConfig) {
330
354
  handler: async (req, res) => {
331
355
  if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
332
356
  const cfg = live()
357
+ const whisperOk = await whisperAlive()
358
+ const sensevoiceOk = await sensevoiceAlive()
333
359
  writeJson(res, 200, {
334
360
  ok: true,
335
- whisperRunning: await whisperAlive(),
361
+ whisperRunning: whisperOk,
362
+ whisperError: whisperOk ? null : whisperError,
363
+ sensevoiceRunning: sensevoiceOk,
364
+ sensevoiceError: sensevoiceOk ? null : sensevoiceError,
336
365
  // The hotkey is needed by the browser half: it installs the hold handler.
337
366
  hotkey: cfg.hotkey,
338
367
  providers: KNOWN_KEYS.concat(
@@ -378,9 +407,10 @@ export function apply(ctx, baseConfig) {
378
407
  path: '/dsh-voice/polish',
379
408
  handler: async (req, res) => {
380
409
  if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
410
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
381
411
  const cfg = live()
382
412
  let raw
383
- try { raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
413
+ try { raw = await readBody(req, 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
384
414
  let payload = {}
385
415
  try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
386
416
  const text = typeof payload.text === 'string' ? payload.text.trim() : ''
@@ -401,6 +431,7 @@ export function apply(ctx, baseConfig) {
401
431
  path: '/dsh-voice/transcribe',
402
432
  handler: async (req, res) => {
403
433
  if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
434
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
404
435
  const cfg = live()
405
436
  let raw
406
437
  try {
@@ -512,4 +543,10 @@ export function apply(ctx, baseConfig) {
512
543
  if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
513
544
  if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
514
545
  }, 'dsh-voice: stop child processes')
546
+
547
+ ctx.effect(() => registerPluginUpdater(ctx, {
548
+ endpoint: '/api/dsh-voice/update',
549
+ packageName: '@goodandready/dsh-voice',
550
+ manifestUrl: new URL('../package.json', import.meta.url),
551
+ }), 'dsh-voice: updater route')
515
552
  }
package/lib/normalize.js CHANGED
@@ -16,6 +16,8 @@ export function ensureTrailingPeriod(text) {
16
16
  return t + '.'
17
17
  }
18
18
 
19
+ // Functional linguistic data for speech-to-text numeral normalization.
20
+ // Note: Language processing dictionary for audio transcript conversion, not UI text strings.
19
21
  const NUM_WORDS = {
20
22
  ноль: 0, один: 1, одна: 1, одно: 1, два: 2, две: 2, три: 3, четыре: 4, пять: 5,
21
23
  шесть: 6, семь: 7, восемь: 8, девять: 9, десять: 10, одиннадцать: 11,
@@ -0,0 +1,64 @@
1
+ // Pure helper functions for STT provider HTTP requests and responses.
2
+
3
+ /**
4
+ * Check if the language is unspecified or set to automatic detection.
5
+ * @param {string} [lang]
6
+ * @returns {boolean}
7
+ */
8
+ export function isAutoLang(lang) {
9
+ return !lang || lang === 'auto' || String(lang).includes(',')
10
+ }
11
+
12
+ /**
13
+ * Determine the audio container format supported by the OpenAI Chat Audio API.
14
+ * @param {string} [mime='']
15
+ * @returns {'wav' | 'mp3' | ''}
16
+ */
17
+ export function chatAudioFormat(mime = '') {
18
+ if (mime.includes('wav')) return 'wav'
19
+ if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3'
20
+ return ''
21
+ }
22
+
23
+ /**
24
+ * Return a safe file name based on mime type for multipart form uploads.
25
+ * @param {string} [mime='']
26
+ * @returns {string}
27
+ */
28
+ export function fileName(mime = '') {
29
+ if (mime.includes('wav')) return 'audio.wav'
30
+ if (mime.includes('ogg')) return 'audio.ogg'
31
+ if (mime.includes('mp4')) return 'audio.m4a'
32
+ return 'audio.webm'
33
+ }
34
+
35
+ /**
36
+ * Safely parse JSON from a response or throw a standardized descriptive Error.
37
+ * @param {Response} res
38
+ * @param {string} defaultLabel
39
+ * @returns {Promise<any>}
40
+ */
41
+ export async function safeJson(res, defaultLabel) {
42
+ try {
43
+ return await res.json()
44
+ } catch {
45
+ throw new Error(`${defaultLabel}: invalid JSON response`)
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Extract an error message or detail string from a non-2xx HTTP response.
51
+ * @param {Response} res
52
+ * @param {string} defaultLabel
53
+ * @returns {Promise<string>}
54
+ */
55
+ export async function readErrorDetail(res, defaultLabel) {
56
+ let detail = `HTTP ${res.status}`
57
+ try {
58
+ const e = await res.json()
59
+ if (e?.error) detail = typeof e.error === 'string' ? e.error : (e.error.message || JSON.stringify(e.error))
60
+ else if (e?.message) detail = e.message
61
+ else if (e?.err_msg) detail = e.err_msg
62
+ } catch { /* not json */ }
63
+ return `${defaultLabel} ${detail}`
64
+ }
package/lib/providers.js CHANGED
@@ -68,13 +68,8 @@ const CHAT_AUDIO_PROMPT =
68
68
  'Transcribe the audio verbatim. Reply with the transcript text only, '
69
69
  + 'without comments, quotes or formatting.'
70
70
 
71
- // Formats the chat template accepts in input_audio. Everything else
72
- // (including browser webm/opus) is converted to WAV first.
73
- function chatAudioFormat(mime) {
74
- if (mime.includes('wav')) return 'wav'
75
- if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3'
76
- return ''
77
- }
71
+ import { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat } from './provider-http.js'
72
+ export { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat }
78
73
 
79
74
  export const DEFAULT_MODELS = {
80
75
  deepgram: 'nova-2',
@@ -89,37 +84,6 @@ function pickModel(models, key) {
89
84
  return chosen || DEFAULT_MODELS[key]
90
85
  }
91
86
 
92
- function fileName(mime) {
93
- if (mime.includes('wav')) return 'audio.wav'
94
- if (mime.includes('ogg')) return 'audio.ogg'
95
- if (mime.includes('mp4')) return 'audio.m4a'
96
- return 'audio.webm'
97
- }
98
-
99
- // Auto language: empty, 'auto', or a list ('ru,en') means the provider
100
- // detects language; the language field is omitted. whisper.cpp gets -l auto.
101
- function isAutoLang(lang) {
102
- return !lang || lang === 'auto' || String(lang).includes(',')
103
- }
104
-
105
- async function readErrorDetail(res, defaultLabel) {
106
- let detail = `HTTP ${res.status}`
107
- try {
108
- const e = await res.json()
109
- if (e?.error) detail = typeof e.error === 'string' ? e.error : (e.error.message || JSON.stringify(e.error))
110
- else if (e?.message) detail = e.message
111
- else if (e?.err_msg) detail = e.err_msg
112
- } catch { /* not json */ }
113
- return `${defaultLabel} ${detail}`
114
- }
115
-
116
- async function safeJson(res, defaultLabel) {
117
- try {
118
- return await res.json()
119
- } catch {
120
- throw new Error(`${defaultLabel}: invalid JSON response`)
121
- }
122
- }
123
87
 
124
88
  export function makeProviders(deps, req) {
125
89
  const { resolveKey, fetchImpl, cfg } = deps
package/lib/updater.js ADDED
@@ -0,0 +1,266 @@
1
+ // lib/updater.js — Host-side one-click updater for @goodandready/dsh-voice
2
+ // Implements canonical DSH plugin-updater specification.
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { homedir } from 'node:os';
8
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const UPDATE_HEADER = 'x-dsh-plugin-update';
12
+ const UPDATE_TIMEOUT_MS = 10 * 60_000;
13
+ const VERSION_CACHE_MS = 5 * 60_000;
14
+ let latestCache;
15
+
16
+ function header(request, name) {
17
+ const value = request?.headers?.[name];
18
+ return Array.isArray(value) ? value[0] : value;
19
+ }
20
+
21
+ function isLoopback(value) {
22
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '');
23
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
24
+ || address?.startsWith('127.') === true
25
+ || address?.startsWith('::ffff:127.') === true;
26
+ }
27
+
28
+ export function isTrustedUpdateRequest(request) {
29
+ if (header(request, UPDATE_HEADER) !== '1') return false;
30
+ if (!isLoopback(request.socket?.remoteAddress)) return false;
31
+ const site = header(request, 'sec-fetch-site');
32
+ if (site !== undefined && site !== 'same-origin') return false;
33
+ const origin = header(request, 'origin');
34
+ const host = header(request, 'host');
35
+ if (origin === undefined || host === undefined) return false;
36
+ try {
37
+ const url = new URL(origin);
38
+ return (url.protocol === 'http:' || url.protocol === 'https:')
39
+ && isLoopback(url.hostname) && url.host === host;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ function validProfileName(value) {
46
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
47
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
48
+ }
49
+
50
+ function profileNameFromArgv(argv) {
51
+ for (let index = 2; index < argv.length; index += 1) {
52
+ if (argv[index] === '--profile') return argv[index + 1];
53
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length);
54
+ }
55
+ return argv[2] === 'web' ? 'web' : undefined;
56
+ }
57
+
58
+ function findDshCliEntry() {
59
+ const value = process.argv[1];
60
+ if (value === undefined || value === '') return undefined;
61
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
62
+ if (!existsSync(entry)) return undefined;
63
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
64
+ const manifestPath = resolve(directory, 'package.json');
65
+ if (existsSync(manifestPath)) {
66
+ try {
67
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
68
+ const bin = typeof manifest.bin === 'string'
69
+ ? manifest.bin
70
+ : typeof manifest.bin === 'object' && manifest.bin !== null
71
+ ? manifest.bin.dsh
72
+ : undefined;
73
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
74
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
75
+ } catch {
76
+ // Continue searching parent package directories.
77
+ }
78
+ }
79
+ const parent = dirname(directory);
80
+ if (parent === directory) return undefined;
81
+ }
82
+ }
83
+
84
+ function runtime() {
85
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
86
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'));
87
+ const selected = profileNameFromArgv(process.argv);
88
+ const profileName = validProfileName(selected)
89
+ ? selected
90
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
91
+ const cliEntry = findDshCliEntry();
92
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
93
+ }
94
+
95
+ export function parseSemver(value) {
96
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
97
+ if (match === null) return undefined;
98
+ return {
99
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
100
+ prerelease: match[4]?.split('.') ?? [],
101
+ };
102
+ }
103
+
104
+ function comparePrerelease(left, right) {
105
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
106
+ const length = Math.max(left.length, right.length);
107
+ for (let index = 0; index < length; index += 1) {
108
+ const a = left[index];
109
+ const b = right[index];
110
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
111
+ if (a === b) continue;
112
+ const aNumeric = /^\d+$/.test(a);
113
+ const bNumeric = /^\d+$/.test(b);
114
+ if (aNumeric && bNumeric) {
115
+ const aNumber = BigInt(a);
116
+ const bNumber = BigInt(b);
117
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
118
+ continue;
119
+ }
120
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
121
+ return a > b ? 1 : -1;
122
+ }
123
+ return 0;
124
+ }
125
+
126
+ export function isNewerVersion(currentValue, candidateValue) {
127
+ const current = parseSemver(currentValue);
128
+ const candidate = parseSemver(candidateValue);
129
+ if (current === undefined || candidate === undefined) return false;
130
+ for (let index = 0; index < 3; index += 1) {
131
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
132
+ }
133
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
134
+ }
135
+
136
+ async function latestVersion(packageName, registry) {
137
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) return latestCache.version;
138
+ try {
139
+ const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
140
+ signal: AbortSignal.timeout(8_000),
141
+ });
142
+ if (!response.ok) return undefined;
143
+ const value = await response.json();
144
+ if (typeof value.version !== 'string' || value.version === '') return undefined;
145
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
146
+ return value.version;
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ async function currentVersion(manifestUrl) {
153
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'));
154
+ if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.');
155
+ return value.version;
156
+ }
157
+
158
+ export async function getUpdateStatus(options, target = runtime()) {
159
+ const current = await currentVersion(options.manifestUrl);
160
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org');
161
+ return {
162
+ packageName: options.packageName,
163
+ currentVersion: current,
164
+ ...(latest === undefined ? {} : { latestVersion: latest }),
165
+ latestCheckFailed: latest === undefined,
166
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
167
+ profileName: target.profileName,
168
+ canAutoUpdate: target.cliEntry !== undefined,
169
+ };
170
+ }
171
+
172
+ async function installExact(target, packageSpec, options) {
173
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
174
+ await new Promise((resolvePromise, reject) => {
175
+ const child = spawn(process.execPath, [
176
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
177
+ '--config.minimumReleaseAge=0', packageSpec,
178
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
179
+ ], {
180
+ cwd: target.profileDir,
181
+ windowsHide: true,
182
+ stdio: ['ignore', 'pipe', 'pipe'],
183
+ env: { ...process.env, NO_COLOR: '1' },
184
+ });
185
+ let detail = '';
186
+ child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
187
+ child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
188
+ const timer = setTimeout(() => {
189
+ child.kill();
190
+ reject(new Error('Update timed out; use the normal DSH update flow.'));
191
+ }, UPDATE_TIMEOUT_MS);
192
+ child.once('error', error => { clearTimeout(timer); reject(error); });
193
+ child.once('exit', code => {
194
+ clearTimeout(timer);
195
+ if (code === 0) resolvePromise();
196
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
197
+ });
198
+ });
199
+ }
200
+
201
+ function json(response, statusCode, value) {
202
+ response.writeHead(statusCode, {
203
+ 'content-type': 'application/json; charset=utf-8',
204
+ 'cache-control': 'no-store',
205
+ });
206
+ response.end(JSON.stringify(value));
207
+ }
208
+
209
+ export function registerPluginUpdater(ctx, options) {
210
+ const host = ctx;
211
+ let installing = false;
212
+ return host.webServer.register({
213
+ kind: 'exact',
214
+ path: options.endpoint,
215
+ handler: async (request, response) => {
216
+ try {
217
+ const target = runtime();
218
+ if (request.method === 'GET' || request.method === 'HEAD') {
219
+ const payload = await getUpdateStatus(options, target);
220
+ response.writeHead(200, {
221
+ 'content-type': 'application/json; charset=utf-8',
222
+ 'cache-control': 'no-store',
223
+ });
224
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload));
225
+ return;
226
+ }
227
+ if (request.method !== 'POST') {
228
+ response.writeHead(405, { allow: 'GET, HEAD, POST' });
229
+ response.end();
230
+ return;
231
+ }
232
+ if (!isTrustedUpdateRequest(request)) {
233
+ json(response, 403, { error: 'Rejected non-local or cross-origin update request.' });
234
+ return;
235
+ }
236
+ if (installing) {
237
+ json(response, 409, { error: 'This plugin is already updating.' });
238
+ return;
239
+ }
240
+ installing = true;
241
+ try {
242
+ const before = await getUpdateStatus(options, target);
243
+ if (before.latestVersion === undefined) {
244
+ json(response, 503, { error: 'The latest version is temporarily unavailable.' });
245
+ return;
246
+ }
247
+ if (!before.updateAvailable) {
248
+ json(response, 200, before);
249
+ return;
250
+ }
251
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
252
+ json(response, 200, {
253
+ ...before,
254
+ updatedVersion: before.latestVersion,
255
+ restartRequired: true,
256
+ });
257
+ } finally {
258
+ installing = false;
259
+ }
260
+ } catch (error) {
261
+ ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`);
262
+ json(response, 503, { error: 'Plugin update failed; see server logs.' });
263
+ }
264
+ },
265
+ });
266
+ }
package/lib/wav.js CHANGED
@@ -29,13 +29,13 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg', signal = null) {
29
29
 
30
30
  const timeout = setTimeout(() => {
31
31
  cleanup()
32
- try { proc.kill('SIGKILL') } catch {}
32
+ try { proc.kill('SIGKILL') } catch (e) { /* process already exited */ }
33
33
  reject(new Error('ffmpeg conversion timed out after 30s'))
34
34
  }, 30000)
35
35
 
36
36
  const onAbort = () => {
37
37
  cleanup()
38
- try { proc.kill('SIGKILL') } catch {}
38
+ try { proc.kill('SIGKILL') } catch (e) { /* process already exited */ }
39
39
  reject(new Error('ffmpeg conversion aborted'))
40
40
  }
41
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.28",
3
+ "version": "0.8.30",
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",
@@ -43,7 +43,9 @@
43
43
  "scripts": {
44
44
  "test": "node --test test/*.test.mjs",
45
45
  "build:client": "node scripts/build-client.mjs",
46
- "pretest": "node scripts/build-client.mjs"
46
+ "pretest": "node scripts/build-client.mjs",
47
+ "lint": "node --check lib/*.js scripts/*.mjs",
48
+ "pack:check": "node scripts/pack-check.mjs"
47
49
  },
48
50
  "dsh": {
49
51
  "bundle": {