@earendil-works/pi-voice 0.1.0

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/catalog/recommendations.json +710 -0
  4. package/index.ts +1 -0
  5. package/package.json +69 -0
  6. package/src/async-limiter.ts +77 -0
  7. package/src/audio-constants.ts +1 -0
  8. package/src/audio.ts +193 -0
  9. package/src/catalog.generated.ts +1305 -0
  10. package/src/catalog.ts +89 -0
  11. package/src/chinese.ts +52 -0
  12. package/src/deferred.ts +30 -0
  13. package/src/dictation-controller.ts +204 -0
  14. package/src/file-audio.ts +164 -0
  15. package/src/file-transcription.ts +212 -0
  16. package/src/index.ts +114 -0
  17. package/src/install-migration.ts +47 -0
  18. package/src/keybindings.ts +118 -0
  19. package/src/languages.ts +22 -0
  20. package/src/microphone-picker.ts +99 -0
  21. package/src/model-activation.ts +74 -0
  22. package/src/model-cells.ts +218 -0
  23. package/src/model-picker.ts +1026 -0
  24. package/src/model-ratings-help.md +41 -0
  25. package/src/model-ratings-help.ts +146 -0
  26. package/src/model-selection-controller.ts +185 -0
  27. package/src/models.ts +263 -0
  28. package/src/onboarding.ts +314 -0
  29. package/src/pcm-chunker.ts +42 -0
  30. package/src/pcm.ts +19 -0
  31. package/src/recommendation-picker.ts +512 -0
  32. package/src/recommendations.ts +423 -0
  33. package/src/runtime.ts +501 -0
  34. package/src/settings-menu.ts +410 -0
  35. package/src/settings-path.ts +13 -0
  36. package/src/settings.ts +235 -0
  37. package/src/shortcut-core.ts +85 -0
  38. package/src/shortcuts.ts +167 -0
  39. package/src/startup-shortcut.ts +24 -0
  40. package/src/transcript-preview.ts +52 -0
  41. package/src/transcription-service.ts +548 -0
  42. package/src/transcription.ts +186 -0
  43. package/src/try-it.ts +327 -0
  44. package/src/ui-components.ts +432 -0
  45. package/src/visualizer.ts +269 -0
