@goodandready/dsh-tts 0.4.10 → 0.4.16

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
@@ -214,7 +214,6 @@ Provider **API keys are never stored in plugin settings**. Paste them in the cha
214
214
 
215
215
  Config-only (not in the card): `*KeyEnv` fields (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). They only rename the credential slot the plugin looks up. Change them in `settings.yaml` if you must rebind a key name; the defaults match the usual environment variable names.
216
216
 
217
- The sidebar `settings.section` entry is a **fallback** and is registered only when `settings.plugin.item` is missing from the build.
218
217
 
219
218
  ## 🤖 HTTP Endpoints Reference
220
219
 
package/README.ru.md CHANGED
@@ -197,7 +197,6 @@ dsh-tts:
197
197
 
198
198
  Только в конфиге (нет в карточке): поля `*KeyEnv` (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). Они задают **имя** credential-слота; по умолчанию совпадают с привычными именами env-переменных. Меняйте в `settings.yaml`, если нужно перевязать имя ключа.
199
199
 
200
- Пункт `settings.section` в боковом меню — **fallback** и регистрируется, только если в сборке нет `settings.plugin.item`.
201
200
 
202
201
  ## 🤖 HTTP роуты API
203
202
 
package/README.zh.md CHANGED
@@ -164,7 +164,6 @@ dsh plugin --profile web add @goodandready/dsh-tts
164
164
 
165
165
  API 密钥不写入插件设置,只在链编辑器粘贴,经 `PUT /dsh-tts/credential` 写入 DSH credential store。
166
166
 
167
- 仅配置文件:`*KeyEnv` 字段(凭证槽位名称)。侧边栏 `settings.section` 仅为 fallback。
168
167
 
169
168
  ## ⚙️ 配置示例(`settings.yaml`)
170
169
 
@@ -256,7 +256,7 @@
256
256
  function connectSseStream() {
257
257
  if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return
258
258
  if (sseSource) {
259
- try { sseSource.close() } catch {}
259
+ try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
260
260
  sseSource = null
261
261
  }
262
262
  try {
@@ -281,7 +281,7 @@
281
281
  drainQueue()
282
282
  }
283
283
  }
284
- } catch {}
284
+ } catch (parseErr) { /* ignore malformed SSE payload */ }
285
285
  })
286
286
  sseSource.addEventListener('chime', (e) => {
287
287
  sseAttempt = 0
@@ -291,11 +291,11 @@
291
291
  player.queue.push(item)
292
292
  drainQueue()
293
293
  }
294
- } catch {}
294
+ } catch (parseErr) { /* ignore malformed chime payload */ }
295
295
  })
296
296
  sseSource.onerror = () => {
297
297
  if (sseSource) {
298
- try { sseSource.close() } catch {}
298
+ try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
299
299
  sseSource = null
300
300
  }
301
301
  sseAttempt++
@@ -307,7 +307,7 @@
307
307
  connectSseStream()
308
308
  }, delay)
309
309
  }
310
- } catch {}
310
+ } catch (esErr) { /* EventSource failed */ }
311
311
  }
312
312
 
313
313
  async function pollPending() {
@@ -413,7 +413,7 @@ async function pollPending() {
413
413
  document.body.appendChild(a)
414
414
  a.click()
415
415
  document.body.removeChild(a)
416
- setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 2000)
416
+ setTimeout(() => { try { URL.revokeObjectURL(url) } catch (alreadyRevoked) { /* already revoked */ } }, 2000)
417
417
  })
418
418
  .catch(() => {})
419
419
  }
@@ -132,33 +132,7 @@
132
132
  )
133
133
  }
134
134
 
