gentle-pi 4.3.0 → 4.4.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.
- package/README.md +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, posix, sep } from "node:path";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
6
|
+
import {
|
|
7
|
+
PARAKEET_MODEL_MANIFEST,
|
|
8
|
+
VOICE_TARGETS,
|
|
9
|
+
voiceRuntimeManifest,
|
|
10
|
+
type VoiceModelManifest,
|
|
11
|
+
type VoiceNpmPackage,
|
|
12
|
+
type VoiceRedirectRule,
|
|
13
|
+
type VoiceRuntimeManifest,
|
|
14
|
+
type VoiceTarget,
|
|
15
|
+
} from "./voice-artifacts.ts";
|
|
16
|
+
|
|
17
|
+
// Explicit acquisition and status of the optional voice runtime and model (S1-S3).
|
|
18
|
+
// Nothing runs at import: /gentle:voice calls these on request. A component is
|
|
19
|
+
// downloaded into a private staging directory under the voice home, verified by
|
|
20
|
+
// size and digest while it streams, and published with a single rename, with its
|
|
21
|
+
// install marker written last. A failure removes only that staging directory, so
|
|
22
|
+
// it never leaves a marker behind or alters a published component. Downloaded
|
|
23
|
+
// code is written to disk only; it is never loaded or executed here.
|
|
24
|
+
|
|
25
|
+
export type VoiceComponentKind = "runtime" | "model";
|
|
26
|
+
|
|
27
|
+
export type VoiceInstallErrorCode = "aborted" | "timeout" | "http" | "redirect" | "size" | "integrity" | "archive" | "unsupported" | "io";
|
|
28
|
+
|
|
29
|
+
export class VoiceInstallError extends Error {
|
|
30
|
+
readonly code: VoiceInstallErrorCode;
|
|
31
|
+
|
|
32
|
+
constructor(code: VoiceInstallErrorCode, message: string, options?: { cause?: unknown }) {
|
|
33
|
+
super(message, options);
|
|
34
|
+
this.name = "VoiceInstallError";
|
|
35
|
+
this.code = code;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The subset of fetch the installer uses. Redirects are always handled here, so
|
|
40
|
+
// every hop is checked against the manifest's rules before it is requested.
|
|
41
|
+
export type VoiceFetch = (url: string, init: { signal: AbortSignal; redirect: "manual"; headers: Record<string, string> }) => Promise<Response>;
|
|
42
|
+
|
|
43
|
+
export interface VoiceInstallProgress {
|
|
44
|
+
component: VoiceComponentKind;
|
|
45
|
+
phase: "download" | "extract" | "publish";
|
|
46
|
+
// The model file or package name the event is about.
|
|
47
|
+
item?: string;
|
|
48
|
+
receivedBytes: number;
|
|
49
|
+
totalBytes: number;
|
|
50
|
+
overallReceivedBytes: number;
|
|
51
|
+
overallTotalBytes: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface VoiceTimeouts {
|
|
55
|
+
// The longest wait for response headers or the next body chunk.
|
|
56
|
+
idleMs: number;
|
|
57
|
+
// The whole install, from the first request to publication.
|
|
58
|
+
totalMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface VoiceInstallCommonOptions {
|
|
62
|
+
// Defaults to resolveVoiceHome(env).
|
|
63
|
+
voiceHome?: string;
|
|
64
|
+
env?: NodeJS.ProcessEnv;
|
|
65
|
+
fetch?: VoiceFetch;
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
timeouts?: Partial<VoiceTimeouts>;
|
|
68
|
+
onProgress?: (progress: VoiceInstallProgress) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface VoiceModelInstallOptions extends VoiceInstallCommonOptions {
|
|
72
|
+
manifest?: VoiceModelManifest;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface VoiceRuntimeInstallOptions extends VoiceInstallCommonOptions {
|
|
76
|
+
target: VoiceTarget;
|
|
77
|
+
manifest?: VoiceRuntimeManifest;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface VoiceInstallResult {
|
|
81
|
+
kind: VoiceComponentKind;
|
|
82
|
+
path: string;
|
|
83
|
+
alreadyInstalled: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface VoiceComponentStatus {
|
|
87
|
+
kind: VoiceComponentKind;
|
|
88
|
+
state: "installed" | "missing" | "invalid";
|
|
89
|
+
path: string;
|
|
90
|
+
reason?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const MARKER = ".gentle-voice.json";
|
|
94
|
+
const MARKER_SCHEMA = 1;
|
|
95
|
+
const MAX_REDIRECTS = 5;
|
|
96
|
+
const IDLE_MS = 60_000;
|
|
97
|
+
const TOTAL_MS: Record<VoiceComponentKind, number> = { runtime: 20 * 60_000, model: 2 * 60 * 60_000 };
|
|
98
|
+
const SAFE_ID = /^[a-z0-9][a-z0-9._-]*$/;
|
|
99
|
+
const SAFE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
100
|
+
|
|
101
|
+
// Everything voice owns lives in one directory of the Gentle AI config home.
|
|
102
|
+
export function resolveVoiceHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
103
|
+
return join(gentlePiConfigHome(env), "voice");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function voiceComponentPath(voiceHome: string, kind: VoiceComponentKind, manifest: { id: string }): string {
|
|
107
|
+
return join(voiceHome, kind === "model" ? "models" : "runtime", manifest.id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface Marker {
|
|
111
|
+
schema: number;
|
|
112
|
+
kind: VoiceComponentKind;
|
|
113
|
+
id: string;
|
|
114
|
+
manifest: string;
|
|
115
|
+
files: Array<{ path: string; size: number }>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fingerprint = (manifest: VoiceModelManifest | VoiceRuntimeManifest) => createHash("sha256").update(JSON.stringify(manifest)).digest("hex");
|
|
119
|
+
|
|
120
|
+
// Relative POSIX paths that resolve to the same file on every supported system:
|
|
121
|
+
// no traversal, separators, drive or stream colons, control characters, Windows
|
|
122
|
+
// device names, or trailing dots and spaces that Windows silently strips.
|
|
123
|
+
const WINDOWS_DEVICE = /^(con|prn|aux|nul|com\d|lpt\d)(\..*)?$/i;
|
|
124
|
+
|
|
125
|
+
function safeRelativeSegments(path: string): string[] | undefined {
|
|
126
|
+
const segments = path.split("/");
|
|
127
|
+
const safe = segments.every((s) => s !== "" && s !== "." && s !== ".." && !/[\\:\0-\x1f]/.test(s) && !/[. ]$/.test(s) && !WINDOWS_DEVICE.test(s));
|
|
128
|
+
return safe ? segments : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Reads only: a missing voice home is reported, never created.
|
|
132
|
+
export async function readVoiceComponentStatus(options: { voiceHome: string; kind: VoiceComponentKind; manifest: VoiceModelManifest | VoiceRuntimeManifest }): Promise<VoiceComponentStatus> {
|
|
133
|
+
const { voiceHome, kind, manifest } = options;
|
|
134
|
+
const path = voiceComponentPath(voiceHome, kind, manifest);
|
|
135
|
+
const invalid = (reason: string): VoiceComponentStatus => ({ kind, state: "invalid", path, reason });
|
|
136
|
+
try {
|
|
137
|
+
if (!(await lstat(path)).isDirectory()) return invalid("the install path is not a directory");
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind, state: "missing", path };
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
let marker: Marker;
|
|
143
|
+
try {
|
|
144
|
+
marker = JSON.parse(await readFile(join(path, MARKER), "utf8")) as Marker;
|
|
145
|
+
} catch {
|
|
146
|
+
return invalid("the install marker is missing or unreadable");
|
|
147
|
+
}
|
|
148
|
+
if (marker?.schema !== MARKER_SCHEMA || marker.kind !== kind || marker.id !== manifest.id || marker.manifest !== fingerprint(manifest) || !Array.isArray(marker.files)) {
|
|
149
|
+
return invalid("the install marker does not match the pinned manifest");
|
|
150
|
+
}
|
|
151
|
+
for (const file of marker.files) {
|
|
152
|
+
const segments = typeof file?.path === "string" ? safeRelativeSegments(file.path) : undefined;
|
|
153
|
+
if (!segments) return invalid("the install marker lists an unsafe path");
|
|
154
|
+
const info = await lstat(join(path, ...segments)).catch(() => undefined);
|
|
155
|
+
if (!info?.isFile() || info.size !== file.size) return invalid(`${file.path} is missing or changed`);
|
|
156
|
+
}
|
|
157
|
+
return { kind, state: "installed", path };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function readVoiceStatus(options: { voiceHome?: string; env?: NodeJS.ProcessEnv; target?: VoiceTarget } = {}): Promise<{ voiceHome: string; model: VoiceComponentStatus; runtime?: VoiceComponentStatus }> {
|
|
161
|
+
const voiceHome = options.voiceHome ?? resolveVoiceHome(options.env);
|
|
162
|
+
const model = await readVoiceComponentStatus({ voiceHome, kind: "model", manifest: PARAKEET_MODEL_MANIFEST });
|
|
163
|
+
if (!options.target || !VOICE_TARGETS.includes(options.target)) return { voiceHome, model };
|
|
164
|
+
const runtime = await readVoiceComponentStatus({ voiceHome, kind: "runtime", manifest: voiceRuntimeManifest(options.target) });
|
|
165
|
+
return { voiceHome, model, runtime };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- Transfer ---------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
interface Transfer {
|
|
171
|
+
component: VoiceComponentKind;
|
|
172
|
+
fetch: VoiceFetch;
|
|
173
|
+
controller: AbortController;
|
|
174
|
+
idleMs: number;
|
|
175
|
+
redirects: VoiceRedirectRule[];
|
|
176
|
+
overallReceivedBytes: number;
|
|
177
|
+
overallTotalBytes: number;
|
|
178
|
+
onProgress?: (progress: VoiceInstallProgress) => void;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Settles with the promise, or rejects with the abort reason (cancel, total or
|
|
182
|
+
// idle timeout) as soon as the install is aborted, whether or not the injected
|
|
183
|
+
// fetch or body stream honours the signal itself.
|
|
184
|
+
function untilAborted<T>(promise: Promise<T>, transfer: Transfer): Promise<T> {
|
|
185
|
+
const { signal } = transfer.controller;
|
|
186
|
+
const timer = setTimeout(() => transfer.controller.abort(new VoiceInstallError("timeout", `No data arrived for ${transfer.idleMs} ms.`)), transfer.idleMs);
|
|
187
|
+
return new Promise<T>((resolve, reject) => {
|
|
188
|
+
const onAbort = () => reject(signal.reason);
|
|
189
|
+
const settle = () => { clearTimeout(timer); signal.removeEventListener("abort", onAbort); };
|
|
190
|
+
if (signal.aborted) { settle(); reject(signal.reason); return; }
|
|
191
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
192
|
+
promise.then((value) => { settle(); resolve(value); }, (error) => { settle(); reject(error); });
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function permitted(url: URL, rules: VoiceRedirectRule[]): boolean {
|
|
197
|
+
if (url.protocol !== "https:" || url.username || url.password) return false;
|
|
198
|
+
return rules.some((rule) => url.origin === rule.origin && url.pathname.startsWith(rule.pathPrefix));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function request(url: string, transfer: Transfer): Promise<Response> {
|
|
202
|
+
let current = new URL(url);
|
|
203
|
+
if (current.protocol !== "https:") throw new VoiceInstallError("redirect", `Refused a non-HTTPS source ${current.origin}.`);
|
|
204
|
+
for (let hop = 0; ; hop++) {
|
|
205
|
+
const response = await untilAborted(transfer.fetch(current.href, { signal: transfer.controller.signal, redirect: "manual", headers: { "accept-encoding": "identity" } }), transfer);
|
|
206
|
+
if (response.status >= 300 && response.status < 400) {
|
|
207
|
+
void response.body?.cancel().catch(() => {});
|
|
208
|
+
const location = response.headers.get("location");
|
|
209
|
+
if (!location || hop >= MAX_REDIRECTS) throw new VoiceInstallError("redirect", `Too many or empty redirects from ${current.origin}${current.pathname}.`);
|
|
210
|
+
const next = new URL(location, current);
|
|
211
|
+
// The query is left out of messages: CDN redirects carry signed tokens.
|
|
212
|
+
if (!permitted(next, transfer.redirects)) throw new VoiceInstallError("redirect", `Refused a redirect to ${next.protocol}//${next.host}${next.pathname}.`);
|
|
213
|
+
current = next;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (response.status !== 200) {
|
|
217
|
+
void response.body?.cancel().catch(() => {});
|
|
218
|
+
throw new VoiceInstallError("http", `${current.origin}${current.pathname} answered HTTP ${response.status}.`);
|
|
219
|
+
}
|
|
220
|
+
return response;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface Expected { item: string; size: number; algorithm: "sha256" | "sha512"; digest: string }
|
|
225
|
+
|
|
226
|
+
const encodeDigest = (algorithm: Expected["algorithm"], raw: Buffer) => (algorithm === "sha512" ? `sha512-${raw.toString("base64")}` : raw.toString("hex"));
|
|
227
|
+
|
|
228
|
+
// Streams one artifact to a new file, hashing as it goes. The body is never held
|
|
229
|
+
// whole in memory, and reading stops as soon as it exceeds the pinned size.
|
|
230
|
+
async function download(url: string, expected: Expected, destination: string, transfer: Transfer): Promise<void> {
|
|
231
|
+
const response = await request(url, transfer);
|
|
232
|
+
const declared = response.headers.get("content-length");
|
|
233
|
+
if (declared !== null && Number(declared) !== expected.size) {
|
|
234
|
+
void response.body?.cancel().catch(() => {});
|
|
235
|
+
throw new VoiceInstallError("size", `${expected.item} is announced as ${declared} bytes, expected ${expected.size}.`);
|
|
236
|
+
}
|
|
237
|
+
if (!response.body) throw new VoiceInstallError("http", `${expected.item} has no response body.`);
|
|
238
|
+
const reader = response.body.getReader();
|
|
239
|
+
const hash = createHash(expected.algorithm);
|
|
240
|
+
const file = await open(destination, "wx", 0o600);
|
|
241
|
+
let received = 0;
|
|
242
|
+
let finished = false;
|
|
243
|
+
try {
|
|
244
|
+
for (;;) {
|
|
245
|
+
const { done, value } = await untilAborted(reader.read(), transfer);
|
|
246
|
+
if (done) { finished = true; break; }
|
|
247
|
+
received += value.byteLength;
|
|
248
|
+
if (received > expected.size) throw new VoiceInstallError("size", `${expected.item} is larger than the pinned ${expected.size} bytes.`);
|
|
249
|
+
hash.update(value);
|
|
250
|
+
await file.write(value);
|
|
251
|
+
transfer.overallReceivedBytes += value.byteLength;
|
|
252
|
+
transfer.onProgress?.({ component: transfer.component, phase: "download", item: expected.item, receivedBytes: received, totalBytes: expected.size, overallReceivedBytes: transfer.overallReceivedBytes, overallTotalBytes: transfer.overallTotalBytes });
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
if (!finished) void reader.cancel().catch(() => {});
|
|
256
|
+
await file.close();
|
|
257
|
+
}
|
|
258
|
+
if (received !== expected.size) throw new VoiceInstallError("size", `${expected.item} ended after ${received} of ${expected.size} bytes.`);
|
|
259
|
+
if (encodeDigest(expected.algorithm, hash.digest()) !== expected.digest) throw new VoiceInstallError("integrity", `${expected.item} does not match its pinned ${expected.algorithm} digest.`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- Staging and publication ------------------------------------------------
|
|
263
|
+
|
|
264
|
+
type Build = (payload: string, staging: string, transfer: Transfer) => Promise<Marker["files"]>;
|
|
265
|
+
|
|
266
|
+
async function install(kind: VoiceComponentKind, manifest: VoiceModelManifest | VoiceRuntimeManifest, overallTotalBytes: number, redirects: VoiceRedirectRule[], options: VoiceInstallCommonOptions, build: Build): Promise<VoiceInstallResult> {
|
|
267
|
+
if (options.signal?.aborted) throw new VoiceInstallError("aborted", "Voice install was cancelled.");
|
|
268
|
+
const voiceHome = options.voiceHome ?? resolveVoiceHome(options.env);
|
|
269
|
+
const finalPath = voiceComponentPath(voiceHome, kind, manifest);
|
|
270
|
+
if ((await readVoiceComponentStatus({ voiceHome, kind, manifest })).state === "installed") return { kind, path: finalPath, alreadyInstalled: true };
|
|
271
|
+
|
|
272
|
+
const controller = new AbortController();
|
|
273
|
+
const onAbort = () => controller.abort(new VoiceInstallError("aborted", "Voice install was cancelled."));
|
|
274
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
275
|
+
const totalMs = options.timeouts?.totalMs ?? TOTAL_MS[kind];
|
|
276
|
+
const deadline = setTimeout(() => controller.abort(new VoiceInstallError("timeout", `Voice install exceeded ${totalMs} ms.`)), totalMs);
|
|
277
|
+
const transfer: Transfer = {
|
|
278
|
+
component: kind,
|
|
279
|
+
fetch: options.fetch ?? ((url, init) => fetch(url, init)),
|
|
280
|
+
controller,
|
|
281
|
+
idleMs: options.timeouts?.idleMs ?? IDLE_MS,
|
|
282
|
+
redirects,
|
|
283
|
+
overallReceivedBytes: 0,
|
|
284
|
+
overallTotalBytes,
|
|
285
|
+
onProgress: options.onProgress,
|
|
286
|
+
};
|
|
287
|
+
let staging: string | undefined;
|
|
288
|
+
try {
|
|
289
|
+
await mkdir(voiceHome, { recursive: true, mode: 0o700 });
|
|
290
|
+
// mkdtemp creates the directory with mode 0700 and a fresh random name.
|
|
291
|
+
staging = await mkdtemp(join(voiceHome, ".staging-"));
|
|
292
|
+
const payload = join(staging, "payload");
|
|
293
|
+
await mkdir(payload, { mode: 0o700 });
|
|
294
|
+
const files = await build(payload, staging, transfer);
|
|
295
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
296
|
+
const marker: Marker = { schema: MARKER_SCHEMA, kind, id: manifest.id, manifest: fingerprint(manifest), files };
|
|
297
|
+
await writeFile(join(payload, MARKER), `${JSON.stringify(marker, null, "\t")}\n`, { flag: "wx", mode: 0o600 });
|
|
298
|
+
transfer.onProgress?.({ component: kind, phase: "publish", receivedBytes: 0, totalBytes: 0, overallReceivedBytes: transfer.overallReceivedBytes, overallTotalBytes });
|
|
299
|
+
return await publish(kind, manifest, voiceHome, staging, payload, finalPath);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error instanceof VoiceInstallError) throw error;
|
|
302
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
303
|
+
throw new VoiceInstallError("io", `Voice install failed: ${(error as Error).message}`, { cause: error });
|
|
304
|
+
} finally {
|
|
305
|
+
clearTimeout(deadline);
|
|
306
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
307
|
+
// Only the staging directory this call created is ever removed.
|
|
308
|
+
if (staging) await rm(staging, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// One rename publishes the complete payload. An invalid previous directory is
|
|
313
|
+
// first moved into this install's staging, so it is restored if the rename fails
|
|
314
|
+
// and discarded with the staging once the new component is in place.
|
|
315
|
+
async function publish(kind: VoiceComponentKind, manifest: VoiceModelManifest | VoiceRuntimeManifest, voiceHome: string, staging: string, payload: string, finalPath: string): Promise<VoiceInstallResult> {
|
|
316
|
+
await mkdir(dirname(finalPath), { recursive: true, mode: 0o700 });
|
|
317
|
+
const existing = await readVoiceComponentStatus({ voiceHome, kind, manifest });
|
|
318
|
+
if (existing.state === "installed") return { kind, path: finalPath, alreadyInstalled: true };
|
|
319
|
+
const previous = join(staging, "previous");
|
|
320
|
+
if (existing.state === "invalid") await rename(finalPath, previous);
|
|
321
|
+
try {
|
|
322
|
+
await rename(payload, finalPath);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (existing.state === "invalid") await rename(previous, finalPath).catch(() => {});
|
|
325
|
+
// A concurrent install may have published the same component first.
|
|
326
|
+
if ((await readVoiceComponentStatus({ voiceHome, kind, manifest })).state === "installed") return { kind, path: finalPath, alreadyInstalled: true };
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
return { kind, path: finalPath, alreadyInstalled: false };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- Model ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
export async function installVoiceModel(options: VoiceModelInstallOptions = {}): Promise<VoiceInstallResult> {
|
|
335
|
+
const manifest = options.manifest ?? PARAKEET_MODEL_MANIFEST;
|
|
336
|
+
if (!SAFE_ID.test(manifest.id) || !manifest.files.every((file) => SAFE_FILE.test(file.name))) {
|
|
337
|
+
throw new VoiceInstallError("unsupported", "The model manifest names an unsafe path.");
|
|
338
|
+
}
|
|
339
|
+
const total = manifest.files.reduce((sum, file) => sum + file.size, 0);
|
|
340
|
+
return install("model", manifest, total, manifest.redirects, options, async (payload, _staging, transfer) => {
|
|
341
|
+
for (const file of manifest.files) {
|
|
342
|
+
await download(`${manifest.baseUrl}/${file.name}`, { item: file.name, size: file.size, algorithm: "sha256", digest: file.sha256 }, join(payload, file.name), transfer);
|
|
343
|
+
}
|
|
344
|
+
return manifest.files.map((file) => ({ path: file.name, size: file.size }));
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- Runtime ----------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
interface ArchiveEntry { path: string; segments: string[]; executable: boolean; data: Buffer }
|
|
351
|
+
|
|
352
|
+
const cString = (block: Buffer, start: number, end: number) => {
|
|
353
|
+
const field = block.subarray(start, end);
|
|
354
|
+
const nul = field.indexOf(0);
|
|
355
|
+
return field.subarray(0, nul === -1 ? field.length : nul).toString("utf8");
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
function octal(block: Buffer, start: number, end: number): number {
|
|
359
|
+
const text = cString(block, start, end).trim();
|
|
360
|
+
return /^[0-7]+$/.test(text) ? Number.parseInt(text, 8) : Number.NaN;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Validates a whole npm tarball before anything is written: only regular files
|
|
364
|
+
// under package/, no links, devices, directories or extended headers, no
|
|
365
|
+
// traversal, case-insensitive duplicates or file/directory clashes, and no more
|
|
366
|
+
// entries or bytes than the registry metadata records.
|
|
367
|
+
function readArchive(compressed: Buffer, pkg: VoiceNpmPackage): ArchiveEntry[] {
|
|
368
|
+
const reject = (reason: string): never => { throw new VoiceInstallError("archive", `${pkg.name}: ${reason}.`); };
|
|
369
|
+
let tar: Buffer;
|
|
370
|
+
try {
|
|
371
|
+
tar = gunzipSync(compressed, { maxOutputLength: pkg.unpackedSize + (pkg.fileCount + 2) * 1024 + 10240 });
|
|
372
|
+
} catch {
|
|
373
|
+
return reject("the archive does not decompress within its pinned size");
|
|
374
|
+
}
|
|
375
|
+
const entries: ArchiveEntry[] = [];
|
|
376
|
+
const files = new Set<string>();
|
|
377
|
+
const directories = new Set<string>();
|
|
378
|
+
let unpacked = 0;
|
|
379
|
+
let offset = 0;
|
|
380
|
+
let ended = false;
|
|
381
|
+
while (offset + 512 <= tar.length) {
|
|
382
|
+
const header = tar.subarray(offset, offset + 512);
|
|
383
|
+
if (header.every((byte) => byte === 0)) {
|
|
384
|
+
if (!tar.subarray(offset).every((byte) => byte === 0)) reject("data follows the end of the archive");
|
|
385
|
+
ended = true;
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
let sum = 0;
|
|
389
|
+
for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 0x20 : header[i]!;
|
|
390
|
+
if (octal(header, 148, 156) !== sum || cString(header, 257, 263) !== "ustar") reject("an entry header is corrupt");
|
|
391
|
+
const type = header[156];
|
|
392
|
+
if (type !== 0x30 && type !== 0) reject(`entry type '${String.fromCharCode(type!)}' is not a regular file`);
|
|
393
|
+
const name = cString(header, 0, 100);
|
|
394
|
+
const prefix = cString(header, 345, 500);
|
|
395
|
+
const full = prefix ? `${prefix}/${name}` : name;
|
|
396
|
+
if (!full.startsWith("package/")) reject(`${full} is outside package/`);
|
|
397
|
+
const relative = full.slice("package/".length);
|
|
398
|
+
const segments = safeRelativeSegments(relative) ?? reject(`${full} is not a safe relative path`);
|
|
399
|
+
const key = relative.toLowerCase();
|
|
400
|
+
if (files.has(key) || directories.has(key)) reject(`${full} is duplicated`);
|
|
401
|
+
for (let i = 1; i < segments.length; i++) {
|
|
402
|
+
const ancestor = segments.slice(0, i).join("/").toLowerCase();
|
|
403
|
+
if (files.has(ancestor)) reject(`${full} uses a file as a directory`);
|
|
404
|
+
directories.add(ancestor);
|
|
405
|
+
}
|
|
406
|
+
files.add(key);
|
|
407
|
+
const size = octal(header, 124, 136);
|
|
408
|
+
if (!Number.isSafeInteger(size)) reject(`${full} has an invalid size`);
|
|
409
|
+
unpacked += size;
|
|
410
|
+
if (entries.length + 1 > pkg.fileCount) reject(`more than ${pkg.fileCount} entries`);
|
|
411
|
+
if (unpacked > pkg.unpackedSize) reject(`more than ${pkg.unpackedSize} unpacked bytes`);
|
|
412
|
+
const start = offset + 512;
|
|
413
|
+
if (start + size > tar.length) reject(`${full} is truncated`);
|
|
414
|
+
entries.push({ path: relative, segments, executable: (octal(header, 100, 108) & 0o111) !== 0, data: tar.subarray(start, start + size) });
|
|
415
|
+
offset = start + Math.ceil(size / 512) * 512;
|
|
416
|
+
}
|
|
417
|
+
if (!ended) reject("the archive has no end marker");
|
|
418
|
+
const present = new Set(entries.map((entry) => entry.path));
|
|
419
|
+
for (const required of pkg.requiredFiles) if (!present.has(required)) reject(`${required} is missing`);
|
|
420
|
+
return entries;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Installs sherpa-onnx-node, its platform package and node-cpal as sibling directories of one node_modules, the layout the sherpa-onnx
|
|
424
|
+
// loader resolves. No package script runs and nothing is loaded.
|
|
425
|
+
export async function installVoiceRuntime(options: VoiceRuntimeInstallOptions): Promise<VoiceInstallResult> {
|
|
426
|
+
if (!VOICE_TARGETS.includes(options.target)) throw new VoiceInstallError("unsupported", `Voice dictation has no pinned runtime for ${String(options.target)}.`);
|
|
427
|
+
const manifest = options.manifest ?? voiceRuntimeManifest(options.target);
|
|
428
|
+
if (manifest.target !== options.target || !SAFE_ID.test(manifest.id) || !manifest.packages.every((pkg) => SAFE_ID.test(pkg.name))) {
|
|
429
|
+
throw new VoiceInstallError("unsupported", "The runtime manifest does not describe this target safely.");
|
|
430
|
+
}
|
|
431
|
+
const total = manifest.packages.reduce((sum, pkg) => sum + pkg.size, 0);
|
|
432
|
+
return install("runtime", manifest, total, manifest.redirects, options, async (payload, staging, transfer) => {
|
|
433
|
+
const downloads = join(staging, "downloads");
|
|
434
|
+
await mkdir(downloads, { mode: 0o700 });
|
|
435
|
+
for (const pkg of manifest.packages) {
|
|
436
|
+
await download(pkg.tarball, { item: pkg.name, size: pkg.size, algorithm: "sha512", digest: pkg.integrity }, join(downloads, `${pkg.name}.tgz`), transfer);
|
|
437
|
+
}
|
|
438
|
+
// Every archive is validated before the first file is extracted.
|
|
439
|
+
const archives: Array<[VoiceNpmPackage, ArchiveEntry[]]> = [];
|
|
440
|
+
for (const pkg of manifest.packages) archives.push([pkg, readArchive(await readFile(join(downloads, `${pkg.name}.tgz`)), pkg)]);
|
|
441
|
+
const files: Marker["files"] = [];
|
|
442
|
+
for (const [pkg, entries] of archives) {
|
|
443
|
+
transfer.onProgress?.({ component: "runtime", phase: "extract", item: pkg.name, receivedBytes: 0, totalBytes: pkg.unpackedSize, overallReceivedBytes: transfer.overallReceivedBytes, overallTotalBytes: total });
|
|
444
|
+
const root = join(payload, "node_modules", pkg.name);
|
|
445
|
+
for (const entry of entries) {
|
|
446
|
+
const destination = join(root, ...entry.segments);
|
|
447
|
+
if (!destination.startsWith(root + sep)) throw new VoiceInstallError("archive", `${pkg.name}: ${entry.path} escapes the package.`);
|
|
448
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o755 });
|
|
449
|
+
await writeFile(destination, entry.data, { flag: "wx", mode: entry.executable ? 0o755 : 0o644 });
|
|
450
|
+
files.push({ path: posix.join("node_modules", pkg.name, entry.path), size: entry.data.length });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return files;
|
|
454
|
+
});
|
|
455
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { release } from "node:os";
|
|
2
|
+
import { VOICE_LINUX_MIN_GLIBC, VOICE_TARGETS, type VoiceTarget } from "./voice-artifacts.ts";
|
|
3
|
+
|
|
4
|
+
// Which of the researched voice targets (S3) this machine is, and what that does
|
|
5
|
+
// and does not promise. The diagnosis is pure over an injected probe; it never
|
|
6
|
+
// loads a native module or opens a device, so "available" means only that a
|
|
7
|
+
// pinned binary for this architecture exists, not that it was proven to run.
|
|
8
|
+
|
|
9
|
+
export interface VoicePlatformProbe {
|
|
10
|
+
platform: string;
|
|
11
|
+
arch: string;
|
|
12
|
+
// The glibc runtime version on Linux; undefined there means musl or unknown.
|
|
13
|
+
glibcVersion?: string;
|
|
14
|
+
wsl?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface VoiceCapability {
|
|
18
|
+
available: boolean;
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VoicePlatformDiagnosis {
|
|
23
|
+
// known: one of the five researched targets. unproven: it maps to one, but the
|
|
24
|
+
// environment (WSL) was never researched. unsupported: no pinned runtime fits.
|
|
25
|
+
support: "known" | "unproven" | "unsupported";
|
|
26
|
+
target?: VoiceTarget;
|
|
27
|
+
transcription: VoiceCapability;
|
|
28
|
+
microphone: VoiceCapability;
|
|
29
|
+
limitations: string[];
|
|
30
|
+
// No target has been exercised on real hardware yet; T4 owns that proof.
|
|
31
|
+
hardwareVerified: false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Reads the running process only when called, never at import.
|
|
35
|
+
export function readVoicePlatformProbe(): VoicePlatformProbe {
|
|
36
|
+
const probe: VoicePlatformProbe = { platform: process.platform, arch: process.arch };
|
|
37
|
+
if (process.platform === "linux") {
|
|
38
|
+
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } | undefined)?.header;
|
|
39
|
+
probe.glibcVersion = header?.glibcVersionRuntime;
|
|
40
|
+
probe.wsl = Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) || /microsoft/i.test(release());
|
|
41
|
+
}
|
|
42
|
+
return probe;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NOT_VERIFIED = "Native loading and microphone capture have not been verified on hardware for this target.";
|
|
46
|
+
|
|
47
|
+
// Prerequisites read from the pinned binaries' headers and metadata. None of them
|
|
48
|
+
// is probed before loading, so they are stated rather than promised.
|
|
49
|
+
const PREREQUISITES: Partial<Record<string, string[]>> = {
|
|
50
|
+
darwin: ["The native binaries target macOS 11 or newer on arm64 and macOS 10.12 or newer on x64; the macOS version is not checked."],
|
|
51
|
+
linux: ["Microphone capture needs the ALSA library libasound.so.2, which is not checked before loading."],
|
|
52
|
+
win32: ["The native binaries need the Microsoft Visual C++ runtime (vcruntime140.dll), which is not checked before loading and is never downloaded by Gentle AI."],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// True when a dotted version is at least the minimum, compared numerically.
|
|
56
|
+
function atLeast(version: string, minimum: string): boolean {
|
|
57
|
+
const have = version.split(".").map(Number);
|
|
58
|
+
const need = minimum.split(".").map(Number);
|
|
59
|
+
for (let i = 0; i < need.length; i++) {
|
|
60
|
+
const part = have[i] ?? 0;
|
|
61
|
+
if (!Number.isFinite(part)) return false;
|
|
62
|
+
if (part !== need[i]) return part > need[i]!;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unsupported(reason: string): VoicePlatformDiagnosis {
|
|
68
|
+
return {
|
|
69
|
+
support: "unsupported",
|
|
70
|
+
transcription: { available: false, reason },
|
|
71
|
+
microphone: { available: false, reason },
|
|
72
|
+
limitations: [reason],
|
|
73
|
+
hardwareVerified: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function diagnoseVoicePlatform(probe: VoicePlatformProbe): VoicePlatformDiagnosis {
|
|
78
|
+
const name = `${probe.platform}-${probe.arch}`;
|
|
79
|
+
if (probe.platform === "win32" && probe.arch === "arm64") {
|
|
80
|
+
return unsupported("Windows ARM64 has no native sherpa-onnx package; running the x64 runtime under emulation is unproven.");
|
|
81
|
+
}
|
|
82
|
+
const target = VOICE_TARGETS.find((candidate) => candidate === name);
|
|
83
|
+
if (!target) return unsupported(`Voice dictation has no pinned runtime for ${name}.`);
|
|
84
|
+
if (probe.platform === "linux" && !probe.glibcVersion) {
|
|
85
|
+
return unsupported("Voice dictation needs glibc Linux; musl and other C libraries have no pinned sherpa-onnx build.");
|
|
86
|
+
}
|
|
87
|
+
if (probe.platform === "linux" && !atLeast(probe.glibcVersion!, VOICE_LINUX_MIN_GLIBC)) {
|
|
88
|
+
return unsupported(`Voice dictation needs glibc ${VOICE_LINUX_MIN_GLIBC} or newer; this system has glibc ${probe.glibcVersion}.`);
|
|
89
|
+
}
|
|
90
|
+
const limitations = [NOT_VERIFIED, ...(PREREQUISITES[probe.platform] ?? [])];
|
|
91
|
+
// "available" means a pinned capture binary for this architecture exists.
|
|
92
|
+
const microphone: VoiceCapability = { available: true };
|
|
93
|
+
let support: VoicePlatformDiagnosis["support"] = "known";
|
|
94
|
+
if (probe.wsl) {
|
|
95
|
+
support = "unproven";
|
|
96
|
+
limitations.push("WSL was not researched: native loading and microphone access there are unproven.");
|
|
97
|
+
}
|
|
98
|
+
return { support, target, transcription: { available: true }, microphone, limitations, hardwareVerified: false };
|
|
99
|
+
}
|