pi-meta-oauth 0.4.1 → 0.4.3

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 CHANGED
@@ -3,6 +3,7 @@
3
3
  # pi-meta-oauth
4
4
 
5
5
  <!-- markdownlint-disable-next-line MD013 -->
6
+ ![X (formerly Twitter) Follow](https://img.shields.io/twitter/follow/blockedpaths?style=flat&link=https%3A%2F%2Fx.com%2FBlockedPaths)
6
7
  [![npm version](https://img.shields.io/npm/v/pi-meta-oauth)](https://www.npmjs.com/package/pi-meta-oauth) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![GitHub stars](https://img.shields.io/github/stars/BlockedPath/pi-meta-oauth?style=social)](https://github.com/BlockedPath/pi-meta-oauth/stargazers) [![Last Commit](https://img.shields.io/github/last-commit/BlockedPath/pi-meta-oauth)](https://github.com/BlockedPath/pi-meta-oauth/commits/main) [![Issues](https://img.shields.io/github/issues/BlockedPath/pi-meta-oauth)](https://github.com/BlockedPath/pi-meta-oauth/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/BlockedPath/pi-meta-oauth/pulls) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://www.conventionalcommits.org/en/v1.0.0/) [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue)](https://www.typescriptlang.org/) [![CI](https://github.com/BlockedPath/pi-meta-oauth/actions/workflows/ci.yml/badge.svg)](https://github.com/BlockedPath/pi-meta-oauth/actions/workflows/ci.yml) [![Pi compatible](https://img.shields.io/badge/pi-Compatible-blueviolet)](https://pi.dev)
7
8
 
8
9
  Meta Model API OAuth and Muse-style voice input for [pi](https://pi.dev).
@@ -65,6 +66,21 @@ Optional overrides:
65
66
  - `PI_META_VOICE_ASR_ENDPOINT`
66
67
  - `PI_META_VOICE_ASR_MODEL`
67
68
 
69
+ ## Media tools
70
+
71
+ Text-only Pi models can delegate media inspection to Muse Spark without switching models:
72
+
73
+ - `meta_analyze_file` inspects one or more images, PDFs, audio files, or videos. Use ordered `sources` with optional labels for comparisons such as before and after screenshots.
74
+ - `meta_describe_video` analyzes MP4 visuals and embedded audio.
75
+ - `meta_transcribe_audio` transcribes MP3 or WAV speech.
76
+ - `meta_upload_file` uploads a large or reusable file and returns a Meta `file_id`.
77
+
78
+ Give each analysis tool a task-specific prompt that asks for the evidence the calling model needs next. Analysis tools default to an 8,000-token Muse generation budget and accept `max_output_tokens` from 4,000 to 32,000. Inline tool output defaults to 20,000 characters and accepts `max_chars` up to 50,000. If a result is truncated, the complete text is saved to a temporary file so the agent can continue with Pi's `read` tool using `offset` and `limit`.
79
+
80
+ Automatic uploads made during analysis expire after 24 hours. Explicit `meta_upload_file` uploads expire after seven days by default; set `expires_after_seconds` to choose another supported duration or `retain: true` to keep a file without expiry.
81
+
82
+ Media tool failures are reported as failed Pi tool calls, allowing the calling model to retry or report the blocker. Media analysis is model-generated observation, so verify consequential details when another source is available.
83
+
68
84
  ## Models
69
85
 
70
86
  Fallback models use a 1,048,576-token context window, up to 256K output tokens, image input, and reasoning levels `minimal`, `low`, `medium`, `high`, and `xhigh`.
@@ -81,6 +97,42 @@ To scope Pi's model picker to Meta models:
81
97
  { "enabledModels": ["meta/*"] }
82
98
  ```
83
99
 
100
+ ### Making context windows visible to external tools
101
+
102
+ Pi knows the Meta context window at runtime, but it does **not** persist
103
+ catalogs for extension-registered providers to `~/.pi/agent/models-store.json`.
104
+ Anything reading Pi's session data from outside the process therefore sees no
105
+ window for `meta` models and can only show an absolute token count.
106
+
107
+ This affects usage tooling such as
108
+ [herdr-agent-usage](https://github.com/senna-lang/herdr-agent-usage), whose
109
+ sidebar shows `⛁ 24k` instead of `⛁ 2% (24k)` for Muse panes.
110
+
111
+ To publish the window, add an override to `~/.pi/agent/models.json`:
112
+
113
+ ```json
114
+ {
115
+ "providers": {
116
+ "meta": {
117
+ "modelOverrides": {
118
+ "muse-spark-1.2": { "contextWindow": 1007997 },
119
+ "muse-spark-1.2-contributor": { "contextWindow": 1007997 }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ `1,007,997` is the effective limit reported for those two models by a cached
127
+ Muse Code 0.1.0/R708.1 catalog snapshot observed on 2026-08-06. That snapshot
128
+ did not include `muse-spark-1.1`, so no effective-limit override is claimed for
129
+ 1.1 here. The bundled fallback in `extensions/meta.ts` uses the nominal
130
+ `1048576`; use the lower value only when you want percentages to match that
131
+ specific Muse snapshot.
132
+
133
+ This only adds metadata — the provider itself is still registered by the
134
+ extension, and `pi --list-models meta` is unchanged.
135
+
84
136
  ## Verify
85
137
 
86
138
  ```bash
@@ -0,0 +1,123 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const DEFAULT_MEDIA_MAX_CHARS = 20_000;
7
+ export const MAX_MEDIA_MAX_CHARS = 50_000;
8
+ export const DEFAULT_MEDIA_MAX_OUTPUT_TOKENS = 8_000;
9
+ export const MIN_MEDIA_MAX_OUTPUT_TOKENS = 4_000;
10
+ export const MAX_MEDIA_MAX_OUTPUT_TOKENS = 32_000;
11
+ const MAX_INLINE_BYTES = 50 * 1024;
12
+ const MAX_INLINE_LINES = 2_000;
13
+
14
+ export interface MediaOutputDetails {
15
+ path?: string;
16
+ chars: number;
17
+ lines: number;
18
+ truncated: boolean;
19
+ incomplete: boolean;
20
+ }
21
+
22
+ function clampInteger(value: number | undefined, fallback: number, minimum: number, maximum: number): number {
23
+ if (value === undefined) return fallback;
24
+ return Math.max(minimum, Math.min(maximum, Math.trunc(value)));
25
+ }
26
+
27
+ export function mediaMaxChars(value: number | undefined): number {
28
+ return clampInteger(value, DEFAULT_MEDIA_MAX_CHARS, 1_000, MAX_MEDIA_MAX_CHARS);
29
+ }
30
+
31
+ export function mediaMaxOutputTokens(value: number | undefined): number {
32
+ return clampInteger(
33
+ value,
34
+ DEFAULT_MEDIA_MAX_OUTPUT_TOKENS,
35
+ MIN_MEDIA_MAX_OUTPUT_TOKENS,
36
+ MAX_MEDIA_MAX_OUTPUT_TOKENS,
37
+ );
38
+ }
39
+
40
+ function inlineSlice(text: string, maxChars: number): string {
41
+ let slice = text.split("\n").slice(0, MAX_INLINE_LINES).join("\n").slice(0, maxChars);
42
+ if (Buffer.byteLength(slice, "utf8") <= MAX_INLINE_BYTES) return slice;
43
+ const bytes = Buffer.from(slice, "utf8").subarray(0, MAX_INLINE_BYTES);
44
+ slice = new TextDecoder().decode(bytes);
45
+ return slice.endsWith("\uFFFD") ? slice.slice(0, -1) : slice;
46
+ }
47
+
48
+ function responseReason(response: unknown): string | undefined {
49
+ if (!response || typeof response !== "object") return undefined;
50
+ const record = response as Record<string, unknown>;
51
+ const details = record.incomplete_details;
52
+ if (details && typeof details === "object") {
53
+ const reason = (details as Record<string, unknown>).reason;
54
+ if (typeof reason === "string") return reason;
55
+ }
56
+ for (const key of ["finish_reason", "stop_reason"]) {
57
+ if (typeof record[key] === "string") return record[key] as string;
58
+ }
59
+ return record.status === "incomplete" ? "incomplete" : undefined;
60
+ }
61
+
62
+ export function responseHitOutputLimit(response: unknown): boolean {
63
+ const reason = responseReason(response)?.toLowerCase();
64
+ return reason === "max_output_tokens" || reason === "length" || reason === "max_tokens";
65
+ }
66
+
67
+ function outputFilePath(identity: string, text: string): string {
68
+ const hash = createHash("sha256").update(`${identity}\0${text.length}\0${text.slice(0, 1024)}`).digest("hex").slice(0, 16);
69
+ return join(tmpdir(), `pi-meta-media-${hash}.txt`);
70
+ }
71
+
72
+ async function saveFullOutput(identity: string, text: string): Promise<string | undefined> {
73
+ const basePath = outputFilePath(identity, text);
74
+ try {
75
+ await mkdir(tmpdir(), { recursive: true });
76
+ try {
77
+ await writeFile(basePath, text, { encoding: "utf8", flag: "wx", mode: 0o600 });
78
+ return basePath;
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
81
+ const uniquePath = `${basePath}.${randomBytes(4).toString("hex")}`;
82
+ await writeFile(uniquePath, text, { encoding: "utf8", flag: "wx", mode: 0o600 });
83
+ return uniquePath;
84
+ }
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ export async function prepareMediaOutput(options: {
91
+ text: string;
92
+ identity: string;
93
+ maxChars?: number;
94
+ response?: unknown;
95
+ }): Promise<{ text: string; details: MediaOutputDetails }> {
96
+ const { text, identity, response } = options;
97
+ const slice = inlineSlice(text, mediaMaxChars(options.maxChars));
98
+ const truncated = slice.length < text.length;
99
+ const lines = text.length === 0 ? 0 : text.split("\n").length;
100
+ const incomplete = responseHitOutputLimit(response);
101
+ const savedPath = truncated ? await saveFullOutput(identity, text) : undefined;
102
+ const notes: string[] = [];
103
+ if (truncated) {
104
+ notes.push(
105
+ savedPath
106
+ ? `content truncated at ${slice.length} of ${text.length} characters; full content saved to: ${savedPath} (${lines} lines). Use the read tool with offset/limit to continue (offset is a 1-based line number).`
107
+ : `content truncated at ${slice.length} of ${text.length} characters; the full content could not be saved, so continuation is unavailable.`,
108
+ );
109
+ }
110
+ if (incomplete) {
111
+ notes.push("Muse exhausted max_output_tokens, so the analysis may be incomplete. Retry with a larger budget or a narrower prompt.");
112
+ }
113
+ return {
114
+ text: `${slice}${notes.length > 0 ? `\n\n[${notes.join(" ")}]` : ""}`,
115
+ details: {
116
+ ...(savedPath ? { path: savedPath } : {}),
117
+ chars: text.length,
118
+ lines,
119
+ truncated,
120
+ incomplete,
121
+ },
122
+ };
123
+ }
@@ -13,6 +13,15 @@ import {
13
13
  import { existsSync, statSync, readFileSync } from "node:fs";
14
14
  import { basename, extname, resolve } from "node:path";
15
15
  import { readFile } from "node:fs/promises";
16
+ import {
17
+ DEFAULT_MEDIA_MAX_CHARS,
18
+ DEFAULT_MEDIA_MAX_OUTPUT_TOKENS,
19
+ MAX_MEDIA_MAX_CHARS,
20
+ MAX_MEDIA_MAX_OUTPUT_TOKENS,
21
+ MIN_MEDIA_MAX_OUTPUT_TOKENS,
22
+ mediaMaxOutputTokens,
23
+ prepareMediaOutput,
24
+ } from "./media-output.ts";
16
25
 
17
26
  // ---------------------------------------------------------------------------
18
27
  // Meta Files API + Responses API
@@ -34,6 +43,11 @@ const FILES_API_LIMIT_BYTES = 1_073_741_824;
34
43
  // "payload_too_large … with `store=true`". Keep inline only for small files
35
44
  // and force Files API or `store:false` above this threshold.
36
45
  const STORE_SAFE_INLINE_BYTES = 15_000_000;
46
+ const AUTOMATIC_UPLOAD_EXPIRY_SECONDS = 24 * 60 * 60;
47
+ const EXPLICIT_UPLOAD_EXPIRY_SECONDS = 7 * 24 * 60 * 60;
48
+ const MIN_UPLOAD_EXPIRY_SECONDS = 3_600;
49
+ const MAX_UPLOAD_EXPIRY_SECONDS = 2_592_000;
50
+ const MAX_ANALYSIS_SOURCES = 50;
37
51
 
38
52
  type SupportedMime =
39
53
  | "video/mp4"
@@ -613,6 +627,48 @@ function dataUrlForFile(path: string): {
613
627
  };
614
628
  }
615
629
 
630
+ type AnalysisSource = { source: string; label?: string };
631
+
632
+ const automaticExpiry = () => ({
633
+ anchor: "created_at" as const,
634
+ seconds: AUTOMATIC_UPLOAD_EXPIRY_SECONDS,
635
+ });
636
+
637
+ function mediaToolError(tool: string, error: unknown): never {
638
+ if (error instanceof Error && error.name === "AbortError") throw error;
639
+ const message = error instanceof Error ? error.message : String(error);
640
+ throw new Error(`${tool} failed: ${message}`, { cause: error });
641
+ }
642
+
643
+ async function resolveGenericMediaSource(
644
+ apiKey: string,
645
+ cwd: string,
646
+ source: string,
647
+ ): Promise<ResponsesContentBlock> {
648
+ if (/^file-[a-zA-Z0-9_-]+$/.test(source)) {
649
+ return { type: "input_file", file_id: source };
650
+ }
651
+ if (source.startsWith("https://") || source.startsWith("data:")) {
652
+ return mediaInputFromSource(source);
653
+ }
654
+ const absolute = resolve(cwd, source.replace(/^@/, ""));
655
+ if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
656
+ const stat = statSync(absolute);
657
+ if (stat.size > FILES_API_LIMIT_BYTES) {
658
+ throw new Error(`File too large (1 GiB limit): ${source}`);
659
+ }
660
+ if (stat.size > STORE_SAFE_INLINE_BYTES) {
661
+ const upload = await uploadMetaFile(apiKey, absolute, automaticExpiry());
662
+ return { type: "input_file", file_id: upload.id };
663
+ }
664
+ const { dataUrl, mime } = dataUrlForFile(absolute);
665
+ return mediaInputFromSource(dataUrl, basename(absolute), mime);
666
+ }
667
+
668
+ function sourceLabel(source: AnalysisSource, index: number): string {
669
+ return source.label?.trim() || `Source ${index + 1}`;
670
+ }
671
+
616
672
  // ---------------------------------------------------------------------------
617
673
  // Before-provider-request rewrite (Option B)
618
674
  // Makes @video.mp4 / @audio.wav / @doc.pdf work transparently even though
@@ -770,6 +826,8 @@ async function uploadInlineMedia(
770
826
  const form = new FormData();
771
827
  form.append("file", blob, filename);
772
828
  form.append("purpose", "user_data");
829
+ form.append("expires_after[anchor]", "created_at");
830
+ form.append("expires_after[seconds]", String(AUTOMATIC_UPLOAD_EXPIRY_SECONDS));
773
831
  const res = await fetch(META_FILES_URL, {
774
832
  method: "POST",
775
833
  headers: { Authorization: `Bearer ${apiKey}` },
@@ -960,6 +1018,11 @@ export default function metaMedia(pi: ExtensionAPI): void {
960
1018
  // so the before_provider_request hook can rewrite them.
961
1019
  pi.on("input", async (event, ctx) => {
962
1020
  if (!shouldEnableNativeRewrite()) return undefined;
1021
+ // Only Meta's request hook knows how to rewrite these pseudo-images into
1022
+ // typed video/audio/file blocks. Other providers (notably Codex) reject a
1023
+ // video/mp4 ImageContent before the Meta tools can be called, so leave the
1024
+ // path as text and let the active model invoke the direct Meta media tool.
1025
+ if (ctx.model?.provider !== META_PROVIDER_ID) return undefined;
963
1026
  const text = event.text ?? "";
964
1027
  // Match @/path/to/file.mp4, @./file.wav, /abs/file.pdf, ./file.mp3 — conservative.
965
1028
  const filePattern =
@@ -1045,133 +1108,77 @@ export default function metaMedia(pi: ExtensionAPI): void {
1045
1108
  name: "meta_describe_video",
1046
1109
  label: "Meta Video Describe",
1047
1110
  description:
1048
- "Analyze an MP4 video with Muse Spark — summarizes visuals and transcribes embedded speech in one call. Provide a local path, public https URL, or existing file_id (from Files API). Handles Files API upload for large files and inline data URLs for small files. Use for: describe what happens in a clip, answer questions about footage, extract structured details. Video without audio (screen recordings) is also valid. Supports 50MB inline, 1GiB via Files API.",
1049
- parameters: Type.Object({
1050
- path: Type.Optional(
1051
- Type.String({
1052
- description:
1053
- "Local .mp4/.m4v file path (relative to cwd or absolute)",
1054
- }),
1055
- ),
1056
- url: Type.Optional(
1057
- Type.String({
1058
- description:
1059
- "Public https:// URL or data:video/mp4;base64,... URL (skips upload)",
1060
- }),
1061
- ),
1062
- file_id: Type.Optional(
1063
- Type.String({
1064
- description:
1065
- "Existing Files API id like file-842549258569145 (skips upload)",
1066
- }),
1067
- ),
1068
- prompt: Type.String({
1069
- description:
1070
- "What to ask about the video (e.g. 'Describe what happens', 'Transcribe speech and list action items')",
1071
- }),
1072
- model: Type.Optional(
1073
- Type.String({ description: "Muse model id, default muse-spark-1.2" }),
1074
- ),
1075
- max_output_tokens: Type.Optional(
1076
- Type.Number({
1077
- description:
1078
- "Default 4000 — reasoning shares budget, lower values can return empty with finish_reason:length",
1079
- }),
1080
- ),
1081
- }),
1111
+ "Analyze an MP4 video with Muse Spark, including visuals and embedded speech. Accepts a local path, public HTTPS/data URL, or Meta file_id. Results are capped by max_chars; when truncated, the full text is saved to a temp file for continuation with read offset/limit.",
1112
+ promptSnippet: "Inspect video and embedded audio when the active model cannot",
1113
+ promptGuidelines: [
1114
+ "Use meta_describe_video when the task depends on an MP4 video the active model cannot inspect directly.",
1115
+ "Give meta_describe_video a task-specific prompt that asks for the exact visual, transcript, timestamp, or defect evidence needed next.",
1116
+ "When a Meta media result is truncated, continue from its saved temp file with read using offset/limit.",
1117
+ ],
1118
+ parameters: Type.Object(
1119
+ {
1120
+ path: Type.Optional(Type.String({ description: "Local .mp4/.m4v path, relative to cwd or absolute" })),
1121
+ url: Type.Optional(Type.String({ description: "Public HTTPS or data:video/mp4 URL" })),
1122
+ file_id: Type.Optional(Type.String({ description: "Existing Meta Files API id" })),
1123
+ prompt: Type.String({ description: "What evidence to extract from the video" }),
1124
+ model: Type.Optional(Type.String({ description: "Muse model id, default muse-spark-1.2" })),
1125
+ max_output_tokens: Type.Optional(Type.Integer({
1126
+ minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
1127
+ maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
1128
+ description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
1129
+ })),
1130
+ max_chars: Type.Optional(Type.Integer({
1131
+ minimum: 1_000,
1132
+ maximum: MAX_MEDIA_MAX_CHARS,
1133
+ description: `Maximum result characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
1134
+ })),
1135
+ },
1136
+ { additionalProperties: false },
1137
+ ),
1082
1138
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
1083
- const { path, url, file_id, prompt, model, max_output_tokens } =
1084
- params as {
1085
- path?: string;
1086
- url?: string;
1087
- file_id?: string;
1088
- prompt: string;
1089
- model?: string;
1090
- max_output_tokens?: number;
1091
- };
1092
- if (!path && !url && !file_id) {
1093
- return {
1094
- content: [
1095
- {
1096
- type: "text",
1097
- text: "Provide one of: path (local .mp4), url (https://...), or file_id (file-...).",
1098
- },
1099
- ],
1100
- details: { error: "missing source" },
1101
- };
1139
+ const { path, url, file_id, prompt, model, max_output_tokens, max_chars } = params as {
1140
+ path?: string; url?: string; file_id?: string; prompt: string; model?: string;
1141
+ max_output_tokens?: number; max_chars?: number;
1142
+ };
1143
+ if ([path, url, file_id].filter(Boolean).length !== 1) {
1144
+ throw new Error("meta_describe_video requires exactly one of path, url, or file_id");
1102
1145
  }
1103
1146
  try {
1104
- const apiKey = await safeGetMetaApiKey(
1105
- ctx as unknown as ExtensionContext,
1106
- );
1147
+ const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
1107
1148
  let videoBlock: ResponsesContentBlock;
1108
- if (file_id) {
1109
- videoBlock = { type: "input_file", file_id };
1110
- } else if (url) {
1111
- videoBlock = mediaInputFromSource(url, "video.mp4", "video/mp4");
1112
- } else if (path) {
1113
- const absolute = resolve(
1114
- (ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
1115
- path,
1116
- );
1117
- if (!existsSync(absolute))
1118
- throw new Error(`File not found: ${absolute}`);
1149
+ if (file_id) videoBlock = { type: "input_file", file_id };
1150
+ else if (url) videoBlock = mediaInputFromSource(url, "video.mp4", "video/mp4");
1151
+ else {
1152
+ const absolute = resolve(ctx.cwd, path as string);
1153
+ if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
1119
1154
  const stat = statSync(absolute);
1120
- if (stat.size > FILES_API_LIMIT_BYTES)
1121
- throw new Error(`File too large (1 GiB limit): ${path}`);
1122
- // Docs recommend Files API (upload → file_id) for video. Inline
1123
- // base64 also hits the ~20 MB `store=true` persistence limit
1124
- // (24 MB → HTTP 413) even when under the 50 MB inline cap.
1125
- // Use Files API for any video above the store-safe threshold
1126
- // and for all larger media; keep inline only for tiny files.
1127
- const useFilesApi = stat.size > STORE_SAFE_INLINE_BYTES;
1128
- if (useFilesApi) {
1129
- const up = await uploadMetaFile(apiKey, absolute);
1130
- videoBlock = { type: "input_file", file_id: up.id };
1155
+ if (stat.size > FILES_API_LIMIT_BYTES) throw new Error(`File too large (1 GiB limit): ${path}`);
1156
+ if (stat.size > STORE_SAFE_INLINE_BYTES) {
1157
+ const upload = await uploadMetaFile(apiKey, absolute, automaticExpiry());
1158
+ videoBlock = { type: "input_file", file_id: upload.id };
1131
1159
  } else {
1132
- const { dataUrl } = dataUrlForFile(absolute);
1133
- videoBlock = mediaInputFromSource(
1134
- dataUrl,
1135
- basename(absolute),
1136
- "video/mp4",
1137
- );
1160
+ videoBlock = mediaInputFromSource(dataUrlForFile(absolute).dataUrl, basename(absolute), "video/mp4");
1138
1161
  }
1139
- } else {
1140
- throw new Error("No source");
1141
1162
  }
1142
1163
  const selectedModel = model ?? "muse-spark-1.2";
1143
- const payload: Record<string, unknown> = {
1164
+ const { text, raw } = await callMetaResponses(apiKey, {
1144
1165
  model: selectedModel,
1145
1166
  store: false,
1146
- input: [
1147
- {
1148
- type: "message",
1149
- role: "user",
1150
- content: [
1151
- { type: "input_text", text: prompt },
1152
- videoBlock as unknown as Record<string, unknown>,
1153
- ],
1154
- },
1155
- ],
1156
- max_output_tokens: Math.max(max_output_tokens ?? 8000, 4000),
1157
- };
1158
- const { text, raw } = await callMetaResponses(apiKey, payload, signal);
1167
+ input: [{ type: "message", role: "user", content: [
1168
+ { type: "input_text", text: prompt },
1169
+ videoBlock as Record<string, unknown>,
1170
+ ] }],
1171
+ max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
1172
+ }, signal);
1173
+ const output = await prepareMediaOutput({ text, response: raw, identity: toolCallId, maxChars: max_chars });
1159
1174
  const usage = extractMetaResponseUsage(raw, selectedModel);
1160
1175
  return {
1161
- content: [{ type: "text", text }],
1162
- details: { raw, videoBlock, usage },
1176
+ content: [{ type: "text", text: output.text }],
1177
+ details: { raw, videoBlock, ...output.details },
1163
1178
  usage,
1164
1179
  };
1165
- } catch (e) {
1166
- return {
1167
- content: [
1168
- {
1169
- type: "text",
1170
- text: `Video analysis failed: ${e instanceof Error ? e.message : String(e)}`,
1171
- },
1172
- ],
1173
- details: { error: String(e) },
1174
- };
1180
+ } catch (error) {
1181
+ mediaToolError("Video analysis", error);
1175
1182
  }
1176
1183
  },
1177
1184
  });
@@ -1180,113 +1187,80 @@ export default function metaMedia(pi: ExtensionAPI): void {
1180
1187
  name: "meta_transcribe_audio",
1181
1188
  label: "Meta Audio Transcribe",
1182
1189
  description:
1183
- "Transcribe speech from a standalone audio file (wav, mp3) via Muse Spark. Provide local path, public URL, or file_id. Handles Files API for large files, inline base64 for small. Tip: always use max_output_tokens ≥4000 and stream for long clips to avoid empty length finish.",
1184
- parameters: Type.Object({
1185
- path: Type.Optional(Type.String({ description: "Local .wav/.mp3 path" })),
1186
- url: Type.Optional(
1187
- Type.String({ description: "Public https:// URL to audio" }),
1188
- ),
1189
- file_id: Type.Optional(Type.String({ description: "Existing file_id" })),
1190
- prompt: Type.Optional(
1191
- Type.String({
1192
- description:
1193
- "Custom prompt, default 'Transcribe this audio. Return only the transcript.'",
1194
- }),
1195
- ),
1196
- model: Type.Optional(
1197
- Type.String({ description: "Model id, default muse-spark-1.2" }),
1198
- ),
1199
- }),
1190
+ "Transcribe MP3 or WAV audio with Muse Spark from a local path, public URL, or Meta file_id. Results are capped by max_chars; when truncated, the full transcript is saved to a temp file for continuation with read offset/limit.",
1191
+ promptSnippet: "Transcribe audio when the active model cannot",
1192
+ promptGuidelines: [
1193
+ "Use meta_transcribe_audio when the task depends on MP3 or WAV speech the active model cannot inspect directly.",
1194
+ "When a Meta media result is truncated, continue from its saved temp file with read using offset/limit.",
1195
+ ],
1196
+ parameters: Type.Object(
1197
+ {
1198
+ path: Type.Optional(Type.String({ description: "Local .wav/.mp3 path" })),
1199
+ url: Type.Optional(Type.String({ description: "Public HTTPS or data audio URL" })),
1200
+ file_id: Type.Optional(Type.String({ description: "Existing Meta Files API id" })),
1201
+ prompt: Type.Optional(Type.String({ description: "Transcription or audio-analysis instruction" })),
1202
+ model: Type.Optional(Type.String({ description: "Muse model id, default muse-spark-1.2" })),
1203
+ max_output_tokens: Type.Optional(Type.Integer({
1204
+ minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
1205
+ maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
1206
+ description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
1207
+ })),
1208
+ max_chars: Type.Optional(Type.Integer({
1209
+ minimum: 1_000,
1210
+ maximum: MAX_MEDIA_MAX_CHARS,
1211
+ description: `Maximum transcript characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
1212
+ })),
1213
+ },
1214
+ { additionalProperties: false },
1215
+ ),
1200
1216
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
1201
- const { path, url, file_id, prompt, model } = params as {
1202
- path?: string;
1203
- url?: string;
1204
- file_id?: string;
1205
- prompt?: string;
1206
- model?: string;
1217
+ const { path, url, file_id, prompt, model, max_output_tokens, max_chars } = params as {
1218
+ path?: string; url?: string; file_id?: string; prompt?: string; model?: string;
1219
+ max_output_tokens?: number; max_chars?: number;
1207
1220
  };
1208
- if (!path && !url && !file_id) {
1209
- return {
1210
- content: [
1211
- { type: "text", text: "Provide path, url, or file_id for audio." },
1212
- ],
1213
- details: { error: "missing source" },
1214
- };
1221
+ if ([path, url, file_id].filter(Boolean).length !== 1) {
1222
+ throw new Error("meta_transcribe_audio requires exactly one of path, url, or file_id");
1215
1223
  }
1216
1224
  try {
1217
- const apiKey = await safeGetMetaApiKey(
1218
- ctx as unknown as ExtensionContext,
1219
- );
1225
+ const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
1220
1226
  let audioBlock: ResponsesContentBlock;
1221
- if (file_id) {
1222
- audioBlock = { type: "input_file", file_id };
1223
- } else if (url) {
1224
- audioBlock = mediaInputFromSource(url, "audio.bin");
1225
- } else if (path) {
1226
- const absolute = resolve(
1227
- (ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
1228
- path,
1229
- );
1230
- if (!existsSync(absolute))
1231
- throw new Error(`File not found: ${absolute}`);
1227
+ if (file_id) audioBlock = { type: "input_file", file_id };
1228
+ else if (url) audioBlock = mediaInputFromSource(url, "audio.bin");
1229
+ else {
1230
+ const absolute = resolve(ctx.cwd, path as string);
1231
+ if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
1232
1232
  const stat = statSync(absolute);
1233
- if (stat.size > FILES_API_LIMIT_BYTES)
1234
- throw new Error(`Audio too large (1 GiB limit)`);
1233
+ if (stat.size > FILES_API_LIMIT_BYTES) throw new Error("Audio too large (1 GiB limit)");
1235
1234
  const mime = mimeForPath(absolute);
1236
- if (!isAudioMime(mime) && mime !== undefined) {
1237
- throw new Error(
1238
- `Unsupported audio mime ${mime}, need audio/wav or audio/mpeg`,
1239
- );
1235
+ if (mime !== undefined && !isAudioMime(mime)) {
1236
+ throw new Error(`Unsupported audio type for ${path}; use MP3 or WAV`);
1240
1237
  }
1241
1238
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
1242
- const up = await uploadMetaFile(apiKey, absolute);
1243
- audioBlock = { type: "input_file", file_id: up.id };
1239
+ const upload = await uploadMetaFile(apiKey, absolute, automaticExpiry());
1240
+ audioBlock = { type: "input_file", file_id: upload.id };
1244
1241
  } else {
1245
- const { dataUrl } = dataUrlForFile(absolute);
1246
- audioBlock = mediaInputFromSource(
1247
- dataUrl,
1248
- basename(absolute),
1249
- mime,
1250
- );
1242
+ audioBlock = mediaInputFromSource(dataUrlForFile(absolute).dataUrl, basename(absolute), mime);
1251
1243
  }
1252
- } else {
1253
- throw new Error("No audio source");
1254
1244
  }
1255
- const payload: Record<string, unknown> = {
1256
- model: model ?? "muse-spark-1.2",
1245
+ const selectedModel = model ?? "muse-spark-1.2";
1246
+ const { text, raw } = await callMetaResponses(apiKey, {
1247
+ model: selectedModel,
1257
1248
  store: false,
1258
- input: [
1259
- {
1260
- type: "message",
1261
- role: "user",
1262
- content: [
1263
- {
1264
- type: "input_text",
1265
- text:
1266
- prompt ??
1267
- "Transcribe this audio. Return only the transcript.",
1268
- },
1269
- audioBlock as unknown as Record<string, unknown>,
1270
- ],
1271
- },
1272
- ],
1273
- max_output_tokens: 4000,
1274
- };
1275
- const { text, raw } = await callMetaResponses(apiKey, payload, signal);
1276
- return {
1277
- content: [{ type: "text", text }],
1278
- details: { raw, audioBlock },
1279
- };
1280
- } catch (e) {
1249
+ input: [{ type: "message", role: "user", content: [
1250
+ { type: "input_text", text: prompt ?? "Transcribe this audio. Return only the transcript." },
1251
+ audioBlock as Record<string, unknown>,
1252
+ ] }],
1253
+ max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
1254
+ }, signal);
1255
+ const output = await prepareMediaOutput({ text, response: raw, identity: toolCallId, maxChars: max_chars });
1256
+ const usage = extractMetaResponseUsage(raw, selectedModel);
1281
1257
  return {
1282
- content: [
1283
- {
1284
- type: "text",
1285
- text: `Audio transcription failed: ${e instanceof Error ? e.message : String(e)}`,
1286
- },
1287
- ],
1288
- details: { error: String(e) },
1258
+ content: [{ type: "text", text: output.text }],
1259
+ details: { raw, audioBlock, ...output.details },
1260
+ usage,
1289
1261
  };
1262
+ } catch (error) {
1263
+ mediaToolError("Audio transcription", error);
1290
1264
  }
1291
1265
  },
1292
1266
  });
@@ -1295,63 +1269,46 @@ export default function metaMedia(pi: ExtensionAPI): void {
1295
1269
  name: "meta_upload_file",
1296
1270
  label: "Meta Files Upload",
1297
1271
  description:
1298
- "Upload a file to Meta Files API (POST /v1/files purpose=user_data) for reuse across video/image/pdf/audio requests. Returns file_id to use with input_file/file_id. Supports 1 GiB limit, 100 GiB/team storage. Set expires_after for auto-expiry (3600..2592000 seconds). Use for large or reused files; small one-offs can use inline data URLs.",
1299
- parameters: Type.Object({
1300
- path: Type.String({
1301
- description:
1302
- "Local file path to upload (mp4, wav, mp3, pdf, png, jpg, etc.)",
1303
- }),
1304
- expires_after_seconds: Type.Optional(
1305
- Type.Number({
1306
- description:
1307
- "Auto-expire after seconds (3600..2592000), default no expiry",
1308
- }),
1309
- ),
1310
- }),
1311
- async execute(toolCallId, params, signal, _onUpdate, ctx) {
1312
- const { path, expires_after_seconds } = params as {
1313
- path: string;
1314
- expires_after_seconds?: number;
1272
+ "Upload a local file to Meta Files API for reuse. Uploads expire after seven days by default. Set expires_after_seconds to choose 3600..2592000 seconds, or retain=true to opt into no expiry.",
1273
+ parameters: Type.Object(
1274
+ {
1275
+ path: Type.String({ description: "Local file path to upload" }),
1276
+ expires_after_seconds: Type.Optional(Type.Integer({
1277
+ minimum: MIN_UPLOAD_EXPIRY_SECONDS,
1278
+ maximum: MAX_UPLOAD_EXPIRY_SECONDS,
1279
+ description: `Expiry in seconds, default ${EXPLICIT_UPLOAD_EXPIRY_SECONDS} (7 days)`,
1280
+ })),
1281
+ retain: Type.Optional(Type.Boolean({ description: "Keep indefinitely instead of applying an expiry" })),
1282
+ },
1283
+ { additionalProperties: false },
1284
+ ),
1285
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1286
+ const { path, expires_after_seconds, retain } = params as {
1287
+ path: string; expires_after_seconds?: number; retain?: boolean;
1315
1288
  };
1289
+ if (retain && expires_after_seconds !== undefined) {
1290
+ throw new Error("meta_upload_file accepts either retain=true or expires_after_seconds, not both");
1291
+ }
1316
1292
  try {
1317
- const apiKey = await safeGetMetaApiKey(
1318
- ctx as unknown as ExtensionContext,
1319
- );
1320
- const absolute = resolve(
1321
- (ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
1322
- path,
1323
- );
1324
- if (!existsSync(absolute))
1325
- throw new Error(`File not found: ${absolute}`);
1326
- let expiresAfter: { anchor: "created_at"; seconds: number } | undefined;
1327
- if (expires_after_seconds !== undefined) {
1328
- if (expires_after_seconds < 3600 || expires_after_seconds > 2592000)
1329
- throw new Error("expires_after_seconds must be 3600..2592000");
1330
- expiresAfter = {
1331
- anchor: "created_at",
1332
- seconds: expires_after_seconds,
1333
- };
1334
- }
1293
+ const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
1294
+ const absolute = resolve(ctx.cwd, path);
1295
+ if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
1296
+ const expiresAfter = retain ? undefined : {
1297
+ anchor: "created_at" as const,
1298
+ seconds: expires_after_seconds ?? EXPLICIT_UPLOAD_EXPIRY_SECONDS,
1299
+ };
1335
1300
  const result = await uploadMetaFile(apiKey, absolute, expiresAfter);
1301
+ const retention = retain
1302
+ ? "retained without expiry"
1303
+ : result.expires_at
1304
+ ? `expires_at ${result.expires_at}`
1305
+ : `expires after ${expiresAfter?.seconds} seconds`;
1336
1306
  return {
1337
- content: [
1338
- {
1339
- type: "text",
1340
- text: `Uploaded ${basename(absolute)} → ${result.id} (${result.bytes} bytes, status: ${result.status}${result.expires_at ? `, expires_at: ${result.expires_at}` : ""}). Use with {type:"input_file", file_id:"${result.id}"}.`,
1341
- },
1342
- ],
1307
+ content: [{ type: "text", text: `Uploaded ${basename(absolute)} to ${result.id} (${result.bytes} bytes, ${retention}).` }],
1343
1308
  details: result,
1344
1309
  };
1345
- } catch (e) {
1346
- return {
1347
- content: [
1348
- {
1349
- type: "text",
1350
- text: `Upload failed: ${e instanceof Error ? e.message : String(e)}`,
1351
- },
1352
- ],
1353
- details: { error: String(e) },
1354
- };
1310
+ } catch (error) {
1311
+ mediaToolError("File upload", error);
1355
1312
  }
1356
1313
  },
1357
1314
  });
@@ -1360,85 +1317,79 @@ export default function metaMedia(pi: ExtensionAPI): void {
1360
1317
  name: "meta_analyze_file",
1361
1318
  label: "Meta File Analyze",
1362
1319
  description:
1363
- "Analyze any supported file (image, pdf, video, audio) via Muse Spark using the Files API or inline. PDFs: text first 100 pages + images first 50 pages (counts to 50-image budget). Images: up to 50/request. For generic 'summarize this document' use this; for video/audio with specific prompts use the dedicated video/audio tools.",
1364
- parameters: Type.Object({
1365
- path: Type.Optional(Type.String({ description: "Local file path" })),
1366
- url: Type.Optional(Type.String({ description: "Public https:// URL" })),
1367
- file_id: Type.Optional(Type.String({ description: "Existing file_id" })),
1368
- prompt: Type.String({ description: "What to ask about the file" }),
1369
- model: Type.Optional(
1370
- Type.String({ description: "Model id, default muse-spark-1.2" }),
1371
- ),
1372
- }),
1320
+ "Analyze one or more images, PDFs, videos, or audio files with Muse Spark. Use path/url/file_id for one source, or ordered sources for comparisons. Images and PDF pages share Meta's 50-image budget; PDFs include text from the first 100 pages and images from the first 50 pages. Truncated text is saved for continuation with read offset/limit.",
1321
+ promptSnippet: "Inspect images, PDFs, or mixed media when the active model cannot",
1322
+ promptGuidelines: [
1323
+ "Use meta_analyze_file when the task depends on an image, PDF, or generic media file the active model cannot inspect directly.",
1324
+ "Give meta_analyze_file a task-specific prompt that asks for the exact visible text, layout, comparison, defect, or action evidence needed next.",
1325
+ "When a Meta media result is truncated, continue from its saved temp file with read using offset/limit.",
1326
+ ],
1327
+ parameters: Type.Object(
1328
+ {
1329
+ path: Type.Optional(Type.String({ description: "Single local file path" })),
1330
+ url: Type.Optional(Type.String({ description: "Single public HTTPS or data URL" })),
1331
+ file_id: Type.Optional(Type.String({ description: "Single existing Meta file_id" })),
1332
+ sources: Type.Optional(Type.Array(Type.Object({
1333
+ source: Type.String({ description: "Local path, HTTPS/data URL, or Meta file_id" }),
1334
+ label: Type.Optional(Type.String({ description: "Human-readable label such as Before or After" })),
1335
+ }, { additionalProperties: false }), {
1336
+ minItems: 1,
1337
+ maxItems: MAX_ANALYSIS_SOURCES,
1338
+ description: "Ordered sources for comparison or combined analysis",
1339
+ })),
1340
+ prompt: Type.String({ description: "What evidence to extract or compare" }),
1341
+ model: Type.Optional(Type.String({ description: "Muse model id, default muse-spark-1.2" })),
1342
+ max_output_tokens: Type.Optional(Type.Integer({
1343
+ minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
1344
+ maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
1345
+ description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
1346
+ })),
1347
+ max_chars: Type.Optional(Type.Integer({
1348
+ minimum: 1_000,
1349
+ maximum: MAX_MEDIA_MAX_CHARS,
1350
+ description: `Maximum result characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
1351
+ })),
1352
+ },
1353
+ { additionalProperties: false },
1354
+ ),
1373
1355
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
1374
- const { path, url, file_id, prompt, model } = params as {
1375
- path?: string;
1376
- url?: string;
1377
- file_id?: string;
1378
- prompt: string;
1379
- model?: string;
1356
+ const { path, url, file_id, sources, prompt, model, max_output_tokens, max_chars } = params as {
1357
+ path?: string; url?: string; file_id?: string; sources?: AnalysisSource[];
1358
+ prompt: string; model?: string; max_output_tokens?: number; max_chars?: number;
1380
1359
  };
1381
- if (!path && !url && !file_id) {
1382
- return {
1383
- content: [{ type: "text", text: "Provide path, url, or file_id." }],
1384
- details: { error: "missing source" },
1385
- };
1360
+ const legacySources = [path, url, file_id].filter((value): value is string => Boolean(value));
1361
+ if (legacySources.length > 1 || (legacySources.length === 1 && sources) || (legacySources.length === 0 && !sources)) {
1362
+ throw new Error("meta_analyze_file requires exactly one path/url/file_id or a sources array");
1363
+ }
1364
+ if (sources && sources.length > MAX_ANALYSIS_SOURCES) {
1365
+ throw new Error(`meta_analyze_file supports at most ${MAX_ANALYSIS_SOURCES} sources`);
1386
1366
  }
1387
1367
  try {
1388
- const apiKey = await safeGetMetaApiKey(
1389
- ctx as unknown as ExtensionContext,
1390
- );
1391
- let fileBlock: ResponsesContentBlock;
1392
- if (file_id) fileBlock = { type: "input_file", file_id };
1393
- else if (url) fileBlock = mediaInputFromSource(url);
1394
- else if (path) {
1395
- const absolute = resolve(
1396
- (ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
1397
- path,
1398
- );
1399
- if (!existsSync(absolute))
1400
- throw new Error(`File not found: ${absolute}`);
1401
- const stat = statSync(absolute);
1402
- if (stat.size > FILES_API_LIMIT_BYTES)
1403
- throw new Error(`File too large (1 GiB limit)`);
1404
- if (stat.size > STORE_SAFE_INLINE_BYTES) {
1405
- const up = await uploadMetaFile(apiKey, absolute);
1406
- fileBlock = { type: "input_file", file_id: up.id };
1407
- } else {
1408
- const { dataUrl, mime } = dataUrlForFile(absolute);
1409
- fileBlock = mediaInputFromSource(dataUrl, basename(absolute), mime);
1368
+ const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
1369
+ const requestedSources: AnalysisSource[] = sources ?? [{ source: legacySources[0] as string }];
1370
+ const content: ResponsesContentBlock[] = [{ type: "input_text", text: prompt }];
1371
+ for (const [index, item] of requestedSources.entries()) {
1372
+ if (requestedSources.length > 1 || item.label) {
1373
+ content.push({ type: "input_text", text: `${sourceLabel(item, index)}:` });
1410
1374
  }
1411
- } else throw new Error("No source");
1412
- const payload: Record<string, unknown> = {
1413
- model: model ?? "muse-spark-1.2",
1375
+ content.push(await resolveGenericMediaSource(apiKey, ctx.cwd, item.source));
1376
+ }
1377
+ const selectedModel = model ?? "muse-spark-1.2";
1378
+ const { text, raw } = await callMetaResponses(apiKey, {
1379
+ model: selectedModel,
1414
1380
  store: false,
1415
- input: [
1416
- {
1417
- type: "message",
1418
- role: "user",
1419
- content: [
1420
- { type: "input_text", text: prompt },
1421
- fileBlock as unknown as Record<string, unknown>,
1422
- ],
1423
- },
1424
- ],
1425
- max_output_tokens: 4000,
1426
- };
1427
- const { text, raw } = await callMetaResponses(apiKey, payload, signal);
1428
- return {
1429
- content: [{ type: "text", text }],
1430
- details: { raw, fileBlock },
1431
- };
1432
- } catch (e) {
1381
+ input: [{ type: "message", role: "user", content: content as Record<string, unknown>[] }],
1382
+ max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
1383
+ }, signal);
1384
+ const output = await prepareMediaOutput({ text, response: raw, identity: toolCallId, maxChars: max_chars });
1385
+ const usage = extractMetaResponseUsage(raw, selectedModel);
1433
1386
  return {
1434
- content: [
1435
- {
1436
- type: "text",
1437
- text: `File analysis failed: ${e instanceof Error ? e.message : String(e)}`,
1438
- },
1439
- ],
1440
- details: { error: String(e) },
1387
+ content: [{ type: "text", text: output.text }],
1388
+ details: { raw, sourceCount: requestedSources.length, ...output.details },
1389
+ usage,
1441
1390
  };
1391
+ } catch (error) {
1392
+ mediaToolError("File analysis", error);
1442
1393
  }
1443
1394
  },
1444
1395
  });
@@ -1490,7 +1441,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
1490
1441
  if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
1491
1442
  const stat = statSync(abs);
1492
1443
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
1493
- const up = await uploadMetaFile(apiKey, abs);
1444
+ const up = await uploadMetaFile(apiKey, abs, automaticExpiry());
1494
1445
  block = { type: "input_file", file_id: up.id };
1495
1446
  } else {
1496
1447
  const { dataUrl } = dataUrlForFile(abs);
@@ -1564,7 +1515,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
1564
1515
  if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
1565
1516
  const stat = statSync(abs);
1566
1517
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
1567
- const up = await uploadMetaFile(apiKey, abs);
1518
+ const up = await uploadMetaFile(apiKey, abs, automaticExpiry());
1568
1519
  block = { type: "input_file", file_id: up.id };
1569
1520
  } else {
1570
1521
  const { dataUrl, mime } = dataUrlForFile(abs);
@@ -1632,7 +1583,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
1632
1583
  if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
1633
1584
  const stat = statSync(abs);
1634
1585
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
1635
- const up = await uploadMetaFile(apiKey, abs);
1586
+ const up = await uploadMetaFile(apiKey, abs, automaticExpiry());
1636
1587
  block = { type: "input_file", file_id: up.id };
1637
1588
  } else {
1638
1589
  const { dataUrl, mime } = dataUrlForFile(abs);
@@ -14,6 +14,7 @@ export const META_MODEL_CATALOG_URL = "https://api.meta.ai/v1/models";
14
14
  export const META_AUTH_BASE_URL = "https://auth.meta.com";
15
15
  export const META_CLIENT_ID = "1031625952748946";
16
16
  export const META_FILES_URL = `${META_API_BASE_URL}/files`;
17
+ const META_ENV_VAR = "META_API_KEY";
17
18
 
18
19
  const DEVICE_AUTHORIZATION_URL = `${META_AUTH_BASE_URL}/oidc/device/authorization/`;
19
20
  const DEVICE_TOKEN_URL = `${META_AUTH_BASE_URL}/oidc/device/token/`;
@@ -339,7 +340,7 @@ function numericCost(value: unknown, fallback: number): number {
339
340
  const number =
340
341
  typeof value === "number"
341
342
  ? value
342
- : typeof value === "string"
343
+ : typeof value === "string" && value.trim()
343
344
  ? Number(value)
344
345
  : Number.NaN;
345
346
  return Number.isFinite(number) && number >= 0 ? number : fallback;
@@ -462,6 +463,7 @@ export function createMetaProviderConfig(): ProviderConfig {
462
463
  name: "Meta Model API",
463
464
  baseUrl: META_API_BASE_URL,
464
465
  api: "openai-responses",
466
+ apiKey: "$META_API_KEY",
465
467
  models: [...FALLBACK_MODELS],
466
468
  refreshModels: refreshMetaModels,
467
469
  oauth: {
@@ -474,5 +476,18 @@ export function createMetaProviderConfig(): ProviderConfig {
474
476
  }
475
477
 
476
478
  export default function metaOAuthProvider(pi: ExtensionAPI): void {
479
+ // Allow MODEL_API_KEY as fallback for API-key users — shim to META_API_KEY so $META_API_KEY interpolation works.
480
+ if (
481
+ process.env[META_ENV_VAR] === undefined &&
482
+ process.env["MODEL_API_KEY"] !== undefined
483
+ ) {
484
+ process.env[META_ENV_VAR] = process.env["MODEL_API_KEY"];
485
+ }
486
+ if (
487
+ process.env["MODEL_API_KEY"] === undefined &&
488
+ process.env[META_ENV_VAR] !== undefined
489
+ ) {
490
+ process.env["MODEL_API_KEY"] = process.env[META_ENV_VAR];
491
+ }
477
492
  pi.registerProvider(META_PROVIDER_ID, createMetaProviderConfig());
478
493
  }
@@ -73,22 +73,12 @@ private final class MicrophoneSession {
73
73
  }
74
74
  let tapBufferSize = AVAudioFrameCount(inputFormat.sampleRate * 0.1)
75
75
  do {
76
- if #available(macOS 27.0, *) {
77
- try inputNode.__installTap(
78
- onBus: 0,
79
- bufferSize: tapBufferSize,
80
- format: inputFormat,
81
- error: (),
82
- block: tap
83
- )
84
- } else {
85
- inputNode.installTap(
86
- onBus: 0,
87
- bufferSize: tapBufferSize,
88
- format: inputFormat,
89
- block: tap
90
- )
91
- }
76
+ inputNode.installTap(
77
+ onBus: 0,
78
+ bufferSize: tapBufferSize,
79
+ format: inputFormat,
80
+ block: tap
81
+ )
92
82
  audioEngine.prepare()
93
83
  try audioEngine.start()
94
84
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-meta-oauth",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Meta Model API OAuth provider and Muse-style voice dictation for pi",
5
5
  "keywords": [
6
6
  "pi-package",