pi-meta-oauth 0.4.2 → 0.4.4

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.
@@ -1,4 +1,7 @@
1
1
  import type {
2
+ Api,
3
+ Model,
4
+ ModelsStoreEntry,
2
5
  OAuthCredentials,
3
6
  OAuthLoginCallbacks,
4
7
  RefreshModelsContext,
@@ -14,6 +17,7 @@ export const META_MODEL_CATALOG_URL = "https://api.meta.ai/v1/models";
14
17
  export const META_AUTH_BASE_URL = "https://auth.meta.com";
15
18
  export const META_CLIENT_ID = "1031625952748946";
16
19
  export const META_FILES_URL = `${META_API_BASE_URL}/files`;
20
+ const META_ENV_VAR = "META_API_KEY";
17
21
 
18
22
  const DEVICE_AUTHORIZATION_URL = `${META_AUTH_BASE_URL}/oidc/device/authorization/`;
19
23
  const DEVICE_TOKEN_URL = `${META_AUTH_BASE_URL}/oidc/device/token/`;
@@ -199,9 +203,19 @@ async function postForm<T>(
199
203
  };
200
204
  }
201
205
 
206
+ function isAbortSignal(value: unknown): value is AbortSignal {
207
+ return (
208
+ typeof value === "object" &&
209
+ value !== null &&
210
+ "aborted" in value &&
211
+ typeof (value as AbortSignal).aborted === "boolean"
212
+ );
213
+ }
214
+
202
215
  export async function mintMetaApiKey(
203
216
  identityToken: string,
204
217
  fetchImpl: Fetch = fetch,
218
+ signal?: AbortSignal,
205
219
  ): Promise<string> {
206
220
  const response = await fetchImpl(API_KEY_MINT_URL, {
207
221
  method: "POST",
@@ -212,6 +226,7 @@ export async function mintMetaApiKey(
212
226
  "x-api-version": "1.0.0",
213
227
  },
214
228
  body: "{}",
229
+ signal,
215
230
  });
216
231
  const body = (await responseBody(response)) as MintResponse &
217
232
  Record<string, unknown>;
@@ -248,9 +263,7 @@ export async function loginMeta(
248
263
  }
249
264
  const device = authorization.body;
250
265
  if (!device.device_code || !device.user_code || !device.verification_uri) {
251
- throw new Error(
252
- "Meta device authorization returned an incomplete response",
253
- );
266
+ throw new Error("Meta device authorization returned an incomplete response");
254
267
  }
255
268
 
256
269
  let intervalSeconds =
@@ -264,8 +277,7 @@ export async function loginMeta(
264
277
  const deadline = Date.now() + expiresInSeconds * 1000;
265
278
  callbacks.onDeviceCode({
266
279
  userCode: device.user_code,
267
- verificationUri:
268
- device.verification_uri_complete || device.verification_uri,
280
+ verificationUri: device.verification_uri_complete || device.verification_uri,
269
281
  intervalSeconds,
270
282
  expiresInSeconds,
271
283
  });
@@ -316,15 +328,23 @@ export async function loginMeta(
316
328
 
317
329
  export async function refreshMetaToken(
318
330
  credentials: OAuthCredentials,
319
- fetchImpl: Fetch = fetch,
331
+ fetchOrSignal: Fetch | AbortSignal = fetch,
320
332
  ): Promise<OAuthCredentials> {
321
333
  if (!credentials.refresh)
322
334
  throw new Error(
323
335
  "Meta login is missing its identity token; run /login meta again",
324
336
  );
337
+ // Pi 0.83 calls refreshToken(credentials). Pi 0.84 passes AbortSignal as
338
+ // the second argument. Tests inject a fetch mock in that slot.
339
+ const fetchImpl =
340
+ typeof fetchOrSignal === "function" ? fetchOrSignal : fetch;
341
+ const signal = isAbortSignal(fetchOrSignal) ? fetchOrSignal : undefined;
342
+ if (signal?.aborted) {
343
+ throw new Error("Meta token refresh was cancelled");
344
+ }
325
345
  return {
326
346
  ...credentials,
327
- access: await mintMetaApiKey(credentials.refresh, fetchImpl),
347
+ access: await mintMetaApiKey(credentials.refresh, fetchImpl, signal),
328
348
  expires: Date.now() + API_KEY_REFRESH_INTERVAL_MS,
329
349
  };
330
350
  }
@@ -339,7 +359,7 @@ function numericCost(value: unknown, fallback: number): number {
339
359
  const number =
340
360
  typeof value === "number"
341
361
  ? value
342
- : typeof value === "string"
362
+ : typeof value === "string" && value.trim()
343
363
  ? Number(value)
344
364
  : Number.NaN;
345
365
  return Number.isFinite(number) && number >= 0 ? number : fallback;
@@ -379,19 +399,17 @@ export function toProviderModels(
379
399
  const metadata = entry.metadata?.["muse-code"];
380
400
  if (metadata?.is_hidden) return [];
381
401
  const fallback = FALLBACK_MODELS.find((model) => model.id === entry.id);
382
- const catalogName =
383
- metadata?.name === entry.id ? undefined : metadata?.name;
402
+ const catalogName = metadata?.name === entry.id ? undefined : metadata?.name;
384
403
  const variants = metadata?.variants ?? {};
385
- const thinkingLevelMap: NonNullable<MetaProviderModel["thinkingLevelMap"]> =
386
- {
387
- off: null,
388
- minimal: variants.minimal?.reasoningEffort ?? "minimal",
389
- low: variants.low?.reasoningEffort ?? "low",
390
- medium: variants.medium?.reasoningEffort ?? "medium",
391
- high: variants.high?.reasoningEffort ?? "high",
392
- xhigh: variants.xhigh?.reasoningEffort ?? "xhigh",
393
- max: null,
394
- };
404
+ const thinkingLevelMap: NonNullable<MetaProviderModel["thinkingLevelMap"]> = {
405
+ off: null,
406
+ minimal: variants.minimal?.reasoningEffort ?? "minimal",
407
+ low: variants.low?.reasoningEffort ?? "low",
408
+ medium: variants.medium?.reasoningEffort ?? "medium",
409
+ high: variants.high?.reasoningEffort ?? "high",
410
+ xhigh: variants.xhigh?.reasoningEffort ?? "xhigh",
411
+ max: null,
412
+ };
395
413
  return [
396
414
  {
397
415
  id: entry.id,
@@ -401,10 +419,7 @@ export function toProviderModels(
401
419
  input: modalitiesToInput(metadata?.modalities?.input, fallback?.input),
402
420
  cost: {
403
421
  input: numericCost(metadata?.cost?.input, fallback?.cost.input ?? 0),
404
- output: numericCost(
405
- metadata?.cost?.output,
406
- fallback?.cost.output ?? 0,
407
- ),
422
+ output: numericCost(metadata?.cost?.output, fallback?.cost.output ?? 0),
408
423
  cacheRead: numericCost(
409
424
  metadata?.cost?.cached,
410
425
  fallback?.cost.cacheRead ?? 0,
@@ -425,36 +440,146 @@ export function toProviderModels(
425
440
  });
426
441
  }
427
442
 
443
+ interface CatalogStore {
444
+ read(): Promise<ModelsStoreEntry | undefined>;
445
+ write(entry: ModelsStoreEntry): Promise<void>;
446
+ }
447
+
448
+ interface CompatibleRefreshContext {
449
+ credential?: RefreshModelsContext["credential"];
450
+ allowNetwork: boolean;
451
+ signal?: AbortSignal;
452
+ // Pi 0.83 catalog persistence API.
453
+ store?: CatalogStore;
454
+ // Pi 0.84 generation-checked catalog persistence API.
455
+ stored?: Readonly<ModelsStoreEntry>;
456
+ publish?(publication: { persist?: ModelsStoreEntry | null }): Promise<boolean>;
457
+ }
458
+
459
+ function providerModelsFromStore(
460
+ entry: Readonly<ModelsStoreEntry> | undefined,
461
+ ): MetaProviderModel[] {
462
+ return (entry?.models ?? []).flatMap((model: Model<Api>) => {
463
+ if (model.provider !== META_PROVIDER_ID || model.api !== "openai-responses")
464
+ return [];
465
+ return [
466
+ {
467
+ id: model.id,
468
+ name: model.name,
469
+ api: model.api,
470
+ baseUrl: model.baseUrl,
471
+ reasoning: model.reasoning,
472
+ thinkingLevelMap: model.thinkingLevelMap,
473
+ input: model.input as MetaProviderModel["input"],
474
+ cost: model.cost,
475
+ contextWindow: model.contextWindow,
476
+ maxTokens: model.maxTokens,
477
+ headers: model.headers,
478
+ compat: model.compat as MetaProviderModel["compat"],
479
+ },
480
+ ];
481
+ });
482
+ }
483
+
484
+ function modelsForStore(
485
+ models: MetaProviderModel[],
486
+ ): Model<"openai-responses">[] {
487
+ return models.map((model) => ({
488
+ ...model,
489
+ api: "openai-responses",
490
+ provider: META_PROVIDER_ID,
491
+ baseUrl: model.baseUrl ?? META_API_BASE_URL,
492
+ input: model.input as Model<"openai-responses">["input"],
493
+ compat: model.compat as Model<"openai-responses">["compat"],
494
+ }));
495
+ }
496
+
497
+ async function cachedMetaModels(
498
+ context: CompatibleRefreshContext,
499
+ ): Promise<MetaProviderModel[]> {
500
+ try {
501
+ const stored = context.stored ?? (await context.store?.read());
502
+ return providerModelsFromStore(stored);
503
+ } catch {
504
+ // Catalog persistence is best-effort; bundled fallbacks remain available.
505
+ return [];
506
+ }
507
+ }
508
+
509
+ async function persistMetaModels(
510
+ context: CompatibleRefreshContext,
511
+ entry: ModelsStoreEntry,
512
+ ): Promise<void> {
513
+ if (context.publish) {
514
+ await context.publish({ persist: entry });
515
+ return;
516
+ }
517
+ await context.store?.write(entry);
518
+ }
519
+
428
520
  export async function refreshMetaModels(
429
521
  context: RefreshModelsContext,
430
522
  fetchImpl: Fetch = fetch,
431
523
  ): Promise<MetaProviderModel[]> {
432
- if (!context.allowNetwork || context.signal?.aborted)
433
- return [...FALLBACK_MODELS];
524
+ const compatibleContext = context as unknown as CompatibleRefreshContext;
525
+ if (!context.allowNetwork || context.signal?.aborted) {
526
+ const cached = await cachedMetaModels(compatibleContext);
527
+ return cached.length > 0 ? cached : [...FALLBACK_MODELS];
528
+ }
434
529
  const apiKey =
435
530
  context.credential?.type === "oauth"
436
531
  ? context.credential.access
437
532
  : context.credential?.type === "api_key"
438
533
  ? context.credential.key
439
534
  : undefined;
440
- if (!apiKey) return [...FALLBACK_MODELS];
535
+ if (!apiKey) {
536
+ const cached = await cachedMetaModels(compatibleContext);
537
+ return cached.length > 0 ? cached : [...FALLBACK_MODELS];
538
+ }
441
539
 
442
- const response = await fetchImpl(META_MODEL_CATALOG_URL, {
443
- headers: {
444
- Accept: "application/json",
445
- Authorization: `Bearer ${apiKey}`,
446
- "x-api-version": "1.0.0",
447
- },
448
- signal: context.signal,
449
- });
450
- const body = (await responseBody(response)) as CatalogResponse &
451
- Record<string, unknown>;
452
- if (!response.ok) {
453
- throw new Error(
454
- `Meta model catalog failed (HTTP ${response.status})${errorDetail(body) ? `: ${errorDetail(body)}` : ""}`,
455
- );
540
+ try {
541
+ const response = await fetchImpl(META_MODEL_CATALOG_URL, {
542
+ headers: {
543
+ Accept: "application/json",
544
+ Authorization: `Bearer ${apiKey}`,
545
+ "x-api-version": "1.0.0",
546
+ },
547
+ signal: context.signal,
548
+ });
549
+ const body = (await responseBody(response)) as CatalogResponse &
550
+ Record<string, unknown>;
551
+ if (!response.ok) {
552
+ throw new Error(
553
+ `Meta model catalog failed (HTTP ${response.status})${errorDetail(body) ? `: ${errorDetail(body)}` : ""}`,
554
+ );
555
+ }
556
+ const models = toProviderModels(body);
557
+ if (models.length === 0) {
558
+ const cached = await cachedMetaModels(compatibleContext);
559
+ return cached.length > 0 ? cached : [...FALLBACK_MODELS];
560
+ }
561
+ if (!context.signal?.aborted) {
562
+ try {
563
+ await persistMetaModels(compatibleContext, {
564
+ models: modelsForStore(models),
565
+ checkedAt: Date.now(),
566
+ });
567
+ } catch {
568
+ // Keep the fresh catalog usable even if persistence fails.
569
+ }
570
+ }
571
+ return models;
572
+ } catch (error) {
573
+ if (context.signal?.aborted) throw error;
574
+ const cached = await cachedMetaModels(compatibleContext);
575
+ return cached.length > 0 ? cached : [...FALLBACK_MODELS];
456
576
  }
457
- return toProviderModels(body);
577
+ }
578
+
579
+ export function metaFallbackCost(
580
+ modelId: string,
581
+ ): MetaProviderModel["cost"] | undefined {
582
+ return FALLBACK_MODELS.find((model) => model.id === modelId)?.cost;
458
583
  }
459
584
 
460
585
  export function createMetaProviderConfig(): ProviderConfig {
@@ -462,6 +587,7 @@ export function createMetaProviderConfig(): ProviderConfig {
462
587
  name: "Meta Model API",
463
588
  baseUrl: META_API_BASE_URL,
464
589
  api: "openai-responses",
590
+ apiKey: "$META_API_KEY",
465
591
  models: [...FALLBACK_MODELS],
466
592
  refreshModels: refreshMetaModels,
467
593
  oauth: {
@@ -474,5 +600,18 @@ export function createMetaProviderConfig(): ProviderConfig {
474
600
  }
475
601
 
476
602
  export default function metaOAuthProvider(pi: ExtensionAPI): void {
603
+ // Allow MODEL_API_KEY as fallback for API-key users — shim to META_API_KEY so $META_API_KEY interpolation works.
604
+ if (
605
+ process.env[META_ENV_VAR] === undefined &&
606
+ process.env["MODEL_API_KEY"] !== undefined
607
+ ) {
608
+ process.env[META_ENV_VAR] = process.env["MODEL_API_KEY"];
609
+ }
610
+ if (
611
+ process.env["MODEL_API_KEY"] === undefined &&
612
+ process.env[META_ENV_VAR] !== undefined
613
+ ) {
614
+ process.env["MODEL_API_KEY"] = process.env[META_ENV_VAR];
615
+ }
477
616
  pi.registerProvider(META_PROVIDER_ID, createMetaProviderConfig());
478
617
  }
@@ -0,0 +1,107 @@
1
+ const DEFAULT_ASR_ENDPOINT =
2
+ "wss://shortwave.facebook.com/voyager/v1/asr/duplex";
3
+ export const DEFAULT_ASR_MODEL = "prod_tbh";
4
+
5
+ export function pcmAudioLevel(chunk: Buffer): number {
6
+ if (chunk.length < 2) return 0;
7
+ let sumSquares = 0;
8
+ let samples = 0;
9
+ for (let offset = 0; offset + 1 < chunk.length; offset += 2) {
10
+ const normalized = chunk.readInt16LE(offset) / 32_768;
11
+ sumSquares += normalized * normalized;
12
+ samples += 1;
13
+ }
14
+ if (samples === 0) return 0;
15
+ const rms = Math.sqrt(sumSquares / samples);
16
+ return Math.min(1, Math.log10(1 + rms * 90));
17
+ }
18
+
19
+ function hypothesisWords(value: string): string[] {
20
+ return value
21
+ .trim()
22
+ .split(/\s+/)
23
+ .map((word) => word.toLocaleLowerCase().replace(/[^\p{L}\p{N}'’]/gu, ""))
24
+ .filter(Boolean);
25
+ }
26
+
27
+ export function chooseTranscript(previous: string, incoming: string): string {
28
+ const next = incoming.trim();
29
+ if (!next) return previous;
30
+ if (!previous.trim()) return next;
31
+
32
+ const previousWords = hypothesisWords(previous);
33
+ const nextWords = hypothesisWords(next);
34
+ const sharedLength = Math.min(previousWords.length, nextWords.length);
35
+ let commonPrefix = 0;
36
+ while (
37
+ commonPrefix < sharedLength &&
38
+ previousWords[commonPrefix] === nextWords[commonPrefix]
39
+ ) {
40
+ commonPrefix += 1;
41
+ }
42
+
43
+ if (commonPrefix === sharedLength || commonPrefix >= 2) {
44
+ const isRegression =
45
+ nextWords.length < previousWords.length &&
46
+ nextWords.every((word, index) => previousWords[index] === word);
47
+ return isRegression ? previous : next;
48
+ }
49
+ return next.length >= previous.length ? next : previous;
50
+ }
51
+
52
+ export function formatAuthorization(apiKey: string): string {
53
+ return apiKey.startsWith("OAuth ") ? apiKey : `OAuth ${apiKey}`;
54
+ }
55
+
56
+ export function asrEndpoint(sessionId: string): string {
57
+ const configured =
58
+ process.env.PI_META_VOICE_ASR_ENDPOINT ??
59
+ process.env.MUSE_VOICE_ASR_ENDPOINT ??
60
+ DEFAULT_ASR_ENDPOINT;
61
+ let endpoint: URL;
62
+ try {
63
+ endpoint = new URL(configured);
64
+ } catch {
65
+ throw new Error("Voice ASR endpoint is not a valid URL");
66
+ }
67
+ const localInsecureEndpoint =
68
+ endpoint.protocol === "ws:" &&
69
+ (endpoint.hostname === "localhost" || endpoint.hostname === "127.0.0.1");
70
+ if (endpoint.protocol !== "wss:" && !localInsecureEndpoint) {
71
+ throw new Error(
72
+ "Voice ASR endpoint must use wss:// (or ws://localhost for testing)",
73
+ );
74
+ }
75
+ endpoint.searchParams.set("sessionId", sessionId);
76
+ return endpoint.toString();
77
+ }
78
+
79
+ export async function socketMessageText(
80
+ data: unknown,
81
+ ): Promise<string | undefined> {
82
+ if (typeof data === "string") return data;
83
+ if (data instanceof Blob) return data.text();
84
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
85
+ if (ArrayBuffer.isView(data)) {
86
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString(
87
+ "utf8",
88
+ );
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ export function parseJsonObject(text: string): Record<string, unknown> | undefined {
94
+ try {
95
+ const value = JSON.parse(text) as unknown;
96
+ if (value && typeof value === "object" && !Array.isArray(value)) {
97
+ return value as Record<string, unknown>;
98
+ }
99
+ } catch {
100
+ // Ignore non-JSON ASR messages.
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ export function isNormalSocketClose(code: number): boolean {
106
+ return code === 1000 || code === 1005;
107
+ }
@@ -0,0 +1,243 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ renameSync,
6
+ statSync,
7
+ unlinkSync,
8
+ } from "node:fs";
9
+ import { homedir, platform } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const AGENT_DIR = join(homedir(), ".pi/agent");
14
+ const HELPER_DIR = dirname(fileURLToPath(import.meta.url));
15
+ const HELPER_SOURCE = join(HELPER_DIR, "macos-audio.swift");
16
+ const HELPER_PLIST = join(HELPER_DIR, "Info.plist");
17
+ const HELPER_ENTITLEMENTS = join(HELPER_DIR, "Entitlements.plist");
18
+ const HELPER_BINARY = join(AGENT_DIR, "bin/pi-meta-oauth-voice-v1");
19
+ const WINDOWS_HELPER_SOURCE = join(HELPER_DIR, "windows-audio.cs");
20
+ const WINDOWS_HELPER_PS1 = join(HELPER_DIR, "windows-audio.ps1");
21
+ const WINDOWS_HELPER_BINARY = join(AGENT_DIR, "bin/pi-meta-oauth-voice-v1.exe");
22
+
23
+ export function isMacOS(): boolean {
24
+ return platform() === "darwin";
25
+ }
26
+
27
+ export function isWindows(): boolean {
28
+ return platform() === "win32";
29
+ }
30
+
31
+ export function isSupportedPlatform(): boolean {
32
+ return isMacOS() || isWindows();
33
+ }
34
+
35
+ export function supportedPlatformLabel(): string {
36
+ if (isMacOS()) return "macOS";
37
+ if (isWindows()) return "Windows";
38
+ return `${platform()}`;
39
+ }
40
+
41
+ async function runProcess(command: string, args: string[]): Promise<void> {
42
+ await new Promise<void>((resolve, reject) => {
43
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
44
+ let stdout = "";
45
+ let stderr = "";
46
+ child.stdout?.on("data", (chunk: Buffer) => {
47
+ stdout = `${stdout}${chunk.toString("utf8")}`.slice(-8_000);
48
+ });
49
+ child.stderr?.on("data", (chunk: Buffer) => {
50
+ stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8_000);
51
+ });
52
+ child.once("error", reject);
53
+ child.once("close", (code) => {
54
+ if (code === 0) {
55
+ resolve();
56
+ return;
57
+ }
58
+ reject(
59
+ new Error(
60
+ (
61
+ stderr ||
62
+ stdout ||
63
+ `${command} exited with code ${code ?? "unknown"}`
64
+ ).trim(),
65
+ ),
66
+ );
67
+ });
68
+ });
69
+ }
70
+
71
+ function helperNeedsBuild(): boolean {
72
+ if (!existsSync(HELPER_BINARY)) return true;
73
+ const binaryTime = statSync(HELPER_BINARY).mtimeMs;
74
+ return [HELPER_SOURCE, HELPER_PLIST, HELPER_ENTITLEMENTS].some(
75
+ (path) => !existsSync(path) || statSync(path).mtimeMs > binaryTime,
76
+ );
77
+ }
78
+
79
+ function windowsHelperNeedsBuild(): boolean {
80
+ if (!existsSync(WINDOWS_HELPER_BINARY)) return true;
81
+ if (!existsSync(WINDOWS_HELPER_SOURCE)) return false;
82
+ return statSync(WINDOWS_HELPER_SOURCE).mtimeMs > statSync(WINDOWS_HELPER_BINARY).mtimeMs;
83
+ }
84
+
85
+ function removeIfExists(path: string): void {
86
+ try {
87
+ unlinkSync(path);
88
+ } catch {
89
+ // Staging or previous binary may already be gone.
90
+ }
91
+ }
92
+
93
+ async function ensureMacOSHelper(): Promise<string> {
94
+ if (!isMacOS()) {
95
+ throw new Error(
96
+ "Muse-style voice input is currently available only on macOS and Windows",
97
+ );
98
+ }
99
+ if (
100
+ !existsSync(HELPER_SOURCE) ||
101
+ !existsSync(HELPER_PLIST) ||
102
+ !existsSync(HELPER_ENTITLEMENTS)
103
+ ) {
104
+ throw new Error("The macOS microphone helper sources are incomplete");
105
+ }
106
+ if (!helperNeedsBuild()) return HELPER_BINARY;
107
+
108
+ mkdirSync(join(AGENT_DIR, "bin"), { recursive: true });
109
+ const staging = `${HELPER_BINARY}.building`;
110
+ removeIfExists(staging);
111
+ try {
112
+ await runProcess("/usr/bin/xcrun", [
113
+ "swiftc",
114
+ HELPER_SOURCE,
115
+ "-o",
116
+ staging,
117
+ "-framework",
118
+ "AVFoundation",
119
+ "-Xlinker",
120
+ "-sectcreate",
121
+ "-Xlinker",
122
+ "__TEXT",
123
+ "-Xlinker",
124
+ "__info_plist",
125
+ "-Xlinker",
126
+ HELPER_PLIST,
127
+ ]);
128
+ await runProcess("/usr/bin/codesign", [
129
+ "--force",
130
+ "--sign",
131
+ "-",
132
+ "--entitlements",
133
+ HELPER_ENTITLEMENTS,
134
+ staging,
135
+ ]);
136
+ renameSync(staging, HELPER_BINARY);
137
+ } catch (error) {
138
+ removeIfExists(staging);
139
+ throw error;
140
+ }
141
+ return HELPER_BINARY;
142
+ }
143
+
144
+ async function tryCompileWindowsHelper(): Promise<boolean> {
145
+ if (!existsSync(WINDOWS_HELPER_SOURCE)) return false;
146
+ mkdirSync(join(AGENT_DIR, "bin"), { recursive: true });
147
+
148
+ const candidates: string[] = [
149
+ "csc",
150
+ "csc.exe",
151
+ "C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe",
152
+ "C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe",
153
+ ];
154
+ const staging = `${WINDOWS_HELPER_BINARY}.building`;
155
+ removeIfExists(staging);
156
+ for (const c of candidates) {
157
+ try {
158
+ await runProcess(c, [
159
+ "/nologo",
160
+ "/target:exe",
161
+ `/out:${staging}`,
162
+ WINDOWS_HELPER_SOURCE,
163
+ ]);
164
+ if (existsSync(staging)) {
165
+ renameSync(staging, WINDOWS_HELPER_BINARY);
166
+ return true;
167
+ }
168
+ } catch {
169
+ removeIfExists(staging);
170
+ }
171
+ }
172
+ return false;
173
+ }
174
+
175
+ function findPowerShellCommand(): string {
176
+ if (existsSync("C:\\Program Files\\PowerShell\\7\\pwsh.exe"))
177
+ return "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
178
+ if (existsSync("C:\\Program Files\\PowerShell\\7\\pwsh")) return "pwsh";
179
+ if (
180
+ existsSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
181
+ )
182
+ return "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
183
+ return "powershell.exe";
184
+ }
185
+
186
+ async function ensureWindowsHelper(): Promise<{
187
+ command: string;
188
+ args: string[];
189
+ }> {
190
+ if (!isWindows()) {
191
+ throw new Error(
192
+ "Muse-style voice input is currently available only on macOS and Windows",
193
+ );
194
+ }
195
+ if (!existsSync(WINDOWS_HELPER_SOURCE) && !existsSync(WINDOWS_HELPER_PS1)) {
196
+ throw new Error("The Windows microphone helper sources are incomplete");
197
+ }
198
+
199
+ if (existsSync(WINDOWS_HELPER_BINARY) && !windowsHelperNeedsBuild()) {
200
+ return { command: WINDOWS_HELPER_BINARY, args: [] };
201
+ }
202
+
203
+ if (existsSync(WINDOWS_HELPER_SOURCE)) {
204
+ const compiled = await tryCompileWindowsHelper();
205
+ if (compiled && existsSync(WINDOWS_HELPER_BINARY)) {
206
+ return { command: WINDOWS_HELPER_BINARY, args: [] };
207
+ }
208
+ }
209
+
210
+ if (existsSync(WINDOWS_HELPER_PS1)) {
211
+ const ps = findPowerShellCommand();
212
+ return {
213
+ command: ps,
214
+ args: [
215
+ "-NoProfile",
216
+ "-ExecutionPolicy",
217
+ "Bypass",
218
+ "-File",
219
+ WINDOWS_HELPER_PS1,
220
+ ],
221
+ };
222
+ }
223
+
224
+ throw new Error(
225
+ "Windows voice helper could not be prepared — no compiled binary and no PowerShell fallback found",
226
+ );
227
+ }
228
+
229
+ export async function ensureHelper(): Promise<{
230
+ command: string;
231
+ args: string[];
232
+ }> {
233
+ if (isMacOS()) {
234
+ const bin = await ensureMacOSHelper();
235
+ return { command: bin, args: [] };
236
+ }
237
+ if (isWindows()) {
238
+ return ensureWindowsHelper();
239
+ }
240
+ throw new Error(
241
+ `Muse-style voice input is currently available only on macOS and Windows (current: ${supportedPlatformLabel()})`,
242
+ );
243
+ }