135
- const CLIENT_IT_TERMS = [
136
- { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
137
- { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
138
- { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
139
- { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
140
- { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
141
- { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
142
- { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
143
- { from: 'API', to: 'апи', whole: true, lang: 'ru' },
144
- { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
145
- { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
146
- { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
147
- { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
148
- { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
149
- { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
150
- { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
151
- { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
152
- { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
153
- { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
154
- { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
155
- { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
156
- { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
157
- { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
158
- { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
159
- { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
160
- { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
161
- ]
135
+ const CLIENT_IT_TERMS = []
162
136
 
163
137
  // Pronunciation dictionary editor: rules apply top-down.
164
138
  function PronEditor(props) {
@@ -187,7 +161,7 @@
187
161
  document.body.appendChild(a)
188
162
  a.click()
189
163
  document.body.removeChild(a)
190
- setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 1000)
164
+ setTimeout(() => { try { URL.revokeObjectURL(url) } catch (alreadyRevoked) { /* already revoked */ } }, 1000)
191
165
  }
192
166
 
193
167
  const triggerImport = () => {
@@ -140,36 +140,14 @@
140
140
  // Primary settings home is the plugin-settings tab card. The tab looks up
141
141
  // a slot by entryKey equal to the namespace name: key must equal NS or
142
142
  // the card never appears, with no log error.
143
- const tryPluginItem = () => {
144
- try {
145
- const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
146
- {
147
- name: 'settings.plugin.item',
148
- key: NS,
149
- locale: NS,
150
- inject: () => ({ ctx: ctx }),
151
- },
152
- TtsCard,
153
- ))
154
- if (res === false) return false
155
- return true
156
- } catch (noPluginItemSlot) {
157
- return false
158
- }
159
- }
160
- if (tryPluginItem()) return
161
- // Fallback for builds without settings.plugin.item: keep the sidebar section
162
- // so settings cannot disappear.
163
- ctx.slots.inject('settings.section', () => ctx.slots.register(
143
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
164
144
  {
165
- name: 'settings.section',
166
- id: '@goodandready/dsh-tts',
167
- order: 32,
145
+ name: 'settings.plugin.item',
146
+ key: NS,
168
147
  locale: NS,
169
- label: () => t('title'),
170
148
  inject: () => ({ ctx: ctx }),
171
149
  },
172
- (props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
150
+ TtsCard,
173
151
  ))
174
152
  }
175
153
 
@@ -182,7 +160,7 @@
182
160
  if (typeof window === 'undefined') return () => {}
183
161
  const onKey = (e) => {
184
162
  if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
185
- else if (e.altKey && (e.key === 's' || e.key === 'S' || e.key === 'ы')) { stopPlayback(); e.preventDefault() }
163
+ else if (e.altKey && (e.code === 'KeyS' || e.key === 's' || e.key === 'S')) { stopPlayback(); e.preventDefault() }
186
164
  else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
187
165
  }
188
166
  window.addEventListener('keydown', onKey)
package/lib/client.js CHANGED
@@ -395,7 +395,7 @@ window.__ModuleLoader__.load({
395
395
  function connectSseStream() {
396
396
  if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return
397
397
  if (sseSource) {
398
- try { sseSource.close() } catch {}
398
+ try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
399
399
  sseSource = null
400
400
  }
401
401
  try {
@@ -420,7 +420,7 @@ window.__ModuleLoader__.load({
420
420
  drainQueue()
421
421
  }
422
422
  }
423
- } catch {}
423
+ } catch (parseErr) { /* ignore malformed SSE payload */ }
424
424
  })
425
425
  sseSource.addEventListener('chime', (e) => {
426
426
  sseAttempt = 0
@@ -430,11 +430,11 @@ window.__ModuleLoader__.load({
430
430
  player.queue.push(item)
431
431
  drainQueue()
432
432
  }
433
- } catch {}
433
+ } catch (parseErr) { /* ignore malformed chime payload */ }
434
434
  })
435
435
  sseSource.onerror = () => {
436
436
  if (sseSource) {
437
- try { sseSource.close() } catch {}
437
+ try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
438
438
  sseSource = null
439
439
  }
440
440
  sseAttempt++
@@ -446,7 +446,7 @@ window.__ModuleLoader__.load({
446
446
  connectSseStream()
447
447
  }, delay)
448
448
  }
449
- } catch {}
449
+ } catch (esErr) { /* EventSource failed */ }
450
450
  }
451
451
 
452
452
  async function pollPending() {
@@ -552,7 +552,7 @@ async function pollPending() {
552
552
  document.body.appendChild(a)
553
553
  a.click()
554
554
  document.body.removeChild(a)
555
- setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 2000)
555
+ setTimeout(() => { try { URL.revokeObjectURL(url) } catch (alreadyRevoked) { /* already revoked */ } }, 2000)
556
556
  })
557
557
  .catch(() => {})
558
558
  }
@@ -1069,33 +1069,7 @@ async function pollPending() {
1069
1069
  )
1070
1070
  }
1071
1071
 
1072
- const CLIENT_IT_TERMS = [
1073
- { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
1074
- { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
1075
- { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
1076
- { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
1077
- { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
1078
- { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
1079
- { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
1080
- { from: 'API', to: 'апи', whole: true, lang: 'ru' },
1081
- { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
1082
- { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
1083
- { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
1084
- { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
1085
- { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
1086
- { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
1087
- { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
1088
- { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
1089
- { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
1090
- { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
1091
- { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
1092
- { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
1093
- { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
1094
- { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
1095
- { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
1096
- { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
1097
- { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
1098
- ]
1072
+ const CLIENT_IT_TERMS = []
1099
1073
 
1100
1074
  // Pronunciation dictionary editor: rules apply top-down.
1101
1075
  function PronEditor(props) {
@@ -1124,7 +1098,7 @@ async function pollPending() {
1124
1098
  document.body.appendChild(a)
1125
1099
  a.click()
1126
1100
  document.body.removeChild(a)
1127
- setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 1000)
1101
+ setTimeout(() => { try { URL.revokeObjectURL(url) } catch (alreadyRevoked) { /* already revoked */ } }, 1000)
1128
1102
  }
1129
1103
 
1130
1104
  const triggerImport = () => {
@@ -1924,36 +1898,14 @@ async function pollPending() {
1924
1898
  // Primary settings home is the plugin-settings tab card. The tab looks up
1925
1899
  // a slot by entryKey equal to the namespace name: key must equal NS or
1926
1900
  // the card never appears, with no log error.
1927
- const tryPluginItem = () => {
1928
- try {
1929
- const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
1930
- {
1931
- name: 'settings.plugin.item',
1932
- key: NS,
1933
- locale: NS,
1934
- inject: () => ({ ctx: ctx }),
1935
- },
1936
- TtsCard,
1937
- ))
1938
- if (res === false) return false
1939
- return true
1940
- } catch (noPluginItemSlot) {
1941
- return false
1942
- }
1943
- }
1944
- if (tryPluginItem()) return
1945
- // Fallback for builds without settings.plugin.item: keep the sidebar section
1946
- // so settings cannot disappear.
1947
- ctx.slots.inject('settings.section', () => ctx.slots.register(
1901
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
1948
1902
  {
1949
- name: 'settings.section',
1950
- id: '@goodandready/dsh-tts',
1951
- order: 32,
1903
+ name: 'settings.plugin.item',
1904
+ key: NS,
1952
1905
  locale: NS,
1953
- label: () => t('title'),
1954
1906
  inject: () => ({ ctx: ctx }),
1955
1907
  },
1956
- (props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
1908
+ TtsCard,
1957
1909
  ))
1958
1910
  }
1959
1911
 
@@ -1966,7 +1918,7 @@ async function pollPending() {
1966
1918
  if (typeof window === 'undefined') return () => {}
1967
1919
  const onKey = (e) => {
1968
1920
  if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
1969
- else if (e.altKey && (e.key === 's' || e.key === 'S' || e.key === 'ы')) { stopPlayback(); e.preventDefault() }
1921
+ else if (e.altKey && (e.code === 'KeyS' || e.key === 's' || e.key === 'S')) { stopPlayback(); e.preventDefault() }
1970
1922
  else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
1971
1923
  }
1972
1924
  window.addEventListener('keydown', onKey)
package/lib/http-util.js CHANGED
@@ -21,7 +21,37 @@ export function readBody(req, maxBytes) {
21
21
  })
22
22
  }
23
23
 
24
- /** Reject cross-site POSTs. Do not require loopback or Origin==Host: the Web UI is used over LAN and reverse proxies. */
24
+ function isLoopback(address) {
25
+ if (!address) return false
26
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address === 'localhost'
27
+ }
28
+
29
+ /**
30
+ * Strict fail-closed check for administrative/settings requests.
31
+ * Accepts loopback, same-origin/same-site sec-fetch-site, matching origin/host, or authorization header.
32
+ * Rejects cross-site or unauthenticated requests missing origin context.
33
+ */
25
34
  export function isTrustedSettingsRequest(request) {
26
- return request.headers['sec-fetch-site'] !== 'cross-site'
35
+ if (!request || !request.headers) return false
36
+
37
+ const authHeader = request.headers['authorization'] || request.headers['x-dsh-auth']
38
+ if (authHeader && authHeader.length > 5) return true
39
+
40
+ const remoteAddr = request.socket?.remoteAddress || request.connection?.remoteAddress
41
+ if (remoteAddr && isLoopback(remoteAddr)) return true
42
+
43
+ const secFetchSite = request.headers['sec-fetch-site']
44
+ if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') {
45
+ return true
46
+ }
47
+ const origin = request.headers['origin']
48
+ const host = request.headers['host']
49
+ if (origin && host) {
50
+ try {
51
+ const originHost = new URL(origin).host
52
+ if (originHost === host) return true
53
+ } catch { /* invalid origin URL */ }
54
+ }
55
+
56
+ return false
27
57
  }
package/lib/routes.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolve } from 'node:path'
2
+ import { registerPluginUpdater } from './updater.js'
1
3
  import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
2
4
  import { addStreamSubscriber, removeStreamSubscriber, streamSubscriberCount } from './stream-hub.js'
3
5
  import { pendingKeyWrites, stripSecretsFromConfig, needsApiKey } from './keys.js'
@@ -229,7 +231,6 @@ export function registerHttpRoutes(ctx, api) {
229
231
  const lang = String(cfg.language || '').toLowerCase()
230
232
  let phrase = 'Voice check.'
231
233
  if (lang.startsWith('zh')) phrase = '语音合成测试。'
232
- else if (lang.startsWith('ru')) phrase = 'Проверка голоса.'
233
234
  try {
234
235
  const probe = Object.assign({}, cfg, {
235
236
  chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
@@ -410,4 +411,10 @@ export function registerHttpRoutes(ctx, api) {
410
411
  }
411
412
  },
412
413
  }), 'dsh-tts: /speak route')
414
+
415
+ registerPluginUpdater(ctx, {
416
+ endpoint: '/dsh-tts/updater',
417
+ packageName: '@goodandready/dsh-tts',
418
+ manifestUrl: new URL('../package.json', import.meta.url),
419
+ })
413
420
  }
package/lib/text.js CHANGED
@@ -15,14 +15,12 @@ export function blocksToText(content) {
15
15
  // the configured speech language; {n} is filled with a line/row count.
16
16
  export const SPEECH_PHRASES = {
17
17
  en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows', summaryIntro: 'Summary of the reply.' },
18
- ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк', summaryIntro: 'Пересказ ответа.' },
19
18
  zh: { codeBlock: '代码块,共 {n} 行', table: '表格,共 {n} 行', summaryIntro: '回复摘要。' },
20
19
  }
21
20
 
22
21
  export function speechPhrases(language) {
23
22
  const lang = String(language || '').toLowerCase()
24
23
  if (lang.startsWith('zh')) return SPEECH_PHRASES.zh
25
- if (lang.startsWith('ru')) return SPEECH_PHRASES.ru
26
24
  return SPEECH_PHRASES.en
27
25
  }
28
26
 
@@ -43,12 +41,12 @@ export function applyNarrationFilters(text, cfg) {
43
41
  return out
44
42
  }
45
43
 
46
- // Rough language heuristic: whichever script has more letters wins.
44
+ // Rough language heuristic using Unicode Script properties.
47
45
  export function detectLang(text) {
48
46
  const s = String(text || '')
49
- const cjk = (s.match(/[\u4E00-\u9FFF]/g) || []).length
50
- const cyr = (s.match(/[\u0400-\u04FF]/g) || []).length
51
- const lat = (s.match(/[A-Za-z]/g) || []).length
47
+ const cjk = (s.match(/\p{Script=Han}/gu) || []).length
48
+ const cyr = (s.match(/\p{Script=Cyrillic}/gu) || []).length
49
+ const lat = (s.match(/\p{Script=Latin}/gu) || []).length
52
50
  if (cjk >= cyr && cjk >= lat && cjk > 0) return 'zh'
53
51
  return cyr > lat ? 'ru' : 'en'
54
52
  }
@@ -61,33 +59,7 @@ function escapeRe(s) {
61
59
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
62
60
  }
63
61
 
64
- export const BUILTIN_IT_DICTIONARY = [
65
- { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
66
- { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
67
- { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
68
- { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
69
- { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
70
- { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
71
- { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
72
- { from: 'API', to: 'апи', whole: true, lang: 'ru' },
73
- { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
74
- { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
75
- { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
76
- { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
77
- { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
78
- { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
79
- { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
80
- { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
81
- { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
82
- { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
83
- { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
84
- { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
85
- { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
86
- { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
87
- { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
88
- { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
89
- { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
90
- ]
62
+ export const BUILTIN_IT_DICTIONARY = []
91
63
 
92
64
  export function applyPronunciation(text, rules, language, enableBuiltinIt = false) {
93
65
  let out = String(text || '')
@@ -152,13 +124,8 @@ export function assistantText(message) {
152
124
  return blocksToText(message.content)
153
125
  }
154
126
 
155
- // Protect common abbreviations from false sentence splits
127
+ // Protect common English abbreviations from false sentence splits
156
128
  const PROTECTED_ABBREVIATIONS = [
157
- // Russian abbreviations
158
- 'т.е.', 'т.д.', 'т.п.', 'т.к.', 'руб.', 'коп.', 'ул.', 'пер.', 'пр.', 'просп.',
159
- 'д.', 'корп.', 'стр.', 'кв.', 'г.', 'гг.', 'в.', 'вв.', 'см.', 'ср.', 'напр.',
160
- 'рис.', 'табл.', 'им.', 'акад.', 'проф.', 'доц.', 'млн.', 'млрд.', 'тыс.',
161
- // English abbreviations
162
129
  'e.g.', 'i.e.', 'vs.', 'etc.', 'dr.', 'mr.', 'mrs.', 'ms.', 'prof.',
163
130
  'approx.', 'dept.', 'est.', 'min.', 'max.', 'no.', 'vol.', 'p.m.', 'a.m.'
164
131
  ]
@@ -173,7 +140,7 @@ export function protectAbbreviations(text) {
173
140
  // Decimal numbers and versions: e.g. 3.14, v0.4.6, 192.168.0.1
174
141
  res = res.replace(/(?<=[vV]?\d+)\.(?=\d+)/g, '\u2024')
175
142
  // List numbering at line/word starts: 1. item, 2. item
176
- res = res.replace(/(?<=^|[\s\n])(\d+)\.(?=\s+[A-Za-zА-Яа-я\u4E00-\u9FFF])/g, '$1\u2024')
143
+ res = res.replace(/(?<=^|[\s\n])(\d+)\.(?=\s+[A-Za-z\u4E00-\u9FFF])/g, '$1\u2024')
177
144
  // Common file extensions: e.g. package.json, index.js, script.py
178
145
  res = res.replace(/(?<=[a-zA-Z0-9_-])\.(?:js|mjs|cjs|ts|tsx|jsx|json|ya?ml|md|py|sh|tgz|tar|gz|zip|html?|css|rs|go|c|cpp|h|txt)(?=[ \t\n"«'\)\]\},;\.\?!]|$)/gi, (m) => m.replace(/\./g, '\u2024'))
179
146
  // Hostnames and domains: e.g. goodandready.app, github.com
package/lib/updater.js ADDED
@@ -0,0 +1,333 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
+
7
+ /**
8
+ * Host-side one-click updater for @goodandready/dsh-tts.
9
+ * Implements the unified DSH plugin updater specification.
10
+ */
11
+
12
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
13
+ const UPDATE_TIMEOUT_MS = 10 * 60_000
14
+ const VERSION_CACHE_MS = 5 * 60_000
15
+ let latestCache = undefined
16
+
17
+ function header(request, name) {
18
+ const value = request?.headers?.[name]
19
+ return Array.isArray(value) ? value[0] : value
20
+ }
21
+
22
+ function isLoopback(value) {
23
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
24
+ return (
25
+ address === 'localhost' ||
26
+ address === 'localhost.' ||
27
+ address === '::1' ||
28
+ address?.startsWith('127.') === true ||
29
+ address?.startsWith('::ffff:127.') === true
30
+ )
31
+ }
32
+
33
+ function isPrivateLan(value) {
34
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
35
+ if (!address) return false
36
+ const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
37
+ return (
38
+ ipv4.startsWith('192.168.') ||
39
+ ipv4.startsWith('10.') ||
40
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
41
+ )
42
+ }
43
+
44
+ export function isTrustedUpdateRequest(request) {
45
+ if (header(request, UPDATE_HEADER) !== '1') return false
46
+ const remote = request.socket?.remoteAddress
47
+ if (!isLoopback(remote) && !isPrivateLan(remote)) return false
48
+ const site = header(request, 'sec-fetch-site')
49
+ if (site !== undefined && site !== 'same-origin') return false
50
+ const origin = header(request, 'origin')
51
+ const host = header(request, 'host')
52
+ if (origin === undefined || host === undefined) return false
53
+ try {
54
+ const url = new URL(origin)
55
+ return (
56
+ (url.protocol === 'http:' || url.protocol === 'https:') &&
57
+ (isLoopback(url.hostname) || isPrivateLan(url.hostname)) &&
58
+ url.host === host
59
+ )
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ function validProfileName(value) {
66
+ return (
67
+ typeof value === 'string' &&
68
+ value !== '' &&
69
+ value !== '.' &&
70
+ value !== '..' &&
71
+ !value.includes('/') &&
72
+ !value.includes('\\') &&
73
+ !/[\0-\x1f\x7f]/.test(value)
74
+ )
75
+ }
76
+
77
+ function profileNameFromArgv(argv) {
78
+ for (let index = 0; index < argv.length; index += 1) {
79
+ const arg = argv[index]
80
+ if (arg === '--profile' && argv[index + 1] !== undefined) return argv[index + 1]
81
+ if (typeof arg === 'string' && arg.startsWith('--profile=')) return arg.slice(10)
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ function findDshCliEntry() {
87
+ const entry = process.argv[1]
88
+ if (typeof entry !== 'string' || entry === '') return undefined
89
+ let directory = process.cwd()
90
+ for (let depth = 0; depth < 6; depth += 1) {
91
+ const manifestPath = resolve(directory, 'package.json')
92
+ if (existsSync(manifestPath)) {
93
+ try {
94
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
95
+ const bin =
96
+ typeof manifest.bin === 'string'
97
+ ? manifest.bin
98
+ : typeof manifest.bin === 'object' && manifest.bin !== null
99
+ ? manifest.bin.dsh
100
+ : undefined
101
+ if (
102
+ manifest.name === '@deepseek-ai/dsh' &&
103
+ typeof bin === 'string' &&
104
+ !isAbsolute(bin) &&
105
+ resolve(directory, bin) === resolve(entry)
106
+ ) {
107
+ return entry
108
+ }
109
+ } catch {
110
+ // Continue searching parent directories
111
+ }
112
+ }
113
+ const parent = dirname(directory)
114
+ if (parent === directory) return undefined
115
+ }
116
+ }
117
+
118
+ function runtime() {
119
+ const profileDir = resolve(
120
+ process.env.DSH_PROFILE_DIR ?? resolve(homedir(), '.dsh', 'profiles', 'web')
121
+ )
122
+ const selected = profileNameFromArgv(process.argv)
123
+ const profileName = validProfileName(selected)
124
+ ? selected
125
+ : validProfileName(basename(profileDir))
126
+ ? basename(profileDir)
127
+ : 'web'
128
+ const cliEntry = findDshCliEntry()
129
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
130
+ }
131
+
132
+ function parseSemver(value) {
133
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
134
+ if (match === null) return undefined
135
+ return {
136
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
137
+ prerelease: match[4]?.split('.') ?? [],
138
+ }
139
+ }
140
+
141
+ function comparePrerelease(left, right) {
142
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1
143
+ const length = Math.max(left.length, right.length)
144
+ for (let index = 0; index < length; index += 1) {
145
+ const a = left[index]
146
+ const b = right[index]
147
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
148
+ if (a === b) continue
149
+ const aNumeric = /^\d+$/.test(a)
150
+ const bNumeric = /^\d+$/.test(b)
151
+ if (aNumeric && bNumeric) {
152
+ const aNumber = BigInt(a)
153
+ const bNumber = BigInt(b)
154
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
155
+ continue
156
+ }
157
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
158
+ return a > b ? 1 : -1
159
+ }
160
+ return 0
161
+ }
162
+
163
+ export function isNewerVersion(currentValue, candidateValue) {
164
+ const current = parseSemver(currentValue)
165
+ const candidate = parseSemver(candidateValue)
166
+ if (current === undefined || candidate === undefined) return false
167
+ for (let index = 0; index < 3; index += 1) {
168
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
169
+ }
170
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
171
+ }
172
+
173
+ async function latestVersion(packageName, registry) {
174
+ if (
175
+ latestCache?.packageName === packageName &&
176
+ latestCache.registry === registry &&
177
+ Date.now() < latestCache.expiresAt
178
+ ) {
179
+ return latestCache.version
180
+ }
181
+ try {
182
+ const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
183
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
184
+ })
185
+ if (!response.ok) return undefined
186
+ const value = await response.json()
187
+ if (typeof value?.version !== 'string' || value.version === '') return undefined
188
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
189
+ return value.version
190
+ } catch {
191
+ return undefined
192
+ }
193
+ }
194
+
195
+ async function currentVersion(manifestUrl) {
196
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
197
+ if (typeof value?.version !== 'string' || value.version === '') {
198
+ throw new Error('Cannot read current plugin version.')
199
+ }
200
+ return value.version
201
+ }
202
+
203
+ export async function status(options, target) {
204
+ const current = await currentVersion(options.manifestUrl)
205
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
206
+ return {
207
+ packageName: options.packageName,
208
+ currentVersion: current,
209
+ ...(latest === undefined ? {} : { latestVersion: latest }),
210
+ latestCheckFailed: latest === undefined,
211
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
212
+ profileName: target.profileName,
213
+ canAutoUpdate: target.cliEntry !== undefined,
214
+ }
215
+ }
216
+
217
+ async function installExact(target, packageSpec, options) {
218
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
219
+ await new Promise((resolvePromise, reject) => {
220
+ const child = spawn(
221
+ process.execPath,
222
+ [
223
+ target.cliEntry,
224
+ 'plugin',
225
+ '--profile',
226
+ target.profileName,
227
+ 'add',
228
+ '--config.minimumReleaseAge=0',
229
+ packageSpec,
230
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
231
+ ],
232
+ {
233
+ cwd: target.profileDir,
234
+ windowsHide: true,
235
+ stdio: ['ignore', 'pipe', 'pipe'],
236
+ env: { ...process.env, NO_COLOR: '1' },
237
+ }
238
+ )
239
+ let detail = ''
240
+ child.stdout?.on('data', (chunk) => {
241
+ detail = (detail + String(chunk)).slice(-4000)
242
+ })
243
+ child.stderr?.on('data', (chunk) => {
244
+ detail = (detail + String(chunk)).slice(-4000)
245
+ })
246
+ const timer = setTimeout(() => {
247
+ child.kill()
248
+ reject(new Error('Update timed out; use the normal DSH update flow.'))
249
+ }, UPDATE_TIMEOUT_MS)
250
+ child.once('error', (error) => {
251
+ clearTimeout(timer)
252
+ reject(error)
253
+ })
254
+ child.once('exit', (code) => {
255
+ clearTimeout(timer)
256
+ if (code === 0) resolvePromise()
257
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
258
+ })
259
+ })
260
+ }
261
+
262
+ function json(response, statusCode, value) {
263
+ response.writeHead(statusCode, {
264
+ 'content-type': 'application/json; charset=utf-8',
265
+ 'cache-control': 'no-store',
266
+ })
267
+ response.end(JSON.stringify(value))
268
+ }
269
+
270
+ export function registerPluginUpdater(ctx, options) {
271
+ let installing = false
272
+ if (typeof ctx?.webServer?.register !== 'function') {
273
+ ctx?.logger?.warn?.('[dsh-tts] webServer service unavailable, updater skipped')
274
+ return () => {}
275
+ }
276
+
277
+ return ctx.webServer.register({
278
+ kind: 'exact',
279
+ path: options.endpoint,
280
+ handler: async (request, response) => {
281
+ try {
282
+ const target = runtime()
283
+ if (request.method === 'GET' || request.method === 'HEAD') {
284
+ const payload = await status(options, target)
285
+ response.writeHead(200, {
286
+ 'content-type': 'application/json; charset=utf-8',
287
+ 'cache-control': 'no-store',
288
+ })
289
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
290
+ return
291
+ }
292
+ if (request.method !== 'POST') {
293
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
294
+ response.end()
295
+ return
296
+ }
297
+ if (!isTrustedUpdateRequest(request)) {
298
+ json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
299
+ return
300
+ }
301
+ if (installing) {
302
+ json(response, 409, { error: 'This plugin is already updating.' })
303
+ return
304
+ }
305
+ installing = true
306
+ try {
307
+ const before = await status(options, target)
308
+ if (before.latestVersion === undefined) {
309
+ json(response, 503, { error: 'The latest version is temporarily unavailable.' })
310
+ return
311
+ }
312
+ if (!before.updateAvailable) {
313
+ json(response, 200, before)
314
+ return
315
+ }
316
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
317
+ json(response, 200, {
318
+ ...before,
319
+ updatedVersion: before.latestVersion,
320
+ restartRequired: true,
321
+ })
322
+ } catch (err) {
323
+ json(response, 503, { error: `Plugin update failed: ${String(err?.message || err)}` })
324
+ } finally {
325
+ installing = false
326
+ }
327
+ } catch (error) {
328
+ ctx?.logger?.warn?.(`[dsh-tts] plugin updater failed: ${String(error)}`)
329
+ json(response, 503, { error: 'Plugin update failed; see server logs.' })
330
+ }
331
+ },
332
+ })
333
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.10",
3
+ "version": "0.4.16",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",