pi-voicekit 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 (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -0
  3. package/extensions/voice/config.ts +395 -0
  4. package/extensions/voice/deepgram.ts +33 -0
  5. package/extensions/voice/device.ts +382 -0
  6. package/extensions/voice/hold-to-talk.ts +69 -0
  7. package/extensions/voice/local.ts +1143 -0
  8. package/extensions/voice/model-download.ts +636 -0
  9. package/extensions/voice/onboarding.ts +739 -0
  10. package/extensions/voice/release-controller.ts +55 -0
  11. package/extensions/voice/settings-panel.ts +1602 -0
  12. package/extensions/voice/sherpa-engine.ts +464 -0
  13. package/extensions/voice/sherpa-loader.ts +143 -0
  14. package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
  15. package/extensions/voice/speak.ts +430 -0
  16. package/extensions/voice/tts-deepgram.ts +454 -0
  17. package/extensions/voice/tts-engine.ts +653 -0
  18. package/extensions/voice/tts-install-progress.ts +257 -0
  19. package/extensions/voice/tts-local-models.ts +1255 -0
  20. package/extensions/voice/tts-onboarding-overlay.ts +186 -0
  21. package/extensions/voice/tts-onboarding.ts +87 -0
  22. package/extensions/voice/tts-playback-indicator.ts +127 -0
  23. package/extensions/voice/tts-playback.ts +675 -0
  24. package/extensions/voice/tts-text-filter.ts +404 -0
  25. package/extensions/voice/ui-aura.ts +272 -0
  26. package/extensions/voice/ui-help-overlay.ts +161 -0
  27. package/extensions/voice/ui-icons.ts +124 -0
  28. package/extensions/voice/ui-locale-labels.ts +110 -0
  29. package/extensions/voice/ui-picker.ts +209 -0
  30. package/extensions/voice/ui-render-ticker.ts +171 -0
  31. package/extensions/voice/ui-widget-base.ts +219 -0
  32. package/extensions/voice/ui-width.ts +112 -0
  33. package/extensions/voice.ts +3644 -0
  34. package/package.json +75 -0
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Model download manager — auto-download ONNX models for local transcription.
3
+ *
4
+ * Downloads from sherpa-onnx GitHub releases and HuggingFace.
5
+ * Defaults to int8 quantized models for smaller downloads and lower RAM usage.
6
+ *
7
+ * Storage: ~/.pi/models/{modelId}/
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import * as os from "node:os";
13
+
14
+ // ─── Types ───────────────────────────────────────────────────────────────────
15
+
16
+ export interface ModelDownloadConfig {
17
+ /** Model identifier (e.g., "whisper-small") */
18
+ modelId: string;
19
+ /** Map of role → download URL */
20
+ files: Record<string, string>;
21
+ /** Expected total download size in bytes (for progress reporting) */
22
+ totalSizeBytes: number;
23
+ }
24
+
25
+ export interface DownloadProgress {
26
+ downloadedBytes: number;
27
+ totalBytes: number;
28
+ file: string;
29
+ fileIndex: number;
30
+ totalFiles: number;
31
+ }
32
+
33
+ // ─── Paths ───────────────────────────────────────────────────────────────────
34
+
35
+ /** Get the base models directory (~/.pi/models/) */
36
+ export function getModelsDir(): string {
37
+ return path.join(os.homedir(), ".pi", "models");
38
+ }
39
+
40
+ /** Get the directory for a specific model */
41
+ export function getModelDir(modelId: string): string {
42
+ return path.join(getModelsDir(), modelId);
43
+ }
44
+
45
+ /** Get the path for a specific model file */
46
+ export function getModelPath(modelId: string): string | null {
47
+ const dir = getModelDir(modelId);
48
+ if (!fs.existsSync(dir)) return null;
49
+ return dir;
50
+ }
51
+
52
+ // ─── Status checks ───────────────────────────────────────────────────────────
53
+
54
+ /** Check if a model is fully downloaded (all expected files present). */
55
+ export function isModelDownloaded(modelId: string, expectedFiles: Record<string, string>): boolean {
56
+ const dir = getModelDir(modelId);
57
+ if (!fs.existsSync(dir)) return false;
58
+
59
+ for (const role of Object.keys(expectedFiles)) {
60
+ const filename = fileNameFromUrl(expectedFiles[role]!);
61
+ const filePath = path.join(dir, filename);
62
+ if (!fs.existsSync(filePath)) return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ /** List downloaded models with disk usage. */
68
+ export function getDownloadedModels(): { id: string; sizeMB: number }[] {
69
+ const baseDir = getModelsDir();
70
+ if (!fs.existsSync(baseDir)) return [];
71
+
72
+ const results: { id: string; sizeMB: number }[] = [];
73
+ try {
74
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
75
+ for (const entry of entries) {
76
+ if (!entry.isDirectory()) continue;
77
+ const modelDir = path.join(baseDir, entry.name);
78
+ const size = getDirSizeMB(modelDir);
79
+ results.push({ id: entry.name, sizeMB: size });
80
+ }
81
+ } catch {
82
+ // Permission error
83
+ }
84
+ return results;
85
+ }
86
+
87
+ /** Delete a downloaded model. */
88
+ export function deleteModel(modelId: string): boolean {
89
+ const dir = getModelDir(modelId);
90
+ if (!fs.existsSync(dir)) return false;
91
+ try {
92
+ fs.rmSync(dir, { recursive: true, force: true });
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ // ─── Download ────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Download all model files for a given model config.
103
+ * Returns the model directory path.
104
+ *
105
+ * Features:
106
+ * - Progress callbacks per file and overall
107
+ * - Resume support via HTTP Range headers (partial downloads)
108
+ * - Atomic writes (download to .tmp, rename on success)
109
+ * - Abort support via AbortSignal
110
+ */
111
+ export async function downloadModel(
112
+ config: ModelDownloadConfig,
113
+ onProgress?: (progress: DownloadProgress) => void,
114
+ signal?: AbortSignal
115
+ ): Promise<string> {
116
+ const dir = getModelDir(config.modelId);
117
+ fs.mkdirSync(dir, { recursive: true });
118
+
119
+ const roles = Object.keys(config.files);
120
+ let overallDownloaded = 0;
121
+
122
+ for (let i = 0; i < roles.length; i++) {
123
+ const role = roles[i]!;
124
+ const url = config.files[role]!;
125
+ const filename = fileNameFromUrl(url);
126
+ const filePath = path.join(dir, filename);
127
+ const tmpPath = filePath + ".tmp";
128
+
129
+ // Skip if already downloaded
130
+ if (fs.existsSync(filePath)) {
131
+ const stat = fs.statSync(filePath);
132
+ overallDownloaded += stat.size;
133
+ continue;
134
+ }
135
+
136
+ // Check for partial download (resume support)
137
+ let startByte = 0;
138
+ if (fs.existsSync(tmpPath)) {
139
+ startByte = fs.statSync(tmpPath).size;
140
+ overallDownloaded += startByte;
141
+ }
142
+
143
+ const headers: Record<string, string> = {};
144
+ if (startByte > 0) {
145
+ headers["Range"] = `bytes=${startByte}-`;
146
+ }
147
+
148
+ const resp = await fetch(url, {
149
+ headers,
150
+ signal,
151
+ redirect: "follow",
152
+ });
153
+
154
+ if (!resp.ok && resp.status !== 206) {
155
+ throw new Error(`Download failed: HTTP ${resp.status} for ${filename}`);
156
+ }
157
+
158
+ // If we requested a Range but server returned 200 (full file), reset to overwrite
159
+ // to avoid appending the full content to an existing partial file
160
+ if (startByte > 0 && resp.status === 200) {
161
+ overallDownloaded -= startByte; // undo the partial credit
162
+ startByte = 0;
163
+ }
164
+
165
+ const contentLength = parseInt(resp.headers.get("content-length") || "0", 10);
166
+ const totalFileSize = startByte + contentLength;
167
+
168
+ if (!resp.body) throw new Error(`No response body for ${filename}`);
169
+
170
+ const writeStream = fs.createWriteStream(tmpPath, { flags: startByte > 0 ? "a" : "w" });
171
+ const reader = resp.body.getReader();
172
+ let fileDownloaded = startByte;
173
+
174
+ try {
175
+ while (true) {
176
+ const { done, value } = await reader.read();
177
+ if (done) break;
178
+
179
+ // Write Uint8Array directly (no Buffer.from copy needed)
180
+ // Handle backpressure to avoid unbounded memory on slow disks
181
+ if (!writeStream.write(value)) {
182
+ await new Promise<void>((resolve, reject) => {
183
+ const onDrain = () => {
184
+ writeStream.removeListener("error", onError);
185
+ resolve();
186
+ };
187
+ const onError = (err: Error) => {
188
+ writeStream.removeListener("drain", onDrain);
189
+ reject(err);
190
+ };
191
+ writeStream.once("drain", onDrain);
192
+ writeStream.once("error", onError);
193
+ });
194
+ }
195
+ fileDownloaded += value.byteLength;
196
+ overallDownloaded += value.byteLength;
197
+
198
+ onProgress?.({
199
+ downloadedBytes: overallDownloaded,
200
+ totalBytes: config.totalSizeBytes,
201
+ file: filename,
202
+ fileIndex: i,
203
+ totalFiles: roles.length,
204
+ });
205
+ }
206
+ } finally {
207
+ writeStream.end();
208
+ await new Promise<void>((resolve, reject) => {
209
+ writeStream.on("finish", resolve);
210
+ writeStream.on("error", reject);
211
+ });
212
+ }
213
+
214
+ // Atomic rename .tmp → final
215
+ fs.renameSync(tmpPath, filePath);
216
+ }
217
+
218
+ return dir;
219
+ }
220
+
221
+ // ─── In-flight deduplication ──────────────────────────────────────────────────
222
+ // Prevents concurrent downloads of the same model from corrupting shared .tmp files.
223
+ const _inFlight = new Map<string, Promise<string>>();
224
+
225
+ /**
226
+ * Ensure a model is downloaded, downloading if needed.
227
+ * This is the main entry point for the transcription engine.
228
+ * Deduplicates concurrent calls for the same model — second caller
229
+ * joins the first download instead of starting a parallel one.
230
+ */
231
+ export async function ensureModelDownloaded(
232
+ modelId: string,
233
+ expectedFiles: Record<string, string>,
234
+ totalSizeBytes: number,
235
+ onProgress?: (progress: DownloadProgress) => void,
236
+ signal?: AbortSignal
237
+ ): Promise<string> {
238
+ if (isModelDownloaded(modelId, expectedFiles)) {
239
+ return getModelDir(modelId);
240
+ }
241
+
242
+ // Join existing download if one is already in progress
243
+ if (_inFlight.has(modelId)) {
244
+ return _inFlight.get(modelId)!;
245
+ }
246
+
247
+ const promise = downloadModel({ modelId, files: expectedFiles, totalSizeBytes }, onProgress, signal).finally(() =>
248
+ _inFlight.delete(modelId)
249
+ );
250
+
251
+ _inFlight.set(modelId, promise);
252
+ return promise;
253
+ }
254
+
255
+ // ─── Pre-download checks ─────────────────────────────────────────────────────
256
+
257
+ export interface PreCheckResult {
258
+ ok: boolean;
259
+ issues: string[];
260
+ }
261
+
262
+ /**
263
+ * Run all pre-download checks before starting a model download.
264
+ * Returns a list of issues (empty = all clear).
265
+ *
266
+ * Checks:
267
+ * 1. Disk space (model size + 20% buffer)
268
+ * 2. Network connectivity (HEAD request to first download URL)
269
+ * 3. Write permissions on models directory
270
+ */
271
+ export async function checkDownloadPrereqs(
272
+ downloadUrls: Record<string, string>,
273
+ totalSizeBytes: number
274
+ ): Promise<PreCheckResult> {
275
+ const issues: string[] = [];
276
+
277
+ // 1. Disk space
278
+ const requiredBytes = Math.ceil(totalSizeBytes * 1.2); // 20% buffer for .tmp files
279
+ const freeBytes = getFreeDiskSpace(getModelsDir());
280
+ if (freeBytes !== null && freeBytes < requiredBytes) {
281
+ const freeMB = Math.round(freeBytes / (1024 * 1024));
282
+ const needMB = Math.round(requiredBytes / (1024 * 1024));
283
+ issues.push(`Insufficient disk space: ${freeMB} MB free, need ${needMB} MB`);
284
+ }
285
+
286
+ // 2. Write permissions
287
+ const modelsDir = getModelsDir();
288
+ try {
289
+ fs.mkdirSync(modelsDir, { recursive: true });
290
+ const testFile = path.join(modelsDir, ".write-test");
291
+ fs.writeFileSync(testFile, "");
292
+ fs.unlinkSync(testFile);
293
+ } catch {
294
+ issues.push(`Cannot write to models directory: ${modelsDir}`);
295
+ }
296
+
297
+ // 3. Network connectivity
298
+ const firstUrl = Object.values(downloadUrls)[0];
299
+ if (firstUrl) {
300
+ try {
301
+ const resp = await fetch(firstUrl, {
302
+ method: "HEAD",
303
+ signal: AbortSignal.timeout(8000),
304
+ redirect: "follow",
305
+ });
306
+ if (!resp.ok && resp.status !== 302 && resp.status !== 301) {
307
+ issues.push(`Model server returned HTTP ${resp.status} — check URL or try again later`);
308
+ }
309
+ } catch (err: any) {
310
+ if (err?.name === "TimeoutError" || err?.name === "AbortError") {
311
+ issues.push("Network timeout — check your internet connection");
312
+ } else if (err?.cause?.code === "ECONNREFUSED" || err?.cause?.code === "ENOTFOUND") {
313
+ issues.push("Cannot reach model server — check your internet connection");
314
+ } else {
315
+ issues.push(`Network error: ${err?.message || err}`);
316
+ }
317
+ }
318
+ }
319
+
320
+ return { ok: issues.length === 0, issues };
321
+ }
322
+
323
+ // ─── Download progress formatting ────────────────────────────────────────────
324
+
325
+ export interface RichProgress {
326
+ /** "45%" */
327
+ percent: number;
328
+ /** "168 MB / 375 MB" */
329
+ sizeLabel: string;
330
+ /** "2.1 MB/s" */
331
+ speed: string;
332
+ /** "~1m 30s left" */
333
+ eta: string;
334
+ /** Full formatted line */
335
+ line: string;
336
+ /** Current file being downloaded */
337
+ file: string;
338
+ /** File progress "2/3" */
339
+ fileProgress: string;
340
+ }
341
+
342
+ /**
343
+ * Create a throttled progress formatter that calculates speed and ETA.
344
+ * Returns a function that accepts raw DownloadProgress and emits RichProgress
345
+ * at most once per `intervalMs` (default 500ms).
346
+ */
347
+ export function createProgressTracker(
348
+ modelName: string,
349
+ intervalMs = 500
350
+ ): (raw: DownloadProgress) => RichProgress | null {
351
+ let startTime = 0;
352
+ let lastEmitTime = 0;
353
+ // Rolling window for speed calculation (last 5 samples)
354
+ const samples: { time: number; bytes: number }[] = [];
355
+
356
+ return (raw: DownloadProgress): RichProgress | null => {
357
+ const now = Date.now();
358
+ if (startTime === 0) startTime = now;
359
+
360
+ // Throttle emissions
361
+ if (now - lastEmitTime < intervalMs && raw.downloadedBytes < raw.totalBytes) {
362
+ return null;
363
+ }
364
+ lastEmitTime = now;
365
+
366
+ // Rolling speed (last 5 samples over ~2.5s window)
367
+ samples.push({ time: now, bytes: raw.downloadedBytes });
368
+ if (samples.length > 10) samples.shift();
369
+
370
+ const oldest = samples[0]!;
371
+ const elapsed = (now - oldest.time) / 1000;
372
+ const bytesInWindow = raw.downloadedBytes - oldest.bytes;
373
+ const speedBps = elapsed > 0 ? bytesInWindow / elapsed : 0;
374
+
375
+ const percent = Math.round((raw.downloadedBytes / raw.totalBytes) * 100);
376
+ const dlMB = (raw.downloadedBytes / (1024 * 1024)).toFixed(0);
377
+ const totalMB = (raw.totalBytes / (1024 * 1024)).toFixed(0);
378
+ const speedMB = (speedBps / (1024 * 1024)).toFixed(1);
379
+
380
+ const remaining = speedBps > 0 ? (raw.totalBytes - raw.downloadedBytes) / speedBps : 0;
381
+ let eta: string;
382
+ if (speedBps === 0 || !Number.isFinite(remaining)) {
383
+ eta = "calculating…";
384
+ } else if (remaining > 60) {
385
+ eta = `~${Math.floor(remaining / 60)}m ${Math.round(remaining % 60)}s left`;
386
+ } else {
387
+ eta = `~${Math.round(remaining)}s left`;
388
+ }
389
+
390
+ const sizeLabel = `${dlMB} / ${totalMB} MB`;
391
+ const speed = `${speedMB} MB/s`;
392
+ const fileProgress = `${raw.fileIndex + 1}/${raw.totalFiles}`;
393
+
394
+ const line = `Downloading ${modelName}… ${percent}% (${sizeLabel}) · ${speed} · ${eta}`;
395
+
396
+ return { percent, sizeLabel, speed, eta, line, file: raw.file, fileProgress };
397
+ };
398
+ }
399
+
400
+ // ─── Post-download verification ──────────────────────────────────────────────
401
+
402
+ /**
403
+ * Verify a downloaded model is complete and usable.
404
+ * Checks that all expected files exist and have non-zero size.
405
+ */
406
+ export function verifyDownload(
407
+ modelId: string,
408
+ downloadUrls: Record<string, string>,
409
+ expectedTotalBytes: number
410
+ ): { ok: boolean; issues: string[] } {
411
+ const issues: string[] = [];
412
+ const dir = getModelDir(modelId);
413
+
414
+ if (!fs.existsSync(dir)) {
415
+ issues.push(`Model directory not found: ${dir}`);
416
+ return { ok: false, issues };
417
+ }
418
+
419
+ let totalSize = 0;
420
+ for (const [role, url] of Object.entries(downloadUrls)) {
421
+ const filename = fileNameFromUrl(url);
422
+ const filePath = path.join(dir, filename);
423
+
424
+ if (!fs.existsSync(filePath)) {
425
+ issues.push(`Missing file: ${filename} (${role})`);
426
+ continue;
427
+ }
428
+
429
+ const stat = fs.statSync(filePath);
430
+ if (stat.size === 0) {
431
+ issues.push(`Empty file: ${filename} (${role}) — may be corrupted`);
432
+ continue;
433
+ }
434
+
435
+ // Check for leftover .tmp files (incomplete download)
436
+ if (fs.existsSync(filePath + ".tmp")) {
437
+ issues.push(`Incomplete download detected: ${filename}.tmp — delete and retry`);
438
+ }
439
+
440
+ totalSize += stat.size;
441
+ }
442
+
443
+ // Sanity check: total should be within 10% of expected
444
+ if (issues.length === 0 && expectedTotalBytes > 0) {
445
+ const ratio = totalSize / expectedTotalBytes;
446
+ if (ratio < 0.5) {
447
+ issues.push(
448
+ `Download appears incomplete: ${Math.round(totalSize / (1024 * 1024))} MB downloaded, expected ~${Math.round(expectedTotalBytes / (1024 * 1024))} MB`
449
+ );
450
+ }
451
+ }
452
+
453
+ return { ok: issues.length === 0, issues };
454
+ }
455
+
456
+ // ─── Handy model import ──────────────────────────────────────────────────────
457
+
458
+ /** Known Handy model directory (macOS) */
459
+ const HANDY_MODELS_DIR = path.join(os.homedir(), "Library", "Application Support", "com.pais.handy", "models");
460
+
461
+ /** Map of handy model directory names → pi model IDs + file mappings */
462
+ const HANDY_MODEL_MAP: Record<
463
+ string,
464
+ {
465
+ piModelId: string;
466
+ /** Map of handy filename → pi expected filename */
467
+ fileMap: Record<string, string>;
468
+ }
469
+ > = {
470
+ "parakeet-tdt-0.6b-v3-int8": {
471
+ piModelId: "parakeet-v3",
472
+ fileMap: {
473
+ "encoder-model.int8.onnx": "encoder.int8.onnx",
474
+ "decoder_joint-model.int8.onnx": "decoder.int8.onnx",
475
+ "nemo128.onnx": "joiner.int8.onnx",
476
+ "vocab.txt": "tokens.txt",
477
+ },
478
+ },
479
+ };
480
+
481
+ export interface HandyModel {
482
+ handyId: string;
483
+ piModelId: string;
484
+ name: string;
485
+ sizeMB: number;
486
+ imported: boolean;
487
+ }
488
+
489
+ /**
490
+ * Scan Handy's model directory for compatible models that can be imported.
491
+ * Returns models found in Handy that have a known mapping to pi model format.
492
+ */
493
+ export function scanHandyModels(): HandyModel[] {
494
+ if (!fs.existsSync(HANDY_MODELS_DIR)) return [];
495
+
496
+ const results: HandyModel[] = [];
497
+ try {
498
+ const entries = fs.readdirSync(HANDY_MODELS_DIR, { withFileTypes: true });
499
+ for (const entry of entries) {
500
+ if (!entry.isDirectory()) continue;
501
+ const mapping = HANDY_MODEL_MAP[entry.name];
502
+ if (!mapping) continue;
503
+
504
+ const handyDir = path.join(HANDY_MODELS_DIR, entry.name);
505
+ const sizeMB = getDirSizeMB(handyDir);
506
+ const piDir = getModelDir(mapping.piModelId);
507
+ const imported = fs.existsSync(piDir) && isSymlinkOrComplete(piDir, mapping);
508
+
509
+ results.push({
510
+ handyId: entry.name,
511
+ piModelId: mapping.piModelId,
512
+ name: entry.name,
513
+ sizeMB,
514
+ imported,
515
+ });
516
+ }
517
+ } catch {
518
+ // Permission error or directory not accessible
519
+ }
520
+ return results;
521
+ }
522
+
523
+ /**
524
+ * Import a Handy model by creating symlinks from pi's model directory
525
+ * to Handy's files with the correct filenames.
526
+ * Avoids duplicating large model files on disk.
527
+ */
528
+ export function importHandyModel(handyId: string): { ok: boolean; error?: string } {
529
+ const mapping = HANDY_MODEL_MAP[handyId];
530
+ if (!mapping) return { ok: false, error: `Unknown Handy model: ${handyId}` };
531
+
532
+ const handyDir = path.join(HANDY_MODELS_DIR, handyId);
533
+ if (!fs.existsSync(handyDir)) return { ok: false, error: `Handy model not found: ${handyDir}` };
534
+
535
+ const piDir = getModelDir(mapping.piModelId);
536
+ fs.mkdirSync(piDir, { recursive: true });
537
+
538
+ for (const [handyFile, piFile] of Object.entries(mapping.fileMap)) {
539
+ const src = path.join(handyDir, handyFile);
540
+ const dest = path.join(piDir, piFile);
541
+
542
+ if (!fs.existsSync(src)) {
543
+ return { ok: false, error: `Missing file in Handy: ${handyFile}` };
544
+ }
545
+
546
+ // Skip if already exists (real file or valid symlink)
547
+ if (fs.existsSync(dest)) continue;
548
+
549
+ try {
550
+ fs.symlinkSync(src, dest);
551
+ } catch (err: any) {
552
+ // Symlink failed — try copying instead (e.g., cross-device)
553
+ try {
554
+ fs.copyFileSync(src, dest);
555
+ } catch (copyErr: any) {
556
+ return { ok: false, error: `Failed to link/copy ${handyFile}: ${copyErr.message}` };
557
+ }
558
+ }
559
+ }
560
+
561
+ return { ok: true };
562
+ }
563
+
564
+ /** Check if a pi model dir has valid symlinks or files for a handy mapping */
565
+ function isSymlinkOrComplete(piDir: string, mapping: { fileMap: Record<string, string> }): boolean {
566
+ for (const piFile of Object.values(mapping.fileMap)) {
567
+ if (!fs.existsSync(path.join(piDir, piFile))) return false;
568
+ }
569
+ return true;
570
+ }
571
+
572
+ // ─── Disk space ──────────────────────────────────────────────────────────────
573
+
574
+ /** Get free disk space in bytes for the given path. Returns null if unavailable. */
575
+ export function getFreeDiskSpace(dirPath: string): number | null {
576
+ try {
577
+ // Node 18.15+ / Bun — statfsSync
578
+ const stats = fs.statfsSync(dirPath);
579
+ return stats.bavail * stats.bsize;
580
+ } catch {
581
+ // statfsSync not available or path doesn't exist yet
582
+ }
583
+
584
+ // Fallback: try parent directory
585
+ const parent = path.dirname(dirPath);
586
+ if (parent !== dirPath) {
587
+ try {
588
+ const stats = fs.statfsSync(parent);
589
+ return stats.bavail * stats.bsize;
590
+ } catch {
591
+ // Give up
592
+ }
593
+ }
594
+
595
+ return null;
596
+ }
597
+
598
+ /** Format bytes as human-readable string. */
599
+ export function formatBytes(bytes: number): string {
600
+ if (bytes < 1024) return `${bytes} B`;
601
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
602
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
603
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
604
+ }
605
+
606
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
607
+
608
+ /** Extract filename from a URL. */
609
+ function fileNameFromUrl(url: string): string {
610
+ const urlPath = new URL(url).pathname;
611
+ return path.basename(urlPath);
612
+ }
613
+
614
+ /** Get total size of a directory in bytes. */
615
+ function getDirSizeBytes(dirPath: string): number {
616
+ let total = 0;
617
+ try {
618
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
619
+ for (const entry of entries) {
620
+ const fullPath = path.join(dirPath, entry.name);
621
+ if (entry.isFile()) {
622
+ total += fs.statSync(fullPath).size;
623
+ } else if (entry.isDirectory()) {
624
+ total += getDirSizeBytes(fullPath);
625
+ }
626
+ }
627
+ } catch {
628
+ // Permission error
629
+ }
630
+ return total;
631
+ }
632
+
633
+ /** Get total size of a directory in MB. */
634
+ function getDirSizeMB(dirPath: string): number {
635
+ return Math.round(getDirSizeBytes(dirPath) / (1024 * 1024));
636
+ }