@hxnnxs/opencode-voice 0.1.8 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## 0.1.9 - 2026-06-21
6
+
7
+ ### Fixed
8
+
9
+ - Rebuilt the managed Windows `whisper-cli.exe` engine asset as a static MinGW binary so clean Windows installs do not need the Visual C++ runtime DLLs.
10
+ - Fixed managed engine path handling so explicit `downloadDir` and Windows platform overrides are honored consistently.
11
+ - Added streaming timeout protection and clearer VC++ runtime diagnostics to managed engine downloads and probes.
12
+
5
13
  ## 0.1.8 - 2026-06-21
6
14
 
7
15
  ### Fixed
package/lib/engines.js CHANGED
@@ -9,7 +9,7 @@ import { sha256, ensureDir, replaceFile } from "./download.js";
9
9
  import { getCacheDir } from "./models.js";
10
10
  import { getBundledEngineDir, resolveCommand } from "./engine.js";
11
11
 
12
- export const DEFAULT_ENGINE_REGISTRY_URL = "https://github.com/ihxnnxs/opencode-voice/releases/download/v0.1.0/registry.json";
12
+ export const DEFAULT_ENGINE_REGISTRY_URL = "https://github.com/ihxnnxs/opencode-voice/releases/download/v0.1.9/registry.json";
13
13
 
