@hxnnxs/opencode-voice 0.1.2 → 0.1.3

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,13 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## 0.1.3 - 2026-06-15
6
+
7
+ ### Fixed
8
+
9
+ - Made Windows engine and model downloads more reliable with five install attempts, longer transfer stall timeouts, safer resume validation, and retrying final file replacement.
10
+ - Added HuggingFace fallback mirrors for Whisper models that have upstream mirror assets.
11
+
5
12
  ## 0.1.2 - 2026-06-15
6
13
 
7
14
  ### Fixed
package/index.js CHANGED
@@ -164,11 +164,11 @@ async function ensureDownloaded(ctx, model, settings) {
164
164
  const startedAt = Date.now();
165
165
  let lastRender = 0;
166
166
  let speedBps = 0;
167
- renderDownloadStatus(ctx, model, { state: "starting", downloaded: 0, total: model.sizeMB ? model.sizeMB * 1024 * 1024 : 0, percent: 0, attempt: 1, attempts: 3, speedBps });
167
+ renderDownloadStatus(ctx, model, { state: "starting", downloaded: 0, total: model.sizeMB ? model.sizeMB * 1024 * 1024 : 0, percent: 0, attempt: 1, attempts: 5, speedBps });
168
168
 
169
169
  toast(ctx.api, `Downloading ${model.name}...`);
170
170
  await downloadModel(model, ctx.options, settings, {
171
- retries: 3,
171
+ retries: 5,
172
172
  onProgress: (progress) => {
173
173
  const now = Date.now();
174
174
  const elapsed = Math.max(1, (now - startedAt) / 1000);
@@ -202,10 +202,10 @@ async function ensureEngineReady(ctx, settings) {
202
202
  if (probe.ok) return true;
203
203
  }
204
204
 
205
- renderEngineInstallStatus(ctx, { state: "registry", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 3 });
205
+ renderEngineInstallStatus(ctx, { state: "registry", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 5 });
206
206
  toast(ctx.api, "Installing local voice engine...");
207
207
  await installManagedEngine("whisper.cpp", commandOptions, settings, {
208
- retries: 3,
208
+ retries: 5,
209
209
  onProgress: (progress) => renderEngineInstallStatus(ctx, progress),
210
210
  onRetry: ({ error, nextAttempt, attempts }) => {
211
211
  renderEngineInstallStatus(ctx, { state: "downloading", downloaded: 0, total: 0, percent: 0, attempt: nextAttempt, attempts });
package/lib/download.js CHANGED
@@ -4,6 +4,9 @@ import { Readable, Transform } from "node:stream";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import { getModelPath, getModelsDir, getModelVerificationPath } from "./models.js";
6
6
 
7
+ const DEFAULT_DOWNLOAD_RETRIES = 5;
8
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120000;
9
+
7
10
  function sleep(ms) {
8
11
  return new Promise((resolve) => setTimeout(resolve, ms));
9
12
  }
@@ -33,18 +36,55 @@ export async function writeModelVerificationMarker(model, file, options = {}, se
33
36
 
34
37
  async function fetchWithTimeout(url, init = {}, timeoutMs = 60000) {
35
38
  const controller = new AbortController();
36
- const timer = setTimeout(() => controller.abort(), timeoutMs);
39
+ let timer;
40
+ const reset = () => {
41
+ clearTimeout(timer);
42
+ timer = setTimeout(() => controller.abort(), timeoutMs);
43
+ };
44
+ const clear = () => clearTimeout(timer);
45
+ reset();
46
+
37
47
  try {
38
- return await fetch(url, { ...init, signal: controller.signal });
48
+ const response = await fetch(url, { ...init, signal: controller.signal });
49
+ return { response, signal: controller.signal, reset, clear };
39
50
  } catch (error) {
51
+ clear();
40
52
  if (error?.name === "AbortError") throw new Error(`Download timed out after ${Math.round(timeoutMs / 1000)}s`);
41
53
  throw error;
42
- } finally {
43
- clearTimeout(timer);
44
54
  }
45
55
  }
46
56
 
47
- async function downloadModelOnce(model, options = {}, settings = {}, hooks = {}, attempt = 1, attempts = 1) {
57
+ function downloadError(error, url, timeoutMs) {
58
+ if (error?.name === "AbortError" || error?.code === "ABORT_ERR") {
59
+ return new Error(`Download timed out or stalled from ${url} after ${Math.round(timeoutMs / 1000)}s`);
60
+ }
61
+ return error;
62
+ }
63
+
64
+ function modelUrls(model) {
65
+ return [...new Set([...(Array.isArray(model.urls) ? model.urls : []), model.url].filter(Boolean))];
66
+ }
67
+
68
+ function contentRangeStart(value) {
69
+ const match = /^bytes\s+(\d+)-/i.exec(value || "");
70
+ return match ? Number(match[1]) : null;
71
+ }
72
+
73
+ async function replaceFile(source, destination) {
74
+ await fs.promises.unlink(destination).catch(() => {});
75
+
76
+ for (let attempt = 1; attempt <= 5; attempt++) {
77
+ try {
78
+ await fs.promises.rename(source, destination);
79
+ return;
80
+ } catch (error) {
81
+ if (attempt >= 5 || !["EBUSY", "EPERM", "EACCES"].includes(error?.code)) throw error;
82
+ await sleep(100 * attempt);
83
+ }
84
+ }
85
+ }
86
+
87
+ async function downloadModelOnce(model, sourceUrl, options = {}, settings = {}, hooks = {}, attempt = 1, attempts = 1) {
48
88
  if (!model?.implemented || !model.url || !model.filename) {
49
89
  throw new Error(`${model?.name || "Model"} is not supported by the current local engine yet`);
50
90
  }
@@ -66,57 +106,83 @@ async function downloadModelOnce(model, options = {}, settings = {}, hooks = {},
66
106
 
67
107
  const partialSize = fs.existsSync(partial) ? fs.statSync(partial).size : 0;
68
108
  const headers = partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined;
69
- const response = await fetchWithTimeout(model.url, { headers }, Number(options.downloadTimeoutMs || hooks.timeoutMs || 60000));
109
+ const timeoutMs = Number(options.downloadTimeoutMs || hooks.timeoutMs || DEFAULT_DOWNLOAD_TIMEOUT_MS);
110
+ const request = await fetchWithTimeout(sourceUrl, { headers }, timeoutMs);
111
+ const response = request.response;
70
112
 
71
- if (response.status === 416 && partialSize > 0) {
72
- await fs.promises.unlink(partial).catch(() => {});
73
- return downloadModelOnce(model, options, settings, hooks, attempt, attempts);
74
- }
113
+ try {
114
+ if (response.status === 416 && partialSize > 0) {
115
+ await fs.promises.unlink(partial).catch(() => {});
116
+ return downloadModelOnce(model, sourceUrl, options, settings, hooks, attempt, attempts);
117
+ }
75
118
 
76
- if (!response.ok && response.status !== 206) {
77
- throw new Error(`Download failed: HTTP ${response.status}`);
78
- }
119
+ if (!response.ok && response.status !== 206) {
120
+ throw new Error(`Download failed from ${sourceUrl}: HTTP ${response.status}`);
121
+ }
79
122
 
80
- const append = response.status === 206 && partialSize > 0;
81
- const contentLength = Number(response.headers.get("content-length") || 0);
82
- const total = contentLength > 0 ? contentLength + (append ? partialSize : 0) : model.sizeMB ? model.sizeMB * 1024 * 1024 : 0;
83
- let downloaded = append ? partialSize : 0;
123
+ const append = response.status === 206 && partialSize > 0;
124
+ const rangeStart = append ? contentRangeStart(response.headers.get("content-range")) : null;
125
+ if (append && rangeStart !== null && rangeStart !== partialSize) {
126
+ await fs.promises.unlink(partial).catch(() => {});
127
+ throw new Error(`Download resume mismatch from ${sourceUrl}: expected byte ${partialSize}, got ${rangeStart}`);
128
+ }
84
129
 
85
- hooks.onProgress?.({ state: "downloading", downloaded, total, percent: total ? (downloaded / total) * 100 : 0, attempt, attempts });
130
+ const contentLength = Number(response.headers.get("content-length") || 0);
131
+ const total = contentLength > 0 ? contentLength + (append ? partialSize : 0) : model.sizeMB ? model.sizeMB * 1024 * 1024 : 0;
132
+ let downloaded = append ? partialSize : 0;
86
133
 
87
- const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
88
- if (!body) throw new Error("Download failed: empty response body");
134
+ hooks.onProgress?.({ state: "downloading", downloaded, total, percent: total ? (downloaded / total) * 100 : 0, attempt, attempts });
89
135
 
90
- const progress = new Transform({
91
- transform(chunk, _encoding, callback) {
92
- downloaded += chunk.length ?? chunk.byteLength ?? 0;
93
- hooks.onProgress?.({ state: "downloading", downloaded, total, percent: total ? (downloaded / total) * 100 : 0, attempt, attempts });
94
- callback(null, chunk);
95
- },
96
- });
136
+ const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
137
+ if (!body) throw new Error("Download failed: empty response body");
97
138
 
98
- await pipeline(body, progress, fs.createWriteStream(partial, { flags: append ? "a" : "w" }));
139
+ const progress = new Transform({
140
+ transform(chunk, _encoding, callback) {
141
+ request.reset();
142
+ downloaded += chunk.length ?? chunk.byteLength ?? 0;
143
+ hooks.onProgress?.({ state: "downloading", downloaded, total, percent: total ? (downloaded / total) * 100 : 0, attempt, attempts });
144
+ callback(null, chunk);
145
+ },
146
+ });
99
147
 
100
- hooks.onProgress?.({ state: "verifying", downloaded, total: total || downloaded, percent: 100, attempt, attempts });
148
+ try {
149
+ await pipeline(body, progress, fs.createWriteStream(partial, { flags: append ? "a" : "w" }), { signal: request.signal });
150
+ } catch (error) {
151
+ throw downloadError(error, sourceUrl, timeoutMs);
152
+ }
153
+ request.clear();
101
154
 
102
- if (!(await verifyModel(model, partial))) {
103
- await fs.promises.unlink(partial).catch(() => {});
104
- throw new Error(`SHA256 mismatch for ${model.name}`);
105
- }
155
+ const actualSize = fs.statSync(partial).size;
156
+ downloaded = actualSize;
157
+ if (contentLength > 0 && actualSize < total) {
158
+ throw new Error(`Download incomplete from ${sourceUrl}: got ${actualSize} of ${total} bytes`);
159
+ }
160
+
161
+ hooks.onProgress?.({ state: "verifying", downloaded, total: total || downloaded, percent: 100, attempt, attempts });
106
162
 
107
- await fs.promises.rename(partial, file);
108
- await writeModelVerificationMarker(model, file, options, settings);
109
- hooks.onProgress?.({ state: "done", downloaded, total: total || downloaded, percent: 100, attempt, attempts });
110
- return file;
163
+ if (!(await verifyModel(model, partial))) {
164
+ await fs.promises.unlink(partial).catch(() => {});
165
+ throw new Error(`SHA256 mismatch for ${model.name}`);
166
+ }
167
+
168
+ await replaceFile(partial, file);
169
+ await writeModelVerificationMarker(model, file, options, settings);
170
+ hooks.onProgress?.({ state: "done", downloaded, total: total || downloaded, percent: 100, attempt, attempts });
171
+ return file;
172
+ } finally {
173
+ request.clear();
174
+ }
111
175
  }
112
176
 
113
177
  export async function downloadModel(model, options = {}, settings = {}, hooks = {}) {
114
- const attempts = Number(options.downloadRetries || hooks.retries || 3);
178
+ const urls = modelUrls(model);
179
+ const attempts = Math.max(Number(options.downloadRetries || hooks.retries || DEFAULT_DOWNLOAD_RETRIES), urls.length || 1);
115
180
  let lastError;
116
181
 
117
182
  for (let attempt = 1; attempt <= attempts; attempt++) {
183
+ const sourceUrl = urls[(attempt - 1) % urls.length];
118
184
  try {
119
- return await downloadModelOnce(model, options, settings, hooks, attempt, attempts);
185
+ return await downloadModelOnce(model, sourceUrl, options, settings, hooks, attempt, attempts);
120
186
  } catch (error) {
121
187
  lastError = error;
122
188
  if (attempt >= attempts) break;
package/lib/engines.js CHANGED
@@ -18,6 +18,8 @@ const ENGINE = {
18
18
  probeContains: ["--model", "--file"],
19
19
  };
20
20
 
21
+ const DEFAULT_ENGINE_DOWNLOAD_RETRIES = 5;
22
+
21
23
  function sleep(ms) {
22
24
  return new Promise((resolve) => setTimeout(resolve, ms));
23
25
  }
@@ -257,7 +259,7 @@ export async function installManagedEngine(engineId = ENGINE.id, options = {}, s
257
259
  await ensureDir(managedDir);
258
260
  const compressedFile = path.join(managedDir, `${path.basename(asset.url || `${platform}.gz`)}.download`);
259
261
  const tmpBinary = temporaryBinaryPath(managedBinary);
260
- const attempts = Number(options.engineDownloadRetries || hooks.retries || 3);
262
+ const attempts = Number(options.engineDownloadRetries || hooks.retries || DEFAULT_ENGINE_DOWNLOAD_RETRIES);
261
263
  let lastError;
262
264
 
263
265
  for (let attempt = 1; attempt <= attempts; attempt++) {
package/lib/models.js CHANGED
@@ -14,6 +14,7 @@ export const MODELS = [
14
14
  recommended: true,
15
15
  filename: "ggml-small.bin",
16
16
  url: "https://blob.handy.computer/ggml-small.bin",
17
+ urls: ["https://blob.handy.computer/ggml-small.bin", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin"],
17
18
  sha256: "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b",
18
19
  sizeMB: 465,
19
20
  languages: "multilingual",
@@ -26,6 +27,7 @@ export const MODELS = [
26
27
  implemented: true,
27
28
  filename: "whisper-medium-q4_1.bin",
28
29
  url: "https://blob.handy.computer/whisper-medium-q4_1.bin",
30
+ urls: ["https://blob.handy.computer/whisper-medium-q4_1.bin"],
29
31
  sha256: "79283fc1f9fe12ca3248543fbd54b73292164d8df5a16e095e2bceeaaabddf57",
30
32
  sizeMB: 469,
31
33
  languages: "multilingual",
@@ -38,6 +40,7 @@ export const MODELS = [
38
40
  implemented: true,
39
41
  filename: "ggml-large-v3-turbo.bin",
40
42
  url: "https://blob.handy.computer/ggml-large-v3-turbo.bin",
43
+ urls: ["https://blob.handy.computer/ggml-large-v3-turbo.bin", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin"],
41
44
  sha256: "1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69",
42
45
  sizeMB: 1549,
43
46
  languages: "multilingual",
@@ -50,6 +53,7 @@ export const MODELS = [
50
53
  implemented: true,
51
54
  filename: "ggml-large-v3-q5_0.bin",
52
55
  url: "https://blob.handy.computer/ggml-large-v3-q5_0.bin",
56
+ urls: ["https://blob.handy.computer/ggml-large-v3-q5_0.bin", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-q5_0.bin"],
53
57
  sha256: "d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1",
54
58
  sizeMB: 1031,
55
59
  languages: "multilingual",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hxnnxs/opencode-voice",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Local voice input plugin for OpenCode",
5
5
  "type": "module",
6
6
  "license": "MIT",