toolnexus 0.23.0 → 0.25.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/README.md +139 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/agents/loop.js +4 -1
- package/dist/agents/runtime.js +4 -1
- package/dist/client.d.ts +55 -9
- package/dist/client.js +154 -41
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp.d.ts +32 -7
- package/dist/mcp.js +125 -36
- package/dist/mcp2026/auth.d.ts +137 -0
- package/dist/mcp2026/auth.js +446 -0
- package/dist/mcp2026/client.d.ts +48 -0
- package/dist/mcp2026/client.js +160 -0
- package/dist/mcp2026/protocol.d.ts +54 -0
- package/dist/mcp2026/protocol.js +113 -0
- package/dist/mcp2026/skills.d.ts +5 -0
- package/dist/mcp2026/skills.js +25 -0
- package/dist/mcp2026/source.d.ts +86 -0
- package/dist/mcp2026/source.js +372 -0
- package/dist/mcp2026/stdio.d.ts +24 -0
- package/dist/mcp2026/stdio.js +99 -0
- package/dist/mcp2026/toolsearch.d.ts +5 -0
- package/dist/mcp2026/toolsearch.js +58 -0
- package/dist/mcphandler.d.ts +169 -0
- package/dist/mcphandler.js +1145 -0
- package/dist/mcpheader.d.ts +5 -0
- package/dist/mcpheader.js +18 -0
- package/dist/mcpserve.d.ts +24 -15
- package/dist/mcpserve.js +105 -2
- package/dist/mcpserverauth.d.ts +48 -0
- package/dist/mcpserverauth.js +203 -0
- package/dist/media.d.ts +126 -0
- package/dist/media.js +540 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolconcurrency.d.ts +16 -0
- package/dist/toolconcurrency.js +59 -0
- package/dist/toolkit.d.ts +23 -1
- package/dist/toolkit.js +26 -4
- package/dist/types.d.ts +26 -1
- package/package.json +5 -3
- package/src/acp.ts +258 -55
- package/src/agents/loop.ts +4 -1
- package/src/agents/runtime.ts +4 -1
- package/src/client.ts +196 -48
- package/src/index.ts +4 -0
- package/src/mcp.ts +162 -43
- package/src/mcp2026/auth.ts +473 -0
- package/src/mcp2026/client.ts +161 -0
- package/src/mcp2026/protocol.ts +135 -0
- package/src/mcp2026/skills.ts +22 -0
- package/src/mcp2026/source.ts +407 -0
- package/src/mcp2026/stdio.ts +102 -0
- package/src/mcp2026/toolsearch.ts +56 -0
- package/src/mcphandler.ts +1252 -0
- package/src/mcpheader.ts +19 -0
- package/src/mcpserve.ts +115 -2
- package/src/mcpserverauth.ts +216 -0
- package/src/media.ts +604 -0
- package/src/skill.ts +50 -0
- package/src/toolconcurrency.ts +67 -0
- package/src/toolkit.ts +35 -5
- package/src/types.ts +27 -1
package/src/media.ts
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media generation (SPEC §7G, ADR 0036) — one engine, two faces: typed verbs a host calls and
|
|
3
|
+
* tools (source "media") an LLM calls. Providers are styles: JSON data, never code.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs"
|
|
6
|
+
import type { ContentPart } from "./content.js"
|
|
7
|
+
import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
|
|
8
|
+
|
|
9
|
+
export type MediaVerb = "image" | "video" | "speech" | "transcribe" | "cloneVoice" | "talking"
|
|
10
|
+
export type MediaKind = "image" | "video" | "audio" | "text"
|
|
11
|
+
|
|
12
|
+
export interface MediaVerbStyle {
|
|
13
|
+
mode: "sync" | "async"
|
|
14
|
+
kind: MediaKind
|
|
15
|
+
start: { method?: string; path: string }
|
|
16
|
+
defaults?: Record<string, unknown>
|
|
17
|
+
args: Record<string, string>
|
|
18
|
+
idPath?: string
|
|
19
|
+
poll?: {
|
|
20
|
+
method?: string
|
|
21
|
+
path: string
|
|
22
|
+
statusPath: string
|
|
23
|
+
done?: string[]
|
|
24
|
+
failed?: string[]
|
|
25
|
+
errorPath?: string
|
|
26
|
+
intervalMs?: number
|
|
27
|
+
timeoutMs?: number
|
|
28
|
+
}
|
|
29
|
+
resultPath: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MediaStyle {
|
|
33
|
+
baseUrl: string
|
|
34
|
+
headers?: Record<string, string>
|
|
35
|
+
errorEnvelope?: { codePath: string; okValues: unknown[]; messagePath?: string }
|
|
36
|
+
verbs: Partial<Record<MediaVerb, MediaVerbStyle>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MediaFile {
|
|
40
|
+
type: "image" | "audio" | "file"
|
|
41
|
+
mimeType: string
|
|
42
|
+
url: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MediaResult {
|
|
46
|
+
files: MediaFile[]
|
|
47
|
+
text?: string
|
|
48
|
+
jobId?: string
|
|
49
|
+
status: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MediaProgress {
|
|
53
|
+
verb: MediaVerb
|
|
54
|
+
jobId: string
|
|
55
|
+
status: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MediaOptions {
|
|
59
|
+
onProgress?: (p: MediaProgress) => void
|
|
60
|
+
/** HTTP transport (same seam as the LLM client's `fetch`). Omit ⇒ global fetch. */
|
|
61
|
+
fetch?: typeof fetch
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-call options of the function face. */
|
|
65
|
+
export interface MediaCallOptions {
|
|
66
|
+
/** Top-level keys overwrite the request body last. Never exposed on the tool face. */
|
|
67
|
+
providerOptions?: Record<string, unknown>
|
|
68
|
+
signal?: AbortSignal
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ImageArgs = { prompt: string; width?: number; height?: number; aspectRatio?: string; seed?: number }
|
|
72
|
+
export type VideoArgs = { prompt: string; image?: string; seconds?: number; aspectRatio?: string }
|
|
73
|
+
export type SpeechArgs = { text: string; voice?: string }
|
|
74
|
+
export type TranscribeArgs = { audio: string }
|
|
75
|
+
export type CloneVoiceArgs = { name: string; samples: string[] }
|
|
76
|
+
export type TalkingArgs = { image: string; audio: string }
|
|
77
|
+
|
|
78
|
+
export interface MediaVerbArg {
|
|
79
|
+
name: string
|
|
80
|
+
type: string
|
|
81
|
+
items?: string
|
|
82
|
+
description: string
|
|
83
|
+
required?: boolean
|
|
84
|
+
}
|
|
85
|
+
export interface MediaVerbSpec {
|
|
86
|
+
verb: MediaVerb
|
|
87
|
+
tool: string
|
|
88
|
+
description: string
|
|
89
|
+
args: MediaVerbArg[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Canonical verbs — `conformance/media/verbs.json`, embedded verbatim (byte contract). */
|
|
93
|
+
export const MEDIA_VERBS: MediaVerbSpec[] = [
|
|
94
|
+
{
|
|
95
|
+
"verb": "image",
|
|
96
|
+
"tool": "generate_image",
|
|
97
|
+
"description": "Generate an image from a text prompt.",
|
|
98
|
+
"args": [
|
|
99
|
+
{
|
|
100
|
+
"name": "prompt",
|
|
101
|
+
"type": "string",
|
|
102
|
+
"description": "What to draw.",
|
|
103
|
+
"required": true
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "width",
|
|
107
|
+
"type": "integer",
|
|
108
|
+
"description": "Width in pixels."
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "height",
|
|
112
|
+
"type": "integer",
|
|
113
|
+
"description": "Height in pixels."
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "aspectRatio",
|
|
117
|
+
"type": "string",
|
|
118
|
+
"description": "Aspect ratio, e.g. 16:9."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "seed",
|
|
122
|
+
"type": "integer",
|
|
123
|
+
"description": "Seed for reproducible output."
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"verb": "video",
|
|
129
|
+
"tool": "generate_video",
|
|
130
|
+
"description": "Generate a short video from a prompt, optionally starting from an image URL. Can take minutes.",
|
|
131
|
+
"args": [
|
|
132
|
+
{
|
|
133
|
+
"name": "prompt",
|
|
134
|
+
"type": "string",
|
|
135
|
+
"description": "What should happen in the video.",
|
|
136
|
+
"required": true
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"name": "image",
|
|
140
|
+
"type": "string",
|
|
141
|
+
"description": "URL of the starting image."
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"name": "seconds",
|
|
145
|
+
"type": "integer",
|
|
146
|
+
"description": "Video length in seconds."
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"name": "aspectRatio",
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "Aspect ratio, e.g. 9:16."
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"verb": "speech",
|
|
157
|
+
"tool": "generate_speech",
|
|
158
|
+
"description": "Speak text aloud and return the audio.",
|
|
159
|
+
"args": [
|
|
160
|
+
{
|
|
161
|
+
"name": "text",
|
|
162
|
+
"type": "string",
|
|
163
|
+
"description": "The text to speak.",
|
|
164
|
+
"required": true
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"name": "voice",
|
|
168
|
+
"type": "string",
|
|
169
|
+
"description": "Voice or speaker id."
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"verb": "transcribe",
|
|
175
|
+
"tool": "transcribe_audio",
|
|
176
|
+
"description": "Transcribe speech in an audio URL to text.",
|
|
177
|
+
"args": [
|
|
178
|
+
{
|
|
179
|
+
"name": "audio",
|
|
180
|
+
"type": "string",
|
|
181
|
+
"description": "URL of the audio.",
|
|
182
|
+
"required": true
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"verb": "cloneVoice",
|
|
188
|
+
"tool": "clone_voice",
|
|
189
|
+
"description": "Create a cloned voice from audio sample URLs and return its voice id.",
|
|
190
|
+
"args": [
|
|
191
|
+
{
|
|
192
|
+
"name": "name",
|
|
193
|
+
"type": "string",
|
|
194
|
+
"description": "Name for the new voice.",
|
|
195
|
+
"required": true
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"name": "samples",
|
|
199
|
+
"type": "array",
|
|
200
|
+
"items": "string",
|
|
201
|
+
"description": "URLs of audio samples of the voice.",
|
|
202
|
+
"required": true
|
|
203
|
+
}
|
|
204
|
+
]
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
"verb": "talking",
|
|
208
|
+
"tool": "talking_video",
|
|
209
|
+
"description": "Make the person in an image speak the given audio (lip-synced video).",
|
|
210
|
+
"args": [
|
|
211
|
+
{
|
|
212
|
+
"name": "image",
|
|
213
|
+
"type": "string",
|
|
214
|
+
"description": "URL of the face image.",
|
|
215
|
+
"required": true
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "audio",
|
|
219
|
+
"type": "string",
|
|
220
|
+
"description": "URL of the speech audio.",
|
|
221
|
+
"required": true
|
|
222
|
+
}
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
/** Read one style file (JSON). */
|
|
228
|
+
export function loadMediaStyle(path: string): MediaStyle {
|
|
229
|
+
const style = JSON.parse(readFileSync(path, "utf8")) as MediaStyle
|
|
230
|
+
validateStyle(style)
|
|
231
|
+
return style
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** §7G Empty dotPath: an args entry mapped to "" is a style error (sorted, so deterministic). */
|
|
235
|
+
function validateStyle(style: MediaStyle): void {
|
|
236
|
+
for (const verb of Object.keys(style.verbs ?? {}).sort()) {
|
|
237
|
+
const args = (style.verbs as Record<string, MediaVerbStyle>)[verb]?.args ?? {}
|
|
238
|
+
for (const key of Object.keys(args).sort()) {
|
|
239
|
+
if (args[key] === "") throw new Error(`style: empty path for argument "${key}"`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- dotPath ---------------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const isIndex = (seg: string) => /^[0-9]+$/.test(seg)
|
|
247
|
+
|
|
248
|
+
function readPath(obj: unknown, path: string): unknown {
|
|
249
|
+
if (path === "") return obj // §7G: empty dotPath is the whole value
|
|
250
|
+
let cur: any = obj
|
|
251
|
+
for (const seg of path.split(".")) {
|
|
252
|
+
if (cur === null || cur === undefined || typeof cur !== "object") return undefined
|
|
253
|
+
if (Array.isArray(cur)) {
|
|
254
|
+
if (!isIndex(seg)) return undefined
|
|
255
|
+
cur = cur[Number(seg)]
|
|
256
|
+
} else {
|
|
257
|
+
if (!Object.prototype.hasOwnProperty.call(cur, seg)) return undefined
|
|
258
|
+
cur = cur[seg]
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return cur
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function writePath(obj: Record<string, unknown>, path: string, value: unknown): void {
|
|
265
|
+
const segs = path.split(".")
|
|
266
|
+
let cur: any = obj
|
|
267
|
+
for (let i = 0; i < segs.length; i++) {
|
|
268
|
+
const seg = segs[i]
|
|
269
|
+
const key: string | number = Array.isArray(cur) && isIndex(seg) ? Number(seg) : seg
|
|
270
|
+
if (Array.isArray(cur) && typeof key === "number") while (cur.length < key) cur.push(null)
|
|
271
|
+
if (i === segs.length - 1) {
|
|
272
|
+
cur[key] = value
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
let next = cur[key]
|
|
276
|
+
// A scalar — or an array followed by a non-numeric segment — is replaced (§7G).
|
|
277
|
+
if (next === null || next === undefined || typeof next !== "object" || (Array.isArray(next) && !isIndex(segs[i + 1]))) {
|
|
278
|
+
next = isIndex(segs[i + 1]) ? [] : {}
|
|
279
|
+
cur[key] = next
|
|
280
|
+
}
|
|
281
|
+
cur = next
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- helpers ---------------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
function expandEnv(v: string): string {
|
|
288
|
+
return v.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, name) => process.env[name] ?? "")
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const MIME: Record<string, string> = {
|
|
292
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif",
|
|
293
|
+
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime",
|
|
294
|
+
mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", flac: "audio/flac",
|
|
295
|
+
}
|
|
296
|
+
const KIND_DEFAULT_MIME: Record<string, string> = { image: "image/png", video: "video/mp4", audio: "audio/mpeg" }
|
|
297
|
+
const KIND_FILE_TYPE: Record<string, MediaFile["type"]> = { image: "image", audio: "audio", video: "file" }
|
|
298
|
+
|
|
299
|
+
function mimeFor(url: string, kind: MediaKind): string {
|
|
300
|
+
let path = url.split("#")[0].split("?")[0]
|
|
301
|
+
const scheme = path.indexOf("://")
|
|
302
|
+
if (scheme >= 0) {
|
|
303
|
+
const slash = path.indexOf("/", scheme + 3)
|
|
304
|
+
path = slash >= 0 ? path.slice(slash) : ""
|
|
305
|
+
}
|
|
306
|
+
const last = path.slice(path.lastIndexOf("/") + 1)
|
|
307
|
+
const dot = last.lastIndexOf(".")
|
|
308
|
+
const ext = dot >= 0 ? last.slice(dot + 1).toLowerCase() : ""
|
|
309
|
+
return MIME[ext] ?? KIND_DEFAULT_MIME[kind]
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function jsonEqual(a: unknown, b: unknown): boolean {
|
|
313
|
+
if (a === b) return true
|
|
314
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false
|
|
315
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
316
|
+
if (Array.isArray(a)) {
|
|
317
|
+
const bb = b as unknown[]
|
|
318
|
+
return a.length === bb.length && a.every((x, i) => jsonEqual(x, bb[i]))
|
|
319
|
+
}
|
|
320
|
+
const ka = Object.keys(a as object), kb = Object.keys(b as object)
|
|
321
|
+
return ka.length === kb.length && ka.every((k) => jsonEqual((a as any)[k], (b as any)[k]))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function renderScalar(v: unknown): string {
|
|
325
|
+
if (typeof v === "string") return v
|
|
326
|
+
if (typeof v === "number" && Number.isInteger(v)) return String(v)
|
|
327
|
+
return JSON.stringify(v)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Poll status text (§7G): string as-is, numbers like the envelope code, anything else "". */
|
|
331
|
+
function statusText(v: unknown): string {
|
|
332
|
+
if (typeof v === "string") return v
|
|
333
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v)
|
|
334
|
+
return ""
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** RFC 3986: keep unreserved A-Z a-z 0-9 - . _ ~, %XX (uppercase) every other UTF-8 byte. */
|
|
338
|
+
function percentEncode(s: string): string {
|
|
339
|
+
return encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase())
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** §7G Redirects: the followed statuses; anything else (or no Location) is an HTTP error. */
|
|
343
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308])
|
|
344
|
+
class TooManyRedirects extends Error {}
|
|
345
|
+
|
|
346
|
+
/** Echoed error bodies keep their first 1000 code points, then "…" (§7G). */
|
|
347
|
+
function capBody(text: string): string {
|
|
348
|
+
const cps = Array.from(text)
|
|
349
|
+
return cps.length <= 1000 ? text : cps.slice(0, 1000).join("") + "\u2026"
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** §7G: an interval/timeout that is absent, not a number or <= 0 takes the default. */
|
|
353
|
+
const positive = (v: unknown, dflt: number): number => (typeof v === "number" && v > 0 ? v : dflt)
|
|
354
|
+
|
|
355
|
+
const originOf = (u: string): string => new URL(u).origin
|
|
356
|
+
|
|
357
|
+
function abortError(signal: AbortSignal): unknown {
|
|
358
|
+
return signal.reason ?? Object.assign(new Error("The operation was aborted"), { name: "AbortError" })
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
if (signal?.aborted) return reject(abortError(signal))
|
|
364
|
+
const onAbort = () => {
|
|
365
|
+
clearTimeout(t)
|
|
366
|
+
reject(abortError(signal!))
|
|
367
|
+
}
|
|
368
|
+
const t = setTimeout(() => {
|
|
369
|
+
signal?.removeEventListener("abort", onAbort)
|
|
370
|
+
resolve()
|
|
371
|
+
}, ms)
|
|
372
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function flattenStrings(v: unknown, out: string[]): void {
|
|
377
|
+
if (typeof v === "string") {
|
|
378
|
+
if (v !== "") out.push(v)
|
|
379
|
+
} else if (Array.isArray(v)) {
|
|
380
|
+
for (const x of v) flattenStrings(x, out)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- engine ----------------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
export class Media {
|
|
387
|
+
private readonly styles: MediaStyle[]
|
|
388
|
+
constructor(styles: MediaStyle | MediaStyle[], private readonly opts: MediaOptions = {}) {
|
|
389
|
+
this.styles = Array.isArray(styles) ? styles : [styles]
|
|
390
|
+
for (const s of this.styles) validateStyle(s)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private resolve(verb: string): { style: MediaStyle; spec: MediaVerbStyle } | undefined {
|
|
394
|
+
for (const style of this.styles) {
|
|
395
|
+
const spec = (style.verbs as Record<string, MediaVerbStyle | undefined>)[verb]
|
|
396
|
+
if (spec) return { style, spec }
|
|
397
|
+
}
|
|
398
|
+
return undefined
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Generic entry point: run `verb` with normalised `args` (§7G engine). */
|
|
402
|
+
async generate(verb: MediaVerb | string, args: Record<string, unknown>, callOpts: MediaCallOptions = {}): Promise<MediaResult> {
|
|
403
|
+
const resolved = this.resolve(verb)
|
|
404
|
+
if (!resolved) throw new Error(`media: no style supports "${verb}"`)
|
|
405
|
+
const { style, spec } = resolved
|
|
406
|
+
const present = Object.entries(args ?? {}).filter(([, v]) => v !== undefined && v !== null)
|
|
407
|
+
for (const key of present.map(([k]) => k).sort()) {
|
|
408
|
+
if (!Object.prototype.hasOwnProperty.call(spec.args, key)) {
|
|
409
|
+
throw new Error(`${verb}: argument "${key}" not supported by this style`)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const body: Record<string, unknown> = spec.defaults ? structuredClone(spec.defaults) : {}
|
|
413
|
+
for (const [key, value] of present) writePath(body, spec.args[key], value)
|
|
414
|
+
for (const [k, v] of Object.entries(callOpts.providerOptions ?? {})) body[k] = v
|
|
415
|
+
|
|
416
|
+
const base = style.baseUrl.replace(/\/+$/, "")
|
|
417
|
+
const signal = callOpts.signal
|
|
418
|
+
const styleHeaders = (): Record<string, string> => {
|
|
419
|
+
const h: Record<string, string> = {}
|
|
420
|
+
for (const [k, v] of Object.entries(style.headers ?? {})) h[k] = expandEnv(v)
|
|
421
|
+
return h
|
|
422
|
+
}
|
|
423
|
+
const doFetch = this.opts.fetch ?? fetch
|
|
424
|
+
// Every request is bounded by the time left until the job deadline (§7G Per-request timeout).
|
|
425
|
+
const timeout = positive(spec.poll?.timeoutMs, 600000)
|
|
426
|
+
const deadline = Date.now() + timeout
|
|
427
|
+
let jobId: string | undefined
|
|
428
|
+
const timedOut = () => new Error(jobId !== undefined ? `job ${jobId} timed out after ${timeout}ms` : `${verb}: timed out after ${timeout}ms`)
|
|
429
|
+
// Redirects are followed by hand so style headers never reach another origin (§7G Redirects).
|
|
430
|
+
const send = async (url: string, method: string, json: unknown, reqSignal: AbortSignal): Promise<Response> => {
|
|
431
|
+
const origin = originOf(url)
|
|
432
|
+
let body = json !== undefined ? JSON.stringify(json) : undefined
|
|
433
|
+
for (let hops = 0; ; hops++) {
|
|
434
|
+
const sameOrigin = originOf(url) === origin
|
|
435
|
+
const headers: Record<string, string> = { ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(sameOrigin ? styleHeaders() : {}) }
|
|
436
|
+
const res = await doFetch(url, { method, headers, body, signal: reqSignal, redirect: "manual" })
|
|
437
|
+
const loc = res.headers?.get?.("location")
|
|
438
|
+
if (!REDIRECTS.has(res.status) || !loc) return res
|
|
439
|
+
await res.body?.cancel().catch(() => {})
|
|
440
|
+
if (hops >= 10) throw new TooManyRedirects()
|
|
441
|
+
url = new URL(loc, url).toString()
|
|
442
|
+
if (res.status <= 303 && method !== "GET" && method !== "HEAD") {
|
|
443
|
+
method = "GET"
|
|
444
|
+
body = undefined
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const call = async (method: string, path: string, json?: unknown): Promise<unknown> => {
|
|
449
|
+
if (signal?.aborted) throw abortError(signal)
|
|
450
|
+
const remaining = deadline - Date.now()
|
|
451
|
+
if (remaining <= 0) throw timedOut()
|
|
452
|
+
const ctl = new AbortController()
|
|
453
|
+
const timer = setTimeout(() => ctl.abort(), remaining)
|
|
454
|
+
const forward = () => ctl.abort()
|
|
455
|
+
signal?.addEventListener("abort", forward, { once: true })
|
|
456
|
+
let res: Response, text: string
|
|
457
|
+
try {
|
|
458
|
+
res = await send(base + path, method, json, ctl.signal)
|
|
459
|
+
text = await res.text()
|
|
460
|
+
} catch (e) {
|
|
461
|
+
if (signal?.aborted) throw abortError(signal)
|
|
462
|
+
if (ctl.signal.aborted) throw timedOut()
|
|
463
|
+
if (e instanceof TooManyRedirects) throw new Error(`${verb}: too many redirects`)
|
|
464
|
+
throw new Error(`${verb}: ${e instanceof Error ? e.message : String(e)}`)
|
|
465
|
+
} finally {
|
|
466
|
+
clearTimeout(timer)
|
|
467
|
+
signal?.removeEventListener("abort", forward)
|
|
468
|
+
}
|
|
469
|
+
if (res.status < 200 || res.status > 299) throw new Error(`HTTP ${res.status}: ${capBody(text)}`)
|
|
470
|
+
let data: unknown
|
|
471
|
+
try {
|
|
472
|
+
data = JSON.parse(text)
|
|
473
|
+
} catch {
|
|
474
|
+
throw new Error(`${verb}: response is not JSON`)
|
|
475
|
+
}
|
|
476
|
+
const env = style.errorEnvelope
|
|
477
|
+
if (env) {
|
|
478
|
+
const code = readPath(data, env.codePath)
|
|
479
|
+
if (code !== undefined && !env.okValues.some((ok) => jsonEqual(ok, code))) {
|
|
480
|
+
const msg = env.messagePath ? readPath(data, env.messagePath) : undefined
|
|
481
|
+
throw new Error(`API error ${renderScalar(code)}: ${typeof msg === "string" ? msg : ""}`)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return data
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
let final: unknown
|
|
488
|
+
let status = "completed"
|
|
489
|
+
const startResp = await call((spec.start.method ?? "POST").toUpperCase(), spec.start.path, body)
|
|
490
|
+
if (spec.mode === "async") {
|
|
491
|
+
const poll = spec.poll!
|
|
492
|
+
const idPath = spec.idPath ?? ""
|
|
493
|
+
const rawId = readPath(startResp, idPath)
|
|
494
|
+
if (typeof rawId === "string" && rawId !== "") jobId = rawId
|
|
495
|
+
else if (typeof rawId === "number" && Number.isInteger(rawId)) jobId = String(rawId)
|
|
496
|
+
else throw new Error(`${verb}: no job id at ${idPath}`)
|
|
497
|
+
const interval = positive(poll.intervalMs, 5000)
|
|
498
|
+
const pollPath = poll.path.split("{id}").join(percentEncode(jobId))
|
|
499
|
+
for (;;) {
|
|
500
|
+
await sleep(interval, signal)
|
|
501
|
+
const resp = await call((poll.method ?? "GET").toUpperCase(), pollPath)
|
|
502
|
+
const s = statusText(readPath(resp, poll.statusPath))
|
|
503
|
+
this.opts.onProgress?.({ verb: verb as MediaVerb, jobId, status: s })
|
|
504
|
+
if ((poll.done ?? []).includes(s)) {
|
|
505
|
+
final = resp
|
|
506
|
+
status = s
|
|
507
|
+
break
|
|
508
|
+
}
|
|
509
|
+
if ((poll.failed ?? []).includes(s)) {
|
|
510
|
+
const m = poll.errorPath ? readPath(resp, poll.errorPath) : undefined
|
|
511
|
+
throw new Error(typeof m === "string" && m !== "" ? `job ${jobId} ${s}: ${m}` : `job ${jobId} ${s}`)
|
|
512
|
+
}
|
|
513
|
+
if (Date.now() >= deadline) throw timedOut()
|
|
514
|
+
}
|
|
515
|
+
} else {
|
|
516
|
+
final = startResp
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const value = readPath(final, spec.resultPath)
|
|
520
|
+
const result: MediaResult = { files: [], status }
|
|
521
|
+
if (spec.kind === "text") {
|
|
522
|
+
if (typeof value === "string") result.text = value
|
|
523
|
+
else if (typeof value === "number") result.text = renderScalar(value)
|
|
524
|
+
else throw new Error(`${verb}: no result at ${spec.resultPath}`)
|
|
525
|
+
} else {
|
|
526
|
+
const urls: string[] = []
|
|
527
|
+
flattenStrings(value, urls)
|
|
528
|
+
if (urls.length === 0) throw new Error(`${verb}: no result at ${spec.resultPath}`)
|
|
529
|
+
result.files = urls.map((url) => ({ type: KIND_FILE_TYPE[spec.kind], mimeType: mimeFor(url, spec.kind), url }))
|
|
530
|
+
}
|
|
531
|
+
if (jobId !== undefined) result.jobId = jobId
|
|
532
|
+
return result
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private typed(verb: MediaVerb, input: object & MediaCallOptions): Promise<MediaResult> {
|
|
536
|
+
const { providerOptions, signal, ...args } = input as Record<string, unknown> & MediaCallOptions
|
|
537
|
+
return this.generate(verb, args, { providerOptions, signal })
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
image(input: ImageArgs & MediaCallOptions): Promise<MediaResult> {
|
|
541
|
+
return this.typed("image", input)
|
|
542
|
+
}
|
|
543
|
+
video(input: VideoArgs & MediaCallOptions): Promise<MediaResult> {
|
|
544
|
+
return this.typed("video", input)
|
|
545
|
+
}
|
|
546
|
+
speech(input: SpeechArgs & MediaCallOptions): Promise<MediaResult> {
|
|
547
|
+
return this.typed("speech", input)
|
|
548
|
+
}
|
|
549
|
+
transcribe(input: TranscribeArgs & MediaCallOptions): Promise<MediaResult> {
|
|
550
|
+
return this.typed("transcribe", input)
|
|
551
|
+
}
|
|
552
|
+
cloneVoice(input: CloneVoiceArgs & MediaCallOptions): Promise<MediaResult> {
|
|
553
|
+
return this.typed("cloneVoice", input)
|
|
554
|
+
}
|
|
555
|
+
talking(input: TalkingArgs & MediaCallOptions): Promise<MediaResult> {
|
|
556
|
+
return this.typed("talking", input)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** One Tool (source "media") per supported verb, in canonical order (§7G tool face). */
|
|
560
|
+
tools(): Tool[] {
|
|
561
|
+
const out: Tool[] = []
|
|
562
|
+
for (const v of MEDIA_VERBS) {
|
|
563
|
+
const resolved = this.resolve(v.verb)
|
|
564
|
+
if (!resolved) continue
|
|
565
|
+
const mapped = v.args.filter((a) => Object.prototype.hasOwnProperty.call(resolved.spec.args, a.name))
|
|
566
|
+
const properties: Record<string, unknown> = {}
|
|
567
|
+
for (const a of mapped) {
|
|
568
|
+
properties[a.name] = a.type === "array"
|
|
569
|
+
? { type: "array", items: { type: a.items }, description: a.description }
|
|
570
|
+
: { type: a.type, description: a.description }
|
|
571
|
+
}
|
|
572
|
+
const inputSchema: JSONSchema = { type: "object", properties, required: mapped.filter((a) => a.required).map((a) => a.name) }
|
|
573
|
+
const kind = resolved.spec.kind
|
|
574
|
+
const mode = resolved.spec.mode
|
|
575
|
+
out.push({
|
|
576
|
+
name: v.tool,
|
|
577
|
+
description: v.description,
|
|
578
|
+
inputSchema,
|
|
579
|
+
source: "media",
|
|
580
|
+
// Each call is an independent remote job: always concurrency-safe (§7G, §8 Tool scheduling).
|
|
581
|
+
concurrencySafe: () => true,
|
|
582
|
+
execute: async (args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> => {
|
|
583
|
+
try {
|
|
584
|
+
const r = await this.generate(v.verb, args ?? {}, { signal: ctx?.signal })
|
|
585
|
+
const metadata: Record<string, unknown> = { source: "media", verb: v.verb }
|
|
586
|
+
if (mode === "async" && r.jobId !== undefined) metadata.jobId = r.jobId
|
|
587
|
+
metadata.status = r.status
|
|
588
|
+
if (kind === "text") return { isError: false, output: r.text ?? "", metadata }
|
|
589
|
+
const parts: ContentPart[] = r.files.map((f) => ({ type: f.type, mimeType: f.mimeType, url: f.url }))
|
|
590
|
+
return { isError: false, output: `${kind} ready: ` + r.files.map((f) => f.url).join("\n"), parts, metadata }
|
|
591
|
+
} catch (e) {
|
|
592
|
+
return { isError: true, output: e instanceof Error ? e.message : String(e) }
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
return out
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Build a media engine over one style or a list (first style declaring a verb wins). */
|
|
602
|
+
export function createMedia(styles: MediaStyle | MediaStyle[], opts: MediaOptions = {}): Media {
|
|
603
|
+
return new Media(styles, opts)
|
|
604
|
+
}
|
package/src/skill.ts
CHANGED
|
@@ -517,3 +517,53 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
|
|
|
517
517
|
},
|
|
518
518
|
}
|
|
519
519
|
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* §7C.2: one skill published through the MCP 2026-07-28 skills extension (SEP-2640) by
|
|
523
|
+
* `mcpHandler({listSkills})`. `files` are paths relative to the skill directory
|
|
524
|
+
* ("/"-separated) and MUST include "SKILL.md". Frontmatter defaults to `{name}`.
|
|
525
|
+
*/
|
|
526
|
+
export interface McpSkill {
|
|
527
|
+
name: string
|
|
528
|
+
frontmatter?: Record<string, unknown>
|
|
529
|
+
files: { path: string; text: string }[]
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* §7C.2: every skill under `dir` (any depth, `**∕SKILL.md`, symlinks NOT followed) for
|
|
534
|
+
* `mcpHandler({listSkills})`. Frontmatter is `{name, description}` parsed as §3 (trimmed); a
|
|
535
|
+
* skill whose frontmatter has no `name` is skipped. Files are every regular file under the
|
|
536
|
+
* skill directory that is valid UTF-8 (binary files are skipped — the wire carries text),
|
|
537
|
+
* sorted by path; skills sorted by name. A missing `dir` throws.
|
|
538
|
+
*/
|
|
539
|
+
export function loadMcpSkills(dir: string): McpSkill[] {
|
|
540
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true })
|
|
541
|
+
const out: McpSkill[] = []
|
|
542
|
+
const walk = (d: string, visit: (full: string) => void) => {
|
|
543
|
+
for (const e of sortEntriesByName(readdirSync(d, { withFileTypes: true }))) {
|
|
544
|
+
const full = path.join(d, e.name)
|
|
545
|
+
if (e.isDirectory()) walk(full, visit)
|
|
546
|
+
else if (e.isFile()) visit(full)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
walk(dir, (p) => {
|
|
550
|
+
if (path.basename(p) !== "SKILL.md") return
|
|
551
|
+
const fm = parseFrontmatter(readFileSync(p, "utf8"))
|
|
552
|
+
if (fm.malformed || !fm.data.name) return
|
|
553
|
+
const root = path.dirname(p)
|
|
554
|
+
const files: { path: string; text: string }[] = []
|
|
555
|
+
walk(root, (fp) => {
|
|
556
|
+
let text: string
|
|
557
|
+
try {
|
|
558
|
+
text = utf8.decode(readFileSync(fp))
|
|
559
|
+
} catch (e) {
|
|
560
|
+
if (e instanceof TypeError) return // not UTF-8
|
|
561
|
+
throw e
|
|
562
|
+
}
|
|
563
|
+
files.push({ path: path.relative(root, fp).split(path.sep).join("/"), text })
|
|
564
|
+
})
|
|
565
|
+
files.sort((a, b) => compareCodePoints(a.path, b.path))
|
|
566
|
+
out.push({ name: fm.data.name, frontmatter: { name: fm.data.name, description: fm.data.description ?? "" }, files })
|
|
567
|
+
})
|
|
568
|
+
return out.sort((a, b) => compareCodePoints(a.name, b.name))
|
|
569
|
+
}
|