pi-meta-oauth 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/extensions/media-output.ts +123 -0
- package/extensions/media.ts +280 -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}` },
|
|
@@ -960,6 +1018,11 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
960
1018
|
// so the before_provider_request hook can rewrite them.
|
|
961
1019
|
pi.on("input", async (event, ctx) => {
|
|
962
1020
|
if (!shouldEnableNativeRewrite()) return undefined;
|
|
1021
|
+
// Only Meta's request hook knows how to rewrite these pseudo-images into
|
|
1022
|
+
// typed video/audio/file blocks. Other providers (notably Codex) reject a
|
|
1023
|
+
// video/mp4 ImageContent before the Meta tools can be called, so leave the
|
|
1024
|
+
// path as text and let the active model invoke the direct Meta media tool.
|
|
1025
|
+
if (ctx.model?.provider !== META_PROVIDER_ID) return undefined;
|
|
963
1026
|
const text = event.text ?? "";
|
|
964
1027
|
// Match @/path/to/file.mp4, @./file.wav, /abs/file.pdf, ./file.mp3 — conservative.
|
|
965
1028
|
const filePattern =
|
|
@@ -1045,133 +1108,77 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1045
1108
|
name: "meta_describe_video",
|
|
1046
1109
|
label: "Meta Video Describe",
|
|
1047
1110
|
description:
|
|
1048
|
-
"Analyze an MP4 video with Muse Spark
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
Type.String({
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
}),
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
}),
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
max_output_tokens: Type.Optional(
|
|
1076
|
-
Type.Number({
|
|
1077
|
-
description:
|
|
1078
|
-
"Default 4000 — reasoning shares budget, lower values can return empty with finish_reason:length",
|
|
1079
|
-
}),
|
|
1080
|
-
),
|
|
1081
|
-
}),
|
|
1111
|
+
"Analyze an MP4 video with Muse Spark, including visuals and embedded speech. Accepts a local path, public HTTPS/data URL, or Meta file_id. Results are capped by max_chars; when truncated, the full text is saved to a temp file for continuation with read offset/limit.",
|
|
1112
|
+
promptSnippet: "Inspect video and embedded audio when the active model cannot",
|
|
1113
|
+
promptGuidelines: [
|
|
1114
|
+
"Use meta_describe_video when the task depends on an MP4 video the active model cannot inspect directly.",
|
|
1115
|
+
"Give meta_describe_video a task-specific prompt that asks for the exact visual, transcript, timestamp, or defect evidence needed next.",
|
|
1116
|
+
"When a Meta media result is truncated, continue from its saved temp file with read using offset/limit.",
|
|
1117
|
+
],
|
|
1118
|
+
parameters: Type.Object(
|
|
1119
|
+
{
|
|
1120
|
+
path: Type.Optional(Type.String({ description: "Local .mp4/.m4v path, relative to cwd or absolute" })),
|
|
1121
|
+
url: Type.Optional(Type.String({ description: "Public HTTPS or data:video/mp4 URL" })),
|
|
1122
|
+
file_id: Type.Optional(Type.String({ description: "Existing Meta Files API id" })),
|
|
1123
|
+
prompt: Type.String({ description: "What evidence to extract from the video" }),
|
|
1124
|
+
model: Type.Optional(Type.String({ description: "Muse model id, default muse-spark-1.2" })),
|
|
1125
|
+
max_output_tokens: Type.Optional(Type.Integer({
|
|
1126
|
+
minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
|
|
1127
|
+
maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
|
|
1128
|
+
description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
|
|
1129
|
+
})),
|
|
1130
|
+
max_chars: Type.Optional(Type.Integer({
|
|
1131
|
+
minimum: 1_000,
|
|
1132
|
+
maximum: MAX_MEDIA_MAX_CHARS,
|
|
1133
|
+
description: `Maximum result characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
|
|
1134
|
+
})),
|
|
1135
|
+
},
|
|
1136
|
+
{ additionalProperties: false },
|
|
1137
|
+
),
|
|
1082
1138
|
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
1083
|
-
const { path, url, file_id, prompt, model, max_output_tokens } =
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
model?: string;
|
|
1090
|
-
max_output_tokens?: number;
|
|
1091
|
-
};
|
|
1092
|
-
if (!path && !url && !file_id) {
|
|
1093
|
-
return {
|
|
1094
|
-
content: [
|
|
1095
|
-
{
|
|
1096
|
-
type: "text",
|
|
1097
|
-
text: "Provide one of: path (local .mp4), url (https://...), or file_id (file-...).",
|
|
1098
|
-
},
|
|
1099
|
-
],
|
|
1100
|
-
details: { error: "missing source" },
|
|
1101
|
-
};
|
|
1139
|
+
const { path, url, file_id, prompt, model, max_output_tokens, max_chars } = params as {
|
|
1140
|
+
path?: string; url?: string; file_id?: string; prompt: string; model?: string;
|
|
1141
|
+
max_output_tokens?: number; max_chars?: number;
|
|
1142
|
+
};
|
|
1143
|
+
if ([path, url, file_id].filter(Boolean).length !== 1) {
|
|
1144
|
+
throw new Error("meta_describe_video requires exactly one of path, url, or file_id");
|
|
1102
1145
|
}
|
|
1103
1146
|
try {
|
|
1104
|
-
const apiKey = await safeGetMetaApiKey(
|
|
1105
|
-
ctx as unknown as ExtensionContext,
|
|
1106
|
-
);
|
|
1147
|
+
const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
|
|
1107
1148
|
let videoBlock: ResponsesContentBlock;
|
|
1108
|
-
if (file_id) {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const absolute = resolve(
|
|
1114
|
-
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
1115
|
-
path,
|
|
1116
|
-
);
|
|
1117
|
-
if (!existsSync(absolute))
|
|
1118
|
-
throw new Error(`File not found: ${absolute}`);
|
|
1149
|
+
if (file_id) videoBlock = { type: "input_file", file_id };
|
|
1150
|
+
else if (url) videoBlock = mediaInputFromSource(url, "video.mp4", "video/mp4");
|
|
1151
|
+
else {
|
|
1152
|
+
const absolute = resolve(ctx.cwd, path as string);
|
|
1153
|
+
if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
|
|
1119
1154
|
const stat = statSync(absolute);
|
|
1120
|
-
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
// (24 MB → HTTP 413) even when under the 50 MB inline cap.
|
|
1125
|
-
// Use Files API for any video above the store-safe threshold
|
|
1126
|
-
// and for all larger media; keep inline only for tiny files.
|
|
1127
|
-
const useFilesApi = stat.size > STORE_SAFE_INLINE_BYTES;
|
|
1128
|
-
if (useFilesApi) {
|
|
1129
|
-
const up = await uploadMetaFile(apiKey, absolute);
|
|
1130
|
-
videoBlock = { type: "input_file", file_id: up.id };
|
|
1155
|
+
if (stat.size > FILES_API_LIMIT_BYTES) throw new Error(`File too large (1 GiB limit): ${path}`);
|
|
1156
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1157
|
+
const upload = await uploadMetaFile(apiKey, absolute, automaticExpiry());
|
|
1158
|
+
videoBlock = { type: "input_file", file_id: upload.id };
|
|
1131
1159
|
} else {
|
|
1132
|
-
|
|
1133
|
-
videoBlock = mediaInputFromSource(
|
|
1134
|
-
dataUrl,
|
|
1135
|
-
basename(absolute),
|
|
1136
|
-
"video/mp4",
|
|
1137
|
-
);
|
|
1160
|
+
videoBlock = mediaInputFromSource(dataUrlForFile(absolute).dataUrl, basename(absolute), "video/mp4");
|
|
1138
1161
|
}
|
|
1139
|
-
} else {
|
|
1140
|
-
throw new Error("No source");
|
|
1141
1162
|
}
|
|
1142
1163
|
const selectedModel = model ?? "muse-spark-1.2";
|
|
1143
|
-
const
|
|
1164
|
+
const { text, raw } = await callMetaResponses(apiKey, {
|
|
1144
1165
|
model: selectedModel,
|
|
1145
1166
|
store: false,
|
|
1146
|
-
input: [
|
|
1147
|
-
{
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
],
|
|
1154
|
-
},
|
|
1155
|
-
],
|
|
1156
|
-
max_output_tokens: Math.max(max_output_tokens ?? 8000, 4000),
|
|
1157
|
-
};
|
|
1158
|
-
const { text, raw } = await callMetaResponses(apiKey, payload, signal);
|
|
1167
|
+
input: [{ type: "message", role: "user", content: [
|
|
1168
|
+
{ type: "input_text", text: prompt },
|
|
1169
|
+
videoBlock as Record<string, unknown>,
|
|
1170
|
+
] }],
|
|
1171
|
+
max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
|
|
1172
|
+
}, signal);
|
|
1173
|
+
const output = await prepareMediaOutput({ text, response: raw, identity: toolCallId, maxChars: max_chars });
|
|
1159
1174
|
const usage = extractMetaResponseUsage(raw, selectedModel);
|
|
1160
1175
|
return {
|
|
1161
|
-
content: [{ type: "text", text }],
|
|
1162
|
-
details: { raw, videoBlock,
|
|
1176
|
+
content: [{ type: "text", text: output.text }],
|
|
1177
|
+
details: { raw, videoBlock, ...output.details },
|
|
1163
1178
|
usage,
|
|
1164
1179
|
};
|
|
1165
|
-
} catch (
|
|
1166
|
-
|
|
1167
|
-
content: [
|
|
1168
|
-
{
|
|
1169
|
-
type: "text",
|
|
1170
|
-
text: `Video analysis failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1171
|
-
},
|
|
1172
|
-
],
|
|
1173
|
-
details: { error: String(e) },
|
|
1174
|
-
};
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
mediaToolError("Video analysis", error);
|
|
1175
1182
|
}
|
|
1176
1183
|
},
|
|
1177
1184
|
});
|
|
@@ -1180,113 +1187,80 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1180
1187
|
name: "meta_transcribe_audio",
|
|
1181
1188
|
label: "Meta Audio Transcribe",
|
|
1182
1189
|
description:
|
|
1183
|
-
"Transcribe
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
Type.String({
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
}),
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1190
|
+
"Transcribe MP3 or WAV audio with Muse Spark from a local path, public URL, or Meta file_id. Results are capped by max_chars; when truncated, the full transcript is saved to a temp file for continuation with read offset/limit.",
|
|
1191
|
+
promptSnippet: "Transcribe audio when the active model cannot",
|
|
1192
|
+
promptGuidelines: [
|
|
1193
|
+
"Use meta_transcribe_audio when the task depends on MP3 or WAV speech the active model cannot inspect directly.",
|
|
1194
|
+
"When a Meta media result is truncated, continue from its saved temp file with read using offset/limit.",
|
|
1195
|
+
],
|
|
1196
|
+
parameters: Type.Object(
|
|
1197
|
+
{
|
|
1198
|
+
path: Type.Optional(Type.String({ description: "Local .wav/.mp3 path" })),
|
|
1199
|
+
url: Type.Optional(Type.String({ description: "Public HTTPS or data audio URL" })),
|
|
1200
|
+
file_id: Type.Optional(Type.String({ description: "Existing Meta Files API id" })),
|
|
1201
|
+
prompt: Type.Optional(Type.String({ description: "Transcription or audio-analysis instruction" })),
|
|
1202
|
+
model: Type.Optional(Type.String({ description: "Muse model id, default muse-spark-1.2" })),
|
|
1203
|
+
max_output_tokens: Type.Optional(Type.Integer({
|
|
1204
|
+
minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
|
|
1205
|
+
maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
|
|
1206
|
+
description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
|
|
1207
|
+
})),
|
|
1208
|
+
max_chars: Type.Optional(Type.Integer({
|
|
1209
|
+
minimum: 1_000,
|
|
1210
|
+
maximum: MAX_MEDIA_MAX_CHARS,
|
|
1211
|
+
description: `Maximum transcript characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
|
|
1212
|
+
})),
|
|
1213
|
+
},
|
|
1214
|
+
{ additionalProperties: false },
|
|
1215
|
+
),
|
|
1200
1216
|
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
1201
|
-
const { path, url, file_id, prompt, model } = params as {
|
|
1202
|
-
path?: string;
|
|
1203
|
-
|
|
1204
|
-
file_id?: string;
|
|
1205
|
-
prompt?: string;
|
|
1206
|
-
model?: string;
|
|
1217
|
+
const { path, url, file_id, prompt, model, max_output_tokens, max_chars } = params as {
|
|
1218
|
+
path?: string; url?: string; file_id?: string; prompt?: string; model?: string;
|
|
1219
|
+
max_output_tokens?: number; max_chars?: number;
|
|
1207
1220
|
};
|
|
1208
|
-
if (
|
|
1209
|
-
|
|
1210
|
-
content: [
|
|
1211
|
-
{ type: "text", text: "Provide path, url, or file_id for audio." },
|
|
1212
|
-
],
|
|
1213
|
-
details: { error: "missing source" },
|
|
1214
|
-
};
|
|
1221
|
+
if ([path, url, file_id].filter(Boolean).length !== 1) {
|
|
1222
|
+
throw new Error("meta_transcribe_audio requires exactly one of path, url, or file_id");
|
|
1215
1223
|
}
|
|
1216
1224
|
try {
|
|
1217
|
-
const apiKey = await safeGetMetaApiKey(
|
|
1218
|
-
ctx as unknown as ExtensionContext,
|
|
1219
|
-
);
|
|
1225
|
+
const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
|
|
1220
1226
|
let audioBlock: ResponsesContentBlock;
|
|
1221
|
-
if (file_id) {
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
const absolute = resolve(
|
|
1227
|
-
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
1228
|
-
path,
|
|
1229
|
-
);
|
|
1230
|
-
if (!existsSync(absolute))
|
|
1231
|
-
throw new Error(`File not found: ${absolute}`);
|
|
1227
|
+
if (file_id) audioBlock = { type: "input_file", file_id };
|
|
1228
|
+
else if (url) audioBlock = mediaInputFromSource(url, "audio.bin");
|
|
1229
|
+
else {
|
|
1230
|
+
const absolute = resolve(ctx.cwd, path as string);
|
|
1231
|
+
if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
|
|
1232
1232
|
const stat = statSync(absolute);
|
|
1233
|
-
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
1234
|
-
throw new Error(`Audio too large (1 GiB limit)`);
|
|
1233
|
+
if (stat.size > FILES_API_LIMIT_BYTES) throw new Error("Audio too large (1 GiB limit)");
|
|
1235
1234
|
const mime = mimeForPath(absolute);
|
|
1236
|
-
if (
|
|
1237
|
-
throw new Error(
|
|
1238
|
-
`Unsupported audio mime ${mime}, need audio/wav or audio/mpeg`,
|
|
1239
|
-
);
|
|
1235
|
+
if (mime !== undefined && !isAudioMime(mime)) {
|
|
1236
|
+
throw new Error(`Unsupported audio type for ${path}; use MP3 or WAV`);
|
|
1240
1237
|
}
|
|
1241
1238
|
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1242
|
-
const
|
|
1243
|
-
audioBlock = { type: "input_file", file_id:
|
|
1239
|
+
const upload = await uploadMetaFile(apiKey, absolute, automaticExpiry());
|
|
1240
|
+
audioBlock = { type: "input_file", file_id: upload.id };
|
|
1244
1241
|
} else {
|
|
1245
|
-
|
|
1246
|
-
audioBlock = mediaInputFromSource(
|
|
1247
|
-
dataUrl,
|
|
1248
|
-
basename(absolute),
|
|
1249
|
-
mime,
|
|
1250
|
-
);
|
|
1242
|
+
audioBlock = mediaInputFromSource(dataUrlForFile(absolute).dataUrl, basename(absolute), mime);
|
|
1251
1243
|
}
|
|
1252
|
-
} else {
|
|
1253
|
-
throw new Error("No audio source");
|
|
1254
1244
|
}
|
|
1255
|
-
const
|
|
1256
|
-
|
|
1245
|
+
const selectedModel = model ?? "muse-spark-1.2";
|
|
1246
|
+
const { text, raw } = await callMetaResponses(apiKey, {
|
|
1247
|
+
model: selectedModel,
|
|
1257
1248
|
store: false,
|
|
1258
|
-
input: [
|
|
1259
|
-
{
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
prompt ??
|
|
1267
|
-
"Transcribe this audio. Return only the transcript.",
|
|
1268
|
-
},
|
|
1269
|
-
audioBlock as unknown as Record<string, unknown>,
|
|
1270
|
-
],
|
|
1271
|
-
},
|
|
1272
|
-
],
|
|
1273
|
-
max_output_tokens: 4000,
|
|
1274
|
-
};
|
|
1275
|
-
const { text, raw } = await callMetaResponses(apiKey, payload, signal);
|
|
1276
|
-
return {
|
|
1277
|
-
content: [{ type: "text", text }],
|
|
1278
|
-
details: { raw, audioBlock },
|
|
1279
|
-
};
|
|
1280
|
-
} catch (e) {
|
|
1249
|
+
input: [{ type: "message", role: "user", content: [
|
|
1250
|
+
{ type: "input_text", text: prompt ?? "Transcribe this audio. Return only the transcript." },
|
|
1251
|
+
audioBlock as Record<string, unknown>,
|
|
1252
|
+
] }],
|
|
1253
|
+
max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
|
|
1254
|
+
}, signal);
|
|
1255
|
+
const output = await prepareMediaOutput({ text, response: raw, identity: toolCallId, maxChars: max_chars });
|
|
1256
|
+
const usage = extractMetaResponseUsage(raw, selectedModel);
|
|
1281
1257
|
return {
|
|
1282
|
-
content: [
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
text: `Audio transcription failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1286
|
-
},
|
|
1287
|
-
],
|
|
1288
|
-
details: { error: String(e) },
|
|
1258
|
+
content: [{ type: "text", text: output.text }],
|
|
1259
|
+
details: { raw, audioBlock, ...output.details },
|
|
1260
|
+
usage,
|
|
1289
1261
|
};
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
mediaToolError("Audio transcription", error);
|
|
1290
1264
|
}
|
|
1291
1265
|
},
|
|
1292
1266
|
});
|
|
@@ -1295,63 +1269,46 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1295
1269
|
name: "meta_upload_file",
|
|
1296
1270
|
label: "Meta Files Upload",
|
|
1297
1271
|
description:
|
|
1298
|
-
"Upload a file to Meta Files API
|
|
1299
|
-
parameters: Type.Object(
|
|
1300
|
-
|
|
1301
|
-
description:
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
async execute(
|
|
1312
|
-
const { path, expires_after_seconds } = params as {
|
|
1313
|
-
path: string;
|
|
1314
|
-
expires_after_seconds?: number;
|
|
1272
|
+
"Upload a local file to Meta Files API for reuse. Uploads expire after seven days by default. Set expires_after_seconds to choose 3600..2592000 seconds, or retain=true to opt into no expiry.",
|
|
1273
|
+
parameters: Type.Object(
|
|
1274
|
+
{
|
|
1275
|
+
path: Type.String({ description: "Local file path to upload" }),
|
|
1276
|
+
expires_after_seconds: Type.Optional(Type.Integer({
|
|
1277
|
+
minimum: MIN_UPLOAD_EXPIRY_SECONDS,
|
|
1278
|
+
maximum: MAX_UPLOAD_EXPIRY_SECONDS,
|
|
1279
|
+
description: `Expiry in seconds, default ${EXPLICIT_UPLOAD_EXPIRY_SECONDS} (7 days)`,
|
|
1280
|
+
})),
|
|
1281
|
+
retain: Type.Optional(Type.Boolean({ description: "Keep indefinitely instead of applying an expiry" })),
|
|
1282
|
+
},
|
|
1283
|
+
{ additionalProperties: false },
|
|
1284
|
+
),
|
|
1285
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1286
|
+
const { path, expires_after_seconds, retain } = params as {
|
|
1287
|
+
path: string; expires_after_seconds?: number; retain?: boolean;
|
|
1315
1288
|
};
|
|
1289
|
+
if (retain && expires_after_seconds !== undefined) {
|
|
1290
|
+
throw new Error("meta_upload_file accepts either retain=true or expires_after_seconds, not both");
|
|
1291
|
+
}
|
|
1316
1292
|
try {
|
|
1317
|
-
const apiKey = await safeGetMetaApiKey(
|
|
1318
|
-
|
|
1319
|
-
);
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
if (!existsSync(absolute))
|
|
1325
|
-
throw new Error(`File not found: ${absolute}`);
|
|
1326
|
-
let expiresAfter: { anchor: "created_at"; seconds: number } | undefined;
|
|
1327
|
-
if (expires_after_seconds !== undefined) {
|
|
1328
|
-
if (expires_after_seconds < 3600 || expires_after_seconds > 2592000)
|
|
1329
|
-
throw new Error("expires_after_seconds must be 3600..2592000");
|
|
1330
|
-
expiresAfter = {
|
|
1331
|
-
anchor: "created_at",
|
|
1332
|
-
seconds: expires_after_seconds,
|
|
1333
|
-
};
|
|
1334
|
-
}
|
|
1293
|
+
const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
|
|
1294
|
+
const absolute = resolve(ctx.cwd, path);
|
|
1295
|
+
if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
|
|
1296
|
+
const expiresAfter = retain ? undefined : {
|
|
1297
|
+
anchor: "created_at" as const,
|
|
1298
|
+
seconds: expires_after_seconds ?? EXPLICIT_UPLOAD_EXPIRY_SECONDS,
|
|
1299
|
+
};
|
|
1335
1300
|
const result = await uploadMetaFile(apiKey, absolute, expiresAfter);
|
|
1301
|
+
const retention = retain
|
|
1302
|
+
? "retained without expiry"
|
|
1303
|
+
: result.expires_at
|
|
1304
|
+
? `expires_at ${result.expires_at}`
|
|
1305
|
+
: `expires after ${expiresAfter?.seconds} seconds`;
|
|
1336
1306
|
return {
|
|
1337
|
-
content: [
|
|
1338
|
-
{
|
|
1339
|
-
type: "text",
|
|
1340
|
-
text: `Uploaded ${basename(absolute)} → ${result.id} (${result.bytes} bytes, status: ${result.status}${result.expires_at ? `, expires_at: ${result.expires_at}` : ""}). Use with {type:"input_file", file_id:"${result.id}"}.`,
|
|
1341
|
-
},
|
|
1342
|
-
],
|
|
1307
|
+
content: [{ type: "text", text: `Uploaded ${basename(absolute)} to ${result.id} (${result.bytes} bytes, ${retention}).` }],
|
|
1343
1308
|
details: result,
|
|
1344
1309
|
};
|
|
1345
|
-
} catch (
|
|
1346
|
-
|
|
1347
|
-
content: [
|
|
1348
|
-
{
|
|
1349
|
-
type: "text",
|
|
1350
|
-
text: `Upload failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1351
|
-
},
|
|
1352
|
-
],
|
|
1353
|
-
details: { error: String(e) },
|
|
1354
|
-
};
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
mediaToolError("File upload", error);
|
|
1355
1312
|
}
|
|
1356
1313
|
},
|
|
1357
1314
|
});
|
|
@@ -1360,85 +1317,79 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1360
1317
|
name: "meta_analyze_file",
|
|
1361
1318
|
label: "Meta File Analyze",
|
|
1362
1319
|
description:
|
|
1363
|
-
"Analyze
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1320
|
+
"Analyze one or more images, PDFs, videos, or audio files with Muse Spark. Use path/url/file_id for one source, or ordered sources for comparisons. Images and PDF pages share Meta's 50-image budget; PDFs include text from the first 100 pages and images from the first 50 pages. Truncated text is saved for continuation with read offset/limit.",
|
|
1321
|
+
promptSnippet: "Inspect images, PDFs, or mixed media when the active model cannot",
|
|
1322
|
+
promptGuidelines: [
|
|
1323
|
+
"Use meta_analyze_file when the task depends on an image, PDF, or generic media file the active model cannot inspect directly.",
|
|
1324
|
+
"Give meta_analyze_file a task-specific prompt that asks for the exact visible text, layout, comparison, defect, or action evidence needed next.",
|
|
1325
|
+
"When a Meta media result is truncated, continue from its saved temp file with read using offset/limit.",
|
|
1326
|
+
],
|
|
1327
|
+
parameters: Type.Object(
|
|
1328
|
+
{
|
|
1329
|
+
path: Type.Optional(Type.String({ description: "Single local file path" })),
|
|
1330
|
+
url: Type.Optional(Type.String({ description: "Single public HTTPS or data URL" })),
|
|
1331
|
+
file_id: Type.Optional(Type.String({ description: "Single existing Meta file_id" })),
|
|
1332
|
+
sources: Type.Optional(Type.Array(Type.Object({
|
|
1333
|
+
source: Type.String({ description: "Local path, HTTPS/data URL, or Meta file_id" }),
|
|
1334
|
+
label: Type.Optional(Type.String({ description: "Human-readable label such as Before or After" })),
|
|
1335
|
+
}, { additionalProperties: false }), {
|
|
1336
|
+
minItems: 1,
|
|
1337
|
+
maxItems: MAX_ANALYSIS_SOURCES,
|
|
1338
|
+
description: "Ordered sources for comparison or combined analysis",
|
|
1339
|
+
})),
|
|
1340
|
+
prompt: Type.String({ description: "What evidence to extract or compare" }),
|
|
1341
|
+
model: Type.Optional(Type.String({ description: "Muse model id, default muse-spark-1.2" })),
|
|
1342
|
+
max_output_tokens: Type.Optional(Type.Integer({
|
|
1343
|
+
minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
|
|
1344
|
+
maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
|
|
1345
|
+
description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
|
|
1346
|
+
})),
|
|
1347
|
+
max_chars: Type.Optional(Type.Integer({
|
|
1348
|
+
minimum: 1_000,
|
|
1349
|
+
maximum: MAX_MEDIA_MAX_CHARS,
|
|
1350
|
+
description: `Maximum result characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
|
|
1351
|
+
})),
|
|
1352
|
+
},
|
|
1353
|
+
{ additionalProperties: false },
|
|
1354
|
+
),
|
|
1373
1355
|
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
1374
|
-
const { path, url, file_id, prompt, model } = params as {
|
|
1375
|
-
path?: string;
|
|
1376
|
-
|
|
1377
|
-
file_id?: string;
|
|
1378
|
-
prompt: string;
|
|
1379
|
-
model?: string;
|
|
1356
|
+
const { path, url, file_id, sources, prompt, model, max_output_tokens, max_chars } = params as {
|
|
1357
|
+
path?: string; url?: string; file_id?: string; sources?: AnalysisSource[];
|
|
1358
|
+
prompt: string; model?: string; max_output_tokens?: number; max_chars?: number;
|
|
1380
1359
|
};
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1360
|
+
const legacySources = [path, url, file_id].filter((value): value is string => Boolean(value));
|
|
1361
|
+
if (legacySources.length > 1 || (legacySources.length === 1 && sources) || (legacySources.length === 0 && !sources)) {
|
|
1362
|
+
throw new Error("meta_analyze_file requires exactly one path/url/file_id or a sources array");
|
|
1363
|
+
}
|
|
1364
|
+
if (sources && sources.length > MAX_ANALYSIS_SOURCES) {
|
|
1365
|
+
throw new Error(`meta_analyze_file supports at most ${MAX_ANALYSIS_SOURCES} sources`);
|
|
1386
1366
|
}
|
|
1387
1367
|
try {
|
|
1388
|
-
const apiKey = await safeGetMetaApiKey(
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
else if (path) {
|
|
1395
|
-
const absolute = resolve(
|
|
1396
|
-
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
1397
|
-
path,
|
|
1398
|
-
);
|
|
1399
|
-
if (!existsSync(absolute))
|
|
1400
|
-
throw new Error(`File not found: ${absolute}`);
|
|
1401
|
-
const stat = statSync(absolute);
|
|
1402
|
-
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
1403
|
-
throw new Error(`File too large (1 GiB limit)`);
|
|
1404
|
-
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1405
|
-
const up = await uploadMetaFile(apiKey, absolute);
|
|
1406
|
-
fileBlock = { type: "input_file", file_id: up.id };
|
|
1407
|
-
} else {
|
|
1408
|
-
const { dataUrl, mime } = dataUrlForFile(absolute);
|
|
1409
|
-
fileBlock = mediaInputFromSource(dataUrl, basename(absolute), mime);
|
|
1368
|
+
const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
|
|
1369
|
+
const requestedSources: AnalysisSource[] = sources ?? [{ source: legacySources[0] as string }];
|
|
1370
|
+
const content: ResponsesContentBlock[] = [{ type: "input_text", text: prompt }];
|
|
1371
|
+
for (const [index, item] of requestedSources.entries()) {
|
|
1372
|
+
if (requestedSources.length > 1 || item.label) {
|
|
1373
|
+
content.push({ type: "input_text", text: `${sourceLabel(item, index)}:` });
|
|
1410
1374
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1375
|
+
content.push(await resolveGenericMediaSource(apiKey, ctx.cwd, item.source));
|
|
1376
|
+
}
|
|
1377
|
+
const selectedModel = model ?? "muse-spark-1.2";
|
|
1378
|
+
const { text, raw } = await callMetaResponses(apiKey, {
|
|
1379
|
+
model: selectedModel,
|
|
1414
1380
|
store: false,
|
|
1415
|
-
input: [
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
{ type: "input_text", text: prompt },
|
|
1421
|
-
fileBlock as unknown as Record<string, unknown>,
|
|
1422
|
-
],
|
|
1423
|
-
},
|
|
1424
|
-
],
|
|
1425
|
-
max_output_tokens: 4000,
|
|
1426
|
-
};
|
|
1427
|
-
const { text, raw } = await callMetaResponses(apiKey, payload, signal);
|
|
1428
|
-
return {
|
|
1429
|
-
content: [{ type: "text", text }],
|
|
1430
|
-
details: { raw, fileBlock },
|
|
1431
|
-
};
|
|
1432
|
-
} catch (e) {
|
|
1381
|
+
input: [{ type: "message", role: "user", content: content as Record<string, unknown>[] }],
|
|
1382
|
+
max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
|
|
1383
|
+
}, signal);
|
|
1384
|
+
const output = await prepareMediaOutput({ text, response: raw, identity: toolCallId, maxChars: max_chars });
|
|
1385
|
+
const usage = extractMetaResponseUsage(raw, selectedModel);
|
|
1433
1386
|
return {
|
|
1434
|
-
content: [
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
text: `File analysis failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1438
|
-
},
|
|
1439
|
-
],
|
|
1440
|
-
details: { error: String(e) },
|
|
1387
|
+
content: [{ type: "text", text: output.text }],
|
|
1388
|
+
details: { raw, sourceCount: requestedSources.length, ...output.details },
|
|
1389
|
+
usage,
|
|
1441
1390
|
};
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
mediaToolError("File analysis", error);
|
|
1442
1393
|
}
|
|
1443
1394
|
},
|
|
1444
1395
|
});
|
|
@@ -1490,7 +1441,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1490
1441
|
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1491
1442
|
const stat = statSync(abs);
|
|
1492
1443
|
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1493
|
-
const up = await uploadMetaFile(apiKey, abs);
|
|
1444
|
+
const up = await uploadMetaFile(apiKey, abs, automaticExpiry());
|
|
1494
1445
|
block = { type: "input_file", file_id: up.id };
|
|
1495
1446
|
} else {
|
|
1496
1447
|
const { dataUrl } = dataUrlForFile(abs);
|
|
@@ -1564,7 +1515,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1564
1515
|
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1565
1516
|
const stat = statSync(abs);
|
|
1566
1517
|
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1567
|
-
const up = await uploadMetaFile(apiKey, abs);
|
|
1518
|
+
const up = await uploadMetaFile(apiKey, abs, automaticExpiry());
|
|
1568
1519
|
block = { type: "input_file", file_id: up.id };
|
|
1569
1520
|
} else {
|
|
1570
1521
|
const { dataUrl, mime } = dataUrlForFile(abs);
|
|
@@ -1632,7 +1583,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1632
1583
|
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1633
1584
|
const stat = statSync(abs);
|
|
1634
1585
|
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1635
|
-
const up = await uploadMetaFile(apiKey, abs);
|
|
1586
|
+
const up = await uploadMetaFile(apiKey, abs, automaticExpiry());
|
|
1636
1587
|
block = { type: "input_file", file_id: up.id };
|
|
1637
1588
|
} else {
|
|
1638
1589
|
const { dataUrl, mime } = dataUrlForFile(abs);
|
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 {
|