pi-meta-oauth 0.2.3 → 0.3.0
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/extensions/media.ts +1446 -0
- package/extensions/meta.ts +43 -8
- package/package.json +2 -1
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { META_FILES_URL, META_API_BASE_URL, META_PROVIDER_ID } from "./meta.ts";
|
|
7
|
+
import { existsSync, statSync, readFileSync } from "node:fs";
|
|
8
|
+
import { basename, extname, resolve } from "node:path";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Meta Files API + Responses API
|
|
13
|
+
// Docs: https://dev.meta.ai/docs/file-handling , /video-understanding
|
|
14
|
+
// - Inline file_data / file_url / video_url / audio : 50 MB limit
|
|
15
|
+
// - Files API POST /v1/files purpose=user_data : 1 GiB, 100 GiB/team storage, no expiry by default
|
|
16
|
+
// - Video: video/mp4 only, reads frames + embedded audio together
|
|
17
|
+
// - Audio: audio/mpeg (.mp3), audio/wav (.wav) via input_audio {data, format}
|
|
18
|
+
// - Image: image/png, image/jpeg, image/gif, image/webp, image/x-icon, up to 50/request
|
|
19
|
+
// - PDF: application/pdf -> text first 100p + images first 50p (counts to 50-image budget)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const INLINE_LIMIT_BYTES = 50_000_000;
|
|
23
|
+
const FILES_API_LIMIT_BYTES = 1_073_741_824;
|
|
24
|
+
|
|
25
|
+
type SupportedMime =
|
|
26
|
+
| "video/mp4"
|
|
27
|
+
| "audio/mpeg"
|
|
28
|
+
| "audio/wav"
|
|
29
|
+
| "application/pdf"
|
|
30
|
+
| "image/png"
|
|
31
|
+
| "image/jpeg"
|
|
32
|
+
| "image/gif"
|
|
33
|
+
| "image/webp"
|
|
34
|
+
| "image/x-icon"
|
|
35
|
+
| "text/plain"
|
|
36
|
+
| "application/json"
|
|
37
|
+
| "application/jsonl";
|
|
38
|
+
|
|
39
|
+
const EXT_TO_MIME: Record<string, SupportedMime> = {
|
|
40
|
+
".mp4": "video/mp4",
|
|
41
|
+
".m4v": "video/mp4",
|
|
42
|
+
".mp3": "audio/mpeg",
|
|
43
|
+
".wav": "audio/wav",
|
|
44
|
+
".pdf": "application/pdf",
|
|
45
|
+
".png": "image/png",
|
|
46
|
+
".jpg": "image/jpeg",
|
|
47
|
+
".jpeg": "image/jpeg",
|
|
48
|
+
".gif": "image/gif",
|
|
49
|
+
".webp": "image/webp",
|
|
50
|
+
".ico": "image/x-icon",
|
|
51
|
+
".txt": "text/plain",
|
|
52
|
+
".json": "application/json",
|
|
53
|
+
".jsonl": "application/jsonl",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function mimeForPath(path: string): SupportedMime | undefined {
|
|
57
|
+
const ext = extname(path).toLowerCase();
|
|
58
|
+
return EXT_TO_MIME[ext];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mimeForDataUrl(dataUrl: string): string | undefined {
|
|
62
|
+
const m = dataUrl.match(/^data:([^;,\s]+)[;,]/);
|
|
63
|
+
return m?.[1]?.toLowerCase();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isVideoMime(m: string | undefined): boolean {
|
|
67
|
+
return m === "video/mp4";
|
|
68
|
+
}
|
|
69
|
+
function isAudioMime(m: string | undefined): boolean {
|
|
70
|
+
return (
|
|
71
|
+
m === "audio/mpeg" ||
|
|
72
|
+
m === "audio/mp3" ||
|
|
73
|
+
m === "audio/wav" ||
|
|
74
|
+
m === "audio/x-wav"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
function isPdfMime(m: string | undefined): boolean {
|
|
78
|
+
return m === "application/pdf";
|
|
79
|
+
}
|
|
80
|
+
function isImageMime(m: string | undefined): boolean {
|
|
81
|
+
return !!m && m.startsWith("image/");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function audioFormatForMime(mime: string): "wav" | "mp3" {
|
|
85
|
+
const lower = mime.toLowerCase();
|
|
86
|
+
if (lower === "audio/wav" || lower === "audio/x-wav") return "wav";
|
|
87
|
+
return "mp3"; // audio/mpeg, audio/mp3
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function base64FromDataUrl(dataUrl: string): string {
|
|
91
|
+
const comma = dataUrl.indexOf(",");
|
|
92
|
+
return comma === -1 ? "" : dataUrl.slice(comma + 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function safeNotify(
|
|
96
|
+
ctx:
|
|
97
|
+
| ExtensionContext
|
|
98
|
+
| { ui: { notify: (msg: string, level?: string) => void } },
|
|
99
|
+
message: string,
|
|
100
|
+
level: "info" | "warning" | "error" = "info",
|
|
101
|
+
): void {
|
|
102
|
+
try {
|
|
103
|
+
ctx.ui.notify(message, level as never);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const msg = String(error);
|
|
106
|
+
if (
|
|
107
|
+
msg.includes("stale after session replacement") ||
|
|
108
|
+
msg.includes("Extension ctx is stale")
|
|
109
|
+
)
|
|
110
|
+
return;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function safeGetMetaApiKey(ctx: ExtensionContext): Promise<string> {
|
|
116
|
+
try {
|
|
117
|
+
return await getMetaApiKey(ctx);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (String(error).includes("stale after session replacement"))
|
|
120
|
+
throw new Error("Session was replaced — please retry the command");
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function getMetaApiKey(ctx: ExtensionContext): Promise<string> {
|
|
126
|
+
const key = await ctx.modelRegistry.getApiKeyForProvider(META_PROVIDER_ID);
|
|
127
|
+
if (!key)
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Meta credentials unavailable — run /login ${META_PROVIDER_ID}`,
|
|
130
|
+
);
|
|
131
|
+
return key;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function getMetaApiKeyFromRegistry(
|
|
135
|
+
_api: ExtensionAPI,
|
|
136
|
+
): Promise<string | undefined> {
|
|
137
|
+
// For tools that receive ExtensionContext we prefer ctx.modelRegistry; this is fallback for non-context paths.
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface UploadResult {
|
|
142
|
+
id: string;
|
|
143
|
+
filename: string;
|
|
144
|
+
bytes: number;
|
|
145
|
+
purpose: string;
|
|
146
|
+
status: string;
|
|
147
|
+
expires_at?: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function uploadMetaFile(
|
|
151
|
+
apiKey: string,
|
|
152
|
+
filePath: string,
|
|
153
|
+
expiresAfter?: { anchor: "created_at"; seconds: number },
|
|
154
|
+
): Promise<UploadResult> {
|
|
155
|
+
const data = await readFile(filePath);
|
|
156
|
+
if (data.byteLength > FILES_API_LIMIT_BYTES) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`File too large for Files API (${data.byteLength} bytes > 1 GiB)`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const mime = mimeForPath(filePath) ?? "application/octet-stream";
|
|
162
|
+
const blob = new Blob([data], { type: mime });
|
|
163
|
+
const form = new FormData();
|
|
164
|
+
// OpenAI SDK uses `file` field name; Meta docs show same.
|
|
165
|
+
form.append("file", blob, basename(filePath));
|
|
166
|
+
form.append("purpose", "user_data");
|
|
167
|
+
if (expiresAfter) {
|
|
168
|
+
form.append("expires_after[anchor]", expiresAfter.anchor);
|
|
169
|
+
form.append("expires_after[seconds]", String(expiresAfter.seconds));
|
|
170
|
+
}
|
|
171
|
+
const res = await fetch(META_FILES_URL, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
174
|
+
body: form,
|
|
175
|
+
});
|
|
176
|
+
const bodyText = await res.text();
|
|
177
|
+
let body: Record<string, unknown> = {};
|
|
178
|
+
try {
|
|
179
|
+
body = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// Non-JSON body — keep empty and use raw text for error detail
|
|
182
|
+
void error;
|
|
183
|
+
}
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
const detail =
|
|
186
|
+
typeof body.error === "string"
|
|
187
|
+
? body.error
|
|
188
|
+
: typeof (body as { message?: unknown }).message === "string"
|
|
189
|
+
? (body as { message?: string }).message
|
|
190
|
+
: typeof (body as { error_description?: unknown })
|
|
191
|
+
.error_description === "string"
|
|
192
|
+
? (body as { error_description?: string }).error_description
|
|
193
|
+
: bodyText.slice(0, 500);
|
|
194
|
+
if (
|
|
195
|
+
res.status === 400 &&
|
|
196
|
+
typeof detail === "string" &&
|
|
197
|
+
detail.includes("storage")
|
|
198
|
+
) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Meta Files storage limit reached (100 GiB/team). Delete old files via DELETE /v1/files/{id}. Detail: ${detail}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Meta Files upload failed (HTTP ${res.status}): ${detail || res.statusText}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const id =
|
|
208
|
+
typeof body.id === "string"
|
|
209
|
+
? body.id
|
|
210
|
+
: typeof (body as { file_id?: unknown }).file_id === "string"
|
|
211
|
+
? (body as { file_id: string }).file_id
|
|
212
|
+
: undefined;
|
|
213
|
+
if (!id)
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Meta Files upload returned no id: ${bodyText.slice(0, 500)}`,
|
|
216
|
+
);
|
|
217
|
+
return {
|
|
218
|
+
id,
|
|
219
|
+
filename: (typeof body.filename === "string"
|
|
220
|
+
? body.filename
|
|
221
|
+
: basename(filePath)) as string,
|
|
222
|
+
bytes: typeof body.bytes === "number" ? body.bytes : data.byteLength,
|
|
223
|
+
purpose: typeof body.purpose === "string" ? body.purpose : "user_data",
|
|
224
|
+
status: typeof body.status === "string" ? body.status : "uploaded",
|
|
225
|
+
expires_at:
|
|
226
|
+
typeof body.expires_at === "number" ? body.expires_at : undefined,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const ALLOWED_META_HOSTS = ["api.meta.ai"] as const;
|
|
231
|
+
const ALLOWED_PURPOSES = ["user_data", "batch"] as const;
|
|
232
|
+
|
|
233
|
+
async function listMetaFiles(
|
|
234
|
+
apiKey: string,
|
|
235
|
+
purpose = "user_data",
|
|
236
|
+
): Promise<unknown> {
|
|
237
|
+
if (purpose && !(ALLOWED_PURPOSES as readonly string[]).includes(purpose)) {
|
|
238
|
+
throw new Error(`Invalid purpose: ${purpose}`);
|
|
239
|
+
}
|
|
240
|
+
let url: URL;
|
|
241
|
+
try {
|
|
242
|
+
url = new URL(META_FILES_URL);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
throw new Error(`Invalid Meta Files URL: ${String(error)}`);
|
|
245
|
+
}
|
|
246
|
+
if (!(ALLOWED_META_HOSTS as readonly string[]).includes(url.hostname))
|
|
247
|
+
throw new Error(`Unexpected Files API host: ${url.hostname}`);
|
|
248
|
+
if (purpose) url.searchParams.set("purpose", purpose);
|
|
249
|
+
const res = await fetch(url.toString(), {
|
|
250
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
251
|
+
});
|
|
252
|
+
const text = await res.text();
|
|
253
|
+
if (!res.ok)
|
|
254
|
+
throw new Error(
|
|
255
|
+
`List files failed (HTTP ${res.status}): ${text.slice(0, 1000)}`,
|
|
256
|
+
);
|
|
257
|
+
try {
|
|
258
|
+
return JSON.parse(text);
|
|
259
|
+
} catch {
|
|
260
|
+
return text;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function deleteMetaFile(
|
|
265
|
+
apiKey: string,
|
|
266
|
+
fileId: string,
|
|
267
|
+
): Promise<unknown> {
|
|
268
|
+
if (!/^file-[a-zA-Z0-9_-]+$/.test(fileId))
|
|
269
|
+
throw new Error(`Invalid file ID: ${fileId}`);
|
|
270
|
+
let fileUrl: string;
|
|
271
|
+
try {
|
|
272
|
+
fileUrl = `${new URL(META_FILES_URL).toString()}/${encodeURIComponent(fileId)}`;
|
|
273
|
+
} catch (error) {
|
|
274
|
+
throw new Error(`Invalid file URL: ${String(error)}`);
|
|
275
|
+
}
|
|
276
|
+
if (!fileUrl.startsWith("https://api.meta.ai/"))
|
|
277
|
+
throw new Error(`Unexpected file delete URL: ${fileUrl}`);
|
|
278
|
+
const res = await fetch(fileUrl, {
|
|
279
|
+
method: "DELETE",
|
|
280
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
281
|
+
});
|
|
282
|
+
const text = await res.text();
|
|
283
|
+
if (!res.ok)
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Delete ${fileId} failed (HTTP ${res.status}): ${text.slice(0, 1000)}`,
|
|
286
|
+
);
|
|
287
|
+
try {
|
|
288
|
+
return JSON.parse(text);
|
|
289
|
+
} catch {
|
|
290
|
+
return text;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function responsesUrl(): string {
|
|
295
|
+
return `${META_API_BASE_URL}/responses`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface ResponsesContentBlock {
|
|
299
|
+
type: string;
|
|
300
|
+
[key: string]: unknown;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Helper to extract output_text from Responses API response (covers streamed and non-streamed shapes)
|
|
304
|
+
function extractResponsesText(json: unknown): string {
|
|
305
|
+
if (!json || typeof json !== "object") return "";
|
|
306
|
+
const j = json as Record<string, unknown>;
|
|
307
|
+
// SDK shape: output_text
|
|
308
|
+
if (typeof j.output_text === "string") return j.output_text;
|
|
309
|
+
// output array
|
|
310
|
+
const output = j.output as unknown;
|
|
311
|
+
if (Array.isArray(output)) {
|
|
312
|
+
const texts: string[] = [];
|
|
313
|
+
for (const item of output as Record<string, unknown>[]) {
|
|
314
|
+
if (!item || typeof item !== "object") continue;
|
|
315
|
+
if (item.type === "message" && Array.isArray(item.content)) {
|
|
316
|
+
for (const c of item.content as Record<string, unknown>[]) {
|
|
317
|
+
if (c.type === "output_text" && typeof c.text === "string")
|
|
318
|
+
texts.push(c.text);
|
|
319
|
+
if (c.type === "refusal" && typeof c.refusal === "string")
|
|
320
|
+
texts.push(c.refusal);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (texts.length) return texts.join("\n\n");
|
|
325
|
+
}
|
|
326
|
+
if (typeof j.text === "string") return j.text;
|
|
327
|
+
return JSON.stringify(json, null, 2).slice(0, 8000);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function callMetaResponses(
|
|
331
|
+
apiKey: string,
|
|
332
|
+
payload: Record<string, unknown>,
|
|
333
|
+
signal?: AbortSignal,
|
|
334
|
+
): Promise<{ text: string; raw: unknown }> {
|
|
335
|
+
const res = await fetch(responsesUrl(), {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: {
|
|
338
|
+
Authorization: `Bearer ${apiKey}`,
|
|
339
|
+
"Content-Type": "application/json",
|
|
340
|
+
"x-api-version": "1.0.0",
|
|
341
|
+
},
|
|
342
|
+
body: JSON.stringify(payload),
|
|
343
|
+
signal,
|
|
344
|
+
});
|
|
345
|
+
const text = await res.text();
|
|
346
|
+
let json: unknown;
|
|
347
|
+
try {
|
|
348
|
+
json = text ? (JSON.parse(text) as unknown) : {};
|
|
349
|
+
} catch {
|
|
350
|
+
json = text;
|
|
351
|
+
}
|
|
352
|
+
if (!res.ok) {
|
|
353
|
+
const detail =
|
|
354
|
+
(json &&
|
|
355
|
+
typeof json === "object" &&
|
|
356
|
+
"error" in (json as Record<string, unknown>)
|
|
357
|
+
? JSON.stringify((json as Record<string, unknown>).error).slice(0, 1000)
|
|
358
|
+
: typeof json === "object" &&
|
|
359
|
+
json !== null &&
|
|
360
|
+
"message" in (json as Record<string, unknown>)
|
|
361
|
+
? String((json as Record<string, unknown>).message).slice(0, 1000)
|
|
362
|
+
: text.slice(0, 1000)) || res.statusText;
|
|
363
|
+
throw new Error(`Meta Responses failed (HTTP ${res.status}): ${detail}`);
|
|
364
|
+
}
|
|
365
|
+
return { text: extractResponsesText(json), raw: json };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function dataUrlForFile(path: string): {
|
|
369
|
+
dataUrl: string;
|
|
370
|
+
mime: string;
|
|
371
|
+
bytes: number;
|
|
372
|
+
} {
|
|
373
|
+
const data = readFileSync(path);
|
|
374
|
+
const mime = mimeForPath(path) ?? "application/octet-stream";
|
|
375
|
+
const b64 = data.toString("base64");
|
|
376
|
+
return {
|
|
377
|
+
dataUrl: `data:${mime};base64,${b64}`,
|
|
378
|
+
mime,
|
|
379
|
+
bytes: data.byteLength,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Before-provider-request rewrite (Option B)
|
|
385
|
+
// Makes @video.mp4 / @audio.wav / @doc.pdf work transparently even though
|
|
386
|
+
// pi-ai's UserMessage is still string | (TextContent | ImageContent)[].
|
|
387
|
+
// We smuggle video/audio/pdf as ImageContent (data:video/..., data:audio/..., data:application/pdf...)
|
|
388
|
+
// via the input handler or via read tool results; this hook rewrites the
|
|
389
|
+
// resulting {type:"input_image", image_url:"data:video/..."} into the correct
|
|
390
|
+
// {type:"input_video", video_url:...} / {type:"input_audio", input_audio:{data,format}} /
|
|
391
|
+
// {type:"input_file", file_data:...} blocks before the request is sent.
|
|
392
|
+
// Also handles https://... video URLs and large inline payloads by uploading via Files API.
|
|
393
|
+
// Enable/disable via PI_META_NATIVE_ATTACHMENTS env (default: on).
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
function shouldEnableNativeRewrite(): boolean {
|
|
397
|
+
const v =
|
|
398
|
+
process.env.PI_META_NATIVE_ATTACHMENTS ?? process.env.PI_META_MEDIA_NATIVE;
|
|
399
|
+
if (v === undefined) return true; // default on — set to "0"/"false" to disable
|
|
400
|
+
return v !== "0" && v.toLowerCase() !== "false" && v.toLowerCase() !== "off";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isInputImageBlock(b: Record<string, unknown>): boolean {
|
|
404
|
+
return b.type === "input_image" && typeof b.image_url === "string";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function rewriteBlock(
|
|
408
|
+
block: Record<string, unknown>,
|
|
409
|
+
): Record<string, unknown> | null {
|
|
410
|
+
if (!isInputImageBlock(block)) return null;
|
|
411
|
+
const url = block.image_url as string;
|
|
412
|
+
const mime = mimeForDataUrl(url);
|
|
413
|
+
// Also handle https URLs ending with .mp4/.wav/.mp3/.pdf
|
|
414
|
+
const lowerUrl = url.toLowerCase();
|
|
415
|
+
const isHttpsVideo =
|
|
416
|
+
url.startsWith("https://") &&
|
|
417
|
+
(lowerUrl.endsWith(".mp4") || lowerUrl.endsWith(".m4v"));
|
|
418
|
+
const isHttpsAudio =
|
|
419
|
+
url.startsWith("https://") &&
|
|
420
|
+
(lowerUrl.endsWith(".mp3") || lowerUrl.endsWith(".wav"));
|
|
421
|
+
const isHttpsPdf = url.startsWith("https://") && lowerUrl.endsWith(".pdf");
|
|
422
|
+
const isHttpsImage =
|
|
423
|
+
url.startsWith("https://") &&
|
|
424
|
+
(lowerUrl.endsWith(".png") ||
|
|
425
|
+
lowerUrl.endsWith(".jpg") ||
|
|
426
|
+
lowerUrl.endsWith(".jpeg") ||
|
|
427
|
+
lowerUrl.endsWith(".gif") ||
|
|
428
|
+
lowerUrl.endsWith(".webp"));
|
|
429
|
+
|
|
430
|
+
if (isHttpsImage || isImageMime(mime)) return null; // keep as image
|
|
431
|
+
|
|
432
|
+
if (isVideoMime(mime) || isHttpsVideo) {
|
|
433
|
+
// input_video with video_url supports both https and data: URLs per docs
|
|
434
|
+
return { type: "input_video", video_url: url };
|
|
435
|
+
}
|
|
436
|
+
if (isAudioMime(mime) || isHttpsAudio) {
|
|
437
|
+
const data = url.startsWith("data:") ? base64FromDataUrl(url) : url;
|
|
438
|
+
const effectiveMime =
|
|
439
|
+
typeof mime === "string"
|
|
440
|
+
? mime
|
|
441
|
+
: lowerUrl.endsWith(".wav")
|
|
442
|
+
? "audio/wav"
|
|
443
|
+
: "audio/mpeg";
|
|
444
|
+
const format = audioFormatForMime(effectiveMime);
|
|
445
|
+
// Responses API input_audio shape: {type:"input_audio", input_audio:{data, format}}
|
|
446
|
+
if (url.startsWith("data:")) {
|
|
447
|
+
return { type: "input_audio", input_audio: { data, format } };
|
|
448
|
+
}
|
|
449
|
+
// For https audio URL, use input_audio with audio_url-like fallback? Docs: input_audio can be file_id or audio_url data URI or inline.
|
|
450
|
+
// For public https audio, the cleanest is input_audio with audio_url is not documented for responses; use file_url via input_file if needed.
|
|
451
|
+
// Fallback: treat as input_file file_url
|
|
452
|
+
return { type: "input_file", file_url: url };
|
|
453
|
+
}
|
|
454
|
+
if (isPdfMime(mime) || isHttpsPdf) {
|
|
455
|
+
if (url.startsWith("https://")) {
|
|
456
|
+
return { type: "input_file", file_url: url };
|
|
457
|
+
}
|
|
458
|
+
// data:application/pdf;base64,... -> file_data
|
|
459
|
+
return { type: "input_file", file_data: url, filename: "document.pdf" };
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function rewriteResponsesPayload(payload: unknown): {
|
|
465
|
+
rewritten: boolean;
|
|
466
|
+
payload: unknown;
|
|
467
|
+
} {
|
|
468
|
+
if (!payload || typeof payload !== "object")
|
|
469
|
+
return { rewritten: false, payload };
|
|
470
|
+
const p = payload as Record<string, unknown>;
|
|
471
|
+
const input = p.input;
|
|
472
|
+
if (!Array.isArray(input)) return { rewritten: false, payload };
|
|
473
|
+
let rewritten = false;
|
|
474
|
+
const newInput = input.map((entry) => {
|
|
475
|
+
if (!entry || typeof entry !== "object") return entry;
|
|
476
|
+
const e = entry as Record<string, unknown>;
|
|
477
|
+
// case 1: user message {role:"user", content:[...]}
|
|
478
|
+
if (Array.isArray(e.content)) {
|
|
479
|
+
const newContent = (e.content as Record<string, unknown>[]).map(
|
|
480
|
+
(block) => {
|
|
481
|
+
const r = rewriteBlock(block as Record<string, unknown>);
|
|
482
|
+
if (r) {
|
|
483
|
+
rewritten = true;
|
|
484
|
+
return r;
|
|
485
|
+
}
|
|
486
|
+
return block;
|
|
487
|
+
},
|
|
488
|
+
);
|
|
489
|
+
if (newContent !== e.content) return { ...e, content: newContent };
|
|
490
|
+
}
|
|
491
|
+
// case 2: function_call_output {type:"function_call_output", output:[...]} where output contains input_image blocks
|
|
492
|
+
if (e.type === "function_call_output" && Array.isArray(e.output)) {
|
|
493
|
+
const newOutput = (e.output as Record<string, unknown>[]).map((block) => {
|
|
494
|
+
// output blocks are like {type:"input_text", text} or {type:"input_image", image_url}
|
|
495
|
+
if (
|
|
496
|
+
block.type === "input_image" &&
|
|
497
|
+
typeof block.image_url === "string"
|
|
498
|
+
) {
|
|
499
|
+
const r = rewriteBlock(block as Record<string, unknown>);
|
|
500
|
+
if (r) {
|
|
501
|
+
rewritten = true;
|
|
502
|
+
// tool outputs use same types as user content; return rewritten block
|
|
503
|
+
return r;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return block;
|
|
507
|
+
});
|
|
508
|
+
if (newOutput !== e.output) return { ...e, output: newOutput };
|
|
509
|
+
}
|
|
510
|
+
// case 3: custom_tool_call_output similar
|
|
511
|
+
if (e.type === "custom_tool_call_output" && Array.isArray(e.output)) {
|
|
512
|
+
const newOutput = (e.output as Record<string, unknown>[]).map((block) => {
|
|
513
|
+
if (
|
|
514
|
+
block.type === "input_image" &&
|
|
515
|
+
typeof block.image_url === "string"
|
|
516
|
+
) {
|
|
517
|
+
const r = rewriteBlock(block as Record<string, unknown>);
|
|
518
|
+
if (r) {
|
|
519
|
+
rewritten = true;
|
|
520
|
+
return r;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return block;
|
|
524
|
+
});
|
|
525
|
+
if (newOutput !== e.output) return { ...e, output: newOutput };
|
|
526
|
+
}
|
|
527
|
+
return e;
|
|
528
|
+
});
|
|
529
|
+
if (!rewritten) return { rewritten: false, payload };
|
|
530
|
+
return { rewritten: true, payload: { ...p, input: newInput } };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// For large inline data URLs (>50MB) we should upload via Files API instead of sending inline.
|
|
534
|
+
// This is async and needs apiKey. We do it lazily in before_provider_request if we can get a key.
|
|
535
|
+
// To avoid blocking forever we only upload when we can resolve a key from env or fallback.
|
|
536
|
+
async function maybeUploadLargeInlineBlocks(
|
|
537
|
+
payload: unknown,
|
|
538
|
+
apiKey: string | undefined,
|
|
539
|
+
): Promise<{ payload: unknown; uploaded: number }> {
|
|
540
|
+
if (!apiKey) return { payload, uploaded: 0 };
|
|
541
|
+
if (!payload || typeof payload !== "object") return { payload, uploaded: 0 };
|
|
542
|
+
const p = payload as Record<string, unknown>;
|
|
543
|
+
const input = p.input;
|
|
544
|
+
if (!Array.isArray(input)) return { payload, uploaded: 0 };
|
|
545
|
+
let uploaded = 0;
|
|
546
|
+
// Need to handle both rewritten and original blocks — check all input_* that carry inline data
|
|
547
|
+
const newInput: unknown[] = [];
|
|
548
|
+
for (const entry of input as Record<string, unknown>[]) {
|
|
549
|
+
if (
|
|
550
|
+
!entry ||
|
|
551
|
+
typeof entry !== "object" ||
|
|
552
|
+
!Array.isArray((entry as Record<string, unknown>).content)
|
|
553
|
+
) {
|
|
554
|
+
newInput.push(entry);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const e = entry as Record<string, unknown>;
|
|
558
|
+
const content = e.content as Record<string, unknown>[];
|
|
559
|
+
const newContent: Record<string, unknown>[] = [];
|
|
560
|
+
for (const block of content) {
|
|
561
|
+
if (
|
|
562
|
+
block.type === "input_video" &&
|
|
563
|
+
typeof block.video_url === "string" &&
|
|
564
|
+
block.video_url.startsWith("data:")
|
|
565
|
+
) {
|
|
566
|
+
const b64 = base64FromDataUrl(block.video_url as string);
|
|
567
|
+
const bytes = Math.floor((b64.length * 3) / 4);
|
|
568
|
+
if (bytes > INLINE_LIMIT_BYTES) {
|
|
569
|
+
// upload: create temp file? Instead, upload directly via fetch with blob
|
|
570
|
+
const mime = mimeForDataUrl(block.video_url as string) ?? "video/mp4";
|
|
571
|
+
const blob = new Blob([Buffer.from(b64, "base64")], { type: mime });
|
|
572
|
+
const form = new FormData();
|
|
573
|
+
form.append(
|
|
574
|
+
"file",
|
|
575
|
+
blob,
|
|
576
|
+
`video${mime === "video/mp4" ? ".mp4" : ""}`,
|
|
577
|
+
);
|
|
578
|
+
form.append("purpose", "user_data");
|
|
579
|
+
const res = await fetch(META_FILES_URL, {
|
|
580
|
+
method: "POST",
|
|
581
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
582
|
+
body: form,
|
|
583
|
+
});
|
|
584
|
+
const text = await res.text();
|
|
585
|
+
if (!res.ok)
|
|
586
|
+
throw new Error(
|
|
587
|
+
`Files upload for large video failed: ${text.slice(0, 500)}`,
|
|
588
|
+
);
|
|
589
|
+
let j: Record<string, unknown>;
|
|
590
|
+
try {
|
|
591
|
+
j = JSON.parse(text) as Record<string, unknown>;
|
|
592
|
+
} catch (error) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`Files upload returned invalid JSON: ${String(error)}`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const id = typeof j.id === "string" ? j.id : "";
|
|
598
|
+
if (!id)
|
|
599
|
+
throw new Error(`Upload returned no id: ${text.slice(0, 500)}`);
|
|
600
|
+
uploaded++;
|
|
601
|
+
newContent.push({ type: "input_file", file_id: id });
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (
|
|
606
|
+
block.type === "input_file" &&
|
|
607
|
+
typeof block.file_data === "string" &&
|
|
608
|
+
block.file_data.startsWith("data:")
|
|
609
|
+
) {
|
|
610
|
+
const b64 = base64FromDataUrl(block.file_data as string);
|
|
611
|
+
const bytes = Math.floor((b64.length * 3) / 4);
|
|
612
|
+
if (bytes > INLINE_LIMIT_BYTES) {
|
|
613
|
+
const mime =
|
|
614
|
+
mimeForDataUrl(block.file_data as string) ??
|
|
615
|
+
"application/octet-stream";
|
|
616
|
+
const blob = new Blob([Buffer.from(b64, "base64")], { type: mime });
|
|
617
|
+
const form = new FormData();
|
|
618
|
+
form.append(
|
|
619
|
+
"file",
|
|
620
|
+
blob,
|
|
621
|
+
`file${extname("file." + (mime.split("/")[1] || ""))}`,
|
|
622
|
+
);
|
|
623
|
+
form.append("purpose", "user_data");
|
|
624
|
+
const res = await fetch(META_FILES_URL, {
|
|
625
|
+
method: "POST",
|
|
626
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
627
|
+
body: form,
|
|
628
|
+
});
|
|
629
|
+
const text = await res.text();
|
|
630
|
+
if (!res.ok)
|
|
631
|
+
throw new Error(
|
|
632
|
+
`Files upload for large file failed: ${text.slice(0, 500)}`,
|
|
633
|
+
);
|
|
634
|
+
let j: Record<string, unknown>;
|
|
635
|
+
try {
|
|
636
|
+
j = JSON.parse(text) as Record<string, unknown>;
|
|
637
|
+
} catch (error) {
|
|
638
|
+
throw new Error(
|
|
639
|
+
`Files upload returned invalid JSON: ${String(error)}`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
const id = typeof j.id === "string" ? j.id : "";
|
|
643
|
+
if (id) {
|
|
644
|
+
uploaded++;
|
|
645
|
+
newContent.push({ type: "input_file", file_id: id });
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (
|
|
651
|
+
block.type === "input_audio" &&
|
|
652
|
+
block.input_audio &&
|
|
653
|
+
typeof (block.input_audio as Record<string, unknown>).data === "string"
|
|
654
|
+
) {
|
|
655
|
+
const data = (block.input_audio as Record<string, unknown>)
|
|
656
|
+
.data as string;
|
|
657
|
+
const bytes = Math.floor((data.length * 3) / 4);
|
|
658
|
+
if (bytes > INLINE_LIMIT_BYTES) {
|
|
659
|
+
const format = (block.input_audio as Record<string, unknown>)
|
|
660
|
+
.format as string;
|
|
661
|
+
const mime = format === "wav" ? "audio/wav" : "audio/mpeg";
|
|
662
|
+
const blob = new Blob([Buffer.from(data, "base64")], { type: mime });
|
|
663
|
+
const form = new FormData();
|
|
664
|
+
form.append("file", blob, `audio.${format}`);
|
|
665
|
+
form.append("purpose", "user_data");
|
|
666
|
+
const res = await fetch(META_FILES_URL, {
|
|
667
|
+
method: "POST",
|
|
668
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
669
|
+
body: form,
|
|
670
|
+
});
|
|
671
|
+
const text = await res.text();
|
|
672
|
+
if (!res.ok)
|
|
673
|
+
throw new Error(
|
|
674
|
+
`Files upload for large audio failed: ${text.slice(0, 500)}`,
|
|
675
|
+
);
|
|
676
|
+
let j: Record<string, unknown>;
|
|
677
|
+
try {
|
|
678
|
+
j = JSON.parse(text) as Record<string, unknown>;
|
|
679
|
+
} catch (error) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`Files upload returned invalid JSON: ${String(error)}`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const id = typeof j.id === "string" ? j.id : "";
|
|
686
|
+
if (id) {
|
|
687
|
+
uploaded++;
|
|
688
|
+
newContent.push({ type: "input_file", file_id: id });
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
newContent.push(block);
|
|
694
|
+
}
|
|
695
|
+
newInput.push({ ...e, content: newContent });
|
|
696
|
+
}
|
|
697
|
+
if (uploaded === 0) return { payload, uploaded: 0 };
|
|
698
|
+
return { payload: { ...p, input: newInput }, uploaded };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
// Main extension
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
export default function metaMedia(pi: ExtensionAPI): void {
|
|
706
|
+
// --- Before provider request rewrite (Option B transparent path) ---
|
|
707
|
+
if (shouldEnableNativeRewrite()) {
|
|
708
|
+
pi.on("before_provider_request", async (event) => {
|
|
709
|
+
const original = event.payload;
|
|
710
|
+
const { rewritten, payload: rewrittenPayload } =
|
|
711
|
+
rewriteResponsesPayload(original);
|
|
712
|
+
if (!rewritten) return undefined;
|
|
713
|
+
// Try to also handle large inline → Files API upload if we can get a key.
|
|
714
|
+
// We attempt to read key from env-adjacent modelRegistry is not available in this event's ctx,
|
|
715
|
+
// so we best-effort fetch from process env or skip.
|
|
716
|
+
// The tool path always uses explicit ctx.modelRegistry, so this is only for @file drag-drop.
|
|
717
|
+
const apiKey =
|
|
718
|
+
process.env.MODEL_API_KEY ?? process.env.META_API_KEY ?? undefined;
|
|
719
|
+
if (apiKey) {
|
|
720
|
+
try {
|
|
721
|
+
const { payload: uploadedPayload } =
|
|
722
|
+
await maybeUploadLargeInlineBlocks(rewrittenPayload, apiKey);
|
|
723
|
+
return uploadedPayload as unknown;
|
|
724
|
+
} catch {
|
|
725
|
+
// fall back to rewritten without upload
|
|
726
|
+
return rewrittenPayload as unknown;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return rewrittenPayload as unknown;
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// --- Input handler: let @video.mp4 / @audio.wav / @doc.pdf be pasted like @image.png ---
|
|
734
|
+
// Pi's TUI already turns @image.png into ImageContent; for other types event.text still
|
|
735
|
+
// contains the literal path and images is empty. We turn those paths into pseudo-ImageContent
|
|
736
|
+
// so the before_provider_request hook can rewrite them.
|
|
737
|
+
pi.on("input", async (event, ctx) => {
|
|
738
|
+
if (!shouldEnableNativeRewrite()) return undefined;
|
|
739
|
+
const text = event.text ?? "";
|
|
740
|
+
// Match @/path/to/file.mp4, @./file.wav, /abs/file.pdf, ./file.mp3 — conservative.
|
|
741
|
+
const filePattern =
|
|
742
|
+
/(?:^|\s)@?((?:[~.]?\/[^\s]+\.(?:mp4|m4v|mp3|wav|pdf))|(?:[^\s]+\.(?:mp4|m4v|mp3|wav|pdf)))/gi;
|
|
743
|
+
const matches = [...text.matchAll(filePattern)];
|
|
744
|
+
if (matches.length === 0) return undefined;
|
|
745
|
+
const cwd = (ctx as unknown as { cwd?: string }).cwd ?? process.cwd();
|
|
746
|
+
const newImages: { type: "image"; data: string; mimeType: string }[] = [
|
|
747
|
+
...(event.images ?? []),
|
|
748
|
+
] as typeof newImages;
|
|
749
|
+
let newText = text;
|
|
750
|
+
let touched = false;
|
|
751
|
+
for (const m of matches) {
|
|
752
|
+
const rawPath = m[1];
|
|
753
|
+
if (!rawPath) continue;
|
|
754
|
+
// Resolve relative to cwd, expand ~, strip @
|
|
755
|
+
const clean = rawPath.replace(/^@/, "");
|
|
756
|
+
const expanded = clean.startsWith("~/")
|
|
757
|
+
? clean.replace("~", process.env.HOME ?? "")
|
|
758
|
+
: clean;
|
|
759
|
+
const absolute = resolve(cwd, expanded);
|
|
760
|
+
if (!existsSync(absolute)) continue;
|
|
761
|
+
const mime = mimeForPath(absolute);
|
|
762
|
+
if (!mime) continue;
|
|
763
|
+
const stat = statSync(absolute);
|
|
764
|
+
if (stat.size > FILES_API_LIMIT_BYTES) {
|
|
765
|
+
safeNotify(
|
|
766
|
+
ctx,
|
|
767
|
+
`Skipping ${basename(absolute)} — too large for Meta Files API (1 GiB limit)`,
|
|
768
|
+
"warning",
|
|
769
|
+
);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
// For large files (>50MB) we could upload here, but we lack apiKey in input context sometimes.
|
|
773
|
+
// Instead smuggle as data URL and let before_provider_request upload if needed.
|
|
774
|
+
// To avoid OOM on huge files, skip inline if >50MB and notify to use /meta-video which does Files API.
|
|
775
|
+
if (stat.size > INLINE_LIMIT_BYTES) {
|
|
776
|
+
safeNotify(
|
|
777
|
+
ctx,
|
|
778
|
+
`Large file ${basename(absolute)} (${(stat.size / 1_000_000).toFixed(1)} MB) — use /meta-video or /meta-audio for Files API upload (50MB inline limit)`,
|
|
779
|
+
"info",
|
|
780
|
+
);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const { dataUrl } = dataUrlForFile(absolute);
|
|
785
|
+
const b64 = base64FromDataUrl(dataUrl);
|
|
786
|
+
// Smuggle as ImageContent with video/audio/pdf mime — before_provider_request will rewrite.
|
|
787
|
+
// pi-ai types allow any mimeType string, so video/mp4 is fine at runtime even though Model.input is cast.
|
|
788
|
+
newImages.push({ type: "image", data: b64, mimeType: mime });
|
|
789
|
+
// Remove the path from text so LLM doesn't see a stray filename; keep a placeholder for UX
|
|
790
|
+
newText = newText.replace(rawPath, `[attached ${basename(absolute)}]`);
|
|
791
|
+
touched = true;
|
|
792
|
+
} catch (e) {
|
|
793
|
+
safeNotify(
|
|
794
|
+
ctx,
|
|
795
|
+
`Failed to attach ${basename(absolute)}: ${e instanceof Error ? e.message : String(e)}`,
|
|
796
|
+
"error",
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (!touched) return undefined;
|
|
801
|
+
return {
|
|
802
|
+
action: "transform",
|
|
803
|
+
text: newText,
|
|
804
|
+
images: newImages as unknown as typeof event.images,
|
|
805
|
+
};
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// -----------------------------------------------------------------------
|
|
809
|
+
// Tools — the reliable path that works even when UserMessage types are limited.
|
|
810
|
+
// These call Meta's Responses API directly with the stored OAuth key, so they
|
|
811
|
+
// don't depend on pi-ai's ImageContent union. Use when @file paste isn't enough
|
|
812
|
+
// or for large/reused files.
|
|
813
|
+
// -----------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
pi.registerTool({
|
|
816
|
+
name: "meta_describe_video",
|
|
817
|
+
label: "Meta Video Describe",
|
|
818
|
+
description:
|
|
819
|
+
"Analyze an MP4 video with Muse Spark — summarizes visuals and transcribes embedded speech in one call. Provide a local path, public https URL, or existing file_id (from Files API). Handles Files API upload for large files and inline data URLs for small files. Use for: describe what happens in a clip, answer questions about footage, extract structured details. Video without audio (screen recordings) is also valid. Supports 50MB inline, 1GiB via Files API.",
|
|
820
|
+
parameters: Type.Object({
|
|
821
|
+
path: Type.Optional(
|
|
822
|
+
Type.String({
|
|
823
|
+
description:
|
|
824
|
+
"Local .mp4/.m4v file path (relative to cwd or absolute)",
|
|
825
|
+
}),
|
|
826
|
+
),
|
|
827
|
+
url: Type.Optional(
|
|
828
|
+
Type.String({
|
|
829
|
+
description:
|
|
830
|
+
"Public https:// URL or data:video/mp4;base64,... URL (skips upload)",
|
|
831
|
+
}),
|
|
832
|
+
),
|
|
833
|
+
file_id: Type.Optional(
|
|
834
|
+
Type.String({
|
|
835
|
+
description:
|
|
836
|
+
"Existing Files API id like file-842549258569145 (skips upload)",
|
|
837
|
+
}),
|
|
838
|
+
),
|
|
839
|
+
prompt: Type.String({
|
|
840
|
+
description:
|
|
841
|
+
"What to ask about the video (e.g. 'Describe what happens', 'Transcribe speech and list action items')",
|
|
842
|
+
}),
|
|
843
|
+
model: Type.Optional(
|
|
844
|
+
Type.String({ description: "Muse model id, default muse-spark-1.2" }),
|
|
845
|
+
),
|
|
846
|
+
max_output_tokens: Type.Optional(
|
|
847
|
+
Type.Number({
|
|
848
|
+
description:
|
|
849
|
+
"Default 4000 — reasoning shares budget, lower values can return empty with finish_reason:length",
|
|
850
|
+
}),
|
|
851
|
+
),
|
|
852
|
+
}),
|
|
853
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
854
|
+
const { path, url, file_id, prompt, model, max_output_tokens } =
|
|
855
|
+
params as {
|
|
856
|
+
path?: string;
|
|
857
|
+
url?: string;
|
|
858
|
+
file_id?: string;
|
|
859
|
+
prompt: string;
|
|
860
|
+
model?: string;
|
|
861
|
+
max_output_tokens?: number;
|
|
862
|
+
};
|
|
863
|
+
if (!path && !url && !file_id) {
|
|
864
|
+
return {
|
|
865
|
+
content: [
|
|
866
|
+
{
|
|
867
|
+
type: "text",
|
|
868
|
+
text: "Provide one of: path (local .mp4), url (https://...), or file_id (file-...).",
|
|
869
|
+
},
|
|
870
|
+
],
|
|
871
|
+
details: { error: "missing source" },
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
try {
|
|
875
|
+
const apiKey = await safeGetMetaApiKey(
|
|
876
|
+
ctx as unknown as ExtensionContext,
|
|
877
|
+
);
|
|
878
|
+
let videoBlock: ResponsesContentBlock;
|
|
879
|
+
if (file_id) {
|
|
880
|
+
videoBlock = { type: "input_file", file_id };
|
|
881
|
+
} else if (url) {
|
|
882
|
+
videoBlock = { type: "input_video", video_url: url };
|
|
883
|
+
} else if (path) {
|
|
884
|
+
const absolute = resolve(
|
|
885
|
+
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
886
|
+
path,
|
|
887
|
+
);
|
|
888
|
+
if (!existsSync(absolute))
|
|
889
|
+
throw new Error(`File not found: ${absolute}`);
|
|
890
|
+
const stat = statSync(absolute);
|
|
891
|
+
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
892
|
+
throw new Error(`File too large (1 GiB limit): ${path}`);
|
|
893
|
+
if (stat.size > INLINE_LIMIT_BYTES) {
|
|
894
|
+
const up = await uploadMetaFile(apiKey, absolute);
|
|
895
|
+
videoBlock = { type: "input_file", file_id: up.id };
|
|
896
|
+
} else {
|
|
897
|
+
const { dataUrl } = dataUrlForFile(absolute);
|
|
898
|
+
// Prefer input_file with file_data for small inline? Docs show input_file file_data and input_video video_url both work.
|
|
899
|
+
// Use input_video with data URL for clarity.
|
|
900
|
+
videoBlock = { type: "input_video", video_url: dataUrl };
|
|
901
|
+
}
|
|
902
|
+
} else {
|
|
903
|
+
throw new Error("No source");
|
|
904
|
+
}
|
|
905
|
+
const payload: Record<string, unknown> = {
|
|
906
|
+
model: model ?? "muse-spark-1.2",
|
|
907
|
+
input: [
|
|
908
|
+
{
|
|
909
|
+
type: "message",
|
|
910
|
+
role: "user",
|
|
911
|
+
content: [
|
|
912
|
+
{ type: "input_text", text: prompt },
|
|
913
|
+
videoBlock as unknown as Record<string, unknown>,
|
|
914
|
+
],
|
|
915
|
+
},
|
|
916
|
+
],
|
|
917
|
+
max_output_tokens: Math.max(max_output_tokens ?? 4000, 4000),
|
|
918
|
+
};
|
|
919
|
+
const { text, raw } = await callMetaResponses(apiKey, payload, signal);
|
|
920
|
+
return {
|
|
921
|
+
content: [{ type: "text", text }],
|
|
922
|
+
details: { raw, videoBlock },
|
|
923
|
+
};
|
|
924
|
+
} catch (e) {
|
|
925
|
+
return {
|
|
926
|
+
content: [
|
|
927
|
+
{
|
|
928
|
+
type: "text",
|
|
929
|
+
text: `Video analysis failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
930
|
+
},
|
|
931
|
+
],
|
|
932
|
+
details: { error: String(e) },
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
pi.registerTool({
|
|
939
|
+
name: "meta_transcribe_audio",
|
|
940
|
+
label: "Meta Audio Transcribe",
|
|
941
|
+
description:
|
|
942
|
+
"Transcribe speech from a standalone audio file (wav, mp3) via Muse Spark. Provide local path, public URL, or file_id. Handles Files API for large files, inline base64 for small. Tip: always use max_output_tokens ≥4000 and stream for long clips to avoid empty length finish.",
|
|
943
|
+
parameters: Type.Object({
|
|
944
|
+
path: Type.Optional(Type.String({ description: "Local .wav/.mp3 path" })),
|
|
945
|
+
url: Type.Optional(
|
|
946
|
+
Type.String({ description: "Public https:// URL to audio" }),
|
|
947
|
+
),
|
|
948
|
+
file_id: Type.Optional(Type.String({ description: "Existing file_id" })),
|
|
949
|
+
prompt: Type.Optional(
|
|
950
|
+
Type.String({
|
|
951
|
+
description:
|
|
952
|
+
"Custom prompt, default 'Transcribe this audio. Return only the transcript.'",
|
|
953
|
+
}),
|
|
954
|
+
),
|
|
955
|
+
model: Type.Optional(
|
|
956
|
+
Type.String({ description: "Model id, default muse-spark-1.2" }),
|
|
957
|
+
),
|
|
958
|
+
}),
|
|
959
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
960
|
+
const { path, url, file_id, prompt, model } = params as {
|
|
961
|
+
path?: string;
|
|
962
|
+
url?: string;
|
|
963
|
+
file_id?: string;
|
|
964
|
+
prompt?: string;
|
|
965
|
+
model?: string;
|
|
966
|
+
};
|
|
967
|
+
if (!path && !url && !file_id) {
|
|
968
|
+
return {
|
|
969
|
+
content: [
|
|
970
|
+
{ type: "text", text: "Provide path, url, or file_id for audio." },
|
|
971
|
+
],
|
|
972
|
+
details: { error: "missing source" },
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const apiKey = await safeGetMetaApiKey(
|
|
977
|
+
ctx as unknown as ExtensionContext,
|
|
978
|
+
);
|
|
979
|
+
let audioBlock: ResponsesContentBlock;
|
|
980
|
+
if (file_id) {
|
|
981
|
+
audioBlock = { type: "input_file", file_id };
|
|
982
|
+
} else if (url) {
|
|
983
|
+
// For URLs, use input_audio with audio_url? Docs show Responses accepts input_audio with file_id or inline; for URL use input_file file_url as fallback.
|
|
984
|
+
// Prefer input_file file_url for public audio URL.
|
|
985
|
+
audioBlock = { type: "input_file", file_url: url };
|
|
986
|
+
} else if (path) {
|
|
987
|
+
const absolute = resolve(
|
|
988
|
+
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
989
|
+
path,
|
|
990
|
+
);
|
|
991
|
+
if (!existsSync(absolute))
|
|
992
|
+
throw new Error(`File not found: ${absolute}`);
|
|
993
|
+
const stat = statSync(absolute);
|
|
994
|
+
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
995
|
+
throw new Error(`Audio too large (1 GiB limit)`);
|
|
996
|
+
const mime = mimeForPath(absolute);
|
|
997
|
+
if (!isAudioMime(mime) && mime !== undefined) {
|
|
998
|
+
throw new Error(
|
|
999
|
+
`Unsupported audio mime ${mime}, need audio/wav or audio/mpeg`,
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
if (stat.size > INLINE_LIMIT_BYTES) {
|
|
1003
|
+
const up = await uploadMetaFile(apiKey, absolute);
|
|
1004
|
+
audioBlock = { type: "input_file", file_id: up.id };
|
|
1005
|
+
} else {
|
|
1006
|
+
const data = readFileSync(absolute).toString("base64");
|
|
1007
|
+
const effectiveMime = typeof mime === "string" ? mime : "audio/wav";
|
|
1008
|
+
const fmt = audioFormatForMime(effectiveMime);
|
|
1009
|
+
audioBlock = {
|
|
1010
|
+
type: "input_audio",
|
|
1011
|
+
input_audio: { data, format: fmt },
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
} else {
|
|
1015
|
+
throw new Error("No audio source");
|
|
1016
|
+
}
|
|
1017
|
+
const payload: Record<string, unknown> = {
|
|
1018
|
+
model: model ?? "muse-spark-1.2",
|
|
1019
|
+
input: [
|
|
1020
|
+
{
|
|
1021
|
+
type: "message",
|
|
1022
|
+
role: "user",
|
|
1023
|
+
content: [
|
|
1024
|
+
{
|
|
1025
|
+
type: "input_text",
|
|
1026
|
+
text:
|
|
1027
|
+
prompt ??
|
|
1028
|
+
"Transcribe this audio. Return only the transcript.",
|
|
1029
|
+
},
|
|
1030
|
+
audioBlock as unknown as Record<string, unknown>,
|
|
1031
|
+
],
|
|
1032
|
+
},
|
|
1033
|
+
],
|
|
1034
|
+
max_output_tokens: 4000,
|
|
1035
|
+
};
|
|
1036
|
+
const { text, raw } = await callMetaResponses(apiKey, payload, signal);
|
|
1037
|
+
return {
|
|
1038
|
+
content: [{ type: "text", text }],
|
|
1039
|
+
details: { raw, audioBlock },
|
|
1040
|
+
};
|
|
1041
|
+
} catch (e) {
|
|
1042
|
+
return {
|
|
1043
|
+
content: [
|
|
1044
|
+
{
|
|
1045
|
+
type: "text",
|
|
1046
|
+
text: `Audio transcription failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
details: { error: String(e) },
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
pi.registerTool({
|
|
1056
|
+
name: "meta_upload_file",
|
|
1057
|
+
label: "Meta Files Upload",
|
|
1058
|
+
description:
|
|
1059
|
+
"Upload a file to Meta Files API (POST /v1/files purpose=user_data) for reuse across video/image/pdf/audio requests. Returns file_id to use with input_file/file_id. Supports 1 GiB limit, 100 GiB/team storage. Set expires_after for auto-expiry (3600..2592000 seconds). Use for large or reused files; small one-offs can use inline data URLs.",
|
|
1060
|
+
parameters: Type.Object({
|
|
1061
|
+
path: Type.String({
|
|
1062
|
+
description:
|
|
1063
|
+
"Local file path to upload (mp4, wav, mp3, pdf, png, jpg, etc.)",
|
|
1064
|
+
}),
|
|
1065
|
+
expires_after_seconds: Type.Optional(
|
|
1066
|
+
Type.Number({
|
|
1067
|
+
description:
|
|
1068
|
+
"Auto-expire after seconds (3600..2592000), default no expiry",
|
|
1069
|
+
}),
|
|
1070
|
+
),
|
|
1071
|
+
}),
|
|
1072
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
1073
|
+
const { path, expires_after_seconds } = params as {
|
|
1074
|
+
path: string;
|
|
1075
|
+
expires_after_seconds?: number;
|
|
1076
|
+
};
|
|
1077
|
+
try {
|
|
1078
|
+
const apiKey = await safeGetMetaApiKey(
|
|
1079
|
+
ctx as unknown as ExtensionContext,
|
|
1080
|
+
);
|
|
1081
|
+
const absolute = resolve(
|
|
1082
|
+
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
1083
|
+
path,
|
|
1084
|
+
);
|
|
1085
|
+
if (!existsSync(absolute))
|
|
1086
|
+
throw new Error(`File not found: ${absolute}`);
|
|
1087
|
+
let expiresAfter: { anchor: "created_at"; seconds: number } | undefined;
|
|
1088
|
+
if (expires_after_seconds !== undefined) {
|
|
1089
|
+
if (expires_after_seconds < 3600 || expires_after_seconds > 2592000)
|
|
1090
|
+
throw new Error("expires_after_seconds must be 3600..2592000");
|
|
1091
|
+
expiresAfter = {
|
|
1092
|
+
anchor: "created_at",
|
|
1093
|
+
seconds: expires_after_seconds,
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
const result = await uploadMetaFile(apiKey, absolute, expiresAfter);
|
|
1097
|
+
return {
|
|
1098
|
+
content: [
|
|
1099
|
+
{
|
|
1100
|
+
type: "text",
|
|
1101
|
+
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}"} or {type:"input_video", file_id} etc.`,
|
|
1102
|
+
},
|
|
1103
|
+
],
|
|
1104
|
+
details: result,
|
|
1105
|
+
};
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
return {
|
|
1108
|
+
content: [
|
|
1109
|
+
{
|
|
1110
|
+
type: "text",
|
|
1111
|
+
text: `Upload failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1112
|
+
},
|
|
1113
|
+
],
|
|
1114
|
+
details: { error: String(e) },
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
pi.registerTool({
|
|
1121
|
+
name: "meta_analyze_file",
|
|
1122
|
+
label: "Meta File Analyze",
|
|
1123
|
+
description:
|
|
1124
|
+
"Analyze any supported file (image, pdf, video, audio) via Muse Spark using the Files API or inline. PDFs: text first 100 pages + images first 50 pages (counts to 50-image budget). Images: up to 50/request. For generic 'summarize this document' use this; for video/audio with specific prompts use the dedicated video/audio tools.",
|
|
1125
|
+
parameters: Type.Object({
|
|
1126
|
+
path: Type.Optional(Type.String({ description: "Local file path" })),
|
|
1127
|
+
url: Type.Optional(Type.String({ description: "Public https:// URL" })),
|
|
1128
|
+
file_id: Type.Optional(Type.String({ description: "Existing file_id" })),
|
|
1129
|
+
prompt: Type.String({ description: "What to ask about the file" }),
|
|
1130
|
+
model: Type.Optional(
|
|
1131
|
+
Type.String({ description: "Model id, default muse-spark-1.2" }),
|
|
1132
|
+
),
|
|
1133
|
+
}),
|
|
1134
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
1135
|
+
const { path, url, file_id, prompt, model } = params as {
|
|
1136
|
+
path?: string;
|
|
1137
|
+
url?: string;
|
|
1138
|
+
file_id?: string;
|
|
1139
|
+
prompt: string;
|
|
1140
|
+
model?: string;
|
|
1141
|
+
};
|
|
1142
|
+
if (!path && !url && !file_id) {
|
|
1143
|
+
return {
|
|
1144
|
+
content: [{ type: "text", text: "Provide path, url, or file_id." }],
|
|
1145
|
+
details: { error: "missing source" },
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
const apiKey = await safeGetMetaApiKey(
|
|
1150
|
+
ctx as unknown as ExtensionContext,
|
|
1151
|
+
);
|
|
1152
|
+
let fileBlock: ResponsesContentBlock;
|
|
1153
|
+
if (file_id) fileBlock = { type: "input_file", file_id };
|
|
1154
|
+
else if (url) fileBlock = { type: "input_file", file_url: url };
|
|
1155
|
+
else if (path) {
|
|
1156
|
+
const absolute = resolve(
|
|
1157
|
+
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
1158
|
+
path,
|
|
1159
|
+
);
|
|
1160
|
+
if (!existsSync(absolute))
|
|
1161
|
+
throw new Error(`File not found: ${absolute}`);
|
|
1162
|
+
const stat = statSync(absolute);
|
|
1163
|
+
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
1164
|
+
throw new Error(`File too large (1 GiB limit)`);
|
|
1165
|
+
if (stat.size > INLINE_LIMIT_BYTES) {
|
|
1166
|
+
const up = await uploadMetaFile(apiKey, absolute);
|
|
1167
|
+
fileBlock = { type: "input_file", file_id: up.id };
|
|
1168
|
+
} else {
|
|
1169
|
+
const { dataUrl, mime } = dataUrlForFile(absolute);
|
|
1170
|
+
const filename = basename(absolute);
|
|
1171
|
+
// Responses API inline: {type:"input_file", file_data, filename}
|
|
1172
|
+
fileBlock = { type: "input_file", file_data: dataUrl, filename };
|
|
1173
|
+
}
|
|
1174
|
+
} else throw new Error("No source");
|
|
1175
|
+
const payload: Record<string, unknown> = {
|
|
1176
|
+
model: model ?? "muse-spark-1.2",
|
|
1177
|
+
input: [
|
|
1178
|
+
{
|
|
1179
|
+
type: "message",
|
|
1180
|
+
role: "user",
|
|
1181
|
+
content: [
|
|
1182
|
+
{ type: "input_text", text: prompt },
|
|
1183
|
+
fileBlock as unknown as Record<string, unknown>,
|
|
1184
|
+
],
|
|
1185
|
+
},
|
|
1186
|
+
],
|
|
1187
|
+
max_output_tokens: 4000,
|
|
1188
|
+
};
|
|
1189
|
+
const { text, raw } = await callMetaResponses(apiKey, payload, signal);
|
|
1190
|
+
return {
|
|
1191
|
+
content: [{ type: "text", text }],
|
|
1192
|
+
details: { raw, fileBlock },
|
|
1193
|
+
};
|
|
1194
|
+
} catch (e) {
|
|
1195
|
+
return {
|
|
1196
|
+
content: [
|
|
1197
|
+
{
|
|
1198
|
+
type: "text",
|
|
1199
|
+
text: `File analysis failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1200
|
+
},
|
|
1201
|
+
],
|
|
1202
|
+
details: { error: String(e) },
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
},
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
// -----------------------------------------------------------------------
|
|
1209
|
+
// Slash commands (human-in-the-loop)
|
|
1210
|
+
// -----------------------------------------------------------------------
|
|
1211
|
+
|
|
1212
|
+
pi.registerCommand("meta-video", {
|
|
1213
|
+
description:
|
|
1214
|
+
"Analyze a video with Muse Spark (mp4). Usage: /meta-video <path|url|file_id> [prompt]",
|
|
1215
|
+
handler: async (args, ctx) => {
|
|
1216
|
+
const cwd = ctx.cwd;
|
|
1217
|
+
const trimmed = (args ?? "").trim();
|
|
1218
|
+
if (!trimmed) {
|
|
1219
|
+
safeNotify(
|
|
1220
|
+
ctx,
|
|
1221
|
+
'Usage: /meta-video <path|url|file_id> [prompt — default "Describe what happens"]',
|
|
1222
|
+
"info",
|
|
1223
|
+
);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
1227
|
+
const source = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
|
|
1228
|
+
const prompt =
|
|
1229
|
+
firstSpace === -1
|
|
1230
|
+
? "Describe what happens in this video. Transcribe any speech."
|
|
1231
|
+
: trimmed.slice(firstSpace + 1).trim() ||
|
|
1232
|
+
"Describe what happens in this video.";
|
|
1233
|
+
const isFileId = source.startsWith("file-");
|
|
1234
|
+
const isUrl = source.startsWith("https://") || source.startsWith("data:");
|
|
1235
|
+
const toolArgs: Record<string, unknown> = { prompt };
|
|
1236
|
+
if (isFileId) toolArgs.file_id = source;
|
|
1237
|
+
else if (isUrl) toolArgs.url = source;
|
|
1238
|
+
else toolArgs.path = source;
|
|
1239
|
+
safeNotify(ctx, `Analyzing video: ${source}`, "info");
|
|
1240
|
+
// Dispatch via LLM tool path by sending a follow-up that triggers the tool
|
|
1241
|
+
// For direct command path, call the API directly:
|
|
1242
|
+
try {
|
|
1243
|
+
const apiKey = await safeGetMetaApiKey(
|
|
1244
|
+
ctx as unknown as ExtensionContext,
|
|
1245
|
+
);
|
|
1246
|
+
let block: ResponsesContentBlock;
|
|
1247
|
+
if (isFileId) block = { type: "input_file", file_id: source };
|
|
1248
|
+
else if (isUrl) block = { type: "input_video", video_url: source };
|
|
1249
|
+
else {
|
|
1250
|
+
const abs = resolve(cwd, source);
|
|
1251
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1252
|
+
const stat = statSync(abs);
|
|
1253
|
+
if (stat.size > INLINE_LIMIT_BYTES) {
|
|
1254
|
+
const up = await uploadMetaFile(apiKey, abs);
|
|
1255
|
+
block = { type: "input_file", file_id: up.id };
|
|
1256
|
+
} else {
|
|
1257
|
+
const { dataUrl } = dataUrlForFile(abs);
|
|
1258
|
+
block = { type: "input_video", video_url: dataUrl };
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const { text } = await callMetaResponses(apiKey, {
|
|
1262
|
+
model: "muse-spark-1.2",
|
|
1263
|
+
input: [
|
|
1264
|
+
{
|
|
1265
|
+
type: "message",
|
|
1266
|
+
role: "user",
|
|
1267
|
+
content: [
|
|
1268
|
+
{ type: "input_text", text: prompt },
|
|
1269
|
+
block as unknown as Record<string, unknown>,
|
|
1270
|
+
],
|
|
1271
|
+
},
|
|
1272
|
+
],
|
|
1273
|
+
max_output_tokens: 4000,
|
|
1274
|
+
});
|
|
1275
|
+
safeNotify(ctx, text.slice(0, 4000), "info");
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
safeNotify(
|
|
1278
|
+
ctx,
|
|
1279
|
+
`Video analysis failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1280
|
+
"error",
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
},
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
pi.registerCommand("meta-audio", {
|
|
1287
|
+
description:
|
|
1288
|
+
"Transcribe audio with Muse Spark (wav/mp3). Usage: /meta-audio <path|url|file_id> [prompt]",
|
|
1289
|
+
handler: async (args, ctx) => {
|
|
1290
|
+
const cwd = ctx.cwd;
|
|
1291
|
+
const trimmed = (args ?? "").trim();
|
|
1292
|
+
if (!trimmed) {
|
|
1293
|
+
safeNotify(
|
|
1294
|
+
ctx,
|
|
1295
|
+
"Usage: /meta-audio <path|url|file_id> [prompt]",
|
|
1296
|
+
"info",
|
|
1297
|
+
);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
1301
|
+
const source = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
|
|
1302
|
+
const prompt =
|
|
1303
|
+
firstSpace === -1
|
|
1304
|
+
? "Transcribe this audio. Return only the transcript."
|
|
1305
|
+
: trimmed.slice(firstSpace + 1).trim() ||
|
|
1306
|
+
"Transcribe this audio. Return only the transcript.";
|
|
1307
|
+
const isFileId = source.startsWith("file-");
|
|
1308
|
+
const isUrl = source.startsWith("https://");
|
|
1309
|
+
try {
|
|
1310
|
+
const apiKey = await safeGetMetaApiKey(
|
|
1311
|
+
ctx as unknown as ExtensionContext,
|
|
1312
|
+
);
|
|
1313
|
+
let block: ResponsesContentBlock;
|
|
1314
|
+
if (isFileId) block = { type: "input_file", file_id: source };
|
|
1315
|
+
else if (isUrl) block = { type: "input_file", file_url: source };
|
|
1316
|
+
else {
|
|
1317
|
+
const abs = resolve(cwd, source);
|
|
1318
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1319
|
+
const stat = statSync(abs);
|
|
1320
|
+
if (stat.size > INLINE_LIMIT_BYTES) {
|
|
1321
|
+
const up = await uploadMetaFile(apiKey, abs);
|
|
1322
|
+
block = { type: "input_file", file_id: up.id };
|
|
1323
|
+
} else {
|
|
1324
|
+
const data = readFileSync(abs).toString("base64");
|
|
1325
|
+
const mime = mimeForPath(abs) ?? "audio/wav";
|
|
1326
|
+
block = {
|
|
1327
|
+
type: "input_audio",
|
|
1328
|
+
input_audio: { data, format: audioFormatForMime(mime) },
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
const { text } = await callMetaResponses(apiKey, {
|
|
1333
|
+
model: "muse-spark-1.2",
|
|
1334
|
+
input: [
|
|
1335
|
+
{
|
|
1336
|
+
type: "message",
|
|
1337
|
+
role: "user",
|
|
1338
|
+
content: [
|
|
1339
|
+
{ type: "input_text", text: prompt },
|
|
1340
|
+
block as unknown as Record<string, unknown>,
|
|
1341
|
+
],
|
|
1342
|
+
},
|
|
1343
|
+
],
|
|
1344
|
+
max_output_tokens: 4000,
|
|
1345
|
+
});
|
|
1346
|
+
safeNotify(ctx, text.slice(0, 4000), "info");
|
|
1347
|
+
} catch (e) {
|
|
1348
|
+
safeNotify(
|
|
1349
|
+
ctx,
|
|
1350
|
+
`Audio transcription failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1351
|
+
"error",
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
pi.registerCommand("meta-file", {
|
|
1358
|
+
description:
|
|
1359
|
+
"Analyze any single file (pdf/image/video/audio) with Muse Spark. Usage: /meta-file <path|url|file_id> <prompt> — e.g. /meta-file ./report.pdf summarize",
|
|
1360
|
+
handler: async (args, ctx) => {
|
|
1361
|
+
const cwd = ctx.cwd;
|
|
1362
|
+
const trimmed = (args ?? "").trim();
|
|
1363
|
+
if (!trimmed) {
|
|
1364
|
+
safeNotify(
|
|
1365
|
+
ctx,
|
|
1366
|
+
"Usage: /meta-file <path|url|file_id> <prompt>",
|
|
1367
|
+
"info",
|
|
1368
|
+
);
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
1372
|
+
if (firstSpace === -1) {
|
|
1373
|
+
safeNotify(ctx, "Provide a prompt after the file source.", "warning");
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const source = trimmed.slice(0, firstSpace);
|
|
1377
|
+
const prompt = trimmed.slice(firstSpace + 1).trim();
|
|
1378
|
+
const isFileId = source.startsWith("file-");
|
|
1379
|
+
const isUrl = source.startsWith("https://");
|
|
1380
|
+
try {
|
|
1381
|
+
const apiKey = await safeGetMetaApiKey(
|
|
1382
|
+
ctx as unknown as ExtensionContext,
|
|
1383
|
+
);
|
|
1384
|
+
let block: ResponsesContentBlock;
|
|
1385
|
+
if (isFileId) block = { type: "input_file", file_id: source };
|
|
1386
|
+
else if (isUrl) block = { type: "input_file", file_url: source };
|
|
1387
|
+
else {
|
|
1388
|
+
const abs = resolve(cwd, source);
|
|
1389
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1390
|
+
const stat = statSync(abs);
|
|
1391
|
+
if (stat.size > INLINE_LIMIT_BYTES) {
|
|
1392
|
+
const up = await uploadMetaFile(apiKey, abs);
|
|
1393
|
+
block = { type: "input_file", file_id: up.id };
|
|
1394
|
+
} else {
|
|
1395
|
+
const { dataUrl } = dataUrlForFile(abs);
|
|
1396
|
+
block = {
|
|
1397
|
+
type: "input_file",
|
|
1398
|
+
file_data: dataUrl,
|
|
1399
|
+
filename: basename(abs),
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
const { text } = await callMetaResponses(apiKey, {
|
|
1404
|
+
model: "muse-spark-1.2",
|
|
1405
|
+
input: [
|
|
1406
|
+
{
|
|
1407
|
+
type: "message",
|
|
1408
|
+
role: "user",
|
|
1409
|
+
content: [
|
|
1410
|
+
{ type: "input_text", text: prompt },
|
|
1411
|
+
block as unknown as Record<string, unknown>,
|
|
1412
|
+
],
|
|
1413
|
+
},
|
|
1414
|
+
],
|
|
1415
|
+
max_output_tokens: 4000,
|
|
1416
|
+
});
|
|
1417
|
+
safeNotify(ctx, text.slice(0, 4000), "info");
|
|
1418
|
+
} catch (e) {
|
|
1419
|
+
safeNotify(
|
|
1420
|
+
ctx,
|
|
1421
|
+
`File analysis failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1422
|
+
"error",
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
},
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
pi.registerCommand("meta-media-help", {
|
|
1429
|
+
description: "Show Meta media (video/audio/file) capabilities",
|
|
1430
|
+
handler: async (_args, ctx) => {
|
|
1431
|
+
safeNotify(
|
|
1432
|
+
ctx,
|
|
1433
|
+
[
|
|
1434
|
+
"Meta media — video (mp4), audio (mp3/wav), pdf, images via Muse Spark",
|
|
1435
|
+
" Tools (LLM): meta_describe_video, meta_transcribe_audio, meta_upload_file, meta_analyze_file",
|
|
1436
|
+
" Commands: /meta-video <path|url|file_id> [prompt] — analyze video + embedded audio",
|
|
1437
|
+
" /meta-audio <path|url|file_id> [prompt] — transcribe",
|
|
1438
|
+
" /meta-file <path|url|file_id> <prompt> — generic (pdf/images too)",
|
|
1439
|
+
" Transparent: @clip.mp4 / @meeting.wav / @doc.pdf pasted like @photo.jpg — uses Files API (1 GiB) or inline (50 MB). Disable with PI_META_NATIVE_ATTACHMENTS=0",
|
|
1440
|
+
" Tips: max_output_tokens ≥4000 for transcription; video without audio is valid; PDFs: first 100p text + 50p images",
|
|
1441
|
+
].join("\n"),
|
|
1442
|
+
"info",
|
|
1443
|
+
);
|
|
1444
|
+
},
|
|
1445
|
+
});
|
|
1446
|
+
}
|