pi-meta-oauth 0.4.2 → 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}` },
@@ -1050,133 +1108,77 @@ export default function metaMedia(pi: ExtensionAPI): void {
1050
1108
  name: "meta_describe_video",
1051
1109
  label: "Meta Video Describe",
1052
1110
  description:
1053
- "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.",
1054
- parameters: Type.Object({
1055
- path: Type.Optional(
1056
- Type.String({
1057
- description:
1058
- "Local .mp4/.m4v file path (relative to cwd or absolute)",
1059
- }),
1060
- ),
1061
- url: Type.Optional(
1062
- Type.String({
1063
- description:
1064
- "Public https:// URL or data:video/mp4;base64,... URL (skips upload)",
1065
- }),
1066
- ),
1067
- file_id: Type.Optional(
1068
- Type.String({
1069
- description:
1070
- "Existing Files API id like file-842549258569145 (skips upload)",
1071
- }),
1072
- ),
1073
- prompt: Type.String({
1074
- description:
1075
- "What to ask about the video (e.g. 'Describe what happens', 'Transcribe speech and list action items')",
1076
- }),
1077
- model: Type.Optional(
1078
- Type.String({ description: "Muse model id, default muse-spark-1.2" }),
1079
- ),
1080
- max_output_tokens: Type.Optional(
1081
- Type.Number({
1082
- description:
1083
- "Default 4000 — reasoning shares budget, lower values can return empty with finish_reason:length",
1084
- }),
1085
- ),
1086
- }),
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
+ ),
1087
1138
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
1088
- const { path, url, file_id, prompt, model, max_output_tokens } =
1089
- params as {
1090
- path?: string;
1091
- url?: string;
1092
- file_id?: string;
1093
- prompt: string;
1094
- model?: string;
1095
- max_output_tokens?: number;
1096
- };
1097
- if (!path && !url && !file_id) {
1098
- return {
1099
- content: [
1100
- {
1101
- type: "text",
1102
- text: "Provide one of: path (local .mp4), url (https://...), or file_id (file-...).",
1103
- },
1104
- ],
1105
- details: { error: "missing source" },
1106
- };
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");
1107
1145
  }
1108
1146
  try {
1109
- const apiKey = await safeGetMetaApiKey(
1110
- ctx as unknown as ExtensionContext,
1111
- );
1147
+ const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
1112
1148
  let videoBlock: ResponsesContentBlock;
1113
- if (file_id) {
1114
- videoBlock = { type: "input_file", file_id };
1115
- } else if (url) {
1116
- videoBlock = mediaInputFromSource(url, "video.mp4", "video/mp4");
1117
- } else if (path) {
1118
- const absolute = resolve(
1119
- (ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
1120
- path,
1121
- );
1122
- if (!existsSync(absolute))
1123
- 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}`);
1124
1154
  const stat = statSync(absolute);
1125
- if (stat.size > FILES_API_LIMIT_BYTES)
1126
- throw new Error(`File too large (1 GiB limit): ${path}`);
1127
- // Docs recommend Files API (upload → file_id) for video. Inline
1128
- // base64 also hits the ~20 MB `store=true` persistence limit
1129
- // (24 MB → HTTP 413) even when under the 50 MB inline cap.
1130
- // Use Files API for any video above the store-safe threshold
1131
- // and for all larger media; keep inline only for tiny files.
1132
- const useFilesApi = stat.size > STORE_SAFE_INLINE_BYTES;
1133
- if (useFilesApi) {
1134
- const up = await uploadMetaFile(apiKey, absolute);
1135
- 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 };
1136
1159
  } else {
1137
- const { dataUrl } = dataUrlForFile(absolute);
1138
- videoBlock = mediaInputFromSource(
1139
- dataUrl,
1140
- basename(absolute),
1141
- "video/mp4",
1142
- );
1160
+ videoBlock = mediaInputFromSource(dataUrlForFile(absolute).dataUrl, basename(absolute), "video/mp4");
1143
1161
  }
1144
- } else {
1145
- throw new Error("No source");
1146
1162
  }
1147
1163
  const selectedModel = model ?? "muse-spark-1.2";
