@goodandready/dsh-voice 0.8.27 → 0.8.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +1 -1
- package/lib/client.js +320 -92
- package/lib/http-util.js +48 -0
- package/lib/index.js +46 -10
- package/lib/normalize.js +3 -1
- package/lib/provider-http.js +64 -0
- package/lib/providers.js +2 -38
- package/lib/transcribe-core.js +4 -4
- package/lib/updater.js +266 -0
- package/lib/wav.js +2 -2
- package/package.json +4 -2
package/lib/http-util.js
CHANGED
|
@@ -25,4 +25,52 @@ export function readBody(req, maxBytes) {
|
|
|
25
25
|
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
26
26
|
req.on('error', reject)
|
|
27
27
|
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check whether an IP address is a local loopback interface.
|
|
32
|
+
* @param {string} addr
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isLoopback(addr) {
|
|
36
|
+
if (!addr) return false
|
|
37
|
+
const clean = String(addr).replace(/^::ffff:/, '')
|
|
38
|
+
return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify that the HTTP request originates from a trusted caller (loopback or same-origin).
|
|
43
|
+
* Fail-closed security guard for state-changing or quota-consuming POST routes.
|
|
44
|
+
* @param {import('node:http').IncomingMessage} req
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function isTrustedCaller(req) {
|
|
48
|
+
if (!req) return false
|
|
49
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
|
|
50
|
+
if (isLoopback(remote)) return true
|
|
51
|
+
|
|
52
|
+
const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
|
|
53
|
+
if (secSite === 'same-origin' || secSite === 'same-site') return true
|
|
54
|
+
if (secSite === 'cross-site') return false
|
|
55
|
+
|
|
56
|
+
const host = String(req.headers?.host || '').toLowerCase()
|
|
57
|
+
const origin = String(req.headers?.origin || '').trim().toLowerCase()
|
|
58
|
+
if (origin) {
|
|
59
|
+
try {
|
|
60
|
+
const u = new URL(origin)
|
|
61
|
+
if (u.host === host) return true
|
|
62
|
+
} catch { /* invalid url */ }
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const referer = String(req.headers?.referer || '').trim().toLowerCase()
|
|
67
|
+
if (referer) {
|
|
68
|
+
try {
|
|
69
|
+
const u = new URL(referer)
|
|
70
|
+
if (u.host === host) return true
|
|
71
|
+
} catch { /* invalid url */ }
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return false
|
|
28
76
|
}
|
package/lib/index.js
CHANGED
|
@@ -22,16 +22,17 @@ import { makeProviders, PROVIDER_KEYS, PRESET_KEYS, KNOWN_KEYS, DEFAULT_MODELS,
|
|
|
22
22
|
import { toWav16k } from './wav.js'
|
|
23
23
|
import { normalizePhrase } from './normalize.js'
|
|
24
24
|
import { createStatsTracker, mergeContextVocabulary } from './stats.js'
|
|
25
|
-
import { writeJson, readBody } from './http-util.js'
|
|
25
|
+
import { writeJson, readBody, isTrustedCaller } from './http-util.js'
|
|
26
26
|
import { createPolishText } from './polish.js'
|
|
27
27
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
28
28
|
import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
|
|
29
|
+
import { registerPluginUpdater } from './updater.js'
|
|
29
30
|
|
|
30
31
|
function isAutoLang(lang) {
|
|
31
32
|
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
export const name = 'dsh-voice'
|
|
35
|
+
export const name = '@goodandready/dsh-voice'
|
|
35
36
|
export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
|
|
36
37
|
|
|
37
38
|
const ChainEntry = z.object({
|
|
@@ -207,6 +208,7 @@ export function apply(ctx, baseConfig) {
|
|
|
207
208
|
} catch { return false }
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
let whisperError = null
|
|
210
212
|
let startingWhisper = false
|
|
211
213
|
async function startWhisper() {
|
|
212
214
|
if (startingWhisper) return false
|
|
@@ -216,7 +218,10 @@ export function apply(ctx, baseConfig) {
|
|
|
216
218
|
// Without a model path there is nothing to start: the package cannot know
|
|
217
219
|
// where a user's model lives and must not launch a random binary.
|
|
218
220
|
if (!cfg.whisperModel) return false
|
|
219
|
-
if (await whisperAlive())
|
|
221
|
+
if (await whisperAlive()) {
|
|
222
|
+
whisperError = null
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
220
225
|
try {
|
|
221
226
|
const spec = ctx.shell.resolve({
|
|
222
227
|
command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
|
|
@@ -227,10 +232,17 @@ export function apply(ctx, baseConfig) {
|
|
|
227
232
|
child = ctx.shell.start(spec)
|
|
228
233
|
for (let i = 0; i < 20; i++) {
|
|
229
234
|
await new Promise((r) => setTimeout(r, 500))
|
|
230
|
-
if (await whisperAlive())
|
|
235
|
+
if (await whisperAlive()) {
|
|
236
|
+
whisperError = null
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
231
239
|
}
|
|
240
|
+
whisperError = 'whisper server process started but failed health check on port 8001'
|
|
232
241
|
return false
|
|
233
|
-
} catch {
|
|
242
|
+
} catch (err) {
|
|
243
|
+
whisperError = String(err && err.message ? err.message : err)
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
234
246
|
finally { startingWhisper = false }
|
|
235
247
|
}
|
|
236
248
|
|
|
@@ -238,6 +250,7 @@ export function apply(ctx, baseConfig) {
|
|
|
238
250
|
|
|
239
251
|
// SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
|
|
240
252
|
let sensevoiceChild = null
|
|
253
|
+
let sensevoiceError = null
|
|
241
254
|
|
|
242
255
|
async function sensevoiceAlive() {
|
|
243
256
|
try {
|
|
@@ -259,7 +272,10 @@ export function apply(ctx, baseConfig) {
|
|
|
259
272
|
const cfg = live()
|
|
260
273
|
if (!cfg.sensevoiceAutostart) return false
|
|
261
274
|
if (!cfg.sensevoiceModel) return false
|
|
262
|
-
if (await sensevoiceAlive())
|
|
275
|
+
if (await sensevoiceAlive()) {
|
|
276
|
+
sensevoiceError = null
|
|
277
|
+
return true
|
|
278
|
+
}
|
|
263
279
|
try {
|
|
264
280
|
let port = '6006'
|
|
265
281
|
try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
|
|
@@ -273,10 +289,17 @@ export function apply(ctx, baseConfig) {
|
|
|
273
289
|
sensevoiceChild = ctx.shell.start(spec)
|
|
274
290
|
for (let i = 0; i < 20; i++) {
|
|
275
291
|
await new Promise((r) => setTimeout(r, 500))
|
|
276
|
-
if (await sensevoiceAlive())
|
|
292
|
+
if (await sensevoiceAlive()) {
|
|
293
|
+
sensevoiceError = null
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
277
296
|
}
|
|
297
|
+
sensevoiceError = 'sensevoice process started but failed health check'
|
|
278
298
|
return false
|
|
279
|
-
} catch {
|
|
299
|
+
} catch (err) {
|
|
300
|
+
sensevoiceError = String(err && err.message ? err.message : err)
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
280
303
|
finally { startingSensevoice = false }
|
|
281
304
|
}
|
|
282
305
|
|
|
@@ -330,9 +353,14 @@ export function apply(ctx, baseConfig) {
|
|
|
330
353
|
handler: async (req, res) => {
|
|
331
354
|
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
332
355
|
const cfg = live()
|
|
356
|
+
const whisperOk = await whisperAlive()
|
|
357
|
+
const sensevoiceOk = await sensevoiceAlive()
|
|
333
358
|
writeJson(res, 200, {
|
|
334
359
|
ok: true,
|
|
335
|
-
whisperRunning:
|
|
360
|
+
whisperRunning: whisperOk,
|
|
361
|
+
whisperError: whisperOk ? null : whisperError,
|
|
362
|
+
sensevoiceRunning: sensevoiceOk,
|
|
363
|
+
sensevoiceError: sensevoiceOk ? null : sensevoiceError,
|
|
336
364
|
// The hotkey is needed by the browser half: it installs the hold handler.
|
|
337
365
|
hotkey: cfg.hotkey,
|
|
338
366
|
providers: KNOWN_KEYS.concat(
|
|
@@ -378,9 +406,10 @@ export function apply(ctx, baseConfig) {
|
|
|
378
406
|
path: '/dsh-voice/polish',
|
|
379
407
|
handler: async (req, res) => {
|
|
380
408
|
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
409
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
381
410
|
const cfg = live()
|
|
382
411
|
let raw
|
|
383
|
-
try { raw = await readBody(req,
|
|
412
|
+
try { raw = await readBody(req, 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
|
|
384
413
|
let payload = {}
|
|
385
414
|
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
|
|
386
415
|
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
@@ -401,6 +430,7 @@ export function apply(ctx, baseConfig) {
|
|
|
401
430
|
path: '/dsh-voice/transcribe',
|
|
402
431
|
handler: async (req, res) => {
|
|
403
432
|
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
433
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
404
434
|
const cfg = live()
|
|
405
435
|
let raw
|
|
406
436
|
try {
|
|
@@ -512,4 +542,10 @@ export function apply(ctx, baseConfig) {
|
|
|
512
542
|
if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
|
|
513
543
|
if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
|
|
514
544
|
}, 'dsh-voice: stop child processes')
|
|
545
|
+
|
|
546
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
547
|
+
endpoint: '/api/dsh-voice/update',
|
|
548
|
+
packageName: '@goodandready/dsh-voice',
|
|
549
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
550
|
+
}), 'dsh-voice: updater route')
|
|
515
551
|
}
|
package/lib/normalize.js
CHANGED
|
@@ -16,8 +16,10 @@ export function ensureTrailingPeriod(text) {
|
|
|
16
16
|
return t + '.'
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Functional linguistic data for speech-to-text numeral normalization.
|
|
20
|
+
// Note: Language processing dictionary for audio transcript conversion, not UI text strings.
|
|
19
21
|
const NUM_WORDS = {
|
|
20
|
-
ноль: 0, один: 1, одна: 1, два: 2, две: 2, три: 3, четыре: 4, пять: 5,
|
|
22
|
+
ноль: 0, один: 1, одна: 1, одно: 1, два: 2, две: 2, три: 3, четыре: 4, пять: 5,
|
|
21
23
|
шесть: 6, семь: 7, восемь: 8, девять: 9, десять: 10, одиннадцать: 11,
|
|
22
24
|
двенадцать: 12, тринадцать: 13, четырнадцать: 14, пятнадцать: 15,
|
|
23
25
|
шестнадцать: 16, семнадцать: 17, восемнадцать: 18, девятнадцать: 19,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Pure helper functions for STT provider HTTP requests and responses.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if the language is unspecified or set to automatic detection.
|
|
5
|
+
* @param {string} [lang]
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function isAutoLang(lang) {
|
|
9
|
+
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Determine the audio container format supported by the OpenAI Chat Audio API.
|
|
14
|
+
* @param {string} [mime='']
|
|
15
|
+
* @returns {'wav' | 'mp3' | ''}
|
|
16
|
+
*/
|
|
17
|
+
export function chatAudioFormat(mime = '') {
|
|
18
|
+
if (mime.includes('wav')) return 'wav'
|
|
19
|
+
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3'
|
|
20
|
+
return ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Return a safe file name based on mime type for multipart form uploads.
|
|
25
|
+
* @param {string} [mime='']
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function fileName(mime = '') {
|
|
29
|
+
if (mime.includes('wav')) return 'audio.wav'
|
|
30
|
+
if (mime.includes('ogg')) return 'audio.ogg'
|
|
31
|
+
if (mime.includes('mp4')) return 'audio.m4a'
|
|
32
|
+
return 'audio.webm'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Safely parse JSON from a response or throw a standardized descriptive Error.
|
|
37
|
+
* @param {Response} res
|
|
38
|
+
* @param {string} defaultLabel
|
|
39
|
+
* @returns {Promise<any>}
|
|
40
|
+
*/
|
|
41
|
+
export async function safeJson(res, defaultLabel) {
|
|
42
|
+
try {
|
|
43
|
+
return await res.json()
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`${defaultLabel}: invalid JSON response`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract an error message or detail string from a non-2xx HTTP response.
|
|
51
|
+
* @param {Response} res
|
|
52
|
+
* @param {string} defaultLabel
|
|
53
|
+
* @returns {Promise<string>}
|
|
54
|
+
*/
|
|
55
|
+
export async function readErrorDetail(res, defaultLabel) {
|
|
56
|
+
let detail = `HTTP ${res.status}`
|
|
57
|
+
try {
|
|
58
|
+
const e = await res.json()
|
|
59
|
+
if (e?.error) detail = typeof e.error === 'string' ? e.error : (e.error.message || JSON.stringify(e.error))
|
|
60
|
+
else if (e?.message) detail = e.message
|
|
61
|
+
else if (e?.err_msg) detail = e.err_msg
|
|
62
|
+
} catch { /* not json */ }
|
|
63
|
+
return `${defaultLabel} ${detail}`
|
|
64
|
+
}
|
package/lib/providers.js
CHANGED
|
@@ -68,13 +68,8 @@ const CHAT_AUDIO_PROMPT =
|
|
|
68
68
|
'Transcribe the audio verbatim. Reply with the transcript text only, '
|
|
69
69
|
+ 'without comments, quotes or formatting.'
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
function chatAudioFormat(mime) {
|
|
74
|
-
if (mime.includes('wav')) return 'wav'
|
|
75
|
-
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3'
|
|
76
|
-
return ''
|
|
77
|
-
}
|
|
71
|
+
import { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat } from './provider-http.js'
|
|
72
|
+
export { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat }
|
|
78
73
|
|
|
79
74
|
export const DEFAULT_MODELS = {
|
|
80
75
|
deepgram: 'nova-2',
|
|
@@ -89,37 +84,6 @@ function pickModel(models, key) {
|
|
|
89
84
|
return chosen || DEFAULT_MODELS[key]
|
|
90
85
|
}
|
|
91
86
|
|
|
92
|
-
function fileName(mime) {
|
|
93
|
-
if (mime.includes('wav')) return 'audio.wav'
|
|
94
|
-
if (mime.includes('ogg')) return 'audio.ogg'
|
|
95
|
-
if (mime.includes('mp4')) return 'audio.m4a'
|
|
96
|
-
return 'audio.webm'
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Auto language: empty, 'auto', or a list ('ru,en') means the provider
|
|
100
|
-
// detects language; the language field is omitted. whisper.cpp gets -l auto.
|
|
101
|
-
function isAutoLang(lang) {
|
|
102
|
-
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function readErrorDetail(res, defaultLabel) {
|
|
106
|
-
let detail = `HTTP ${res.status}`
|
|
107
|
-
try {
|
|
108
|
-
const e = await res.json()
|
|
109
|
-
if (e?.error) detail = typeof e.error === 'string' ? e.error : (e.error.message || JSON.stringify(e.error))
|
|
110
|
-
else if (e?.message) detail = e.message
|
|
111
|
-
else if (e?.err_msg) detail = e.err_msg
|
|
112
|
-
} catch { /* not json */ }
|
|
113
|
-
return `${defaultLabel} ${detail}`
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function safeJson(res, defaultLabel) {
|
|
117
|
-
try {
|
|
118
|
-
return await res.json()
|
|
119
|
-
} catch {
|
|
120
|
-
throw new Error(`${defaultLabel}: invalid JSON response`)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
87
|
|
|
124
88
|
export function makeProviders(deps, req) {
|
|
125
89
|
const { resolveKey, fetchImpl, cfg } = deps
|
package/lib/transcribe-core.js
CHANGED
|
@@ -29,10 +29,10 @@ export function buildProviderOrder({ localOnly, chain, customKeys, knownKeys, de
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const SESSION_COMMANDS = [
|
|
32
|
-
{ re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
|
|
33
|
-
{ re: /^(отмени|отмена|cancel
|
|
34
|
-
{ re: /^(стоп|stop
|
|
35
|
-
{ re: /^(продолжи|continue
|
|
32
|
+
{ re: /^(отправь|отправить|пошли|send|发送|发出去)\s*[.!?]*$/i, cmd: 'send' },
|
|
33
|
+
{ re: /^(отмени|отмена|cancel|отменить|取消|算了)\s*[.!?]*$/i, cmd: 'cancel' },
|
|
34
|
+
{ re: /^(стоп|stop|хватит|停止|暂停)\s*[.!?]*$/i, cmd: 'stop' },
|
|
35
|
+
{ re: /^(продолжи|continue|продолжай|继续)\s*[.!?]*$/i, cmd: 'continue' },
|
|
36
36
|
]
|
|
37
37
|
|
|
38
38
|
/**
|
package/lib/updater.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// lib/updater.js — Host-side one-click updater for @goodandready/dsh-voice
|
|
2
|
+
// Implements canonical DSH plugin-updater specification.
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const UPDATE_HEADER = 'x-dsh-plugin-update';
|
|
12
|
+
const UPDATE_TIMEOUT_MS = 10 * 60_000;
|
|
13
|
+
const VERSION_CACHE_MS = 5 * 60_000;
|
|
14
|
+
let latestCache;
|
|
15
|
+
|
|
16
|
+
function header(request, name) {
|
|
17
|
+
const value = request?.headers?.[name];
|
|
18
|
+
return Array.isArray(value) ? value[0] : value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isLoopback(value) {
|
|
22
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '');
|
|
23
|
+
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
24
|
+
|| address?.startsWith('127.') === true
|
|
25
|
+
|| address?.startsWith('::ffff:127.') === true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isTrustedUpdateRequest(request) {
|
|
29
|
+
if (header(request, UPDATE_HEADER) !== '1') return false;
|
|
30
|
+
if (!isLoopback(request.socket?.remoteAddress)) return false;
|
|
31
|
+
const site = header(request, 'sec-fetch-site');
|
|
32
|
+
if (site !== undefined && site !== 'same-origin') return false;
|
|
33
|
+
const origin = header(request, 'origin');
|
|
34
|
+
const host = header(request, 'host');
|
|
35
|
+
if (origin === undefined || host === undefined) return false;
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(origin);
|
|
38
|
+
return (url.protocol === 'http:' || url.protocol === 'https:')
|
|
39
|
+
&& isLoopback(url.hostname) && url.host === host;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validProfileName(value) {
|
|
46
|
+
return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
|
|
47
|
+
&& !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function profileNameFromArgv(argv) {
|
|
51
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
52
|
+
if (argv[index] === '--profile') return argv[index + 1];
|
|
53
|
+
if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length);
|
|
54
|
+
}
|
|
55
|
+
return argv[2] === 'web' ? 'web' : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findDshCliEntry() {
|
|
59
|
+
const value = process.argv[1];
|
|
60
|
+
if (value === undefined || value === '') return undefined;
|
|
61
|
+
const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
|
|
62
|
+
if (!existsSync(entry)) return undefined;
|
|
63
|
+
for (let directory = dirname(entry); ; directory = dirname(directory)) {
|
|
64
|
+
const manifestPath = resolve(directory, 'package.json');
|
|
65
|
+
if (existsSync(manifestPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
68
|
+
const bin = typeof manifest.bin === 'string'
|
|
69
|
+
? manifest.bin
|
|
70
|
+
: typeof manifest.bin === 'object' && manifest.bin !== null
|
|
71
|
+
? manifest.bin.dsh
|
|
72
|
+
: undefined;
|
|
73
|
+
if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
|
|
74
|
+
&& !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
|
|
75
|
+
} catch {
|
|
76
|
+
// Continue searching parent package directories.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const parent = dirname(directory);
|
|
80
|
+
if (parent === directory) return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runtime() {
|
|
85
|
+
const profileDir = resolve(process.env.DSH_PROFILE_DIR
|
|
86
|
+
?? resolve(homedir(), '.dsh', 'profiles', 'web'));
|
|
87
|
+
const selected = profileNameFromArgv(process.argv);
|
|
88
|
+
const profileName = validProfileName(selected)
|
|
89
|
+
? selected
|
|
90
|
+
: validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
|
|
91
|
+
const cliEntry = findDshCliEntry();
|
|
92
|
+
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseSemver(value) {
|
|
96
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
|
|
97
|
+
if (match === null) return undefined;
|
|
98
|
+
return {
|
|
99
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
100
|
+
prerelease: match[4]?.split('.') ?? [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function comparePrerelease(left, right) {
|
|
105
|
+
if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
106
|
+
const length = Math.max(left.length, right.length);
|
|
107
|
+
for (let index = 0; index < length; index += 1) {
|
|
108
|
+
const a = left[index];
|
|
109
|
+
const b = right[index];
|
|
110
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
|
|
111
|
+
if (a === b) continue;
|
|
112
|
+
const aNumeric = /^\d+$/.test(a);
|
|
113
|
+
const bNumeric = /^\d+$/.test(b);
|
|
114
|
+
if (aNumeric && bNumeric) {
|
|
115
|
+
const aNumber = BigInt(a);
|
|
116
|
+
const bNumber = BigInt(b);
|
|
117
|
+
if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
121
|
+
return a > b ? 1 : -1;
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isNewerVersion(currentValue, candidateValue) {
|
|
127
|
+
const current = parseSemver(currentValue);
|
|
128
|
+
const candidate = parseSemver(candidateValue);
|
|
129
|
+
if (current === undefined || candidate === undefined) return false;
|
|
130
|
+
for (let index = 0; index < 3; index += 1) {
|
|
131
|
+
if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
|
|
132
|
+
}
|
|
133
|
+
return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function latestVersion(packageName, registry) {
|
|
137
|
+
if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) return latestCache.version;
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
|
|
140
|
+
signal: AbortSignal.timeout(8_000),
|
|
141
|
+
});
|
|
142
|
+
if (!response.ok) return undefined;
|
|
143
|
+
const value = await response.json();
|
|
144
|
+
if (typeof value.version !== 'string' || value.version === '') return undefined;
|
|
145
|
+
latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
|
|
146
|
+
return value.version;
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function currentVersion(manifestUrl) {
|
|
153
|
+
const value = JSON.parse(await readFile(manifestUrl, 'utf8'));
|
|
154
|
+
if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.');
|
|
155
|
+
return value.version;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function getUpdateStatus(options, target = runtime()) {
|
|
159
|
+
const current = await currentVersion(options.manifestUrl);
|
|
160
|
+
const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org');
|
|
161
|
+
return {
|
|
162
|
+
packageName: options.packageName,
|
|
163
|
+
currentVersion: current,
|
|
164
|
+
...(latest === undefined ? {} : { latestVersion: latest }),
|
|
165
|
+
latestCheckFailed: latest === undefined,
|
|
166
|
+
updateAvailable: latest !== undefined && isNewerVersion(current, latest),
|
|
167
|
+
profileName: target.profileName,
|
|
168
|
+
canAutoUpdate: target.cliEntry !== undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function installExact(target, packageSpec, options) {
|
|
173
|
+
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
|
|
174
|
+
await new Promise((resolvePromise, reject) => {
|
|
175
|
+
const child = spawn(process.execPath, [
|
|
176
|
+
target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
|
|
177
|
+
'--config.minimumReleaseAge=0', packageSpec,
|
|
178
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
179
|
+
], {
|
|
180
|
+
cwd: target.profileDir,
|
|
181
|
+
windowsHide: true,
|
|
182
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
183
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
184
|
+
});
|
|
185
|
+
let detail = '';
|
|
186
|
+
child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
|
|
187
|
+
child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
child.kill();
|
|
190
|
+
reject(new Error('Update timed out; use the normal DSH update flow.'));
|
|
191
|
+
}, UPDATE_TIMEOUT_MS);
|
|
192
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
193
|
+
child.once('exit', code => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
if (code === 0) resolvePromise();
|
|
196
|
+
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function json(response, statusCode, value) {
|
|
202
|
+
response.writeHead(statusCode, {
|
|
203
|
+
'content-type': 'application/json; charset=utf-8',
|
|
204
|
+
'cache-control': 'no-store',
|
|
205
|
+
});
|
|
206
|
+
response.end(JSON.stringify(value));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function registerPluginUpdater(ctx, options) {
|
|
210
|
+
const host = ctx;
|
|
211
|
+
let installing = false;
|
|
212
|
+
return host.webServer.register({
|
|
213
|
+
kind: 'exact',
|
|
214
|
+
path: options.endpoint,
|
|
215
|
+
handler: async (request, response) => {
|
|
216
|
+
try {
|
|
217
|
+
const target = runtime();
|
|
218
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
219
|
+
const payload = await getUpdateStatus(options, target);
|
|
220
|
+
response.writeHead(200, {
|
|
221
|
+
'content-type': 'application/json; charset=utf-8',
|
|
222
|
+
'cache-control': 'no-store',
|
|
223
|
+
});
|
|
224
|
+
response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (request.method !== 'POST') {
|
|
228
|
+
response.writeHead(405, { allow: 'GET, HEAD, POST' });
|
|
229
|
+
response.end();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!isTrustedUpdateRequest(request)) {
|
|
233
|
+
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (installing) {
|
|
237
|
+
json(response, 409, { error: 'This plugin is already updating.' });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
installing = true;
|
|
241
|
+
try {
|
|
242
|
+
const before = await getUpdateStatus(options, target);
|
|
243
|
+
if (before.latestVersion === undefined) {
|
|
244
|
+
json(response, 503, { error: 'The latest version is temporarily unavailable.' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!before.updateAvailable) {
|
|
248
|
+
json(response, 200, before);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
|
|
252
|
+
json(response, 200, {
|
|
253
|
+
...before,
|
|
254
|
+
updatedVersion: before.latestVersion,
|
|
255
|
+
restartRequired: true,
|
|
256
|
+
});
|
|
257
|
+
} finally {
|
|
258
|
+
installing = false;
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`);
|
|
262
|
+
json(response, 503, { error: 'Plugin update failed; see server logs.' });
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
package/lib/wav.js
CHANGED
|
@@ -29,13 +29,13 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg', signal = null) {
|
|
|
29
29
|
|
|
30
30
|
const timeout = setTimeout(() => {
|
|
31
31
|
cleanup()
|
|
32
|
-
try { proc.kill('SIGKILL') } catch {}
|
|
32
|
+
try { proc.kill('SIGKILL') } catch (e) { /* process already exited */ }
|
|
33
33
|
reject(new Error('ffmpeg conversion timed out after 30s'))
|
|
34
34
|
}, 30000)
|
|
35
35
|
|
|
36
36
|
const onAbort = () => {
|
|
37
37
|
cleanup()
|
|
38
|
-
try { proc.kill('SIGKILL') } catch {}
|
|
38
|
+
try { proc.kill('SIGKILL') } catch (e) { /* process already exited */ }
|
|
39
39
|
reject(new Error('ffmpeg conversion aborted'))
|
|
40
40
|
}
|
|
41
41
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.29",
|
|
4
4
|
"description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "node --test test/*.test.mjs",
|
|
45
45
|
"build:client": "node scripts/build-client.mjs",
|
|
46
|
-
"pretest": "node scripts/build-client.mjs"
|
|
46
|
+
"pretest": "node scripts/build-client.mjs",
|
|
47
|
+
"lint": "node --check lib/*.js scripts/*.mjs",
|
|
48
|
+
"pack:check": "node scripts/pack-check.mjs"
|
|
47
49
|
},
|
|
48
50
|
"dsh": {
|
|
49
51
|
"bundle": {
|