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 +52 -0
- package/extensions/media-output.ts +123 -0
- package/extensions/media.ts +275 -329
- package/extensions/meta.ts +16 -1
- package/extensions/voice/macos-audio.swift +6 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# pi-meta-oauth
|
|
4
4
|
|
|
5
5
|
<!-- markdownlint-disable-next-line MD013 -->
|
|
6
|
+

|
|
6
7
|
[](https://www.npmjs.com/package/pi-meta-oauth) [](https://opensource.org/licenses/MIT) [](https://github.com/BlockedPath/pi-meta-oauth/stargazers) [](https://github.com/BlockedPath/pi-meta-oauth/commits/main) [](https://github.com/BlockedPath/pi-meta-oauth/issues) [](https://github.com/BlockedPath/pi-meta-oauth/pulls) [](https://www.conventionalcommits.org/en/v1.0.0/) [](https://www.typescriptlang.org/) [](https://github.com/BlockedPath/pi-meta-oauth/actions/workflows/ci.yml) [](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
|
+
}
|
package/extensions/media.ts
CHANGED
|
@@ -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
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
Type.String({
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
}),
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
}),
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
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
|
|
1164
|
+
const { text, raw } = await callMetaResponses(apiKey, {
|
|
1149
1165
|
model: selectedModel,
|
|
1150
1166
|
store: false,
|
|
1151
|
-
input: [
|
|
1152
|
-
{
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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,
|
|
1176
|
+
content: [{ type: "text", text: output.text }],
|
|
1177
|
+
details: { raw, videoBlock, ...output.details },
|
|
1168
1178
|
usage,
|
|
1169
1179
|
};
|
|
1170
|
-
} catch (
|
|
1171
|
-
|
|
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
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
Type.String({
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
}),
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
-
|
|
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 (
|
|
1214
|
-
|
|
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
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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 (
|
|
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
|
|
1248
|
-
audioBlock = { type: "input_file", file_id:
|
|
1239
|
+
const upload = await uploadMetaFile(apiKey, absolute, automaticExpiry());
|
|
1240
|
+
audioBlock = { type: "input_file", file_id: upload.id };
|
|
1249
1241
|
} else {
|
|
1250
|
-
|
|
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
|
|
1261
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
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
|
|
1304
|
-
parameters: Type.Object(
|
|
1305
|
-
|
|
1306
|
-
description:
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
async execute(
|
|
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
|
-
|
|
1324
|
-
);
|
|
1325
|
-
const
|
|
1326
|
-
|
|
1327
|
-
|
|
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 (
|
|
1351
|
-
|
|
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
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
-
|
|
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);
|
package/extensions/meta.ts
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 {
|