@goodandready/dsh-voice 0.9.35 → 0.9.37

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.
@@ -1,6 +1,6 @@
1
1
  // HTTP host routes registered on ctx.webServer.
2
2
  import os from 'node:os'
3
- import { readBody, isLoopback, isTrustedCaller } from './http-util.js'
3
+ import { readBody, isLoopback, isTrustedCaller, getVoiceSessionToken } from './http-util.js'
4
4
  import { resolveSensevoiceProvider } from './sensevoice-installer.js'
5
5
  import { applyVoiceMacros } from './macros.js'
6
6
  import { createSyntheticWav } from './wav.js'
@@ -45,6 +45,7 @@ export function registerHostRoutes(ctx, deps) {
45
45
  const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
46
46
  writeJson(res, 200, {
47
47
  ok: true,
48
+ sessionToken: getVoiceSessionToken(),
48
49
  whisperRunning: whisperOk,
49
50
  whisperError: whisperOk ? null : whisperError,
50
51
  sensevoiceRunning: sensevoiceOk,
@@ -154,7 +155,7 @@ export function registerHostRoutes(ctx, deps) {
154
155
  path: '/dsh-voice/benchmark',
155
156
  handler: async (req, res) => {
156
157
  if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
157
- if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
158
+ if (!isTrustedCaller(req, { loopbackOnly: true })) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
158
159
 
159
160
  const cfg = live()
160
161
  const effectiveProvider = resolveSensevoiceProvider(cfg)
package/lib/http-util.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Pure HTTP helpers shared by host routes. No cordis, no network.
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
2
3
 
3
4
  export class BodyTooLargeError extends Error {
4
5
  constructor(message = 'body too large') {
@@ -9,6 +10,29 @@ export class BodyTooLargeError extends Error {
9
10
  }
10
11
  }
11
12
 
13
+ let sessionSecret = ''
14
+ export function getVoiceSessionToken() {
15
+ if (!sessionSecret) {
16
+ sessionSecret = randomBytes(24).toString('hex')
17
+ }
18
+ return sessionSecret
19
+ }
20
+
21
+ export function setVoiceSessionToken(t) {
22
+ sessionSecret = String(t || '')
23
+ }
24
+
25
+ export function verifyVoiceSessionToken(token) {
26
+ if (!token || typeof token !== 'string') return false
27
+ const secret = getVoiceSessionToken()
28
+ if (token.length !== secret.length) return false
29
+ try {
30
+ return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
12
36
  export function writeJson(res, code, body) {
13
37
  try {
14
38
  const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }
@@ -77,12 +101,16 @@ export function isLoopback(addr) {
77
101
  }
78
102
 
79
103
  /**
80
- * Verify that the HTTP request originates from a trusted caller (loopback or same-origin).
81
- * Fail-closed security guard for state-changing or quota-consuming POST routes.
104
+ * Verify that the HTTP request originates from a trusted caller.
105
+ * Fail-closed security guard for state-changing or quota-consuming POST routes (#117, #173, #277).
106
+ * - Loopback callers (127.0.0.1, ::1) are trusted unless rejected by browser security headers.
107
+ * - Non-loopback LAN mutating/quota-consuming POST/PUT requests require valid session token (#277).
108
+ * - Routes marked loopbackOnly strictly reject non-loopback callers.
82
109
  * @param {import('node:http').IncomingMessage} req
110
+ * @param {{ loopbackOnly?: boolean }} [options]
83
111
  * @returns {boolean}
84
112
  */
85
- export function isTrustedCaller(req) {
113
+ export function isTrustedCaller(req, options = {}) {
86
114
  if (!req) return false
87
115
 
88
116
  // 1. Browser security headers: immediately reject cross-site and same-site requests (#117, #173)
@@ -113,7 +141,6 @@ export function isTrustedCaller(req) {
113
141
  }
114
142
 
115
143
  // 4. For state-changing or data-carrying requests (POST/PUT/PATCH), require application/json or octet-stream
116
- // Cross-origin simple form submissions cannot set application/json without a CORS preflight.
117
144
  const method = String(req.method || 'GET').toUpperCase()
118
145
  if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
119
146
  const contentType = String(req.headers?.['content-type'] || '').toLowerCase()
@@ -128,7 +155,20 @@ export function isTrustedCaller(req) {
128
155
  const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
129
156
  if (isLoopback(remote)) return true
130
157
 
131
- // 6. Non-loopback requests must be verified same-origin
158
+ // If route strictly requires loopback (e.g. host model installer or hardware benchmark)
159
+ if (options && options.loopbackOnly) return false
160
+
161
+ // 6. Non-loopback LAN requests: POST/PUT/PATCH quota-burning operations require session auth token (#277)
162
+ const isMutating = method === 'POST' || method === 'PUT' || method === 'PATCH'
163
+ if (isMutating) {
164
+ const incomingToken = String(req.headers?.['x-dsh-voice-token'] || req.headers?.['x-voice-token'] || '')
165
+ if (incomingToken && verifyVoiceSessionToken(incomingToken)) {
166
+ return true
167
+ }
168
+ return false
169
+ }
170
+
171
+ // 7. Non-state-changing GET requests from LAN: verify same-origin
132
172
  if (secSite === 'same-origin') return true
133
173
  if (origin) {
134
174
  try {
@@ -146,4 +186,4 @@ export function isTrustedCaller(req) {
146
186
  }
147
187
 
148
188
  return false
149
- }
189
+ }
package/lib/index.js CHANGED
@@ -17,6 +17,7 @@ import { registerConfigRoutes } from './config-routes.js'
17
17
  import { registerHostRoutes } from './host-routes.js'
18
18
  import { createConfigReader } from './config-compat.js'
19
19
  import { createStatsTracker, mergeContextVocabulary } from './stats.js'
20
+ import { buildTranscriptionPrompt } from './transcribe-core.js'
20
21
  import { registerTranscribeAudioTool } from './tool.js'
21
22
  import { createPolishText } from './polish.js'
22
23
  import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
@@ -114,7 +115,9 @@ export function apply(ctx, baseConfig) {
114
115
  ctx.inject(['settings'], (sctx) => {
115
116
  settingsService = sctx.settings
116
117
  try {
117
- scope = sctx.settings.register(NS, Config, { base: baseConfig })
118
+ if (typeof sctx.settings?.register === 'function') {
119
+ scope = sctx.settings.register(NS, Config, { base: baseConfig })
120
+ }
118
121
  if (scope && typeof scope.get === 'function') {
119
122
  getConfig = () => scope.get() ?? describeRow()?.config ?? readEntryConfig()
120
123
  } else {
@@ -133,16 +136,10 @@ export function apply(ctx, baseConfig) {
133
136
  triggerAutostart()
134
137
  })
135
138
 
136
- if (typeof ctx.on === 'function') {
137
- if (typeof ctx.effect === 'function') {
138
- ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()), 'dsh-voice: volatile autostart')
139
- ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()), 'dsh-voice: settings autostart')
140
- ctx.effect(() => ctx.on('config', () => triggerAutostart()), 'dsh-voice: config autostart')
141
- } else {
142
- ctx.on('loader/volatile-update', () => triggerAutostart())
143
- ctx.on('settings/document-updated', () => triggerAutostart())
144
- ctx.on('config', () => triggerAutostart())
145
- }
139
+ if (typeof ctx.effect === 'function' && typeof ctx.on === 'function') {
140
+ ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()), 'dsh-voice: volatile autostart')
141
+ ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()), 'dsh-voice: settings autostart')
142
+ ctx.effect(() => ctx.on('config', () => triggerAutostart()), 'dsh-voice: config autostart')
146
143
  }
147
144
 
148
145
  triggerAutostart()
@@ -161,13 +158,17 @@ export function apply(ctx, baseConfig) {
161
158
  defaultModels: DEFAULT_MODELS,
162
159
  })
163
160
 
164
- const vocab = cfg.contextGlossary
165
- ? mergeContextVocabulary(cfg.vocabulary, contextWords)
166
- : (Array.isArray(cfg.vocabulary) ? cfg.vocabulary : [])
161
+ const { promptText, keywords, vocabulary: vocab } = buildTranscriptionPrompt({
162
+ vocabulary: cfg.vocabulary,
163
+ contextWords: cfg.contextGlossary ? contextWords : [],
164
+ bilingualDeveloperPrompt: cfg.bilingualDeveloperPrompt !== false,
165
+ bilingualCustomPrompt: cfg.bilingualCustomPrompt || '',
166
+ language: modeCfg.language,
167
+ })
167
168
 
168
169
  const providers = makeProviders(
169
170
  { resolveKey, fetchImpl: fetch, cfg, toWav: (b, sig) => toWav16k(b, cfg.ffmpegBin, sig || signal) },
170
- { bytes, mime, lang: modeCfg.language, signal, models, vocabulary: vocab },
171
+ { bytes, mime, lang: modeCfg.language, signal, models, vocabulary: vocab, promptText, keywords },
171
172
  )
172
173
  return runChain(order, providers, statsTracker.record)
173
174
  }
@@ -35,8 +35,10 @@ export function createLocalDaemons({ live, ctx }) {
35
35
  try { child.kill() } catch { /* ignore */ }
36
36
  child = null
37
37
  }
38
+ const rawLang = cfg?.dictation?.language
39
+ const safeLang = isAutoLang(rawLang) ? 'auto' : String(rawLang || '').replace(/[^a-zA-Z0-9_-]/g, '') || 'auto'
38
40
  const command = `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
39
- + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`
41
+ + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${JSON.stringify(safeLang)}`
40
42
  const specOpts = { command, onExpiry: 'none', timeoutMs: 60000, stdoutMaxBytes: 4 * 1024 * 1024 }
41
43
  const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
42
44
 
package/lib/providers.js CHANGED
@@ -87,10 +87,12 @@ function pickModel(models, key) {
87
87
 
88
88
  export function makeProviders(deps, req) {
89
89
  const { resolveKey, fetchImpl, cfg } = deps
90
- const { bytes, mime, lang, signal, models } = req
90
+ const { bytes, mime, lang, signal, models, promptText, keywords } = req
91
91
  const vocab = Array.isArray(req.vocabulary)
92
92
  ? req.vocabulary.map((w) => String(w || '').trim()).filter(Boolean).join(', ')
93
93
  : ''
94
+ const effectivePrompt = (typeof promptText === 'string' && promptText.trim()) ? promptText.trim() : vocab
95
+ const effectiveKeywords = (typeof keywords === 'string' && keywords.trim()) ? keywords.trim() : vocab
94
96
 
95
97
  async function deepgram() {
96
98
  const key = await resolveKey(cfg.deepgramKeyEnv)
@@ -99,7 +101,7 @@ export function makeProviders(deps, req) {
99
101
  const base = String(cfg.deepgramBaseUrl || 'https://api.deepgram.com').replace(/\/+$/, '')
100
102
  const url = `${base}/v1/listen?model=${encodeURIComponent(model)}`
101
103
  + (!isAutoLang(lang) ? `&language=${encodeURIComponent(lang)}` : '')
102
- + (vocab ? `&keywords=${encodeURIComponent(vocab)}` : '')
104
+ + (effectiveKeywords ? `&keywords=${encodeURIComponent(effectiveKeywords)}` : '')
103
105
  + '&smart_format=true'
104
106
  const res = await fetchImpl(url, {
105
107
  method: 'POST',
@@ -124,7 +126,7 @@ export function makeProviders(deps, req) {
124
126
  form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
125
127
  form.append('model', pickModel(models, 'groq'))
126
128
  if (!isAutoLang(lang)) form.append('language', lang)
127
- if (vocab) form.append('prompt', vocab)
129
+ if (effectivePrompt) form.append('prompt', effectivePrompt)
128
130
  form.append('response_format', 'json')
129
131
  const res = await fetchImpl('https://api.groq.com/openai/v1/audio/transcriptions', {
130
132
  method: 'POST',
@@ -187,7 +189,7 @@ export function makeProviders(deps, req) {
187
189
  // Also override a server started with --translate: this plugin always needs
188
190
  // a transcript in the spoken language, never Whisper's English translation.
189
191
  form.append('translate', 'false')
190
- if (vocab) form.append('prompt', vocab)
192
+ if (effectivePrompt) form.append('prompt', effectivePrompt)
191
193
  form.append('response_format', 'json')
192
194
  const res = await fetchImpl(cfg.whisperUrl, { method: 'POST', body: form, signal })
193
195
  // whisper.cpp returns 400 with a JSON body on internal failures (e.g. decode
@@ -228,7 +230,7 @@ export function makeProviders(deps, req) {
228
230
  if (isOpenAI) {
229
231
  form.append('model', pickModel(models, 'sensevoice'))
230
232
  if (!isAutoLang(lang)) form.append('language', lang)
231
- if (vocab) form.append('prompt', vocab)
233
+ if (effectivePrompt) form.append('prompt', effectivePrompt)
232
234
  form.append('response_format', 'json')
233
235
  }
234
236
 
@@ -282,7 +284,7 @@ export function makeProviders(deps, req) {
282
284
  form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
283
285
  form.append('model', model)
284
286
  if (lang && lang !== 'auto') form.append('language', lang)
285
- if (vocab) form.append('prompt', vocab)
287
+ if (effectivePrompt) form.append('prompt', effectivePrompt)
286
288
  form.append('response_format', 'json')
287
289
  const res = await fetchImpl(`${base}/audio/transcriptions`, {
288
290
  method: 'POST', headers, body: form, signal,
@@ -303,7 +305,7 @@ export function makeProviders(deps, req) {
303
305
  format = 'wav'
304
306
  }
305
307
  const ask = (spec.prompt || CHAT_AUDIO_PROMPT)
306
- + (vocab ? ` Vocabulary hints (spell these correctly): ${vocab}.` : '')
308
+ + (effectivePrompt ? ` Vocabulary hints (spell these correctly): ${effectivePrompt}.` : '')
307
309
  + (lang && lang !== 'auto' ? ` The audio language is ${lang}.` : '')
308
310
  const res = await fetchImpl(`${base}/chat/completions`, {
309
311
  method: 'POST',
package/lib/schema.js CHANGED
@@ -112,6 +112,10 @@ export const BaseConfig = z.object({
112
112
  .description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
113
113
  contextGlossary: z.boolean().default(true)
114
114
  .description('Automatically extract code words and terms from context and hint them to STT providers.'),
115
+ bilingualDeveloperPrompt: z.boolean().default(true)
116
+ .description('Inject bilingual developer primer (git, docker, npm, PR, etc.) into the STT prompt so mixed RU/EN technical speech is transcribed accurately.'),
117
+ bilingualCustomPrompt: z.string().default('')
118
+ .description('Optional custom terms or prompt primer for the bilingual developer prompt.'),
115
119
  noiseSuppression: z.boolean().default(true)
116
120
  .description('Enable hardware noise suppression, echo cancellation, and auto gain control.'),
117
121
  voiceCommands: z.boolean().default(false)
@@ -1,7 +1,8 @@
1
1
  // lib/sensevoice-installer.js — 1-Click installer for SenseVoice-Small & Whisper Large-v3-Turbo
2
2
  // Downloads pre-quantized ONNX models and configures autostart on the host via sherpa-onnx.
3
3
 
4
- import { createWriteStream, existsSync, readdirSync } from 'node:fs'
4
+ import { createWriteStream, createReadStream, existsSync, readdirSync } from 'node:fs'
5
+ import { createHash } from 'node:crypto'
5
6
  import { mkdir, unlink } from 'node:fs/promises'
6
7
  import { homedir } from 'node:os'
7
8
  import path from 'node:path'
@@ -20,6 +21,29 @@ export const WHISPER_TURBO_MODEL_URL =
20
21
  export const WHISPER_TURBO_MODEL_MIRROR =
21
22
  'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-turbo/resolve/main/sherpa-onnx-whisper-turbo.tar.bz2'
22
23
 
24
+ export const MODEL_SHA256 = {
25
+ 'sensevoice': 'f6b2a72ebcb1ac7a764d4cfccd886e6bcb2a95c4657c2199d0ba95ed4b9ea71a',
26
+ 'whisper-turbo': 'b11acbbcd660b44a8e0df33724feb5aaa709cf65668f2823d59f656312544f22',
27
+ }
28
+
29
+ export function verifyArchiveSha256(filePath, expectedSha256) {
30
+ if (!expectedSha256) return Promise.resolve(true)
31
+ return new Promise((resolve, reject) => {
32
+ const hash = createHash('sha256')
33
+ const stream = createReadStream(filePath)
34
+ stream.on('data', (chunk) => hash.update(chunk))
35
+ stream.on('end', () => {
36
+ const digest = hash.digest('hex').toLowerCase()
37
+ if (digest === expectedSha256.toLowerCase()) {
38
+ resolve(true)
39
+ } else {
40
+ reject(new Error(`SHA-256 verification failed: expected ${expectedSha256}, got ${digest}`))
41
+ }
42
+ })
43
+ stream.on('error', reject)
44
+ })
45
+ }
46
+
23
47
  let installerState = {
24
48
  installing: false,
25
49
  progress: 0,
@@ -176,7 +200,7 @@ export async function downloadArchive(url, destPath, onProgress) {
176
200
 
177
201
  export function extractTarBz2(archivePath, targetDir) {
178
202
  return new Promise((resolve, reject) => {
179
- const child = spawn('tar', ['-xjf', archivePath, '-C', targetDir], { windowsHide: true })
203
+ const child = spawn('tar', ['--no-same-owner', '-xjf', archivePath, '-C', targetDir], { windowsHide: true })
180
204
  let stderr = ''
181
205
  child.stderr.on('data', (d) => { stderr += d })
182
206
  child.on('close', (code) => {
@@ -233,6 +257,13 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
233
257
 
234
258
  if (!downloadOk) throw new Error(`Could not download ${targetModel} model from primary or mirror URL`)
235
259
 
260
+ installerState.step = 'verifying checksum'
261
+ installerState.progress = 92
262
+ const expectedHash = MODEL_SHA256[targetModel]
263
+ if (expectedHash) {
264
+ await verifyArchiveSha256(archivePath, expectedHash)
265
+ }
266
+
236
267
  installerState.step = 'extracting'
237
268
  installerState.progress = 95
238
269
  await extractTarBz2(archivePath, modelDir)
@@ -281,7 +312,7 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
281
312
  kind: 'exact',
282
313
  path: '/dsh-voice/sensevoice-installer',
283
314
  handler: async (req, res) => {
284
- if (!isTrustedCaller(req)) {
315
+ if (!isTrustedCaller(req, { loopbackOnly: true })) {
285
316
  writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
286
317
  return
287
318
  }
@@ -1,3 +1,4 @@
1
+ import { mergeContextVocabulary } from './stats.js'
1
2
  // Pure helpers for the transcribe path: chain selection and session commands.
2
3
 
3
4
  /**
@@ -47,4 +48,46 @@ export function sessionCommand(text) {
47
48
  if (re.test(t)) return cmd
48
49
  }
49
50
  return null
50
- }
51
+ }
52
+
53
+ export const DEFAULT_BILINGUAL_PROMPT = 'TypeScript, Python, JavaScript, Docker, Git commit, PR, push, merge, branch, npm, pnpm, API, JSON, async, await, bug, deploy, Linux, bash. Смешанная речь разработчика: русский язык с английскими терминами, командами и кодом.'
54
+
55
+ export function buildTranscriptionPrompt({
56
+ vocabulary = [],
57
+ contextWords = [],
58
+ bilingualDeveloperPrompt = true,
59
+ bilingualCustomPrompt = '',
60
+ language = 'ru',
61
+ maxWords = 50,
62
+ } = {}) {
63
+ const mergedVocab = mergeContextVocabulary(vocabulary, contextWords, maxWords)
64
+ const isRuOrAuto = !language || language === 'ru' || language === 'auto'
65
+ let primer = ''
66
+ if (bilingualDeveloperPrompt && isRuOrAuto) {
67
+ primer = (typeof bilingualCustomPrompt === 'string' && bilingualCustomPrompt.trim())
68
+ ? bilingualCustomPrompt.trim()
69
+ : DEFAULT_BILINGUAL_PROMPT
70
+ }
71
+ const vocabStr = mergedVocab.join(', ')
72
+ let promptText = ''
73
+ if (primer && vocabStr) {
74
+ promptText = `${primer} Термины: ${vocabStr}.`
75
+ } else if (primer) {
76
+ promptText = primer
77
+ } else {
78
+ promptText = vocabStr
79
+ }
80
+
81
+ const keywords = [...mergedVocab]
82
+ if (bilingualDeveloperPrompt && isRuOrAuto) {
83
+ for (const w of ['Docker', 'Git', 'commit', 'PR', 'merge', 'branch', 'pnpm', 'npm', 'API', 'JSON', 'TypeScript']) {
84
+ if (!keywords.includes(w)) keywords.push(w)
85
+ }
86
+ }
87
+
88
+ return {
89
+ vocabulary: mergedVocab,
90
+ promptText: promptText.trim(),
91
+ keywords: keywords.slice(0, maxWords).join(', '),
92
+ }
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.35",
3
+ "version": "0.9.37",
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",