14
14
  const ENGINE = {
15
15
  id: "whisper.cpp",
@@ -19,13 +19,14 @@ const ENGINE = {
19
19
  };
20
20
 
21
21
  const DEFAULT_ENGINE_DOWNLOAD_RETRIES = 5;
22
+ const DEFAULT_ENGINE_DOWNLOAD_TIMEOUT_MS = 120000;
22
23
 
23
24
  function sleep(ms) {
24
25
  return new Promise((resolve) => setTimeout(resolve, ms));
25
26
  }
26
27
 
27
- function executableName() {
28
- return process.platform === "win32" ? "whisper-cli.exe" : "whisper-cli";
28
+ function executableName(options = {}) {
29
+ return (options.platform || process.platform) === "win32" ? "whisper-cli.exe" : "whisper-cli";
29
30
  }
30
31
 
31
32
  function temporaryBinaryPath(binaryPath) {
@@ -45,13 +46,17 @@ export function getEnginePlatformKey(options = {}) {
45
46
  return `${options.platform || process.platform}-${normalizePlatform(options.arch || process.arch)}`;
46
47
  }
47
48
 
49
+ function withDownloadDir(options = {}, settings = {}) {
50
+ return { ...options, downloadDir: settings.downloadDir || options.downloadDir };
51
+ }
52
+
48
53
  export function getManagedEngineDir(engineId = ENGINE.id, options = {}, settings = {}) {
49
54
  if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
50
- return getBundledEngineDir(ENGINE.command, { ...options, downloadDir: settings.downloadDir });
55
+ return getBundledEngineDir(ENGINE.command, withDownloadDir(options, settings));
51
56
  }
52
57
 
53
58
  export function getManagedEngineBinary(engineId = ENGINE.id, options = {}, settings = {}) {
54
- return path.join(getManagedEngineDir(engineId, options, settings), executableName());
59
+ return path.join(getManagedEngineDir(engineId, options, settings), executableName(options));
55
60
  }
56
61
 
57
62
  export function getEngineManifestPath(engineId = ENGINE.id, options = {}, settings = {}) {
@@ -77,32 +82,52 @@ function engineSource(resolved, managedBinary, options = {}) {
77
82
  }
78
83
 
79
84
  export function getEngineStatus(engineId = ENGINE.id, options = {}, settings = {}) {
80
- const commandOptions = { ...options, downloadDir: settings.downloadDir };
81
- const managedDir = getManagedEngineDir(engineId, options, settings);
82
- const managedBinary = getManagedEngineBinary(engineId, options, settings);
83
- const manifest = readEngineManifest(engineId, options, settings);
85
+ const commandOptions = withDownloadDir(options, settings);
86
+ const managedDir = getManagedEngineDir(engineId, commandOptions);
87
+ const managedBinary = getManagedEngineBinary(engineId, commandOptions);
88
+ const manifest = readEngineManifest(engineId, commandOptions);
84
89
  const resolvedBinary = resolveCommand(ENGINE.command, commandOptions);
85
90
 
86
91
  return {
87
92
  id: engineId,
88
93
  command: ENGINE.command,
89
- platform: getEnginePlatformKey(options),
90
- cacheDir: getCacheDir(options, settings),
94
+ platform: getEnginePlatformKey(commandOptions),
95
+ cacheDir: getCacheDir(commandOptions),
91
96
  managedDir,
92
97
  managedBinary,
93
98
  managedInstalled: fs.existsSync(managedBinary),
94
99
  manifest,
95
100
  resolvedBinary,
96
- source: engineSource(resolvedBinary, managedBinary, options),
101
+ source: engineSource(resolvedBinary, managedBinary, commandOptions),
97
102
  };
98
103
  }
99
104
 
105
+ function summarizeProbeOutput(output) {
106
+ return String(output || "").replace(/\s+/g, " ").trim().slice(0, 240);
107
+ }
108
+
109
+ function engineProbeMessage(message) {
110
+ const text = summarizeProbeOutput(message);
111
+ if (/\b(MSVCP140|VCOMP140|VCRUNTIME140|VCRUNTIME140_1|api-ms-win-crt)\b/i.test(text)) {
112
+ return `${text}; Windows VC++ runtime dependency is missing`;
113
+ }
114
+ return text || "unknown probe failure";
115
+ }
116
+
100
117
  export async function probeEngine(engineId = ENGINE.id, binaryPath, options = {}) {
101
118
  if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
102
119
  if (!binaryPath) return { ok: false, message: "missing binary" };
103
120
 
104
121
  return new Promise((resolve) => {
105
122
  let output = "";
123
+ let settled = false;
124
+ let timer;
125
+ const finish = (result) => {
126
+ if (settled) return;
127
+ settled = true;
128
+ clearTimeout(timer);
129
+ resolve(result);
130
+ };
106
131
  const localLib = path.join(os.homedir(), ".local", "lib");
107
132
  const env = {
108
133
  ...process.env,
@@ -112,11 +137,11 @@ export async function probeEngine(engineId = ENGINE.id, binaryPath, options = {}
112
137
  ...options.env,
113
138
  };
114
139
  const proc = spawn(binaryPath, ENGINE.probeArgs, { stdio: ["ignore", "pipe", "pipe"], env });
115
- const timer = setTimeout(() => {
140
+ timer = setTimeout(() => {
116
141
  try {
117
142
  proc.kill("SIGKILL");
118
143
  } catch {}
119
- resolve({ ok: false, message: "probe timed out" });
144
+ finish({ ok: false, message: "probe timed out" });
120
145
  }, options.timeoutMs || 10000);
121
146
 
122
147
  proc.stdout.on("data", (chunk) => {
@@ -126,13 +151,12 @@ export async function probeEngine(engineId = ENGINE.id, binaryPath, options = {}
126
151
  output += chunk.toString();
127
152
  });
128
153
  proc.on("error", (error) => {
129
- clearTimeout(timer);
130
- resolve({ ok: false, message: error.message });
154
+ finish({ ok: false, message: engineProbeMessage(error.message) });
131
155
  });
132
- proc.on("exit", () => {
133
- clearTimeout(timer);
156
+ proc.on("exit", (code, signal) => {
134
157
  const ok = ENGINE.probeContains.every((item) => output.includes(item));
135
- resolve({ ok, message: ok ? "ok" : "unexpected whisper-cli help output" });
158
+ const detail = summarizeProbeOutput(output) || `exit ${code ?? signal ?? "unknown"}`;
159
+ finish({ ok, message: ok ? "ok" : engineProbeMessage(`unexpected whisper-cli help output: ${detail}`) });
136
160
  });
137
161
  });
138
162
  }
@@ -150,6 +174,14 @@ async function fetchWithTimeout(url, init = {}, timeoutMs = 60000) {
150
174
  }
151
175
  }
152
176
 
177
+ function engineDownloadTimeoutMs(options = {}, hooks = {}) {
178
+ return Number(options.engineDownloadTimeoutMs || options.downloadTimeoutMs || hooks.timeoutMs || DEFAULT_ENGINE_DOWNLOAD_TIMEOUT_MS);
179
+ }
180
+
181
+ function engineDownloadTimeoutError(url, timeoutMs) {
182
+ return new Error(`Engine download stalled after ${Math.round(timeoutMs / 1000)}s: ${url}`);
183
+ }
184
+
153
185
  async function readJsonUrl(url, options = {}) {
154
186
  if (url.startsWith("file://")) return JSON.parse(await fs.promises.readFile(new URL(url), "utf8"));
155
187
  if (!/^https?:\/\//.test(url)) return JSON.parse(await fs.promises.readFile(path.resolve(url), "utf8"));
@@ -201,7 +233,8 @@ async function downloadAsset(asset, compressedFile, hooks = {}, attempt = 1, att
201
233
  return;
202
234
  }
203
235
 
204
- const response = await fetchWithTimeout(asset.url, {}, Number(options.downloadTimeoutMs || hooks.timeoutMs || 120000));
236
+ const timeoutMs = engineDownloadTimeoutMs(options, hooks);
237
+ const response = await fetchWithTimeout(asset.url, {}, timeoutMs);
205
238
  if (!response.ok) throw new Error(`Engine download failed: HTTP ${response.status}`);
206
239
 
207
240
  const contentLength = Number(response.headers.get("content-length") || asset.size || 0);
@@ -211,15 +244,31 @@ async function downloadAsset(asset, compressedFile, hooks = {}, attempt = 1, att
211
244
  const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
212
245
  if (!body) throw new Error("Engine download failed: empty response body");
213
246
 
247
+ const controller = new AbortController();
248
+ let stallTimer;
249
+ const resetStallTimer = () => {
250
+ clearTimeout(stallTimer);
251
+ stallTimer = setTimeout(() => controller.abort(), timeoutMs);
252
+ };
253
+
214
254
  const progress = new Transform({
215
255
  transform(chunk, _encoding, callback) {
256
+ resetStallTimer();
216
257
  downloaded += chunk.length ?? chunk.byteLength ?? 0;
217
258
  hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: contentLength ? (downloaded / contentLength) * 100 : 0, attempt, attempts });
218
259
  callback(null, chunk);
219
260
  },
220
261
  });
221
262
 
222
- await pipeline(body, progress, fs.createWriteStream(compressedFile));
263
+ resetStallTimer();
264
+ try {
265
+ await pipeline(body, progress, fs.createWriteStream(compressedFile), { signal: controller.signal });
266
+ } catch (error) {
267
+ if (error?.name === "AbortError") throw engineDownloadTimeoutError(asset.url, timeoutMs);
268
+ throw error;
269
+ } finally {
270
+ clearTimeout(stallTimer);
271
+ }
223
272
  }
224
273
 
225
274
  async function writeManifest(engineId, managedBinary, source, options = {}) {
@@ -244,29 +293,30 @@ async function writeManifest(engineId, managedBinary, source, options = {}) {
244
293
  export async function installManagedEngine(engineId = ENGINE.id, options = {}, settings = {}, hooks = {}) {
245
294
  if (engineId !== ENGINE.id) throw new Error(`Unsupported engine: ${engineId}`);
246
295
 
247
- const managedDir = getManagedEngineDir(engineId, options, settings);
248
- const managedBinary = getManagedEngineBinary(engineId, options, settings);
296
+ const commandOptions = withDownloadDir(options, settings);
297
+ const managedDir = getManagedEngineDir(engineId, commandOptions);
298
+ const managedBinary = getManagedEngineBinary(engineId, commandOptions);
249
299
  if (!hooks.force && fs.existsSync(managedBinary)) {
250
300
  const probe = await probeEngine(engineId, managedBinary);
251
- if (probe.ok) return { manifest: readEngineManifest(engineId, options, settings), managedBinary, skipped: true };
301
+ if (probe.ok) return { manifest: readEngineManifest(engineId, commandOptions), managedBinary, skipped: true };
252
302
  }
253
303
 
254
304
  hooks.onProgress?.({ state: "registry", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 1 });
255
- const { url: registryUrl, registry } = await loadEngineRegistry(options);
256
- const platform = getEnginePlatformKey(options);
305
+ const { url: registryUrl, registry } = await loadEngineRegistry(commandOptions);
306
+ const platform = getEnginePlatformKey(commandOptions);
257
307
  const { engine, asset } = selectEngineAsset(registry, engineId, platform);
258
308
 
259
309
  await ensureDir(managedDir);
260
310
  const compressedFile = path.join(managedDir, `${path.basename(asset.url || `${platform}.gz`)}.download`);
261
311
  const tmpBinary = temporaryBinaryPath(managedBinary);
262
- const attempts = Number(options.engineDownloadRetries || hooks.retries || DEFAULT_ENGINE_DOWNLOAD_RETRIES);
312
+ const attempts = Number(commandOptions.engineDownloadRetries || hooks.retries || DEFAULT_ENGINE_DOWNLOAD_RETRIES);
263
313
  let lastError;
264
314
 
265
315
  for (let attempt = 1; attempt <= attempts; attempt++) {
266
316
  try {
267
317
  await fs.promises.unlink(compressedFile).catch(() => {});
268
318
  await fs.promises.unlink(tmpBinary).catch(() => {});
269
- await downloadAsset(asset, compressedFile, hooks, attempt, attempts, options);
319
+ await downloadAsset(asset, compressedFile, hooks, attempt, attempts, commandOptions);
270
320
 
271
321
  hooks.onProgress?.({ state: "verifying", downloaded: asset.size || 0, total: asset.size || 0, percent: 100, attempt, attempts });
272
322
  if (asset.sha256 && (await sha256(compressedFile)) !== asset.sha256) throw new Error("Engine archive SHA256 mismatch");
@@ -294,7 +344,7 @@ export async function installManagedEngine(engineId = ENGINE.id, options = {}, s
294
344
  version: engine.version || registry.version || "registry",
295
345
  upstream: engine.upstream,
296
346
  },
297
- options,
347
+ commandOptions,
298
348
  );
299
349
  hooks.onProgress?.({ state: "done", downloaded: asset.binary?.size || asset.size || 0, total: asset.binary?.size || asset.size || 0, percent: 100, attempt, attempts });
300
350
  return { manifest, managedBinary, skipped: false };
@@ -318,8 +368,9 @@ export async function importManagedEngine(engineId = ENGINE.id, sourcePath, opti
318
368
  const stat = await fs.promises.stat(source).catch(() => null);
319
369
  if (!stat?.isFile()) throw new Error(`Engine binary not found: ${source}`);
320
370
 
321
- const managedDir = getManagedEngineDir(engineId, options, settings);
322
- const managedBinary = getManagedEngineBinary(engineId, options, settings);
371
+ const commandOptions = withDownloadDir(options, settings);
372
+ const managedDir = getManagedEngineDir(engineId, commandOptions);
373
+ const managedBinary = getManagedEngineBinary(engineId, commandOptions);
323
374
  const tmp = temporaryBinaryPath(managedBinary);
324
375
  await ensureDir(managedDir);
325
376
  await fs.promises.copyFile(source, tmp);
@@ -332,7 +383,7 @@ export async function importManagedEngine(engineId = ENGINE.id, sourcePath, opti
332
383
  }
333
384
 
334
385
  await replaceFile(tmp, managedBinary);
335
- const manifest = await writeManifest(engineId, managedBinary, { type: "local-file", path: source }, options);
386
+ const manifest = await writeManifest(engineId, managedBinary, { type: "local-file", path: source }, commandOptions);
336
387
  return { manifest, managedBinary };
337
388
  }
338
389
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hxnnxs/opencode-voice",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Local voice input plugin for OpenCode",
5
5
  "type": "module",
6
6
  "license": "MIT",