1148
- const payload: Record<string, unknown> = {
1164
+ const { text, raw } = await callMetaResponses(apiKey, {
1149
1165
  model: selectedModel,
1150
1166
  store: false,
1151
- input: [
1152
- {
1153
- type: "message",
1154
- role: "user",
1155
- content: [
1156
- { type: "input_text", text: prompt },
1157
- videoBlock as unknown as Record<string, unknown>,
1158
- ],
1159
- },
1160
- ],
1161
- max_output_tokens: Math.max(max_output_tokens ?? 8000, 4000),
1162
- };
1163
- 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 });
1164
1174
  const usage = extractMetaResponseUsage(raw, selectedModel);
1165
1175
  return {
1166
- content: [{ type: "text", text }],
1167
- details: { raw, videoBlock, usage },
1176
+ content: [{ type: "text", text: output.text }],
1177
+ details: { raw, videoBlock, ...output.details },
1168
1178
  usage,
1169
1179
  };
1170
- } catch (e) {
1171
- return {
1172
- content: [
1173
- {
1174
- type: "text",
1175
- text: `Video analysis failed: ${e instanceof Error ? e.message : String(e)}`,
1176
- },
1177
- ],
1178
- details: { error: String(e) },
1179
- };
1180
+ } catch (error) {
1181
+ mediaToolError("Video analysis", error);
1180
1182
  }
1181
1183
  },
1182
1184
  });
@@ -1185,113 +1187,80 @@ export default function metaMedia(pi: ExtensionAPI): void {
1185
1187
  name: "meta_transcribe_audio",
1186
1188
  label: "Meta Audio Transcribe",
1187
1189
  description:
1188
- "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.",
1189
- parameters: Type.Object({
1190
- path: Type.Optional(Type.String({ description: "Local .wav/.mp3 path" })),
1191
- url: Type.Optional(
1192
- Type.String({ description: "Public https:// URL to audio" }),
1193
- ),
1194
- file_id: Type.Optional(Type.String({ description: "Existing file_id" })),
1195
- prompt: Type.Optional(
1196
- Type.String({
1197
- description:
1198
- "Custom prompt, default 'Transcribe this audio. Return only the transcript.'",
1199
- }),
1200
- ),
1201
- model: Type.Optional(
1202
- Type.String({ description: "Model id, default muse-spark-1.2" }),
1203
- ),
1204
- }),
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
+ ),
1205
1216
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
1206
- const { path, url, file_id, prompt, model } = params as {
1207
- path?: string;
1208
- url?: string;
1209
- file_id?: string;
1210
- prompt?: string;
1211
- 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;
1212
1220
  };
1213
- if (!path && !url && !file_id) {
1214
- return {
1215
- content: [
1216
- { type: "text", text: "Provide path, url, or file_id for audio." },
1217
- ],
1218
- details: { error: "missing source" },
1219
- };
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");
1220
1223
  }
