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.
- package/README.md +40 -3
- package/extensions/media/files.ts +131 -0
- package/extensions/media/limits.ts +12 -0
- package/extensions/media/mime.ts +178 -0
- package/extensions/media/payload.ts +303 -0
- package/extensions/media/responses.ts +186 -0
- package/extensions/media-output.ts +127 -0
- package/extensions/media.ts +352 -1245
- package/extensions/meta.ts +181 -42
- package/extensions/voice/asr.ts +107 -0
- package/extensions/voice/helpers.ts +243 -0
- package/extensions/voice/macos-audio.swift +6 -16
- package/extensions/voice.ts +27 -333
- package/package.json +1 -1
package/extensions/meta.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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)
|
|
535
|
+
if (!apiKey) {
|
|
536
|
+
const cached = await cachedMetaModels(compatibleContext);
|
|
537
|
+
return cached.length > 0 ? cached : [...FALLBACK_MODELS];
|
|
538
|
+
}
|
|
441
539
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
+
}
|