@@ -0,0 +1,41 @@
1
+ ## Accuracy
2
+
3
+ You don't always need the most accurate model. Programming assistants can often understand you even when a few words are wrong.
4
+ The grades we give roughly correspond to the following:
5
+
6
+ - **A — Very few mistakes.** Your request usually comes through clearly
7
+ - **B — Some mistakes.** The assistant can often work around them
8
+ - **C — Frequent mistakes.** Some details may get lost
9
+ - **D — Many mistakes.** Expect to check or repeat your request
10
+ - **F — Often unclear.** The assistant may struggle to understand you
11
+
12
+ **+** and **−** give some indication of accuracy within a grade. Small differences may not be noticeable in use.
13
+
14
+ ## Speed
15
+
16
+ The speed bars represent how long it took to transcribe 30 seconds of audio using a Lenovo T14s laptop (with 4750U CPU/GPU) from 2020.
17
+ Your machine may be faster or slower at transcribing depending on your system configuration.
18
+
19
+ - **▰▰▰▰▰:** Less than 1.5 seconds
20
+ - **▰▰▰▰▱:** 1.5 to 3 seconds
21
+ - **▰▰▰▱▱:** 3 to 5 seconds
22
+ - **▰▰▱▱▱:** 5 to 10 seconds
23
+ - **▰▱▱▱▱:** 10 to 20 seconds
24
+ - **▱▱▱▱▱:** 20 seconds or more
25
+
26
+ ## Other symbols
27
+
28
+ - **✓** — Your selected model
29
+ - **—** — The model doesn't support this language
30
+ - **?** — The model supports this language, but we don't have a accuracy grade for it
31
+ - **manual lang** — The model requires you to pick the language you want to transcribe
32
+
33
+ <!-- catalog-only -->
34
+ ## Recommended Models
35
+
36
+ These suggestions are for your chosen languages
37
+
38
+ - **Best** — Our suggested balance of speed and accuracy.
39
+ - **Fast** — A choice that prioritizes quick transcription while keeping accuracy usable.
40
+ - **Accurate** — A choice that prioritizes fewer mistakes without an excessive wait.
41
+ <!-- /catalog-only -->
@@ -0,0 +1,146 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ Markdown,
5
+ truncateToWidth,
6
+ visibleWidth,
7
+ type Component,
8
+ type TUI,
9
+ } from "@earendil-works/pi-tui";
10
+ import { gradeStyle } from "./model-cells.js";
11
+ import type { VoiceKeys } from "./keybindings.js";
12
+ import { PANEL_PADDING, panelBorder, paneRowBudget } from "./ui-components.js";
13
+
14
+ type UiTheme = ExtensionContext["ui"]["theme"];
15
+
16
+ /**
17
+ * An in-place help view owned by the picker. The picker stays mounted and routes
18
+ * rendering/input here while open; no overlay, focus transfer, or navigation.
19
+ * Copy lives in model-ratings-help.md and is read again on each opening.
20
+ */
21
+ export class ModelRatingsHelp implements Component {
22
+ private markdown: Markdown | undefined;
23
+ private title = "Model ratings";
24
+ private offset = 0;
25
+ private pageSize = 1;
26
+ private lineCount = 0;
27
+
28
+ constructor(
29
+ private readonly tui: TUI,
30
+ private readonly theme: UiTheme,
31
+ private readonly keys: VoiceKeys,
32
+ private readonly catalog: boolean,
33
+ ) {}
34
+
35
+ get isOpen(): boolean { return this.markdown !== undefined; }
36
+
37
+ open(): void {
38
+ if (this.isOpen) return;
39
+ const source = readFileSync(new URL("./model-ratings-help.md", import.meta.url), "utf8")
40
+ .replace(/<!-- catalog-only -->([\s\S]*?)<!-- \/catalog-only -->/g,
41
+ (_block, content: string) => this.catalog ? content : "");
42
+ // A document title is optional; plain section headings work without one.
43
+ this.title = source.match(/^# (.+)$/m)?.[1] ?? "Model ratings";
44
+ const theme = this.theme;
45
+ this.markdown = new Markdown(source.replace(/^# .+\r?\n/m, "").trim(), PANEL_PADDING, 0, {
46
+ heading: (text) => theme.fg("accent", theme.bold(text)),
47
+ link: (text) => theme.fg("mdLink", text),
48
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
49
+ code: (text) => theme.fg("mdCode", text),
50
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
51
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
52
+ quote: (text) => theme.fg("mdQuote", text),
53
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
54
+ hr: (text) => theme.fg("mdHr", text),
55
+ listBullet: (text) => theme.fg("mdListBullet", text),
56
+ bold: (text) => {
57
+ // Match the grade letters in "A — Very few mistakes" so the help
58
+ // uses the same visual language as the model columns. Keep the
59
+ // explanation itself bold but uncoloured.
60
+ const grade = text.match(/^([ABCDF])(?=\s+—)/)?.[1];
61
+ if (!grade) return theme.bold(text);
62
+ return gradeStyle(theme, grade as "A" | "B" | "C" | "D" | "F", theme.bold(grade)) +
63
+ theme.bold(text.slice(grade.length));
64
+ },
65
+ italic: (text) => theme.italic(text),
66
+ strikethrough: (text) => theme.strikethrough(text),
67
+ underline: (text) => theme.underline(text),
68
+ });
69
+ this.offset = 0;
70
+ this.tui.requestRender();
71
+ }
72
+
73
+ close(): void {
74
+ if (!this.isOpen) return;
75
+ this.markdown = undefined;
76
+ this.tui.requestRender();
77
+ }
78
+
79
+ invalidate(): void { this.markdown?.invalidate(); }
80
+
81
+ render(width: number): string[] {
82
+ if (width < 1 || !this.markdown) return [];
83
+ const budget = Math.max(1, paneRowBudget(this.tui) ?? 24);
84
+ const row = (text: string) => truncateToWidth(`${" ".repeat(PANEL_PADDING)}${text}`, width, "");
85
+ const border = panelBorder(this.theme).render(width);
86
+ // Match the model pages' full-width horizontal rules and left alignment.
87
+ // On tiny terminals, drop decoration before sacrificing text or Back.
88
+ const framed = budget >= 6;
89
+ const spaced = budget >= 10;
90
+ const header = [
91
+ ...(framed ? border : []),
92
+ ...(spaced ? [""] : []),
93
+ ...(budget >= 3 ? [row(this.theme.fg("accent", this.theme.bold(this.title)))] : []),
94
+ ...(spaced ? [""] : []),
95
+ ];
96
+ const footerRows = 1 + (spaced ? 2 : 0) + (framed ? border.length : 0);
97
+ this.pageSize = Math.max(0, budget - header.length - footerRows);
98
+ const lines = this.markdown.render(width);
99
+ this.lineCount = lines.length;
100
+ this.offset = Math.max(0, Math.min(this.offset, lines.length - this.pageSize));
101
+ const page = lines.slice(this.offset, this.offset + this.pageSize);
102
+
103
+ const scrollable = lines.length > this.pageSize;
104
+ const scroll = scrollable ? `${this.keys.navHint("scroll")} ` : "";
105
+ const more = [this.offset > 0 ? "↑ above" : "", this.offset + this.pageSize < lines.length ? "↓ more" : ""]
106
+ .filter(Boolean).join(" · ");
107
+ const backKeys = ["voice.ratingsHelp.close", "tui.select.cancel"] as const;
108
+ const back = this.keys.hint(backKeys, "back to models");
109
+ const hints = [
110
+ `${scroll}${more ? `${this.theme.fg("dim", more)} ` : ""}${back}`,
111
+ `${scroll}${back}`,
112
+ back,
113
+ this.keys.hint(backKeys, "back"),
114
+ this.keys.hint("voice.ratingsHelp.close", "back"),
115
+ ];
116
+ const footer = hints.find((hint) => visibleWidth(hint) <= width - PANEL_PADDING * 2) ?? hints[hints.length - 1]!;
117
+ return [
118
+ ...header,
119
+ ...page,
120
+ ...(spaced ? [""] : []),
121
+ row(footer),
122
+ ...(spaced ? [""] : []),
123
+ ...(framed ? border : []),
124
+ ];
125
+ }
126
+
127
+ handleInput(data: string): void {
128
+ if (
129
+ this.keys.matches(data, "tui.select.cancel") ||
130
+ this.keys.matches(data, "voice.ratingsHelp.close")
131
+ ) {
132
+ this.close();
133
+ return;
134
+ }
135
+ if (this.keys.matches(data, "tui.select.up")) this.offset--;
136
+ else if (this.keys.matches(data, "tui.select.down")) this.offset++;
137
+ // Keep a little context across pages, particularly section headings.
138
+ else if (this.keys.matches(data, "tui.select.pageUp")) this.offset -= Math.max(1, this.pageSize - 2);
139
+ else if (this.keys.matches(data, "tui.select.pageDown")) this.offset += Math.max(1, this.pageSize - 2);
140
+ else if (this.keys.matches(data, "voice.scroll.top")) this.offset = 0;
141
+ else if (this.keys.matches(data, "voice.scroll.bottom")) this.offset = this.lineCount - this.pageSize;
142
+ else return; // In particular, Enter and typing never reach the model list.
143
+ this.offset = Math.max(0, Math.min(this.offset, this.lineCount - this.pageSize));
144
+ this.tui.requestRender();
145
+ }
146
+ }
@@ -0,0 +1,185 @@
1
+ import type { CatalogModel } from "./catalog.js";
2
+ import type { CatalogModelActivation } from "./model-activation.js";
3
+ import { findCachedCatalogModel, type CachedCatalogModel } from "./models.js";
4
+
5
+ export type DownloadState = {
6
+ readonly model: CatalogModel;
7
+ readonly downloaded: number;
8
+ readonly total: number;
9
+ readonly message: string;
10
+ };
11
+
12
+ type Activation = {
13
+ model: CatalogModel;
14
+ controller: AbortController;
15
+ cached: CachedCatalogModel | undefined;
16
+ };
17
+
18
+ /** UI-independent selection lifecycle shared by the recommended and full pickers. */
19
+ export class ModelSelectionController<R> {
20
+ readonly cachedById = new Map<string, CachedCatalogModel>();
21
+ committedModelId: string | undefined;
22
+ selectedDuringSession: boolean;
23
+ download: DownloadState | undefined;
24
+ feedback: { type: "success" | "error" | "muted"; text: string } | undefined;
25
+ private target: Activation | undefined;
26
+ private pendingExit: { result: R } | undefined;
27
+ private closed = false;
28
+ private disposed = false;
29
+ private samples: { time: number; bytes: number }[] = [];
30
+ private readonly findCached: typeof findCachedCatalogModel;
31
+ private readonly now: () => number;
32
+
33
+ constructor(
34
+ private readonly activate: CatalogModelActivation,
35
+ private readonly options: {
36
+ models: readonly CatalogModel[];
37
+ currentModelId?: string;
38
+ activatedInFlow?: boolean;
39
+ advance: boolean;
40
+ completion: R;
41
+ onChange: () => void;
42
+ onExit: (result: R) => void;
43
+ findCached?: typeof findCachedCatalogModel;
44
+ now?: () => number;
45
+ },
46
+ ) {
47
+ this.findCached = options.findCached ?? findCachedCatalogModel;
48
+ this.now = options.now ?? Date.now;
49
+ this.committedModelId = options.currentModelId;
50
+ this.selectedDuringSession = options.activatedInFlow ?? false;
51
+ for (const model of options.models) {
52
+ const cached = this.findCached(model);
53
+ if (cached) this.cachedById.set(model.id, cached);
54
+ }
55
+ }
56
+
57
+ get acceptsInput(): boolean {
58
+ return !this.closed && !this.disposed && !this.pendingExit;
59
+ }
60
+
61
+ get displayedModelId(): string | undefined {
62
+ return this.target?.model.id ?? this.committedModelId;
63
+ }
64
+
65
+ get downloadSpeed(): number | undefined {
66
+ const samples = this.samples.filter((sample) => this.now() - sample.time <= 5000);
67
+ if (samples.length < 2) return undefined;
68
+ const first = samples[0]!;
69
+ const last = samples[samples.length - 1]!;
70
+ const elapsed = last.time - first.time;
71
+ return elapsed >= 500 ? (last.bytes - first.bytes) * 1000 / elapsed : undefined;
72
+ }
73
+
74
+ private changed(): void {
75
+ if (this.closed || this.disposed) return;
76
+ // A rendering failure must not prevent the download/save from running.
77
+ try { this.options.onChange(); } catch { /* Presentation is not part of the commit. */ }
78
+ }
79
+
80
+ private finish(result: R): void {
81
+ if (this.closed || this.disposed) return;
82
+ this.closed = true;
83
+ this.pendingExit = undefined;
84
+ try { this.options.onExit(result); } catch { /* Never reinterpret a committed save as a failure. */ }
85
+ }
86
+
87
+ /** Cached saves finish before navigation; download cancellation is explicit. */
88
+ requestExit(result: R): void {
89
+ if (!this.acceptsInput || this.download) return;
90
+ if (this.target) this.pendingExit = { result };
91
+ else this.finish(result);
92
+ }
93
+
94
+ select(model: CatalogModel): void {
95
+ if (!this.acceptsInput || this.download) return;
96
+ const cached = this.cachedById.get(model.id);
97
+ if (!this.target && cached && model.id === this.committedModelId &&
98
+ (this.selectedDuringSession || !this.options.advance)) {
99
+ this.selectedDuringSession = true;
100
+ if (this.options.advance) this.finish(this.options.completion);
101
+ else this.changed();
102
+ return;
103
+ }
104
+
105
+ this.target?.controller.abort();
106
+ const target: Activation = { model, cached, controller: new AbortController() };
107
+ this.target = target;
108
+ this.feedback = undefined;
109
+ this.samples = [];
110
+ this.download = cached ? undefined : {
111
+ model, downloaded: 0, total: 0, message: "Connecting to Hugging Face…",
112
+ };
113
+ this.changed();
114
+
115
+ // Catch synchronous implementations too. The real pipeline reports only
116
+ // after its ordered settings commit, not merely after the download.
117
+ let work: Promise<{ path: string }>;
118
+ try {
119
+ work = this.activate(model, {
120
+ cached,
121
+ signal: target.controller.signal,
122
+ onProgress: ({ downloaded, total }) => {
123
+ if (this.disposed || this.closed || this.target !== target || target.controller.signal.aborted) return;
124
+ if (!this.download) return;
125
+ const firstReport = this.download.total === 0 && total > 0;
126
+ this.download = {
127
+ model, downloaded, total,
128
+ message: firstReport
129
+ ? downloaded > 0 ? "Resuming download from Hugging Face…" : "Downloading from Hugging Face…"
130
+ : this.download.message,
131
+ };
132
+ this.samples.push({ time: this.now(), bytes: downloaded });
133
+ if (this.samples.length > 64) this.samples.shift();
134
+ this.changed();
135
+ },
136
+ });
137
+ } catch (error) {
138
+ work = Promise.reject(error);
139
+ }
140
+ void work.then(
141
+ ({ path }) => {
142
+ // A superseded save may already have committed. Record that fact even
143
+ // after disposal; only presentation and navigation ignore stale work.
144
+ this.cachedById.set(model.id, { path });
145
+ this.committedModelId = model.id;
146
+ this.selectedDuringSession = true;
147
+ if (this.target !== target) { this.changed(); return; }
148
+ this.target = undefined;
149
+ this.download = undefined;
150
+ this.feedback = cached ? undefined : {
151
+ type: "success", text: `✓ Downloaded and selected ${model.name}`,
152
+ };
153
+ if (this.pendingExit) this.finish(this.pendingExit.result);
154
+ else if (this.options.advance) this.finish(this.options.completion);
155
+ else this.changed();
156
+ },
157
+ (error: unknown) => {
158
+ const cached = this.findCached(model);
159
+ if (cached) this.cachedById.set(model.id, cached);
160
+ else this.cachedById.delete(model.id);
161
+ if (this.target !== target) { this.changed(); return; }
162
+ this.target = undefined;
163
+ this.pendingExit = undefined;
164
+ this.download = undefined;
165
+ this.feedback = target.controller.signal.aborted
166
+ ? { type: "muted", text: "Download stopped — progress saved. Select the model again to resume." }
167
+ : { type: "error", text: `Could not select ${model.name}: ${error instanceof Error ? error.message : String(error)}` };
168
+ this.changed();
169
+ },
170
+ );
171
+ }
172
+
173
+ cancelDownload(): void {
174
+ if (!this.acceptsInput || !this.download) return;
175
+ this.target?.controller.abort();
176
+ this.download = { ...this.download, message: "Stopping…" };
177
+ this.changed();
178
+ }
179
+
180
+ dispose(): void {
181
+ this.disposed = true;
182
+ // Selecting a cached model is a save, not a cancellable download.
183
+ if (this.download) this.target?.controller.abort();
184
+ }
185
+ }
package/src/models.ts ADDED
@@ -0,0 +1,263 @@
1
+ import {
2
+ downloadFile,
3
+ fileDownloadInfo,
4
+ getHFHubCachePath,
5
+ getRepoFolderName,
6
+ } from "@huggingface/hub";
7
+ import { createHash } from "node:crypto";
8
+ import {
9
+ createReadStream,
10
+ existsSync,
11
+ statSync,
12
+ } from "node:fs";
13
+ import {
14
+ copyFile,
15
+ mkdir,
16
+ open,
17
+ rename,
18
+ rm,
19
+ stat,
20
+ symlink,
21
+ } from "node:fs/promises";
22
+ import { dirname, join, relative } from "node:path";
23
+ import type { ReadableStream } from "node:stream/web";
24
+ import type { CatalogModel } from "./catalog.js";
25
+
26
+ function repositoryCacheDirectory(model: CatalogModel): string {
27
+ return join(
28
+ getHFHubCachePath(),
29
+ getRepoFolderName({ name: model.repository, type: "model" }),
30
+ );
31
+ }
32
+
33
+ export type CachedCatalogModel = {
34
+ path: string;
35
+ };
36
+
37
+ /** Find the exact catalog revision in the standard Hugging Face cache. */
38
+ export function findCachedCatalogModel(model: CatalogModel): CachedCatalogModel | undefined {
39
+ const path = join(
40
+ repositoryCacheDirectory(model),
41
+ "snapshots",
42
+ model.revision,
43
+ model.filename,
44
+ );
45
+ if (!existsSync(path)) return undefined;
46
+ try {
47
+ return statSync(path).size === model.size ? { path } : undefined;
48
+ } catch {
49
+ // A cache entry can disappear during concurrent HF cache maintenance.
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ type DownloadProgress = {
55
+ downloaded: number;
56
+ total: number;
57
+ };
58
+
59
+ async function createCacheLink(blobPath: string, pointerPath: string): Promise<void> {
60
+ await rm(pointerPath, { force: true });
61
+ try {
62
+ await symlink(relative(dirname(pointerPath), blobPath), pointerPath);
63
+ } catch {
64
+ // Match Hugging Face's Windows fallback when symlinks are unavailable.
65
+ await copyFile(blobPath, pointerPath);
66
+ }
67
+ }
68
+
69
+ export async function downloadCatalogModel(
70
+ model: CatalogModel,
71
+ options: {
72
+ signal?: AbortSignal;
73
+ onProgress?: (progress: DownloadProgress) => void;
74
+ /** Overridable for tests; defaults to the global fetch. */
75
+ fetch?: typeof fetch;
76
+ } = {},
77
+ ): Promise<string> {
78
+ const { signal, onProgress } = options;
79
+ const fetchImpl = options.fetch ?? fetch;
80
+ signal?.throwIfAborted();
81
+
82
+ const storage = repositoryCacheDirectory(model);
83
+ const pointerPath = join(storage, "snapshots", model.revision, model.filename);
84
+ if (await stat(pointerPath).then((value) => value.size === model.size, () => false)) {
85
+ onProgress?.({ downloaded: model.size, total: model.size });
86
+ return pointerPath;
87
+ }
88
+
89
+ const token = process.env.HF_TOKEN?.trim();
90
+ const credentials = token ? { accessToken: token } : {};
91
+ const abortingFetch: typeof fetch = (input, init) =>
92
+ fetchImpl(input, { ...init, signal });
93
+ const info = await fileDownloadInfo({
94
+ repo: model.repository,
95
+ path: model.filename,
96
+ revision: model.revision,
97
+ fetch: abortingFetch,
98
+ ...credentials,
99
+ });
100
+ if (!info) throw new Error(`Could not find ${model.filename} on Hugging Face`);
101
+ if (info.size !== model.size) {
102
+ throw new Error(
103
+ `${model.name} size mismatch: catalog has ${model.size} bytes, Hugging Face reports ${info.size}`,
104
+ );
105
+ }
106
+
107
+ // Match Hugging Face's standard cache key for these LFS-backed model files.
108
+ const etag = info.etag.replace(/^W\//, "").replace(/^"|"$/g, "");
109
+ const blobPath = join(storage, "blobs", etag);
110
+ // The blob key names this exact content (the size was checked against the
111
+ // catalog above), so a blob of any other size is a broken write by another
112
+ // cache user and is replaced by a fresh download.
113
+ const blobUsable = (): Promise<boolean> =>
114
+ stat(blobPath).then((value) => value.size === model.size, () => false);
115
+ await mkdir(dirname(blobPath), { recursive: true });
116
+ await mkdir(dirname(pointerPath), { recursive: true });
117
+
118
+ if (await blobUsable()) {
119
+ await createCacheLink(blobPath, pointerPath);
120
+ onProgress?.({ downloaded: model.size, total: model.size });
121
+ return pointerPath;
122
+ }
123
+
124
+ // A stable partial name lets a later attempt resume after cancellation or a
125
+ // dropped connection. The picker serializes downloads; simultaneous writes
126
+ // from separate processes are deliberately outside this best-effort design.
127
+ const partialPath = `${blobPath}.incomplete`;
128
+
129
+ const transfer = async (resume: boolean): Promise<void> => {
130
+ const digest = createHash("sha256");
131
+ let downloaded = 0;
132
+ let body: ReadableStream<Uint8Array> | undefined;
133
+
134
+ if (resume) {
135
+ // Hash the saved prefix, then request and append only the remainder. A
136
+ // full-size partial skips the payload request and goes to verification.
137
+ for await (const chunk of createReadStream(partialPath)) {
138
+ signal?.throwIfAborted();
139
+ digest.update(chunk as Buffer);
140
+ downloaded += (chunk as Buffer).length;
141
+ }
142
+ if (downloaded < model.size) {
143
+ const response = await abortingFetch(info.url, {
144
+ headers: {
145
+ Range: `bytes=${downloaded}-`,
146
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
147
+ },
148
+ });
149
+ if (response.status === 200 && response.body) {
150
+ // The server ignored the range; discard the response and restart.
151
+ await response.body.cancel().catch(() => undefined);
152
+ await rm(partialPath, { force: true });
153
+ return transfer(false);
154
+ }
155
+ if (response.status !== 206 || !response.body) {
156
+ // Keep the saved prefix across transient server failures.
157
+ await response.body?.cancel().catch(() => undefined);
158
+ throw new Error(
159
+ `Hugging Face returned HTTP ${response.status} while resuming ${model.name}`,
160
+ );
161
+ }
162
+ const contentRange = response.headers.get("content-range");
163
+ const range = contentRange?.match(/^bytes (\d+)-\d+\/(\d+)$/);
164
+ if (!range || Number(range[1]) !== downloaded || Number(range[2]) !== model.size) {
165
+ // Never append a response for different bytes.
166
+ await response.body.cancel().catch(() => undefined);
167
+ throw new Error(
168
+ `Hugging Face returned an unexpected resume range (${contentRange ?? "missing"}) for ${model.name}`,
169
+ );
170
+ }
171
+ body = response.body as unknown as ReadableStream<Uint8Array>;
172
+ }
173
+ } else {
174
+ await rm(partialPath, { force: true });
175
+ const blob = await downloadFile({
176
+ repo: model.repository,
177
+ path: model.filename,
178
+ revision: model.revision,
179
+ downloadInfo: info,
180
+ fetch: abortingFetch,
181
+ ...credentials,
182
+ });
183
+ if (!blob) throw new Error(`Could not download ${model.filename}`);
184
+ body = blob.stream() as unknown as ReadableStream<Uint8Array>;
185
+ }
186
+
187
+ onProgress?.({ downloaded, total: model.size });
188
+ if (body) {
189
+ let lastReport = 0;
190
+ const file = await open(partialPath, resume ? "a" : "w");
191
+ try {
192
+ for await (const value of body) {
193
+ signal?.throwIfAborted();
194
+ const chunk = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
195
+ // Await each write so any later abort or stream error leaves a clean,
196
+ // fully written prefix that can safely be resumed.
197
+ await file.writeFile(chunk);
198
+ downloaded += chunk.length;
199
+ digest.update(chunk);
200
+ const now = Date.now();
201
+ if (now - lastReport >= 100 || downloaded === model.size) {
202
+ lastReport = now;
203
+ onProgress?.({ downloaded, total: model.size });
204
+ }
205
+ }
206
+ } finally {
207
+ await file.close();
208
+ }
209
+ }
210
+ signal?.throwIfAborted();
211
+
212
+ const written = await stat(partialPath);
213
+ if (written.size < model.size) {
214
+ throw new Error(`${model.name} download ended early; partial bytes were kept`);
215
+ }
216
+ if (written.size > model.size || digest.digest("hex") !== model.sha256) {
217
+ await rm(partialPath, { force: true });
218
+ throw new Error(`${model.name} verification failed; incomplete bytes were removed`);
219
+ }
220
+ };
221
+
222
+ const partialBytes = await stat(partialPath).then((value) => value.size, () => 0);
223
+ await transfer(partialBytes > 0 && partialBytes <= model.size);
224
+
225
+ if (await blobUsable()) {
226
+ await rm(partialPath, { force: true });
227
+ } else {
228
+ try {
229
+ // rename() replaces a wrong-size blob left by an interrupted writer.
230
+ await rename(partialPath, blobPath);
231
+ } catch (error) {
232
+ // Another process may have published the same verified blob first.
233
+ if (!(await blobUsable())) throw error;
234
+ await rm(partialPath, { force: true });
235
+ }
236
+ }
237
+ await createCacheLink(blobPath, pointerPath);
238
+ onProgress?.({ downloaded: model.size, total: model.size });
239
+ return pointerPath;
240
+ }
241
+
242
+ /** A resumable partial download of this model on disk, if any. */
243
+ export function findIncompleteDownload(
244
+ model: CatalogModel,
245
+ ): { bytes: number } | undefined {
246
+ // For these LFS-backed files the cache key (the server etag) is the content
247
+ // sha256, so the catalog names the partial exactly; partials from other
248
+ // revisions can never match. If the server ever reported a different etag,
249
+ // this merely under-promises: the footer says "download", selecting still
250
+ // resumes the server-etag partial.
251
+ const partialPath = join(
252
+ repositoryCacheDirectory(model),
253
+ "blobs",
254
+ `${model.sha256}.incomplete`,
255
+ );
256
+ try {
257
+ const bytes = statSync(partialPath).size;
258
+ if (bytes > 0 && bytes < model.size) return { bytes };
259
+ } catch {
260
+ // Missing file means no partial download.
261
+ }
262
+ return undefined;
263
+ }