@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 +14 -18
- package/lib/host-routes.js +1 -0
- package/lib/http-util.js +133 -40
- package/lib/schema.js +2 -0
- package/package.json +1 -1
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
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
package/lib/host-routes.js
CHANGED
|
@@ -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
|
|
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()
|
|
227
|
+
const origin = String(req.headers.origin || '').trim()
|
|
122
228
|
if (origin) {
|
|
123
|
-
|
|
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()
|
|
232
|
+
const referer = String(req.headers.referer || '').trim()
|
|
132
233
|
if (referer) {
|
|
133
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
178
|
-
const referer = String(req.headers?.referer || '').trim()
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|