@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/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()) return true
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()) return true
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 { return false }
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()) return true
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()) return true
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 { return false }
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: await whisperAlive(),
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, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
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
- // Formats the chat template accepts in input_audio. Everything else
72
- // (including browser webm/opus) is converted to WAV first.
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
@@ -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|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
34
- { re: /^(стоп|stop|хватит)\s*[.!?]*$/i, cmd: 'stop' },
35
- { re: /^(продолжи|continue|продолжай)\s*[.!?]*$/i, cmd: '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.27",
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": {