1221
1224
  try {
1222
- const apiKey = await safeGetMetaApiKey(
1223
- ctx as unknown as ExtensionContext,
1224
- );
1225
+ const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
1225
1226
  let audioBlock: ResponsesContentBlock;
1226
- if (file_id) {
1227
- audioBlock = { type: "input_file", file_id };
1228
- } else if (url) {
1229
- audioBlock = mediaInputFromSource(url, "audio.bin");
1230
- } else if (path) {
1231
- const absolute = resolve(
1232
- (ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
1233
- path,
1234
- );
1235
- if (!existsSync(absolute))
1236
- 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}`);
1237
1232
  const stat = statSync(absolute);
1238
- if (stat.size > FILES_API_LIMIT_BYTES)
1239
- 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)");
1240
1234
  const mime = mimeForPath(absolute);
1241
- if (!isAudioMime(mime) && mime !== undefined) {
1242
- throw new Error(
1243
- `Unsupported audio mime ${mime}, need audio/wav or audio/mpeg`,
1244
- );
1235
+ if (mime !== undefined && !isAudioMime(mime)) {
1236
+ throw new Error(`Unsupported audio type for ${path}; use MP3 or WAV`);
1245
1237
  }
1246
1238
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
1247
- const up = await uploadMetaFile(apiKey, absolute);
1248
- 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 };
1249
1241
  } else {
1250
- const { dataUrl } = dataUrlForFile(absolute);
1251
- audioBlock = mediaInputFromSource(
1252
- dataUrl,
1253
- basename(absolute),
1254
- mime,
1255
- );
1242
+ audioBlock = mediaInputFromSource(dataUrlForFile(absolute).dataUrl, basename(absolute), mime);
1256
1243
  }
1257
- } else {
1258
- throw new Error("No audio source");
1259
1244
  }
1260
- const payload: Record<string, unknown> = {
1261
- model: model ?? "muse-spark-1.2",
1245
+ const selectedModel = model ?? "muse-spark-1.2";
1246
+ const { text, raw } = await callMetaResponses(apiKey, {
1247
+ model: selectedModel,
1262
1248
  store: false,
1263
- input: [
1264
- {
1265
- type: "message",
1266
- role: "user",
1267
- content: [
1268
- {
1269
- type: "input_text",
1270
- text:
1271
- prompt ??
1272
- "Transcribe this audio. Return only the transcript.",
1273
- },
1274
- audioBlock as unknown as Record<string, unknown>,
1275
- ],
1276
- },
1277
- ],
1278
- max_output_tokens: 4000,
1279
- };
1280
- const { text, raw } = await callMetaResponses(apiKey, payload, signal);
1281
- return {
1282
- content: [{ type: "text", text }],
1283
- details: { raw, audioBlock },
1284
- };
1285
- } 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);
1286
1257
  return {
1287
- content: [
1288
- {
1289
- type: "text",
1290
- text: `Audio transcription failed: ${e instanceof Error ? e.message : String(e)}`,
1291
- },
1292
- ],
1293
- details: { error: String(e) },
1258
+ content: [{ type: "text", text: output.text }],
1259
+ details: { raw, audioBlock, ...output.details },
1260
+ usage,
1294
1261
  };
1262
+ } catch (error) {
1263
+ mediaToolError("Audio transcription", error);
1295
1264
  }
1296
1265
  },
1297
1266
  });
@@ -1300,63 +1269,46 @@ export default function metaMedia(pi: ExtensionAPI): void {
1300
1269
  name: "meta_upload_file",
1301
1270
  label: "Meta Files Upload",
1302
1271
  description:
1303
- "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.",
1304
- parameters: Type.Object({
1305
- path: Type.String({
1306
- description:
1307
- "Local file path to upload (mp4, wav, mp3, pdf, png, jpg, etc.)",
1308
- }),
1309
- expires_after_seconds: Type.Optional(
1310
- Type.Number({
1311
- description:
1312
- "Auto-expire after seconds (3600..2592000), default no expiry",
1313
- }),
1314
- ),
1315
- }),
1316
- async execute(toolCallId, params, signal, _onUpdate, ctx) {
1317
- const { path, expires_after_seconds } = params as {
1318
- path: string;
1319
- 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;
1320
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
+ }
1321
1292
  try {
1322
- const apiKey = await safeGetMetaApiKey(
1323
- ctx as unknown as ExtensionContext,
1324
- );
1325
- const absolute = resolve(
1326
- (ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
1327
- path,
1328
- );
1329
- if (!existsSync(absolute))
1330
- throw new Error(`File not found: ${absolute}`);
1331
- let expiresAfter: { anchor: "created_at"; seconds: number } | undefined;
1332
- if (expires_after_seconds !== undefined) {
1333
- if (expires_after_seconds < 3600 || expires_after_seconds > 2592000)
1334
- throw new Error("expires_after_seconds must be 3600..2592000");
1335
- expiresAfter = {
1336
- anchor: "created_at",
1337
- seconds: expires_after_seconds,
1338
- };
1339
- }
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
+ };
1340
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`;
1341
1306
  return {
1342
- content: [
1343
- {
1344
- type: "text",
1345
- 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}"}.`,
1346
- },
1347
- ],
1307
+ content: [{ type: "text", text: `Uploaded ${basename(absolute)} to ${result.id} (${result.bytes} bytes, ${retention}).` }],
1348
1308
  details: result,
1349
1309
  };
1350
- } catch (e) {
1351
- return {
1352
- content: [
1353
- {
1354
- type: "text",
1355
- text: `Upload failed: ${e instanceof Error ? e.message : String(e)}`,
1356
- },
1357
- ],
1358
- details: { error: String(e) },
1359
- };
1310
+ } catch (error) {
1311
+ mediaToolError("File upload", error);
1360
1312
  }
1361
1313
  },
1362
1314
  });
