@goodandready/dsh-voice 0.9.42 → 0.9.44

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/lib/client.js CHANGED
@@ -852,8 +852,7 @@ window.__ModuleLoader__.load({
852
852
  const text = chunks.join(' ')
853
853
  if (!text || text.length < 3) return []
854
854
  const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
855
- // Linguistic filter: functional stop-word dictionary for vocabulary keyword extraction.
856
- // Language processing data for context analysis, distinct from user-facing UI text.
855
+ // Stop-word dictionary for vocabulary keyword extraction
857
856
  const stop = new Set([
858
857
  'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was',
859
858
  'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now',
@@ -983,9 +982,7 @@ window.__ModuleLoader__.load({
983
982
  return lead + s + trail
984
983
  }
985
984
 
986
- // Functional speech edit commands (#37): spoken line breaks and punctuation -> symbols.
987
- // Linguistic data for audio transcript post-processing; not user-facing UI text.
988
- // Russian phrases match RU STT output; English covers EN dictation.
985
+ // Speech edit commands (#37): spoken line breaks and punctuation -> symbols
989
986
  const VOICE_COMMANDS = [
990
987
  [/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
991
988
  [/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
@@ -1033,22 +1030,26 @@ window.__ModuleLoader__.load({
1033
1030
  function insertDraftText(text, target) {
1034
1031
  if (voice.disposed) return false
1035
1032
  const span = target && target.span
1033
+ const sIn = (target && target.input) || voice.input
1034
+ const sDraft = (sIn && typeof sIn.draft === 'string') ? sIn.draft : ''
1035
+ const spaced = (sDraft && !/\s$/.test(sDraft) && !/^\s/.test(text)
1036
+ && !/^[.,;:!?…»)\]}]/.test(text) && !/[\s(«"'\[]$/.test(sDraft)) ? ' ' + text : text
1036
1037
  if (span) {
1037
1038
  if (typeof span.insert === 'function') {
1038
1039
  try {
1039
- const r = span.insert(text)
1040
+ const r = span.insert(spaced)
1040
1041
  if (r !== false) return true
1041
1042
  return false
1042
1043
  } catch (_) {}
1043
1044
  } else if (typeof span.insertText === 'function') {
1044
1045
  try {
1045
- const r = span.insertText(text)
1046
+ const r = span.insertText(spaced)
1046
1047
  if (r !== false) return true
1047
1048
  return false
1048
1049
  } catch (_) {}
1049
1050
  } else if (typeof span === 'function') {
1050
1051
  try {
1051
- const r = span(text)
1052
+ const r = span(spaced)
1052
1053
  if (r !== false) return true
1053
1054
  return false
1054
1055
  } catch (_) {}
@@ -1062,7 +1063,7 @@ window.__ModuleLoader__.load({
1062
1063
  }
1063
1064
  if (typeof actions.insertText === 'function') {
1064
1065
  try {
1065
- const res = actions.insertText(text, span)
1066
+ const res = actions.insertText(spaced, span)
1066
1067
  if (res !== false) {
1067
1068
  if (target && typeof actions.captureInsertion === 'function') {
1068
1069
  try { target.span = actions.captureInsertion() } catch (_) {}
@@ -1124,14 +1125,7 @@ window.__ModuleLoader__.load({
1124
1125
  } catch (noAudio) { /* no audio — not critical */ }
1125
1126
  }
1126
1127
 
1127
- // ------------------------------------------------- browser recognition
1128
- //
1129
- // A separate leg unlike the others: the browser recognizes speech itself,
1130
- // nothing is uploaded to the host, no keys are needed, and text appears
1131
- // word by word while you speak.
1132
- //
1133
- // Cost: in Chrome audio goes to Google servers. This provider is never
1134
- // enabled automatically — only when placed explicitly in a chain.
1128
+ // browser recognition: in-browser, no upload, only enabled when in chain
1135
1129
  function speechRecognitionCtor() {
1136
1130
  if (typeof window === 'undefined') return null
1137
1131
  return window.SpeechRecognition || window.webkitSpeechRecognition || null
@@ -1658,7 +1652,8 @@ voice._chainsPromise = null
1658
1652
  } else {
1659
1653
  rec.sileroSpeech = null
1660
1654
  }
1661
- if (rec.hadSpeech && raw > 0 && raw < 0.08) {
1655
+ const lowT = Number(voice.settings?.lowVolumeThreshold ?? 0.08)
1656
+ if (rec.hadSpeech && raw > 0 && raw < lowT) {
1662
1657
  rec.lowVolumeUntil = now + 800
1663
1658
  rec.hasLowVolume = true
1664
1659
  }
@@ -3156,6 +3151,7 @@ function cycleDictationMode() {
3156
3151
  sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
3157
3152
  noiseSuppression: data && data.noiseSuppression !== false,
3158
3153
  noiseGateDb: Number(data && data.noiseGateDb !== undefined ? data.noiseGateDb : -45),
3154
+ lowVolumeThreshold: Number(data && data.lowVolumeThreshold !== undefined ? data.lowVolumeThreshold : 0.08),
3159
3155
  contextGlossary: data && data.contextGlossary !== false,
3160
3156
  visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
3161
3157
  gatedTurnTaking: data && data.gatedTurnTaking !== false,
@@ -74,6 +74,7 @@ export function registerHostRoutes(ctx, deps) {
74
74
  localOnly: cfg.localOnly,
75
75
  micDeviceId: cfg.micDeviceId,
76
76
  noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
77
+ lowVolumeThreshold: typeof cfg.lowVolumeThreshold === 'number' ? cfg.lowVolumeThreshold : 0.08,
77
78
  historyLimit: cfg.historyLimit,
78
79
  voiceCommands: cfg.voiceCommands,
79
80
  wakeWord: String(cfg.wakeWord || ''),
package/lib/http-util.js CHANGED
@@ -102,11 +102,117 @@ export function readBody(req, maxBytes) {
102
102
  export function isLoopback(addr) {
103
103
  if (!addr) return false
104
104
  const clean = String(addr).replace(/^::ffff:/, '')
105
- return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost'
105
+ return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost' || clean === '[::1]'
106
106
  }
107
107
 
108
108
  /**
109
- * Check whether a request originates from the same origin as the Host header (#292).
109
+ * Check whether an incoming HTTP request is from the DeepSeek desktop shell (#12, #295).
110
+ * Matches patterns from @deepseek-ai/dsh-desktop and official DSH shell wrappers.
111
+ * @param {import('node:http').IncomingMessage} req
112
+ * @returns {boolean}
113
+ */
114
+ export function isDesktopShellRequest(req) {
115
+ if (!req || !req.headers) return false
116
+ const ua = String(req.headers['user-agent'] || '')
117
+ const cookie = String(req.headers.cookie || '')
118
+ if (ua.includes('@deepseek-ai/dsh-desktop/') && cookie.includes('dsh-auth-')) return true
119
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
120
+ if (ua.includes('dsh-desktop') && isLoopback(remote)) return true
121
+ return false
122
+ }
123
+
124
+ /**
125
+ * Check whether an incoming HTTP request carries a DeepSeek Harness session cookie (#295).
126
+ * DSH core sets 'dsh-auth-<hash>=v1...' as a SameSite=Strict HttpOnly cookie.
127
+ * @param {import('node:http').IncomingMessage} req
128
+ * @returns {boolean}
129
+ */
130
+ export function hasDshAuthCookie(req) {
131
+ if (!req || !req.headers) return false
132
+ const cookie = String(req.headers.cookie || '')
133
+ return cookie.includes('dsh-auth-')
134
+ }
135
+
136
+ /**
137
+ * Split host header or URL host into lowercase hostname and port string.
138
+ * @param {string} rawHost
139
+ * @returns {{ hostname: string, port: string }}
140
+ */
141
+ function parseHost(rawHost) {
142
+ if (!rawHost) return { hostname: '', port: '' }
143
+ const clean = String(rawHost).trim().toLowerCase()
144
+ const ipv6Match = clean.match(/^\[([^\]]+)\](?::(\d+))?$/)
145
+ let hostname = ''
146
+ let port = ''
147
+ if (ipv6Match) {
148
+ hostname = ipv6Match[1]
149
+ port = ipv6Match[2] || ''
150
+ } else {
151
+ const parts = clean.split(':')
152
+ hostname = parts[0]
153
+ port = parts[1] || ''
154
+ }
155
+ if (port === '80' || port === '443') {
156
+ port = ''
157
+ }
158
+ return { hostname, port }
159
+ }
160
+
161
+ /**
162
+ * Verify whether an Origin or Referer URL is allowed for the given Host header (#12, #292, #295).
163
+ * @param {string} urlStr
164
+ * @param {string} hostHeader
165
+ * @param {import('node:http').IncomingMessage} req
166
+ * @returns {boolean}
167
+ */
168
+ export function isOriginOrRefererAllowed(urlStr, hostHeader, req) {
169
+ if (!urlStr || typeof urlStr !== 'string') return false
170
+ const trimmed = urlStr.trim()
171
+ if (!trimmed) return false
172
+
173
+ const remote = req?.socket?.remoteAddress || req?.connection?.remoteAddress || ''
174
+ const remoteIsLoopback = isLoopback(remote)
175
+
176
+ // 1. Literal 'null' origin (sent by Chromium/Electron for local files or sandboxed views)
177
+ if (trimmed === 'null') {
178
+ return remoteIsLoopback && (isDesktopShellRequest(req) || hasDshAuthCookie(req))
179
+ }
180
+
181
+ try {
182
+ const u = new URL(trimmed)
183
+
184
+ // 2. Official DeepSeek desktop custom app scheme (e.g. dsh-app://app or dsh-app://localhost)
185
+ if (u.protocol === 'dsh-app:') {
186
+ const appHost = u.host.toLowerCase()
187
+ const allowedAppHost = !appHost || appHost === 'app' || appHost === 'localhost'
188
+ return allowedAppHost && remoteIsLoopback
189
+ }
190
+
191
+ // 3. Web schemes: HTTP and HTTPS
192
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
193
+ if (!hostHeader) return false
194
+ const target = parseHost(hostHeader)
195
+ const origin = parseHost(u.host)
196
+
197
+ // Exact match
198
+ if (origin.hostname === target.hostname && origin.port === target.port) {
199
+ return true
200
+ }
201
+
202
+ // Loopback equivalence (e.g. localhost:3080 vs 127.0.0.1:3080 or ::1:3080)
203
+ if (isLoopback(origin.hostname) && isLoopback(target.hostname) && origin.port === target.port) {
204
+ return true
205
+ }
206
+ }
207
+
208
+ return false
209
+ } catch {
210
+ return false
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Check whether a request originates from the same origin as the Host header or trusted desktop shell (#12, #292, #295).
110
216
  * @param {import('node:http').IncomingMessage} req
111
217
  * @returns {boolean}
112
218
  */
@@ -118,38 +224,33 @@ export function hasSameOrigin(req) {
118
224
  const host = String(req.headers.host || '').trim().toLowerCase()
119
225
  if (!host) return false
120
226
 
121
- const origin = String(req.headers.origin || '').trim().toLowerCase()
227
+ const origin = String(req.headers.origin || '').trim()
122
228
  if (origin) {
123
- try {
124
- const u = new URL(origin)
125
- return u.host.toLowerCase() === host
126
- } catch {
127
- return false
128
- }
229
+ return isOriginOrRefererAllowed(origin, host, req)
129
230
  }
130
231
 
131
- const referer = String(req.headers.referer || '').trim().toLowerCase()
232
+ const referer = String(req.headers.referer || '').trim()
132
233
  if (referer) {
133
- try {
134
- const u = new URL(referer)
135
- return u.host.toLowerCase() === host
136
- } catch {
137
- return false
138
- }
234
+ return isOriginOrRefererAllowed(referer, host, req)
139
235
  }
140
236
 
237
+ // Same-origin metadata from browser Fetch API
141
238
  if (secSite === 'same-origin') return true
142
239
 
240
+ // DeepSeek Desktop shell or authenticated DSH session on loopback/same-host
241
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
242
+ if (isDesktopShellRequest(req)) return true
243
+ if (hasDshAuthCookie(req) && (isLoopback(remote) || host)) return true
244
+
143
245
  return false
144
246
  }
145
247
 
146
248
  /**
147
249
  * Verify that the HTTP request originates from a trusted caller.
148
- * Fail-closed security guard for state-changing or quota-consuming routes (#117, #173, #277, #292).
250
+ * Fail-closed security guard for state-changing or quota-consuming routes (#117, #173, #277, #292, #295).
149
251
  * - Routes marked loopbackOnly strictly reject non-loopback callers.
150
- * - Mutating requests (POST, PUT, PATCH) or WS upgrades require validated same-origin or valid session token (#292).
151
- * Loopback callers without Origin/Referer through dsh-lanmode bridge are rejected without token.
152
- * - Non-loopback LAN requests require valid session token for mutating operations (#277).
252
+ * - Mutating requests (POST, PUT, PATCH) or WS upgrades require validated same-origin or valid session token (#12, #292, #295).
253
+ * Raw LAN callers or reverse bridges without Origin/Referer/Cookie and without token are fail-closed rejected.
153
254
  * - Non-mutating GET requests require loopback or same-origin.
154
255
  * @param {import('node:http').IncomingMessage} req
155
256
  * @param {{ loopbackOnly?: boolean }} [options]
@@ -162,27 +263,18 @@ export function isTrustedCaller(req, options = {}) {
162
263
  const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
163
264
  if (secSite === 'cross-site' || secSite === 'same-site') return false
164
265
 
165
- // 2. If Origin header is present, it must strictly match the Host header
166
266
  const host = String(req.headers?.host || '').toLowerCase()
167
- const origin = String(req.headers?.origin || '').trim().toLowerCase()
267
+
268
+ // 2. If Origin header is present, verify against host or trusted desktop scheme (#12, #292, #295)
269
+ const origin = String(req.headers?.origin || '').trim()
168
270
  if (origin) {
169
- try {
170
- const u = new URL(origin)
171
- if (u.host !== host) return false
172
- } catch {
173
- return false
174
- }
271
+ if (!isOriginOrRefererAllowed(origin, host, req)) return false
175
272
  }
176
273
 
177
- // 3. If Referer header is present, it must strictly match the Host header
178
- const referer = String(req.headers?.referer || '').trim().toLowerCase()
274
+ // 3. If Referer header is present, verify against host or trusted desktop scheme (#12, #292, #295)
275
+ const referer = String(req.headers?.referer || '').trim()
179
276
  if (referer) {
180
- try {
181
- const u = new URL(referer)
182
- if (u.host !== host) return false
183
- } catch {
184
- return false
185
- }
277
+ if (!isOriginOrRefererAllowed(referer, host, req)) return false
186
278
  }
187
279
 
188
280
  // 4. For state-changing or data-carrying requests (POST/PUT/PATCH), require application/json or octet-stream
@@ -200,7 +292,7 @@ export function isTrustedCaller(req, options = {}) {
200
292
  const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
201
293
  const remoteIsLoopback = isLoopback(remote)
202
294
  const hostName = host.replace(/:\d+$/, '')
203
- const hostIsLoopback = hostName === '127.0.0.1' || hostName === 'localhost' || hostName === '::1' || hostName === '[::1]'
295
+ const hostIsLoopback = isLoopback(hostName)
204
296
  const isPureLocal = remoteIsLoopback && hostIsLoopback
205
297
 
206
298
  // 5. If route strictly requires loopback (e.g. host model installer or hardware benchmark)
@@ -212,6 +304,7 @@ export function isTrustedCaller(req, options = {}) {
212
304
  // 6. Mutating requests (POST/PUT/PATCH):
213
305
  // Even from loopback (which may be proxied via dsh-lanmode bridge), requests without Origin/Referer
214
306
  // must present a valid x-dsh-voice-token (#292).
307
+ // Non-loopback LAN callers strictly require a valid session token to prevent forged LAN mutations (#277).
215
308
  if (isMutating) {
216
309
  const incomingToken = String(req.headers?.['x-dsh-voice-token'] || req.headers?.['x-voice-token'] || '')
217
310
  const hasValidToken = Boolean(incomingToken && verifyVoiceSessionToken(incomingToken))
@@ -221,8 +314,8 @@ export function isTrustedCaller(req, options = {}) {
221
314
  return hasValidToken
222
315
  }
223
316
 
224
- // Loopback callers (or reverse bridge dsh-lanmode):
225
- // If request has verified same-origin (browser UI), allow it.
317
+ // Loopback callers (local browser, desktop shell, or reverse bridge dsh-lanmode):
318
+ // If request has verified same-origin or desktop shell, allow it (#12, #295).
226
319
  if (hasSameOrigin(req)) return true
227
320
  // If request has valid session token, allow it.
228
321
  if (hasValidToken) return true
@@ -233,7 +326,7 @@ export function isTrustedCaller(req, options = {}) {
233
326
  // 7. Loopback remote address for non-mutating GET requests
234
327
  if (remoteIsLoopback) return true
235
328
 
236
- // 8. Non-loopback LAN GET requests: require same-origin
329
+ // 8. Non-loopback LAN GET requests: require same-origin or authenticated session
237
330
  if (secSite === 'same-origin') return true
238
331
  if (hasSameOrigin(req)) return true
239
332
 
package/lib/schema.js CHANGED
@@ -106,6 +106,8 @@ export const BaseConfig = z.object({
106
106
  .description('Microphone device id for recording. Empty means the system default.'),
107
107
  noiseGateDb: z.number().default(-45)
108
108
  .description('Audio noise gate threshold in dB for client-side recording (-50 to -25 dB, or <= -90 to disable). Silence below this threshold cuts ambient background noise and prevents false VAD triggers.'),
109
+ lowVolumeThreshold: z.number().min(0).max(1).default(0.08)
110
+ .description('Recording level (0..1) below which the "Low signal / speak louder" hint appears while dictating. Lower it for quiet microphones; 0 disables the hint.'),
109
111
  vadEngine: z.union(['silero', 'amplitude']).default('silero')
110
112
  .description('Voice activity detection engine: neural Silero VAD (WASM/ONNX) or classic amplitude threshold.'),
111
113
  streamingWs: z.boolean().default(true)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.42",
3
+ "version": "0.9.44",
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",