@@ -1365,85 +1317,79 @@ export default function metaMedia(pi: ExtensionAPI): void {
1365
1317
  name: "meta_analyze_file",
1366
1318
  label: "Meta File Analyze",
1367
1319
  description:
1368
- "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.",
1369
- parameters: Type.Object({
1370
- path: Type.Optional(Type.String({ description: "Local file path" })),
1371
- url: Type.Optional(Type.String({ description: "Public https:// URL" })),
1372
- file_id: Type.Optional(Type.String({ description: "Existing file_id" })),
1373
- prompt: Type.String({ description: "What to ask about the file" }),
1374
- model: Type.Optional(
1375
- Type.String({ description: "Model id, default muse-spark-1.2" }),
1376
- ),
1377
- }),
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
+ ),
1378
1355
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
1379
- const { path, url, file_id, prompt, model } = params as {
1380
- path?: string;
1381
- url?: string;
1382
- file_id?: string;
1383
- prompt: string;
1384
- 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;
1385
1359
  };
1386
- if (!path && !url && !file_id) {
1387
- return {
1388
- content: [{ type: "text", text: "Provide path, url, or file_id." }],
1389
- details: { error: "missing source" },
1390
- };
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`);
1391
1366
  }
1392
1367
  try {
1393
- const apiKey = await safeGetMetaApiKey(
1394
- ctx as unknown as ExtensionContext,
1395
- );
1396
- let fileBlock: ResponsesContentBlock;
1397
- if (file_id) fileBlock = { type: "input_file", file_id };
1398
- else if (url) fileBlock = mediaInputFromSource(url);
1399
- else if (path) {
1400
- const absolute = resolve(
1401
- (ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
1402
- path,
1403
- );
1404
- if (!existsSync(absolute))
1405
- throw new Error(`File not found: ${absolute}`);
1406
- const stat = statSync(absolute);
1407
- if (stat.size > FILES_API_LIMIT_BYTES)
1408
- throw new Error(`File too large (1 GiB limit)`);
1409
- if (stat.size > STORE_SAFE_INLINE_BYTES) {
1410
- const up = await uploadMetaFile(apiKey, absolute);
1411
- fileBlock = { type: "input_file", file_id: up.id };
1412
- } else {
1413
- const { dataUrl, mime } = dataUrlForFile(absolute);
1414
- 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)}:` });
1415
1374
  }
1416
- } else throw new Error("No source");
1417
- const payload: Record<string, unknown> = {
1418
- 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,
1419
1380
  store: false,
1420
- input: [
1421
- {
1422
- type: "message",
1423
- role: "user",
1424
- content: [
1425
- { type: "input_text", text: prompt },
1426
- fileBlock as unknown as Record<string, unknown>,
1427
- ],
1428
- },
1429
- ],
1430
- max_output_tokens: 4000,
1431
- };
1432
- const { text, raw } = await callMetaResponses(apiKey, payload, signal);
1433
- return {
1434
- content: [{ type: "text", text }],
1435
- details: { raw, fileBlock },
1436
- };
1437
- } 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);
1438
1386
  return {
1439
- content: [
1440
- {
1441
- type: "text",
1442
- text: `File analysis failed: ${e instanceof Error ? e.message : String(e)}`,
1443
- },
1444
- ],
1445
- details: { error: String(e) },
1387
+ content: [{ type: "text", text: output.text }],
1388
+ details: { raw, sourceCount: requestedSources.length, ...output.details },
1389
+ usage,
1446
1390
  };
1391
+ } catch (error) {
1392
+ mediaToolError("File analysis", error);
1447
1393
  }
1448
1394
  },
1449
1395
  });
@@ -1495,7 +1441,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
1495
1441
  if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
1496
1442
  const stat = statSync(abs);
1497
1443
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
1498
- const up = await uploadMetaFile(apiKey, abs);
1444
+ const up = await uploadMetaFile(apiKey, abs, automaticExpiry());
1499
1445
  block = { type: "input_file", file_id: up.id };
1500
1446
  } else {
1501
1447
  const { dataUrl } = dataUrlForFile(abs);
@@ -1569,7 +1515,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
1569
1515
  if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
1570
1516
  const stat = statSync(abs);
1571
1517
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
1572
- const up = await uploadMetaFile(apiKey, abs);
1518
+ const up = await uploadMetaFile(apiKey, abs, automaticExpiry());
1573
1519
  block = { type: "input_file", file_id: up.id };
1574
1520
  } else {
1575
1521
  const { dataUrl, mime } = dataUrlForFile(abs);
@@ -1637,7 +1583,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
1637
1583
  if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
1638
1584
  const stat = statSync(abs);
1639
1585
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
1640
- const up = await uploadMetaFile(apiKey, abs);
1586
+ const up = await uploadMetaFile(apiKey, abs, automaticExpiry());
1641
1587
  block = { type: "input_file", file_id: up.id };
1642
1588
  } else {
1643
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.2",
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",