@kolbo/mcp 1.62.0 → 1.64.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/package.json +1 -1
- package/skill/SKILL.md +1 -0
- package/skill/references/workflows/media-library.md +6 -0
- package/src/apps/widgets/generation.js +2 -1
- package/src/client.js +48 -7
- package/src/index.js +1 -1
- package/src/polling.js +63 -4
- package/src/tools/_shared.js +14 -0
- package/src/tools/chat.js +1 -1
- package/src/tools/generate.js +161 -73
- package/src/tools/media.js +10 -1
- package/src/tools/models.js +12 -3
- package/src/tools/music_library.js +5 -5
- package/src/tools/stock_library.js +14 -4
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -177,6 +177,7 @@ Everything in Kolbo — sessions, generations, media, docs — lives inside a PR
|
|
|
177
177
|
1. **User names a project** ("in my Acme project", "for the film") → call `list_projects` ONCE to resolve the name to an ObjectId, then pass that id as `project_id` on **EVERY** subsequent `generate_*` / `upload_media` / `create_doc` / `chat_send_message` call in the conversation. It is **per-call, NOT sticky** — any call that omits it silently lands in the default "API Generations" bucket (`is_default: true`).
|
|
178
178
|
2. **No project mentioned** → omit `project_id`; the default bucket is correct. Don't ask unless intent is ambiguous.
|
|
179
179
|
3. **Work landed in the wrong project? MOVE it, never regenerate**: `move_session` relocates a whole session + all its media (works for any session type — the `session_id` from generation responses, chats, transcriptions); `move_media` / `bulk_move_media` / `move_folder_contents` relocate individual media items.
|
|
180
|
+
4. **Inside the project, keep a related set in ONE session.** Every generation response returns a `session_id`. When a set needs several calls — shot 2, 3, 4 of a sequence, or more takes of the same idea — pass the FIRST call's `session_id` back on each follow-up. Without it every call opens its own session and the user's sidebar fills with near-identical single-item sessions. Prefer a real batch where one exists (`prompts` on `generate_image` / `generate_video`, `items` on `generate_video_from_image` — up to 8 `{image_url, prompt}` pairs animated in one call and one widget — `num_images`, `generate_creative_director`); use `session_id` for the tools that animate/edit one thing per call (`generate_elements`, `generate_first_last_frame`, `generate_lipsync`, `generate_video_from_video`, `edit_image`, `edit_video`), and across batches when a sequence runs longer than 8. Start a NEW session (omit it) when the next generation is unrelated.
|
|
180
181
|
|
|
181
182
|
|
|
182
183
|
## Cost Awareness — Quick Rules
|
|
@@ -53,6 +53,12 @@ URLs to any generation tool.
|
|
|
53
53
|
curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/abs/path/clip.mp3"
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
**Pace a batch.** The upload endpoint is rate limited — the ticket response says by
|
|
57
|
+
how much in `rate_limit` (currently 40 uploads per 60s). Firing 55 files back to
|
|
58
|
+
back stalls at file 41. Sleep ~2s between files, or read the 429: it carries a
|
|
59
|
+
`Retry-After` header and `retry_after_seconds` in the body. Wait exactly that long
|
|
60
|
+
and continue — a 429 is a "not yet", not a failed upload, and nothing was charged.
|
|
61
|
+
|
|
56
62
|
Do **not** fall back to `upload_media`'s `source_base64` for anything but a tiny file —
|
|
57
63
|
it pushes the whole file through the model's context, twice. And do not reach for
|
|
58
64
|
cloud credentials or a bucket of your own; the ticket is the sanctioned path.
|
|
@@ -293,7 +293,8 @@ function releaseSeen() {
|
|
|
293
293
|
function schedulePoll(sc) {
|
|
294
294
|
if (cancelRequested) return;
|
|
295
295
|
if (!seen) { whenSeenFns.push(function () { schedulePoll(sc); }); return; }
|
|
296
|
-
// The call itself long-waits server-side
|
|
296
|
+
// The call itself long-waits server-side, for one transport-safe window
|
|
297
|
+
// (~45s over the remote connector — see WAIT_WINDOW_MS in tools/generate.js).
|
|
297
298
|
// This short pause only separates successive wait windows — the FIRST call
|
|
298
299
|
// goes out immediately, so a card revealed by scrolling resolves at once.
|
|
299
300
|
var delay = pollStart ? 1500 : 0;
|
package/src/client.js
CHANGED
|
@@ -47,6 +47,35 @@ function isAbortError(err) {
|
|
|
47
47
|
return err && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// 429 handling
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// A 429 is rejected by the rate-limit middleware BEFORE the handler runs: no
|
|
54
|
+
// credits spent, no generation started, nothing half-done. That makes it the
|
|
55
|
+
// one status it is safe to replay — but only ONCE, and only when the server
|
|
56
|
+
// told us how long to wait. An unbounded backoff loop would hide a real
|
|
57
|
+
// capacity problem and stall the tool call past the host's own timeout.
|
|
58
|
+
const RETRY_429_MAX_WAIT_S = 65;
|
|
59
|
+
|
|
60
|
+
function retryAfterSeconds(response) {
|
|
61
|
+
const raw = response.headers.get('retry-after');
|
|
62
|
+
if (!raw) return null;
|
|
63
|
+
const n = Number(raw);
|
|
64
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function retryOnce429(attempt) {
|
|
68
|
+
try {
|
|
69
|
+
return await attempt();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const wait = err && err.status === 429 ? err.retryAfterSeconds : null;
|
|
72
|
+
if (wait === null || wait === undefined || wait > RETRY_429_MAX_WAIT_S) throw err;
|
|
73
|
+
await progress.tick(); // keepalive — the wait can be up to a minute
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, (wait + 1) * 1000));
|
|
75
|
+
return attempt();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
50
79
|
// ---------------------------------------------------------------------------
|
|
51
80
|
// Partner / whitelabel resolution (mirrors CLI's brand/partner.ts)
|
|
52
81
|
// ---------------------------------------------------------------------------
|
|
@@ -326,11 +355,13 @@ class KolboClient {
|
|
|
326
355
|
data._kolbo_auth_expired = true;
|
|
327
356
|
fullMessage = `${fullMessage} [KOLBO_AUTH_EXPIRED]`;
|
|
328
357
|
}
|
|
329
|
-
|
|
358
|
+
const apiError = new KolboApiError(fullMessage, {
|
|
330
359
|
code,
|
|
331
360
|
status: response.status,
|
|
332
361
|
data
|
|
333
362
|
});
|
|
363
|
+
if (response.status === 429) apiError.retryAfterSeconds = retryAfterSeconds(response);
|
|
364
|
+
throw apiError;
|
|
334
365
|
}
|
|
335
366
|
|
|
336
367
|
const generationId = data?.generation_id || data?.generationId;
|
|
@@ -360,13 +391,21 @@ class KolboClient {
|
|
|
360
391
|
return this.request('DELETE', reqPath, body);
|
|
361
392
|
}
|
|
362
393
|
|
|
394
|
+
// Uploads are the one thing agents genuinely do in a tight loop (`upload_media`
|
|
395
|
+
// per file), and /v1/media/upload shares the per-minute SDK generation bucket —
|
|
396
|
+
// so a batch trips 429 long before the user's patience does. Absorb exactly one
|
|
397
|
+
// of those, honouring the server's Retry-After. Everything else (including the
|
|
398
|
+
// poll loop, which does its own capped backoff in polling.js) still surfaces
|
|
399
|
+
// the 429 straight to the caller, now with the wait spelled out in the message.
|
|
363
400
|
async postMultipart(reqPath, formData) {
|
|
364
401
|
if (!this.apiKey) await this._ensureLogin();
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
402
|
+
return retryOnce429(async () => {
|
|
403
|
+
const result = await this._doMultipart(reqPath, formData);
|
|
404
|
+
if (result._status === 401 && this._tryRefreshKey()) {
|
|
405
|
+
return this._doMultipart(reqPath, formData);
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
});
|
|
370
409
|
}
|
|
371
410
|
|
|
372
411
|
async _doMultipart(reqPath, formData) {
|
|
@@ -428,11 +467,13 @@ class KolboClient {
|
|
|
428
467
|
data._kolbo_auth_expired = true;
|
|
429
468
|
fullMessage = `${fullMessage} [KOLBO_AUTH_EXPIRED]`;
|
|
430
469
|
}
|
|
431
|
-
|
|
470
|
+
const apiError = new KolboApiError(fullMessage, {
|
|
432
471
|
code,
|
|
433
472
|
status: response.status,
|
|
434
473
|
data
|
|
435
474
|
});
|
|
475
|
+
if (response.status === 429) apiError.retryAfterSeconds = retryAfterSeconds(response);
|
|
476
|
+
throw apiError;
|
|
436
477
|
}
|
|
437
478
|
|
|
438
479
|
return data;
|
package/src/index.js
CHANGED
|
@@ -124,7 +124,7 @@ function createServer(opts = {}) {
|
|
|
124
124
|
'6. TIMEOUT HANDLING (applies to EVERY generate_* / edit_* / chat_send_message / transcribe_audio tool): each tool blocks and polls internally, then gives up after its own window if the job is not yet terminal. A timeout is NOT a failure — it returns `_timed_out:true` with the `generation_id` (not an error), because the job is almost always STILL RUNNING on the server (or already finished). Call `get_generation_status` with that `generation_id` and `wait=true` to keep checking until state="completed". NEVER conclude the generation failed and re-run the same tool from scratch after a `_timed_out:true` result — that wastes the user\'s credits by paying twice. DIRECTOR / BATCH JOBS follow the same convention through a dedicated tool: generate_creative_director runs its scenes (image OR video) in parallel and only reports state="completed" once EVERY scene is terminal; on `_timed_out:true` call `get_creative_director_status` (not get_generation_status) with the returned generation_id and keep checking. If scenes already carry image_urls/video_urls, they are done; do not regenerate.',
|
|
125
125
|
'7. SESSION CONTINUITY — one task, one session, always: every generation tool returns a `session_id`. For ANY follow-up, refinement, retry, or next step on the SAME task, pass that session_id back — never start fresh. BATCH RULE (critical): when a single user request produces multiple parallel generations (e.g. "animate these 5 images", "generate 3 variants"), do NOT launch them all at once without a session_id. Instead: (1) run the FIRST generation without session_id to create the session, (2) capture the session_id from its response, (3) pass that session_id to ALL remaining generations in the batch. This keeps the entire batch in one session. Exception: only omit session_id and start fresh when the user explicitly starts an unrelated new task.',
|
|
126
126
|
'8. LOCAL FILES / REFERENCE MEDIA — HOW TO HANDLE EVERY CASE. (A) User has a LOCAL file (audio, video, image, document) on their machine. What matters is WHERE THIS SERVER RUNS, not what your client can do — your own filesystem access is irrelevant if the server is somewhere else. On a LOCAL stdio install (server and client share a machine) → call `upload_media` with the absolute path, or pass the path straight to tools like `transcribe_audio` that accept local paths. Over a REMOTE connector the server cannot see that path no matter how capable you are, so a local path will always fail: if you can run shell commands or issue HTTP requests → call `create_upload_ticket` and POST the file to the returned upload_url yourself (fastest, no user interaction); if you cannot → call `media_upload_widget` IMMEDIATELY, the user uploads, and a `media.kolbo.ai` CDN URL comes back for any follow-up call. (B) You already have a public URL (media.kolbo.ai, any CDN, any direct link) → pass it directly; all Kolbo tools accept public URLs. NEVER search for DO Spaces keys, DigitalOcean credentials, or server-side upload credentials. NEVER ask the user to put the file on Google Drive, Dropbox, or Loom. NEVER invent or guess a URL. NEVER base64 anything but a tiny file — it costs context in proportion to file size; use the ticket or the widget instead.',
|
|
127
|
-
'9. MODEL SELECTION: ALWAYS pass a specific `model` on every generation tool — do NOT omit it
|
|
127
|
+
'9. MODEL SELECTION — ROUTE BY THE STRENGTHS SUMMARY, NEVER BY THE BADGE OR THE PRICE TAG: ALWAYS pass a specific `model` on every generation tool — do NOT omit it (omitting falls back to "Smart Select" auto-routing, which hides the choice from the user; use it ONLY if the user explicitly asks you to auto-pick). To choose: call `list_models` with the matching `type` and read each model\'s STRENGTHS SUMMARY — the "— …" clause printed after the credit cost. That summary IS the routing instruction: match it against what the user actually asked for (subject, style, motion, length, quality bar, speed), then pick the CHEAPEST model whose summary covers the task. `[NEW]` and `[RECOMMENDED]` badges, a high credit number, and "flagship"/"most intelligent" wording are NOT selection signals — never pick a model because it is newest, biggest or most expensive. Escalate to a premium/frontier model only when the user explicitly asks for maximum quality, or when no cheaper summary covers the requirement. Models printed under "Named-only" (no summary) are opt-in: use them only when the user names them. TEXT/CHAT: `chat_send_message` bills PER TOKEN, so the listed credit number is not the cost — a frontier text model (Claude Fable 5, GPT-5.6 Sol, Pro-class) costs 5-30x a mid-tier one per reply. Default ordinary chat (writing, brainstorming, Q&A, summarising) to a balanced mid-tier model and reserve the frontier tier for hard reasoning or long-form code the user asked for.',
|
|
128
128
|
'10. IMAGE EDITING: for ANY prompt-driven / content edit of an existing image — "make it night", changing scene/lighting/colors, adding/removing/replacing objects, restyling — use `generate_image_edit` (it runs on strong dedicated editing models, same as image generation). Do NOT use `edit_image` for content edits — `edit_image` is ONLY for mechanical enhancements (upscale, expand/outpaint, remove-background, skin retouch). Its `magic_edit` operation is deprecated in favor of `generate_image_edit`. EXPANDING AN IMAGE: to widen/extend/uncrop an image or fit it into a wider frame while KEEPING the existing artwork, use `edit_image` with operation="zoom_out" (outpainting — original pixels preserved; size it with `zoom_out_percentage` or the `expand_left/right/top/bottom` pixel args). The "reframe" operation is NOT this: it re-generates the whole picture at a new aspect ratio and the subject comes back re-imagined. Only pick "reframe" when the user wants the shot re-taken, never when they want their image extended.'
|
|
129
129
|
].join('\n')
|
|
130
130
|
});
|
package/src/polling.js
CHANGED
|
@@ -100,12 +100,71 @@ async function pollUntilDone(client, generationId, options = {}) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// Still running: put a byte on the wire before going quiet again, so no
|
|
103
|
-
// intermediary mistakes a
|
|
103
|
+
// intermediary mistakes a long wait for a dead connection.
|
|
104
104
|
await progress.tick();
|
|
105
105
|
|
|
106
|
-
// Wait before next poll
|
|
107
|
-
|
|
106
|
+
// Wait before next poll — but never past the deadline. The check at the top
|
|
107
|
+
// of the loop only runs BETWEEN sleeps, so an unclamped sleep let the call
|
|
108
|
+
// overshoot `timeout` by up to a full interval (a 45s window with a 15s
|
|
109
|
+
// cadence could return at 60s). That is the difference between landing
|
|
110
|
+
// inside the caller's transport window and blowing straight through it.
|
|
111
|
+
const remaining = timeout - (Date.now() - startTime);
|
|
112
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(interval, remaining))));
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
|
|
111
|
-
|
|
116
|
+
// ─── Blocking-wait window for the STATUS tools ──────────────────────────────
|
|
117
|
+
// How long get_generation_status / get_creative_director_status may block
|
|
118
|
+
// inside ONE tool call before handing back a non-terminal result the caller
|
|
119
|
+
// re-issues. This is NOT the generation's lifetime — the job keeps running
|
|
120
|
+
// server-side either way.
|
|
121
|
+
//
|
|
122
|
+
// It used to be a flat 180s, which over the remote HTTP connector no caller
|
|
123
|
+
// could ever reach: there the whole tool call has to fit inside a single
|
|
124
|
+
// POST /mcp response, and every hop in front of us has a shorter fuse.
|
|
125
|
+
//
|
|
126
|
+
// • MCP client request timeout — 60s (SDK DEFAULT_REQUEST_TIMEOUT_MSEC), and
|
|
127
|
+
// it only resets on a progress notification when the client opted into
|
|
128
|
+
// resetTimeoutOnProgress, whose SDK default is false. We cannot make that
|
|
129
|
+
// choice on the host's behalf, so this is the ceiling we must respect.
|
|
130
|
+
// • Cloudflare origin read — 100s. api.kolbo.ai is Cloudflare-proxied.
|
|
131
|
+
// • kolbo-api httpServer.timeout — 120s. Measured against the production
|
|
132
|
+
// settings: a SILENT stream is RST at exactly 120.0s, while a 15s write
|
|
133
|
+
// cadence survives 200s. So progress.tick() does defeat this hop — but no
|
|
134
|
+
// amount of ticking defeats a client timeout that does not reset.
|
|
135
|
+
//
|
|
136
|
+
// Net effect of the old 180s: a 185s music generation made wait=true fail with
|
|
137
|
+
// "the connector's server isn't responding" every single time, on a perfectly
|
|
138
|
+
// healthy paid generation. Returning early with state:"processing" is strictly
|
|
139
|
+
// better than erroring — the caller re-issues and nothing is lost.
|
|
140
|
+
//
|
|
141
|
+
// stdio hosts have no hop in between and do reset on our ticks, so they keep
|
|
142
|
+
// the long window. KOLBO_MCP_WAIT_MS overrides both without a release, if a
|
|
143
|
+
// host ever proves tighter still.
|
|
144
|
+
const TRANSPORT_CEILING_MS = 60000;
|
|
145
|
+
// Headroom for everything that happens AFTER the last poll and before the
|
|
146
|
+
// response is on the wire: the final status read, addDisplayNames' catalog
|
|
147
|
+
// lookups, JSON serialization.
|
|
148
|
+
const RESULT_ASSEMBLY_BUDGET_MS = 10000;
|
|
149
|
+
const REMOTE_WAIT_MS = 45000;
|
|
150
|
+
const STDIO_WAIT_MS = 180000;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {object} [options] tool options; `apps === true` is the remote-HTTP
|
|
154
|
+
* transport signal (set only by kolbo-api's connector).
|
|
155
|
+
*/
|
|
156
|
+
function waitWindowMs(options = {}) {
|
|
157
|
+
const override = Number(process.env.KOLBO_MCP_WAIT_MS);
|
|
158
|
+
if (Number.isFinite(override) && override > 0) return override;
|
|
159
|
+
return options.apps === true ? REMOTE_WAIT_MS : STDIO_WAIT_MS;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
pollUntilDone,
|
|
164
|
+
PollingTimeoutError,
|
|
165
|
+
GenerationFailedError,
|
|
166
|
+
waitWindowMs,
|
|
167
|
+
TRANSPORT_CEILING_MS,
|
|
168
|
+
RESULT_ASSEMBLY_BUDGET_MS,
|
|
169
|
+
REMOTE_WAIT_MS,
|
|
170
|
+
};
|
package/src/tools/_shared.js
CHANGED
|
@@ -356,6 +356,16 @@ const projectIdField = z.string().optional().describe(
|
|
|
356
356
|
'Project ObjectId to drop this generation into. Call `list_projects` to discover IDs (the API has no concept of project names — only ObjectIds). IMPORTANT: this is per-call, NOT sticky — once the user has named a working project, pass its id on EVERY generation call in the conversation; any call that omits it silently lands in the default "API Generations" project instead. Requires owner / edit / full permission on the project; view-only is rejected.'
|
|
357
357
|
);
|
|
358
358
|
|
|
359
|
+
// Shared zod schema for the optional `session_id` arg on generation tools.
|
|
360
|
+
// WHY IT EXISTS: without it, each generation call gets its own session, so a
|
|
361
|
+
// set of related clips lands in the app's left rail as a stack of near-identical
|
|
362
|
+
// single-item sessions. (The server has a per-day fallback bucket, but it is not
|
|
363
|
+
// something a caller can rely on — see kolbo-api sdkSessionManager.) Threading
|
|
364
|
+
// the id returned by the FIRST call is the deterministic way to group a batch.
|
|
365
|
+
const sessionIdField = z.string().optional().describe(
|
|
366
|
+
'Existing session to add this generation to, so a related set lands in ONE session instead of a stack of single-item sessions in the Kolbo sidebar. HOW TO USE: omit it on the FIRST call of a set, read `session_id` off that call\'s result, then pass that SAME value on every follow-up call belonging to the same set (e.g. shot 2, 3, 4 of one sequence). Only group things that genuinely belong together — an unrelated generation should start a fresh session by omitting this. The id must come from a session of the same kind (image tools share one session type, video tools another); `list_sessions` also returns ids. When set, `project_id` is ignored — the session\'s own project wins.'
|
|
367
|
+
);
|
|
368
|
+
|
|
359
369
|
// Read-scope variant for list/get tools that can surface a SHARED project's
|
|
360
370
|
// assets (a teammate's Visual DNAs / moodboards). Pass a project id you have
|
|
361
371
|
// edit+ on to also see that project owner's shared assets; omit to see only your
|
|
@@ -543,6 +553,9 @@ async function uiGenerating(p) {
|
|
|
543
553
|
const text = JSON.stringify({
|
|
544
554
|
status: 'submitted',
|
|
545
555
|
generation_id: p.gen.generation_id,
|
|
556
|
+
// The session this landed in. Pass it back as `session_id` on the next call
|
|
557
|
+
// of the same set to keep the whole set in one session (see sessionIdField).
|
|
558
|
+
session_id: p.gen.session_id,
|
|
546
559
|
...(Array.isArray(p.generation_ids) && p.generation_ids.length > 1
|
|
547
560
|
? { batch: true, generation_ids: p.generation_ids } : {}),
|
|
548
561
|
...(p.failed_submissions && p.failed_submissions.length
|
|
@@ -661,6 +674,7 @@ module.exports = {
|
|
|
661
674
|
pollOrTimedOut,
|
|
662
675
|
creditFields,
|
|
663
676
|
projectIdField,
|
|
677
|
+
sessionIdField,
|
|
664
678
|
projectScopeReadField,
|
|
665
679
|
inlineImageBlocks,
|
|
666
680
|
buildOpenUrl,
|
package/src/tools/chat.js
CHANGED
|
@@ -14,7 +14,7 @@ function registerChatTools(server, client) {
|
|
|
14
14
|
'Send a chat message to Kolbo AI. Starts a new conversation (omit session_id) or continues an existing one. Returns the assistant response when complete. Supports image/video/audio analysis via media_urls — pass public URLs and the model auto-routes to a vision-capable model (e.g. Gemini) when media is detected. Supports web search and deep think modes.',
|
|
15
15
|
{
|
|
16
16
|
message: z.string().describe('The user message to send'),
|
|
17
|
-
model: z.string().optional().describe('Model identifier from list_models type="text". Identifiers resolve leniently, so the DISPLAY NAME that list_models shows works too ("Grok 4.5" → its identifier). Do NOT hardcode an id you have not seen in list_models — the text catalog turns over fast. Prefer passing a SPECIFIC model — omitting falls back to Smart Select auto-routing, which we avoid unless the user explicitly asks for auto-pick. Exception: when media_urls contains video or audio, omitting is fine — routing goes to a Gemini vision model regardless of this field.'),
|
|
17
|
+
model: z.string().optional().describe('Model identifier from list_models type="text". Identifiers resolve leniently, so the DISPLAY NAME that list_models shows works too ("Grok 4.5" → its identifier). Do NOT hardcode an id you have not seen in list_models — the text catalog turns over fast. Prefer passing a SPECIFIC model — omitting falls back to Smart Select auto-routing, which we avoid unless the user explicitly asks for auto-pick. Choose it by matching the task to each model\'s STRENGTHS SUMMARY in `list_models type="text"` — not by the newest/biggest name. Chat bills PER TOKEN, so the listed credit number is not the cost: a frontier model (Claude Fable 5, GPT-5.6 Sol, Pro-class) costs 5-30x a mid-tier one per reply. Default ordinary chat (writing, brainstorming, Q&A, summarising) to a balanced mid-tier model whose summary covers the task (Claude Sonnet, Gemini Flash, GPT nano/mini class) and reserve the frontier tier for hard reasoning or long-form code the user asked for. Exception: when media_urls contains video or audio, omitting is fine — routing goes to a Gemini vision model regardless of this field.'),
|
|
18
18
|
session_id: z.string().optional().describe('Existing chat session ID to continue. Omit to start a new conversation.'),
|
|
19
19
|
system_prompt: z.string().optional().describe('System prompt for the conversation. Only applied when creating a new session.'),
|
|
20
20
|
web_search: z.boolean().optional().describe('Enable web search for this message. Default: false'),
|
package/src/tools/generate.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const FormData = require('form-data');
|
|
8
|
-
const { pollUntilDone } = require('../polling');
|
|
9
|
-
const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
|
|
8
|
+
const { pollUntilDone, waitWindowMs } = require('../polling');
|
|
9
|
+
const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
|
|
10
10
|
const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
|
|
11
11
|
|
|
12
12
|
// ─── Cinematic Dimensions schema (shared by generate_image + generate_image_edit) ───
|
|
@@ -37,8 +37,8 @@ const CINEMATIC_SCHEMA = z.object({
|
|
|
37
37
|
'validated against their dimension server-side. Dimensions are data-driven — never hardcode ids.'
|
|
38
38
|
);
|
|
39
39
|
|
|
40
|
-
// ─── Batch fan-out (prompts[])
|
|
41
|
-
// One tool call, N DIFFERENT
|
|
40
|
+
// ─── Batch fan-out (prompts[] / items[]) ────────────────────────────────────
|
|
41
|
+
// One tool call, N DIFFERENT inputs → N generations tracked by ONE widget.
|
|
42
42
|
// The manual-control twin of generate_creative_director: no orchestration pass,
|
|
43
43
|
// the user's exact prompts verbatim. Submit failures never sink the batch —
|
|
44
44
|
// successful ids proceed, failed prompts are reported alongside.
|
|
@@ -47,22 +47,32 @@ const CINEMATIC_SCHEMA = z.object({
|
|
|
47
47
|
// the caller had no way to know which prompt vanished. `promptsField` also caps
|
|
48
48
|
// the array in the schema (so hosts see `maxItems` before calling); the guard
|
|
49
49
|
// below is the choke point EVERY batch tool routes through, and names the count.
|
|
50
|
+
//
|
|
51
|
+
// An item is EITHER a bare prompt string (generate_image / generate_video, where
|
|
52
|
+
// only the prompt varies) OR an object carrying its own per-item inputs
|
|
53
|
+
// alongside the prompt (generate_video_from_image: `{ image_url, prompt }` — the
|
|
54
|
+
// image is what varies, and that is the whole point). Either way the widget
|
|
55
|
+
// captions each tile with the item's prompt, so that is the label we carry.
|
|
50
56
|
const MAX_BATCH_PROMPTS = 8;
|
|
51
|
-
async function submitBatch(
|
|
52
|
-
if (
|
|
57
|
+
async function submitBatch(rawItems, submitOne) {
|
|
58
|
+
if (rawItems.length > MAX_BATCH_PROMPTS) {
|
|
53
59
|
throw new Error(
|
|
54
|
-
`Too many prompts: ${
|
|
55
|
-
`Split them across ${Math.ceil(
|
|
60
|
+
`Too many prompts: ${rawItems.length} received, max ${MAX_BATCH_PROMPTS} per call. ` +
|
|
61
|
+
`Split them across ${Math.ceil(rawItems.length / MAX_BATCH_PROMPTS)} calls of at most ${MAX_BATCH_PROMPTS}.`
|
|
56
62
|
);
|
|
57
63
|
}
|
|
58
|
-
const
|
|
59
|
-
|
|
64
|
+
const items = rawItems
|
|
65
|
+
.map((it) => (typeof it === 'object' && it !== null
|
|
66
|
+
? { input: it, label: String(it.prompt || '').trim() }
|
|
67
|
+
: { input: String(it).trim(), label: String(it).trim() }))
|
|
68
|
+
.filter((x) => x.label);
|
|
69
|
+
const settled = await Promise.allSettled(items.map((x) => submitOne(x.input)));
|
|
60
70
|
const ok = [], failed = [];
|
|
61
71
|
settled.forEach((s, i) => {
|
|
62
|
-
if (s.status === 'fulfilled' && s.value && s.value.generation_id) ok.push({ prompt:
|
|
63
|
-
else failed.push({ prompt:
|
|
72
|
+
if (s.status === 'fulfilled' && s.value && s.value.generation_id) ok.push({ prompt: items[i].label, gen: s.value });
|
|
73
|
+
else failed.push({ prompt: items[i].label, error: (s.reason && s.reason.message) || 'submit failed' });
|
|
64
74
|
});
|
|
65
|
-
if (!ok.length) throw new Error(`All ${
|
|
75
|
+
if (!ok.length) throw new Error(`All ${items.length} batch submissions failed: ${failed[0].error}`);
|
|
66
76
|
return { ok, failed, ids: ok.map((o) => o.gen.generation_id) };
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -77,6 +87,7 @@ async function pollBatch(client, batch, { interval, timeout }) {
|
|
|
77
87
|
type: 'text',
|
|
78
88
|
text: JSON.stringify({
|
|
79
89
|
batch: true,
|
|
90
|
+
session_id: batch.ok[0].gen.session_id,
|
|
80
91
|
generations,
|
|
81
92
|
failed_submissions: batch.failed.length ? batch.failed : undefined
|
|
82
93
|
}, null, 2)
|
|
@@ -115,6 +126,18 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
115
126
|
// "submitted" response + a live ui://kolbo/generation.html widget that keeps
|
|
116
127
|
// one wait=true status call in flight. Text-only hosts never take this branch.
|
|
117
128
|
const ui = () => appsEnabled(server, options);
|
|
129
|
+
|
|
130
|
+
// How long the STATUS tools may block inside one tool call before handing
|
|
131
|
+
// back a non-terminal result the caller re-issues. Bounded by the transport,
|
|
132
|
+
// not by the generation — full reasoning and the measured numbers live next
|
|
133
|
+
// to the constants in ../polling.js.
|
|
134
|
+
const WAIT_WINDOW_MS = waitWindowMs(options);
|
|
135
|
+
const WAIT_WINDOW_S = Math.round(WAIT_WINDOW_MS / 1000);
|
|
136
|
+
// What to tell a caller holding a still-running generation. Never "don't call
|
|
137
|
+
// again" — for anything longer than the window, calling again IS the protocol.
|
|
138
|
+
const stillRunningHint = (idsPhrase) =>
|
|
139
|
+
`Still running — this is NOT a failure and no credits were lost. Each wait=true call blocks for at most ~${WAIT_WINDOW_S}s and then returns whatever the state is, so a long job (music ~3 min, video can be longer) legitimately needs SEVERAL wait=true calls in a row. Call get_generation_status again with wait=true${idsPhrase}. Do not spin with wait=false, and do not re-run the generation tool.`;
|
|
140
|
+
|
|
118
141
|
// ─── generate_image ────────────────────────────────────────
|
|
119
142
|
server.tool(
|
|
120
143
|
'generate_image',
|
|
@@ -135,14 +158,15 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
135
158
|
preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.'),
|
|
136
159
|
cinematic: CINEMATIC_SCHEMA,
|
|
137
160
|
skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one image ungraded.'),
|
|
138
|
-
project_id: projectIdField
|
|
161
|
+
project_id: projectIdField,
|
|
162
|
+
session_id: sessionIdField
|
|
139
163
|
},
|
|
140
|
-
async ({ prompt, prompts, model, aspect_ratio, enhance_prompt = false, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id }) => {
|
|
164
|
+
async ({ prompt, prompts, model, aspect_ratio, enhance_prompt = false, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id }) => {
|
|
141
165
|
if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
|
|
142
166
|
model = await canonicalModelId(client, model, 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
143
167
|
const shared = {
|
|
144
168
|
model, aspect_ratio, enhance_prompt,
|
|
145
|
-
reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id
|
|
169
|
+
reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
|
|
146
170
|
};
|
|
147
171
|
|
|
148
172
|
// Batch mode: N different prompts, one widget owning all generation ids.
|
|
@@ -180,6 +204,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
180
204
|
type: 'text',
|
|
181
205
|
text: JSON.stringify({
|
|
182
206
|
...creditFields(result),
|
|
207
|
+
session_id: gen.session_id,
|
|
183
208
|
urls: result.result.urls,
|
|
184
209
|
model: result.result.model,
|
|
185
210
|
prompt_used: result.result.prompt_used,
|
|
@@ -207,13 +232,14 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
207
232
|
resolution: z.string().optional().describe('Image resolution tier: "1K" / "2K" / "3K" / "4K". Model-dependent — call list_models and read supported_resolutions. Default: "1K" for most edit models.'),
|
|
208
233
|
cinematic: CINEMATIC_SCHEMA,
|
|
209
234
|
skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one edit ungraded.'),
|
|
210
|
-
project_id: projectIdField
|
|
235
|
+
project_id: projectIdField,
|
|
236
|
+
session_id: sessionIdField
|
|
211
237
|
},
|
|
212
|
-
async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id }) => {
|
|
238
|
+
async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id, session_id }) => {
|
|
213
239
|
model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
214
240
|
const gen = await client.post('/v1/generate/image-edit', {
|
|
215
241
|
prompt, model, source_images, aspect_ratio, enhance_prompt, num_images,
|
|
216
|
-
visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id
|
|
242
|
+
visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id, session_id
|
|
217
243
|
});
|
|
218
244
|
|
|
219
245
|
if (ui()) return uiGenerating({
|
|
@@ -240,6 +266,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
240
266
|
type: 'text',
|
|
241
267
|
text: JSON.stringify({
|
|
242
268
|
...creditFields(result),
|
|
269
|
+
session_id: gen.session_id,
|
|
243
270
|
urls: result.result.urls,
|
|
244
271
|
model: result.result.model,
|
|
245
272
|
prompt_used: result.result.prompt_used,
|
|
@@ -352,10 +379,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
352
379
|
// blocking poll window) needs this tool to be re-checked until done.
|
|
353
380
|
server.tool(
|
|
354
381
|
'get_creative_director_status',
|
|
355
|
-
'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Set wait=true to block
|
|
382
|
+
'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Set wait=true to block until the batch is terminal or the wait window closes, whichever comes first — a batch longer than one window returns state="processing" and you simply call again with wait=true. Prefer this over the generic get_generation_status for Creative Director ids — the generic tool now returns the same scene data (it delegates here), but this one is the direct route.',
|
|
356
383
|
{
|
|
357
384
|
generation_id: z.string().describe('The Creative Director generation_id returned by generate_creative_director.'),
|
|
358
|
-
wait: z.boolean().optional().describe(
|
|
385
|
+
wait: z.boolean().optional().describe(`If true, block until the batch is terminal, for at most ~${WAIT_WINDOW_S}s per call. A batch that outlives one window comes back state="processing" (not an error) — call again with wait=true until it is terminal. Always prefer this over polling with wait=false.`)
|
|
359
386
|
},
|
|
360
387
|
async ({ generation_id, wait }) => {
|
|
361
388
|
const statusUrl = `/v1/generate/creative-director/${encodeURIComponent(generation_id)}/status`;
|
|
@@ -364,7 +391,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
364
391
|
try {
|
|
365
392
|
status = await pollUntilDone(client, generation_id, {
|
|
366
393
|
interval: 15000,
|
|
367
|
-
timeout:
|
|
394
|
+
timeout: WAIT_WINDOW_MS,
|
|
368
395
|
statusUrl
|
|
369
396
|
});
|
|
370
397
|
} catch (err) {
|
|
@@ -394,7 +421,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
394
421
|
completed_scenes: completed,
|
|
395
422
|
_hint: status.state === 'completed'
|
|
396
423
|
? 'All scenes terminal. Every completed scene\'s image_urls/video_urls are final.'
|
|
397
|
-
:
|
|
424
|
+
: `Still running — not a failure. Each wait=true call blocks for at most ~${WAIT_WINDOW_S}s, and a video batch routinely outlasts several windows, so call get_creative_director_status again with wait=true and keep going until state is terminal. Scenes that already carry image_urls/video_urls are done; never re-run generate_creative_director.`
|
|
398
425
|
}, null, 2) }] };
|
|
399
426
|
}
|
|
400
427
|
);
|
|
@@ -419,13 +446,14 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
419
446
|
preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.'),
|
|
420
447
|
sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1, Kling V3/2.6, PixVerse V6). On `sound_generation_type: "none"` models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.'),
|
|
421
448
|
skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one video ungraded.'),
|
|
422
|
-
project_id: projectIdField
|
|
449
|
+
project_id: projectIdField,
|
|
450
|
+
session_id: sessionIdField
|
|
423
451
|
},
|
|
424
|
-
async ({ prompt, prompts, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id }) => {
|
|
452
|
+
async ({ prompt, prompts, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id, session_id }) => {
|
|
425
453
|
if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
|
|
426
454
|
model = await canonicalModelId(client, model, 'text_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
427
455
|
const shared = {
|
|
428
|
-
model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id
|
|
456
|
+
model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id, session_id
|
|
429
457
|
};
|
|
430
458
|
|
|
431
459
|
// Batch mode: N different prompts, one widget owning all generation ids.
|
|
@@ -465,6 +493,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
465
493
|
type: 'text',
|
|
466
494
|
text: JSON.stringify({
|
|
467
495
|
...creditFields(result),
|
|
496
|
+
session_id: gen.session_id,
|
|
468
497
|
urls: result.result.urls,
|
|
469
498
|
model: result.result.model,
|
|
470
499
|
duration: result.result.duration,
|
|
@@ -480,10 +509,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
480
509
|
// ─── generate_video_from_image ─────────────────────────────
|
|
481
510
|
server.tool(
|
|
482
511
|
'generate_video_from_image',
|
|
483
|
-
'Animate an existing still image into a video using Kolbo AI. The image comes from `image_url`; `prompt` describes the motion (not the subject — the subject is already in the image). For generating a video from scratch, use generate_video. Returns the final video URL when complete.',
|
|
512
|
+
'Animate an existing still image into a video using Kolbo AI. The image comes from `image_url`; `prompt` describes the motion (not the subject — the subject is already in the image). For generating a video from scratch, use generate_video. ANIMATING SEVERAL SHOTS OF THE SAME SEQUENCE? Pass them ALL in `items` in ONE call (one combined widget) — never a series of separate calls, which buries the chat under one widget per clip. Across calls (a sequence longer than the batch cap), make the first call without `session_id`, take the `session_id` from its result, and pass that same `session_id` on every following call — otherwise each clip becomes its own session and the user gets a stack of near-identical single-clip sessions in the Kolbo sidebar. Returns the final video URL(s) when complete.',
|
|
484
513
|
{
|
|
485
|
-
image_url: z.string().describe('URL of the source image to animate'),
|
|
486
|
-
prompt: z.string().describe('Text description of the desired MOTION (e.g., "camera slowly pans right while the character walks forward")'),
|
|
514
|
+
image_url: z.string().optional().describe('URL of the source image to animate. Required unless `items` is provided.'),
|
|
515
|
+
prompt: z.string().optional().describe('Text description of the desired MOTION (e.g., "camera slowly pans right while the character walks forward"). Required unless `items` is provided.'),
|
|
516
|
+
items: z.array(z.object({
|
|
517
|
+
image_url: z.string().describe('URL of the source image to animate for THIS clip.'),
|
|
518
|
+
prompt: z.string().describe('Text description of the desired MOTION for THIS clip.'),
|
|
519
|
+
})).max(MAX_BATCH_PROMPTS).optional().describe(
|
|
520
|
+
`BATCH MODE — several DIFFERENT stills (2–${MAX_BATCH_PROMPTS}) animated concurrently in ONE call and rendered together in ONE combined widget. Unlike the \`prompts\` array on generate_image / generate_video, each entry pairs its OWN \`image_url\` with its OWN motion \`prompt\` — the image is what varies, and that is the point. **Hard cap: ${MAX_BATCH_PROMPTS} items per call — more than that is REJECTED with an error (never silently truncated), so split a longer sequence across several calls of at most ${MAX_BATCH_PROMPTS}.** Whenever the user wants several stills animated (a shot sequence, a storyboard, an animatic), ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All items share the same model / duration / resolution / aspect_ratio / sound_enabled / project_id / session_id. When set, \`image_url\` and \`prompt\` are ignored.`
|
|
521
|
+
),
|
|
487
522
|
model: z.string().optional().describe('Model identifier — pick a SPECIFIC model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Strong current defaults: "seedance-2" (versatile) or "veo3" (Veo 3.1, cinematic + native audio); the Kling family (call list_models for exact ids like kling-video/v3/pro/image-to-video) is strongest for motion. Call list_models type="img_to_video" to see all options and choose per the user\'s intent.'),
|
|
488
523
|
aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "16:9"'),
|
|
489
524
|
duration: z.number().optional().describe('Duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
|
|
@@ -492,13 +527,34 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
492
527
|
resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Some models use labels like "512P"/"1024P"/"768P"/"1080P". Model-dependent — call list_models and read supported_resolutions.'),
|
|
493
528
|
sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1 Lite, Kling V3 4K, PixVerse V6, Kling 2.6/v3). On `sound_generation_type: "none"` models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.'),
|
|
494
529
|
skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one video ungraded.'),
|
|
495
|
-
project_id: projectIdField
|
|
530
|
+
project_id: projectIdField,
|
|
531
|
+
session_id: sessionIdField
|
|
496
532
|
},
|
|
497
|
-
async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id }) => {
|
|
533
|
+
async ({ image_url, prompt, items, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id, session_id }) => {
|
|
534
|
+
if (!(items && items.length) && !(image_url && prompt)) throw new Error('Provide image_url + prompt, or items');
|
|
498
535
|
model = await canonicalModelId(client, model, 'img_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
}
|
|
536
|
+
const shared = {
|
|
537
|
+
model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id, session_id
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// Batch mode: N different stills, one widget owning all generation ids.
|
|
541
|
+
// Same fan-out as generate_video's prompts[], except the varying part is
|
|
542
|
+
// the (image_url, prompt) PAIR — submitBatch carries the prompt as the
|
|
543
|
+
// per-tile caption either way.
|
|
544
|
+
if (items && items.length) {
|
|
545
|
+
const batch = await submitBatch(items, (it) => client.post('/v1/generate/video/from-image', { ...shared, image_url: it.image_url, prompt: it.prompt }));
|
|
546
|
+
if (ui()) return uiGenerating({
|
|
547
|
+
tool: 'generate_video_from_image', kind: 'video', gen: batch.ok[0].gen, client, model,
|
|
548
|
+
count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
|
|
549
|
+
generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
|
|
550
|
+
failed_submissions: batch.failed,
|
|
551
|
+
status_args: { generation_ids: batch.ids, wait: true },
|
|
552
|
+
reference_image: items[0].image_url
|
|
553
|
+
});
|
|
554
|
+
return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 8) * 1000, timeout: 900000 });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const gen = await client.post('/v1/generate/video/from-image', { ...shared, image_url, prompt });
|
|
502
558
|
|
|
503
559
|
if (ui()) return uiGenerating({
|
|
504
560
|
tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
|
|
@@ -520,11 +576,12 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
520
576
|
type: 'text',
|
|
521
577
|
text: JSON.stringify({
|
|
522
578
|
...creditFields(result),
|
|
579
|
+
session_id: gen.session_id,
|
|
523
580
|
urls: result.result.urls,
|
|
524
581
|
model: result.result.model,
|
|
525
582
|
duration: result.result.duration,
|
|
526
583
|
thumbnail_url: result.result.thumbnail_url,
|
|
527
|
-
_followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video or generate_video_from_video. Do NOT re-run generate_video_from_image unless they want a fresh animation from a different source image.'
|
|
584
|
+
_followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video or generate_video_from_video. Do NOT re-run generate_video_from_image unless they want a fresh animation from a different source image. Animating more shots of THIS same sequence? Pass the session_id above back on each of those calls so they all land in one session.'
|
|
528
585
|
}, null, 2)
|
|
529
586
|
}]
|
|
530
587
|
};
|
|
@@ -555,15 +612,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
555
612
|
use_composition_plan: z.boolean().optional().describe('Suno: enable structured composition planning (verse/chorus structure).'),
|
|
556
613
|
singing_dna_id: z.string().optional().describe('Visual DNA character id whose singing voice to use (must be owned by the caller).'),
|
|
557
614
|
singing_voice_id: z.string().optional().describe('Custom cloned singing-voice id (must be owned by the caller).'),
|
|
558
|
-
project_id: projectIdField
|
|
615
|
+
project_id: projectIdField,
|
|
616
|
+
session_id: sessionIdField
|
|
559
617
|
},
|
|
560
|
-
async ({ prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags, duration_seconds, enhance_prompt = false, preset_id, style_weight, weirdness, audio_weight, persona_id, use_composition_plan, singing_dna_id, singing_voice_id, project_id }) => {
|
|
618
|
+
async ({ prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags, duration_seconds, enhance_prompt = false, preset_id, style_weight, weirdness, audio_weight, persona_id, use_composition_plan, singing_dna_id, singing_voice_id, project_id, session_id }) => {
|
|
561
619
|
model = await canonicalModelId(client, model, 'music_gen'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
562
620
|
const gen = await client.post('/v1/generate/music', {
|
|
563
621
|
prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags,
|
|
564
622
|
duration_seconds, enhance_prompt, preset_id,
|
|
565
623
|
style_weight, weirdness, audio_weight, persona_id, use_composition_plan,
|
|
566
|
-
singing_dna_id, singing_voice_id, project_id
|
|
624
|
+
singing_dna_id, singing_voice_id, project_id, session_id
|
|
567
625
|
});
|
|
568
626
|
|
|
569
627
|
if (ui()) return uiGenerating({
|
|
@@ -583,6 +641,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
583
641
|
type: 'text',
|
|
584
642
|
text: JSON.stringify({
|
|
585
643
|
...creditFields(result),
|
|
644
|
+
session_id: gen.session_id,
|
|
586
645
|
urls: result.result.urls,
|
|
587
646
|
title: result.result.title,
|
|
588
647
|
duration: result.result.duration,
|
|
@@ -627,9 +686,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
627
686
|
minimax_vol: z.number().optional().describe('MiniMax volume, 0–10. Default 1.'),
|
|
628
687
|
minimax_intensity: z.number().optional().describe('MiniMax voice intensity.'),
|
|
629
688
|
minimax_timbre: z.number().optional().describe('MiniMax voice timbre.'),
|
|
630
|
-
project_id: projectIdField
|
|
689
|
+
project_id: projectIdField,
|
|
690
|
+
session_id: sessionIdField
|
|
631
691
|
},
|
|
632
|
-
async ({ text, voice, model, language, style_instructions, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id }) => {
|
|
692
|
+
async ({ text, voice, model, language, style_instructions, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id, session_id }) => {
|
|
633
693
|
model = await canonicalModelId(client, model, 'text_to_speech'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
634
694
|
// Resolve the requested voice against the REAL catalog (cached) so the card
|
|
635
695
|
// can show its display name + portrait instead of a raw id, and so an id
|
|
@@ -647,7 +707,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
647
707
|
similarity_boost, style, use_speaker_boost,
|
|
648
708
|
variance, tempo, promptBoost, seed, accentControl, voiceTitle,
|
|
649
709
|
minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre,
|
|
650
|
-
project_id
|
|
710
|
+
project_id, session_id
|
|
651
711
|
});
|
|
652
712
|
|
|
653
713
|
if (ui()) return uiGenerating({
|
|
@@ -669,6 +729,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
669
729
|
type: 'text',
|
|
670
730
|
text: JSON.stringify({
|
|
671
731
|
...creditFields(result),
|
|
732
|
+
session_id: gen.session_id,
|
|
672
733
|
urls: result.result.urls,
|
|
673
734
|
voice: result.result.voice,
|
|
674
735
|
duration: result.result.duration,
|
|
@@ -701,15 +762,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
701
762
|
seed_pitch: z.number().optional().describe('FAL Seed-Audio: pitch shift in semitones.'),
|
|
702
763
|
seed_reference_audio_urls: z.array(z.string()).optional().describe('FAL Seed-Audio: up to 3 reference audio URLs to condition the sound.'),
|
|
703
764
|
seed_reference_image_url: z.string().optional().describe('FAL Seed-Audio: a reference image URL to condition the sound.'),
|
|
704
|
-
project_id: projectIdField
|
|
765
|
+
project_id: projectIdField,
|
|
766
|
+
session_id: sessionIdField
|
|
705
767
|
},
|
|
706
|
-
async ({ prompt, model, duration, prompt_influence, cfg_strength, sound_loop, sound_tempo, sound_key, seed_voice, seed_speed, seed_volume, seed_pitch, seed_reference_audio_urls, seed_reference_image_url, project_id }) => {
|
|
768
|
+
async ({ prompt, model, duration, prompt_influence, cfg_strength, sound_loop, sound_tempo, sound_key, seed_voice, seed_speed, seed_volume, seed_pitch, seed_reference_audio_urls, seed_reference_image_url, project_id, session_id }) => {
|
|
707
769
|
model = await canonicalModelId(client, model, 'text_to_sound'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
708
770
|
const gen = await client.post('/v1/generate/sound', {
|
|
709
771
|
prompt, model, duration, prompt_influence,
|
|
710
772
|
cfg_strength, sound_loop, sound_tempo, sound_key,
|
|
711
773
|
seed_voice, seed_speed, seed_volume, seed_pitch,
|
|
712
|
-
seed_reference_audio_urls, seed_reference_image_url, project_id
|
|
774
|
+
seed_reference_audio_urls, seed_reference_image_url, project_id, session_id
|
|
713
775
|
});
|
|
714
776
|
|
|
715
777
|
if (ui()) return uiGenerating({
|
|
@@ -729,6 +791,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
729
791
|
type: 'text',
|
|
730
792
|
text: JSON.stringify({
|
|
731
793
|
...creditFields(result),
|
|
794
|
+
session_id: gen.session_id,
|
|
732
795
|
urls: result.result.urls,
|
|
733
796
|
duration: result.result.duration
|
|
734
797
|
}, null, 2)
|
|
@@ -766,11 +829,11 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
766
829
|
// ─── get_generation_status ─────────────────────────────────
|
|
767
830
|
server.tool(
|
|
768
831
|
'get_generation_status',
|
|
769
|
-
|
|
832
|
+
`Check the status of one or more generations. Use after a generation tool returned "submitted" (widget hosts) or timed out. Tracking SEVERAL concurrent generations? Pass them ALL in generation_ids — one call returns an all_done summary. Need the final result? Set wait=true and the server blocks until every generation finishes, for at most ~${WAIT_WINDOW_S}s per call. A job that outlives one window (music is ~3 min, video longer) comes back state="processing" — that is a normal result, not an error: call again with wait=true and keep going until every id is terminal. Never poll with wait=false in a loop.`,
|
|
770
833
|
{
|
|
771
834
|
generation_id: z.string().optional().describe('A single generation ID to check'),
|
|
772
835
|
generation_ids: z.array(z.string()).optional().describe('Multiple generation IDs to check in ONE call. Returns { all_done, pending, generations[] } — always prefer this over checking IDs one by one.'),
|
|
773
|
-
wait: z.boolean().optional().describe(
|
|
836
|
+
wait: z.boolean().optional().describe(`If true, block until every generation reaches a terminal state (completed/failed), for at most ~${WAIT_WINDOW_S}s per call, then return whatever state they are in. Anything still processing is reported, not errored — re-issue with wait=true and only the still-pending ids. This is always better than polling with wait=false.`)
|
|
774
837
|
},
|
|
775
838
|
async ({ generation_id, generation_ids, wait }) => {
|
|
776
839
|
const ids = (generation_ids && generation_ids.length > 0)
|
|
@@ -785,15 +848,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
785
848
|
if (wait) {
|
|
786
849
|
// Widgets use this long-wait path. A 15s API check cadence keeps
|
|
787
850
|
// completion responsive without multiplying backend traffic for
|
|
788
|
-
// every card left open in a host conversation.
|
|
789
|
-
|
|
851
|
+
// every card left open in a host conversation. The window itself is
|
|
852
|
+
// bounded by the transport — see WAIT_WINDOW_MS above.
|
|
853
|
+
const result = await pollUntilDone(client, id, { interval: 15000, timeout: WAIT_WINDOW_MS });
|
|
790
854
|
return { generation_id: id, ...result };
|
|
791
855
|
}
|
|
792
856
|
const result = await client.get(`/v1/generate/${encodeURIComponent(id)}/status`);
|
|
793
857
|
return { generation_id: id, ...result };
|
|
794
858
|
} catch (err) {
|
|
795
859
|
if (err.timedOut) {
|
|
796
|
-
return { generation_id: id, state: 'processing', _timed_out: true, note:
|
|
860
|
+
return { generation_id: id, state: 'processing', _timed_out: true, note: `Still running after this ~${WAIT_WINDOW_S}s wait window — call get_generation_status again with wait=true.` };
|
|
797
861
|
}
|
|
798
862
|
if (err.name === 'GenerationFailedError') {
|
|
799
863
|
return { generation_id: id, state: 'failed', error: err.message };
|
|
@@ -807,9 +871,15 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
807
871
|
|
|
808
872
|
const pending = results.filter(r => r.state !== 'completed' && r.state !== 'failed' && r.state !== 'cancelled');
|
|
809
873
|
const doneHint = 'ALL generations are in a final state — do NOT poll again. Report the results to the user.';
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
874
|
+
// The old wait=false hint said "call it ONCE with wait=true ... to block
|
|
875
|
+
// until they finish". That is the advice that broke: one wait=true call
|
|
876
|
+
// cannot outlast a 185s music job, and a caller that obeyed it got a
|
|
877
|
+
// transport error instead of a result. Say what actually works.
|
|
878
|
+
const pendingIds = pending.map(r => r.generation_id);
|
|
879
|
+
const idsPhrase = pendingIds.length > 1
|
|
880
|
+
? ` and ONLY the still-pending ids: ${JSON.stringify(pendingIds)}`
|
|
881
|
+
: '';
|
|
882
|
+
const pendingHint = stillRunningHint(idsPhrase);
|
|
813
883
|
|
|
814
884
|
// Single-id calls keep the original flat shape — the generation widget
|
|
815
885
|
// waits on this tool with { generation_id, wait:true } and reads
|
|
@@ -917,9 +987,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
917
987
|
image_url: z.string().describe('Public URL of the keyframe image'),
|
|
918
988
|
timestamp_seconds: z.number().describe('Moment on the OUTPUT timeline (seconds, 0 = first frame) where this image is pinned')
|
|
919
989
|
})).optional().describe('Timeline-pinned keyframes for multi-keyframe models (e.g. "flux-3-keyframes", "luma-ray-3-2-storyboard"): the model generates the motion BETWEEN the pinned images. Only models with `supports_keyframes: true` in list_models accept this; cap = `max_keyframes` (FLUX 3: 10). Requires an explicit `duration` — timestamps beyond it are clamped. Ignored by ordinary elements models. OPTIONAL for flux-3-keyframes: if omitted, pass the images via reference_images instead — they are played through IN ORDER, timed from timing language in the prompt or spaced evenly. Pass explicit keyframes only when you need exact control.'),
|
|
920
|
-
project_id: projectIdField
|
|
990
|
+
project_id: projectIdField,
|
|
991
|
+
session_id: sessionIdField
|
|
921
992
|
},
|
|
922
|
-
async ({ prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt = false, visual_dna_ids, resolution, keyframes, project_id }) => {
|
|
993
|
+
async ({ prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt = false, visual_dna_ids, resolution, keyframes, project_id, session_id }) => {
|
|
923
994
|
model = await canonicalModelId(client, model, 'elements'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
924
995
|
if (!prompt) throw new Error('prompt is required');
|
|
925
996
|
|
|
@@ -943,6 +1014,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
943
1014
|
if (resolution) form.append('resolution', resolution);
|
|
944
1015
|
if (keyframes) form.append('keyframes', JSON.stringify(keyframes));
|
|
945
1016
|
if (project_id) form.append('project_id', project_id);
|
|
1017
|
+
if (session_id) form.append('session_id', session_id);
|
|
946
1018
|
for (const f of resolved) {
|
|
947
1019
|
form.append('files', f.buffer, { filename: f.filename, contentType: f.contentType });
|
|
948
1020
|
}
|
|
@@ -950,7 +1022,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
950
1022
|
} else {
|
|
951
1023
|
// URL-only mode: plain JSON.
|
|
952
1024
|
startResponse = await client.post('/v1/generate/elements', {
|
|
953
|
-
prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, keyframes, project_id
|
|
1025
|
+
prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, keyframes, project_id, session_id
|
|
954
1026
|
});
|
|
955
1027
|
}
|
|
956
1028
|
|
|
@@ -972,6 +1044,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
972
1044
|
type: 'text',
|
|
973
1045
|
text: JSON.stringify({
|
|
974
1046
|
...creditFields(result),
|
|
1047
|
+
session_id: startResponse.session_id,
|
|
975
1048
|
urls: result.result?.urls || [],
|
|
976
1049
|
thumbnail_url: result.result?.thumbnail_url || null,
|
|
977
1050
|
duration: result.result?.duration || null,
|
|
@@ -998,9 +1071,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
998
1071
|
enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
|
|
999
1072
|
visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false`, DNA is silently ignored.**'),
|
|
1000
1073
|
resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
|
|
1001
|
-
project_id: projectIdField
|
|
1074
|
+
project_id: projectIdField,
|
|
1075
|
+
session_id: sessionIdField
|
|
1002
1076
|
},
|
|
1003
|
-
async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt = false, visual_dna_ids, resolution, project_id }) => {
|
|
1077
|
+
async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt = false, visual_dna_ids, resolution, project_id, session_id }) => {
|
|
1004
1078
|
model = await canonicalModelId(client, model, 'firstlastgenerations'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
1005
1079
|
const urlMode = first_frame_url && last_frame_url;
|
|
1006
1080
|
const fileMode = first_frame && last_frame;
|
|
@@ -1028,10 +1102,11 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1028
1102
|
if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
|
|
1029
1103
|
if (resolution) form.append('resolution', resolution);
|
|
1030
1104
|
if (project_id) form.append('project_id', project_id);
|
|
1105
|
+
if (session_id) form.append('session_id', session_id);
|
|
1031
1106
|
startResponse = await client.postMultipart('/v1/generate/first-last-frame', form);
|
|
1032
1107
|
} else {
|
|
1033
1108
|
startResponse = await client.post('/v1/generate/first-last-frame', {
|
|
1034
|
-
first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, project_id
|
|
1109
|
+
first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, project_id, session_id
|
|
1035
1110
|
});
|
|
1036
1111
|
}
|
|
1037
1112
|
|
|
@@ -1053,6 +1128,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1053
1128
|
type: 'text',
|
|
1054
1129
|
text: JSON.stringify({
|
|
1055
1130
|
...creditFields(result),
|
|
1131
|
+
session_id: startResponse.session_id,
|
|
1056
1132
|
urls: result.result?.urls || [],
|
|
1057
1133
|
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1058
1134
|
duration: result.result?.duration || null,
|
|
@@ -1088,9 +1164,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1088
1164
|
bounding_boxes_url: z.string().optional().describe('URL to a JSON file with per-frame boxes.'),
|
|
1089
1165
|
face_image: z.string().optional().describe('Base64-encoded reference face image.')
|
|
1090
1166
|
}).optional().describe('Sync-3 only: choose which speaker gets synced in a multi-person video. Use auto_detect:true for automatic, or coordinates + frame_number to pin a specific face.'),
|
|
1091
|
-
project_id: projectIdField
|
|
1167
|
+
project_id: projectIdField,
|
|
1168
|
+
session_id: sessionIdField
|
|
1092
1169
|
},
|
|
1093
|
-
async ({ source, audio, text_prompt, model, bounding_box_target, sync_mode, model_mode, emotion, temperature, occlusion_detection_enabled, active_speaker_detection, project_id }) => {
|
|
1170
|
+
async ({ source, audio, text_prompt, model, bounding_box_target, sync_mode, model_mode, emotion, temperature, occlusion_detection_enabled, active_speaker_detection, project_id, session_id }) => {
|
|
1094
1171
|
model = await canonicalModelId(client, model, ['lipsync-image', 'lipsync-video']); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
1095
1172
|
if (!source) throw new Error('source is required (URL or absolute local path to image/video)');
|
|
1096
1173
|
if (!audio) throw new Error('audio is required (URL or absolute local path to audio file)');
|
|
@@ -1114,7 +1191,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1114
1191
|
temperature,
|
|
1115
1192
|
occlusion_detection_enabled,
|
|
1116
1193
|
active_speaker_detection,
|
|
1117
|
-
project_id
|
|
1194
|
+
project_id, session_id
|
|
1118
1195
|
});
|
|
1119
1196
|
} else {
|
|
1120
1197
|
// File mode (or mixed — resolve any local paths, pass URLs through as body fields)
|
|
@@ -1144,6 +1221,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1144
1221
|
if (occlusion_detection_enabled !== undefined) form.append('occlusion_detection_enabled', String(occlusion_detection_enabled));
|
|
1145
1222
|
if (active_speaker_detection) form.append('active_speaker_detection', JSON.stringify(active_speaker_detection));
|
|
1146
1223
|
if (project_id) form.append('project_id', project_id);
|
|
1224
|
+
if (session_id) form.append('session_id', session_id);
|
|
1147
1225
|
startResponse = await client.postMultipart('/v1/generate/lipsync', form);
|
|
1148
1226
|
}
|
|
1149
1227
|
|
|
@@ -1165,6 +1243,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1165
1243
|
type: 'text',
|
|
1166
1244
|
text: JSON.stringify({
|
|
1167
1245
|
...creditFields(result),
|
|
1246
|
+
session_id: startResponse.session_id,
|
|
1168
1247
|
urls: result.result?.urls || [],
|
|
1169
1248
|
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1170
1249
|
duration: result.result?.duration || null,
|
|
@@ -1209,9 +1288,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1209
1288
|
highlighted: z.object({ font: z.string().optional(), weight: z.number().int().min(100).max(900).optional(), color: z.string().optional() }).optional().describe('Highlighted word tier styling.'),
|
|
1210
1289
|
}).optional(),
|
|
1211
1290
|
}).optional().describe('VEED Subtitles only: style overrides. Any omitted field keeps the preset default. Best supported by Basic presets.'),
|
|
1212
|
-
project_id: projectIdField
|
|
1291
|
+
project_id: projectIdField,
|
|
1292
|
+
session_id: sessionIdField
|
|
1213
1293
|
},
|
|
1214
|
-
async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, reference_images, reference_videos, elements, preset, source_language, translation_language, srt_content, srt_file_url, vocabulary, customization, project_id }) => {
|
|
1294
|
+
async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, reference_images, reference_videos, elements, preset, source_language, translation_language, srt_content, srt_file_url, vocabulary, customization, project_id, session_id }) => {
|
|
1215
1295
|
model = await canonicalModelId(client, model, 'video_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
1216
1296
|
if (!source_video) throw new Error('source_video is required');
|
|
1217
1297
|
|
|
@@ -1221,7 +1301,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1221
1301
|
startResponse = await client.post('/v1/generate/video-from-video', {
|
|
1222
1302
|
video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution,
|
|
1223
1303
|
reference_images, reference_videos, elements, preset, source_language, translation_language,
|
|
1224
|
-
srt_content, srt_file_url, vocabulary, customization, project_id
|
|
1304
|
+
srt_content, srt_file_url, vocabulary, customization, project_id, session_id
|
|
1225
1305
|
});
|
|
1226
1306
|
} else {
|
|
1227
1307
|
const resolved = await resolveToBuffer(source_video, 'video');
|
|
@@ -1245,6 +1325,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1245
1325
|
if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
|
|
1246
1326
|
if (elements) form.append('elements', JSON.stringify(elements));
|
|
1247
1327
|
if (project_id) form.append('project_id', project_id);
|
|
1328
|
+
if (session_id) form.append('session_id', session_id);
|
|
1248
1329
|
startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
|
|
1249
1330
|
}
|
|
1250
1331
|
|
|
@@ -1267,6 +1348,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1267
1348
|
type: 'text',
|
|
1268
1349
|
text: JSON.stringify({
|
|
1269
1350
|
...creditFields(result),
|
|
1351
|
+
session_id: startResponse.session_id,
|
|
1270
1352
|
urls: result.result?.urls || [],
|
|
1271
1353
|
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1272
1354
|
duration: result.result?.duration || null,
|
|
@@ -1291,15 +1373,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1291
1373
|
words_per_line: z.number().optional().describe('SRT: max words per subtitle line, 1–18. Default: 12.'),
|
|
1292
1374
|
lines_per_subtitle: z.number().optional().describe('SRT: max lines per subtitle cue, 1–4. Default: 2.'),
|
|
1293
1375
|
stretch_captions: z.boolean().optional().describe('SRT: extend each cue\'s end time to the next cue\'s start (gap-free subtitles). Default: true.'),
|
|
1294
|
-
project_id: projectIdField
|
|
1376
|
+
project_id: projectIdField,
|
|
1377
|
+
session_id: sessionIdField
|
|
1295
1378
|
},
|
|
1296
|
-
async ({ source, language, diarize, tag_audio_events, remove_punctuation, generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id }) => {
|
|
1379
|
+
async ({ source, language, diarize, tag_audio_events, remove_punctuation, generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id, session_id }) => {
|
|
1297
1380
|
if (!source) throw new Error('source is required (URL or absolute local path)');
|
|
1298
1381
|
|
|
1299
1382
|
// Advanced transcription controls forwarded when provided (undefined keys are dropped by the client).
|
|
1300
1383
|
const opts = {
|
|
1301
1384
|
language, diarize, tag_audio_events, remove_punctuation,
|
|
1302
|
-
generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id
|
|
1385
|
+
generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id, session_id
|
|
1303
1386
|
};
|
|
1304
1387
|
|
|
1305
1388
|
const isUrl = /^https?:\/\//i.test(source);
|
|
@@ -1343,6 +1426,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1343
1426
|
type: 'text',
|
|
1344
1427
|
text: JSON.stringify({
|
|
1345
1428
|
...creditFields(result),
|
|
1429
|
+
session_id: startResponse.session_id,
|
|
1346
1430
|
text: result.result?.text || '',
|
|
1347
1431
|
srt_url: result.result?.srt_url || null,
|
|
1348
1432
|
word_by_word_srt_url: result.result?.word_by_word_srt_url || null,
|
|
@@ -1506,13 +1590,14 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1506
1590
|
ai_optimize: z.boolean().optional()
|
|
1507
1591
|
.describe('Whether to let Kolbo AI enhance your prompt before sending to the model. Default: false — your prompt reaches the model exactly as written. Only pass true if the user explicitly asks to enhance/improve the prompt.'),
|
|
1508
1592
|
|
|
1509
|
-
project_id: projectIdField
|
|
1593
|
+
project_id: projectIdField,
|
|
1594
|
+
session_id: sessionIdField
|
|
1510
1595
|
},
|
|
1511
1596
|
async ({
|
|
1512
1597
|
image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
|
|
1513
1598
|
mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize = false,
|
|
1514
1599
|
zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
|
|
1515
|
-
project_id
|
|
1600
|
+
project_id, session_id
|
|
1516
1601
|
}) => {
|
|
1517
1602
|
// No `type` argument: these are operation-routed tools (upscale / reframe /
|
|
1518
1603
|
// removebg / …), each operation with its own model family — there is no single
|
|
@@ -1530,7 +1615,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1530
1615
|
image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
|
|
1531
1616
|
mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize,
|
|
1532
1617
|
zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
|
|
1533
|
-
project_id
|
|
1618
|
+
project_id, session_id
|
|
1534
1619
|
});
|
|
1535
1620
|
|
|
1536
1621
|
if (ui()) return uiGenerating({
|
|
@@ -1552,6 +1637,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1552
1637
|
type: 'text',
|
|
1553
1638
|
text: JSON.stringify({
|
|
1554
1639
|
...creditFields(result),
|
|
1640
|
+
session_id: gen.session_id,
|
|
1555
1641
|
urls: result.result?.urls || [],
|
|
1556
1642
|
edit_type: result.result?.edit_type || null,
|
|
1557
1643
|
model: result.result?.model || null
|
|
@@ -1656,7 +1742,8 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1656
1742
|
start_time: z.number().optional()
|
|
1657
1743
|
.describe('Start time in seconds of the segment to retake. Used with operation="retake".'),
|
|
1658
1744
|
|
|
1659
|
-
project_id: projectIdField
|
|
1745
|
+
project_id: projectIdField,
|
|
1746
|
+
session_id: sessionIdField
|
|
1660
1747
|
},
|
|
1661
1748
|
async ({
|
|
1662
1749
|
video_url, operation, model, aspect_ratio, scale, prompt,
|
|
@@ -1668,7 +1755,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1668
1755
|
text_prompt, context,
|
|
1669
1756
|
mask_video_url, object_prompt, video_strength,
|
|
1670
1757
|
start_time,
|
|
1671
|
-
project_id
|
|
1758
|
+
project_id, session_id
|
|
1672
1759
|
}) => {
|
|
1673
1760
|
// No `type` argument: these are operation-routed tools (upscale / reframe /
|
|
1674
1761
|
// removebg / …), each operation with its own model family — there is no single
|
|
@@ -1693,7 +1780,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1693
1780
|
text_prompt, context,
|
|
1694
1781
|
mask_video_url, object_prompt, video_strength,
|
|
1695
1782
|
start_time,
|
|
1696
|
-
project_id
|
|
1783
|
+
project_id, session_id
|
|
1697
1784
|
});
|
|
1698
1785
|
|
|
1699
1786
|
if (ui()) return uiGenerating({
|
|
@@ -1715,6 +1802,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1715
1802
|
type: 'text',
|
|
1716
1803
|
text: JSON.stringify({
|
|
1717
1804
|
...creditFields(result),
|
|
1805
|
+
session_id: gen.session_id,
|
|
1718
1806
|
urls: result.result?.urls || [],
|
|
1719
1807
|
download_url: result.result?.download_url || null,
|
|
1720
1808
|
edit_type: result.result?.edit_type || null,
|
package/src/tools/media.js
CHANGED
|
@@ -27,17 +27,26 @@ async function mintUploadTicket(client) {
|
|
|
27
27
|
// holding a token they must use immediately, and telling one of them to go call
|
|
28
28
|
// another tool to learn the POST shape would spend the round trip this whole
|
|
29
29
|
// path exists to remove.
|
|
30
|
+
// The ticket is reusable for a whole batch, but the endpoint is rate limited —
|
|
31
|
+
// and a batch uploader that only learns that from the 41st POST has already
|
|
32
|
+
// stalled halfway through. kolbo-api sends the real numbers in `rate_limit`;
|
|
33
|
+
// this fallback covers an older deployment that predates that field.
|
|
34
|
+
const DEFAULT_UPLOAD_RATE = { max_uploads: 40, per_seconds: 60 };
|
|
35
|
+
|
|
30
36
|
function uploadTicketPayload(ticket) {
|
|
37
|
+
const rate = ticket.rate_limit || DEFAULT_UPLOAD_RATE;
|
|
31
38
|
return {
|
|
32
39
|
upload_url: ticket.upload_url,
|
|
33
40
|
token: ticket.token,
|
|
34
41
|
expires_in_seconds: ticket.expires_in,
|
|
35
42
|
max_file_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
|
|
36
43
|
accepted: ticket.accepted,
|
|
44
|
+
rate_limit: rate,
|
|
37
45
|
how_to_upload: {
|
|
38
46
|
example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3"',
|
|
39
47
|
optional_fields: ['project_id', 'description'],
|
|
40
48
|
response: 'JSON — the stable CDN URL is at media.url. One POST per file; reuse the ticket for a batch.',
|
|
49
|
+
pacing: `RATE LIMIT: ${rate.max_uploads} uploads per ${rate.per_seconds}s. For a batch larger than that, pace it (e.g. sleep ${Math.max(1, Math.ceil(rate.per_seconds / rate.max_uploads))}s between files) instead of firing them back to back. Over the limit you get HTTP 429 with a Retry-After header and retry_after_seconds in the body — wait that long, then continue; do not guess a backoff and do not treat it as a failed upload.`,
|
|
41
50
|
// Git Bash hands curl a POSIX-style /c/Users/... path that Windows curl
|
|
42
51
|
// cannot open (exit 26). Real trap — it cost a round trip to find.
|
|
43
52
|
windows_note: 'Give curl a native path (C:/Users/...) — a Git Bash /c/Users/... path fails to open.',
|
|
@@ -119,7 +128,7 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
119
128
|
// ─── create_upload_ticket ──────────────────────────────────
|
|
120
129
|
server.tool(
|
|
121
130
|
'create_upload_ticket',
|
|
122
|
-
'Get a short-lived ticket for uploading LOCAL files straight into the user\'s Kolbo media library, with NO upload card and no user interaction. ' + ticketRouting + ' Why it exists: when the server cannot read the caller\'s disk, the only other ways in are making the user click an upload card (`media_upload_widget`) or inlining the file as base64 via `upload_media` — base64 is slow and burns context in proportion to file size, so do not use it for anything but a tiny file. Returns `upload_url` + `token`; POST each file as multipart field `file` with header `Authorization: Bearer <token>` and read the CDN URL from `media.url` in the response. One POST per file; the ticket is reusable until it expires. Then pass those URLs to any generation tool (transcribe_audio, generate_image_edit, generate_video_from_image, generate_lipsync, visual DNA, …).',
|
|
131
|
+
'Get a short-lived ticket for uploading LOCAL files straight into the user\'s Kolbo media library, with NO upload card and no user interaction. ' + ticketRouting + ' Why it exists: when the server cannot read the caller\'s disk, the only other ways in are making the user click an upload card (`media_upload_widget`) or inlining the file as base64 via `upload_media` — base64 is slow and burns context in proportion to file size, so do not use it for anything but a tiny file. Returns `upload_url` + `token`; POST each file as multipart field `file` with header `Authorization: Bearer <token>` and read the CDN URL from `media.url` in the response. One POST per file; the ticket is reusable until it expires. The endpoint is RATE LIMITED (the exact cap and window come back in the ticket\'s `rate_limit` field — currently 40 uploads/minute): pace a batch bigger than that rather than firing every file at once, and on HTTP 429 honour the `Retry-After` header / `retry_after_seconds` body field instead of guessing a backoff. Then pass those URLs to any generation tool (transcribe_audio, generate_image_edit, generate_video_from_image, generate_lipsync, visual DNA, …).',
|
|
123
132
|
{},
|
|
124
133
|
async () => {
|
|
125
134
|
const ticket = await mintUploadTicket(client);
|
package/src/tools/models.js
CHANGED
|
@@ -325,8 +325,14 @@ function registerModelTools(server, client, options = {}) {
|
|
|
325
325
|
const flat = String(s).replace(/\s+/g, ' ').trim();
|
|
326
326
|
return flat.length > 130 ? flat.slice(0, 127).trimEnd() + '…' : flat;
|
|
327
327
|
};
|
|
328
|
+
// Text models bill per token — the flat `credit` is not what the user pays,
|
|
329
|
+
// so show the real per-1K rates when the API supplies them. Without this the
|
|
330
|
+
// "cheapest model that fits" rule is unusable for chat.
|
|
331
|
+
const cost = m => (m.output_token_rate != null
|
|
332
|
+
? `${m.input_token_rate ?? '?'}/${m.output_token_rate} credits per 1K tokens (in/out)`
|
|
333
|
+
: `${m.credit} credits`);
|
|
328
334
|
const formatModel = m =>
|
|
329
|
-
`${m.identifier} (${m.name}) - ${m
|
|
335
|
+
`${m.identifier} (${m.name}) - ${cost(m)}${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${detailed ? m.summary : brief(m.summary)}` : ''}${detailed ? formatSpecs(m) : ''}`;
|
|
330
336
|
|
|
331
337
|
const sections = [];
|
|
332
338
|
|
|
@@ -341,7 +347,10 @@ function registerModelTools(server, client, options = {}) {
|
|
|
341
347
|
}
|
|
342
348
|
const text = `Kolbo model catalog — ${result.count} models total.\n\n`
|
|
343
349
|
+ `${sections.join('\n\n')}\n\n`
|
|
344
|
-
+ 'This is
|
|
350
|
+
+ 'This shortlist is BADGE-BASED (recommended/new) — it is not a recommendation to '
|
|
351
|
+
+ 'use the newest or biggest model. To pick properly, re-call with `type` and choose by '
|
|
352
|
+
+ 'each model\'s strengths summary, taking the cheapest one that covers the task. '
|
|
353
|
+
+ 'To see everything in a '
|
|
345
354
|
+ 'category (with per-model resolutions, durations, aspect ratios and reference-image '
|
|
346
355
|
+ 'caps), re-call with `type`:\n'
|
|
347
356
|
+ ' text_to_img · image_editing · text_to_video · img_to_video · video_to_video ·\n'
|
|
@@ -354,7 +363,7 @@ function registerModelTools(server, client, options = {}) {
|
|
|
354
363
|
}
|
|
355
364
|
|
|
356
365
|
if (withSummary.length > 0) {
|
|
357
|
-
sections.push(`Auto-selectable models (${withSummary.length}) —
|
|
366
|
+
sections.push(`Auto-selectable models (${withSummary.length}) — CHOOSE BY THE SUMMARY after each "—": match it to what the user asked for, then take the CHEAPEST model that fits. Credit cost, [NEW] and [RECOMMENDED] are not reasons to pick a model:\n${withSummary.map(formatModel).join('\n')}`);
|
|
358
367
|
}
|
|
359
368
|
if (withoutSummary.length > 0) {
|
|
360
369
|
sections.push(`Named-only models (${withoutSummary.length}) — only use if the user explicitly requests by name:\n${withoutSummary.map(formatModel).join('\n')}`);
|
|
@@ -30,7 +30,7 @@ function tracksResult(ui, title, tracks, total) {
|
|
|
30
30
|
if (!tracks.length) return { content: [{ type: 'text', text: 'No SYNCI tracks found.' }] };
|
|
31
31
|
const text = [
|
|
32
32
|
`Found ${tracks.length} track${tracks.length === 1 ? '' : 's'}${total ? ` (of ${total})` : ''}.`,
|
|
33
|
-
'Playback URLs are watermarked previews. Use acquire_clean_music_track for final use; it
|
|
33
|
+
'Playback URLs are watermarked previews. Use acquire_clean_music_track for final use. ' + "Free for subscribers, org members and anyone who already bought the track; for everyone else it COSTS CREDITS (the search response carries the exact price in cleanTrackCredits, and cleanAccess:false means this caller will be charged). State the cost and get the user's agreement BEFORE calling it.",
|
|
34
34
|
'',
|
|
35
35
|
tracks.map(trackLine).join('\n\n'),
|
|
36
36
|
].join('\n');
|
|
@@ -68,12 +68,12 @@ function registerMusicLibraryTools(server, client, options = {}) {
|
|
|
68
68
|
'search_music_library',
|
|
69
69
|
'Search the licensed SYNCI catalog — the PAID third-party option. ' +
|
|
70
70
|
'⚠️ NOT the default for music. Kolbo has its OWN large AI music library that is FREE and ' +
|
|
71
|
-
'
|
|
71
|
+
'is usually cheaper or free: call search_stock_media with source="kolbo-ai" and mediaType="music" ' +
|
|
72
72
|
'(it also supports natural-language vibe search, e.g. "uplifting hopeful corporate background"). ' +
|
|
73
73
|
'Reach for SYNCI only when the user explicitly asks for the licensed/SYNCI catalog, names a real ' +
|
|
74
74
|
'artist or commercial track, or needs stems / a specific licensed cue. ' +
|
|
75
75
|
'Results here contain watermarked preview audio only; any download or timeline use requires ' +
|
|
76
|
-
'acquire_clean_music_track,
|
|
76
|
+
'acquire_clean_music_track. ' + "Free for subscribers, org members and anyone who already bought the track; for everyone else it COSTS CREDITS (the search response carries the exact price in cleanTrackCredits, and cleanAccess:false means this caller will be charged). State the cost and get the user's agreement BEFORE calling it.",
|
|
77
77
|
{
|
|
78
78
|
query: z.string().max(200).optional(),
|
|
79
79
|
mood: z.string().optional(),
|
|
@@ -171,7 +171,7 @@ function registerMusicLibraryTools(server, client, options = {}) {
|
|
|
171
171
|
|
|
172
172
|
server.tool(
|
|
173
173
|
'acquire_clean_music_track',
|
|
174
|
-
'Acquire a clean, unwatermarked SYNCI MP3 or WAV for download or Adobe timeline use.
|
|
174
|
+
'Acquire a clean, unwatermarked SYNCI MP3 or WAV for download or Adobe timeline use. Charges IMMEDIATELY with no confirmation dialog. ' + "Free for subscribers, org members and anyone who already bought the track; for everyone else it COSTS CREDITS (the search response carries the exact price in cleanTrackCredits, and cleanAccess:false means this caller will be charged). State the cost and get the user's agreement BEFORE calling it." + ' Once bought, the track is owned permanently: the other format, its stems and every re-download are free. Reuse request_id when retrying the same intended action.',
|
|
175
175
|
{
|
|
176
176
|
track_id: z.string().min(1).max(64),
|
|
177
177
|
format: z.enum(['mp3', 'wav']).optional().describe('Default mp3. Use wav only when the search result reports hqAvailable=true.'),
|
|
@@ -193,7 +193,7 @@ function registerMusicLibraryTools(server, client, options = {}) {
|
|
|
193
193
|
|
|
194
194
|
server.tool(
|
|
195
195
|
'import_music_track_to_library',
|
|
196
|
-
'Acquire one clean SYNCI file and copy it into the Kolbo media library.
|
|
196
|
+
'Acquire one clean SYNCI file and copy it into the Kolbo media library. Charges IMMEDIATELY unless the track is already in the library or already owned. ' + "Free for subscribers, org members and anyone who already bought the track; for everyone else it COSTS CREDITS (the search response carries the exact price in cleanTrackCredits, and cleanAccess:false means this caller will be charged). State the cost and get the user's agreement BEFORE calling it." + ' Defaults to clean MP3.',
|
|
197
197
|
{
|
|
198
198
|
track_id: z.string().min(1).max(64),
|
|
199
199
|
format: z.enum(['mp3', 'wav']).optional(),
|
|
@@ -7,12 +7,17 @@ const { compactList } = require('./_shared');
|
|
|
7
7
|
|
|
8
8
|
// Compact one-line render of a normalized stock asset.
|
|
9
9
|
function assetLine(a) {
|
|
10
|
+
// What THIS caller would pay, stamped per asset by the API. 0/absent = free
|
|
11
|
+
// (subscriber, org member, or already bought). Surfaced in the text the agent
|
|
12
|
+
// reads so it can warn before spending someone's credits.
|
|
13
|
+
const price = a.meta && a.meta.priceCredits;
|
|
14
|
+
const cost = (a.meta && a.meta.owned) ? ' [purchased]' : (price ? ` [${price} credits]` : '');
|
|
10
15
|
const dims = a.width && a.height ? `${a.width}x${a.height}` : null;
|
|
11
16
|
const dur = a.durationSeconds != null ? `${Math.round(a.durationSeconds)}s` : null;
|
|
12
17
|
const meta = [a.mediaType, dims, dur].filter(Boolean).join(' · ');
|
|
13
18
|
const by = a.author?.name ? ` by ${a.author.name}` : '';
|
|
14
19
|
const variants = Array.isArray(a.downloadVariants) ? a.downloadVariants.map((v) => v.label).join('/') : '';
|
|
15
|
-
return `[${a.source}:${a.sourceId}] ${a.title || '(untitled)'}${by}\n ${meta}${variants ? ` variants: ${variants}` : ''}${a.thumbnailUrl ? `\n thumb: ${a.thumbnailUrl}` : ''}`;
|
|
20
|
+
return `[${a.source}:${a.sourceId}] ${a.title || '(untitled)'}${by}${cost}\n ${meta}${variants ? ` variants: ${variants}` : ''}${a.thumbnailUrl ? `\n thumb: ${a.thumbnailUrl}` : ''}`;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
// Returns the raw querystring (no leading '?'). Callers inline it as
|
|
@@ -37,7 +42,7 @@ function registerStockLibraryTools(server, client, options = {}) {
|
|
|
37
42
|
server.tool(
|
|
38
43
|
'search_stock_media',
|
|
39
44
|
'Search the Kolbo unified stock media library and return matching assets. ' +
|
|
40
|
-
'★ THE DEFAULT TOOL FOR MUSIC: Kolbo\'s own AI music library
|
|
45
|
+
'★ THE DEFAULT TOOL FOR MUSIC: Kolbo\'s own AI music library, free for subscribers and org members and priced per track otherwise (every result states its own cost) — ' +
|
|
41
46
|
'use source="kolbo-ai" with mediaType="music" for background/score/bed requests. Prefer it over ' +
|
|
42
47
|
'search_music_library (SYNCI), which is the paid licensed catalog and should only be used when the ' +
|
|
43
48
|
'user explicitly asks for it. ' +
|
|
@@ -84,7 +89,12 @@ function registerStockLibraryTools(server, client, options = {}) {
|
|
|
84
89
|
media_type: mt,
|
|
85
90
|
url: (a.downloadVariants?.[0]?.url) || a.thumbnailUrl,
|
|
86
91
|
preview_audio: mt === 'audio' ? (a.previewUrl || a.downloadVariants?.[0]?.url) : undefined,
|
|
87
|
-
|
|
92
|
+
badge: (a.meta && a.meta.owned) ? 'Purchased' : ((a.meta && a.meta.priceCredits) ? a.meta.priceCredits + ' credits' : undefined),
|
|
93
|
+
price_credits: (a.meta && a.meta.priceCredits) || undefined,
|
|
94
|
+
owned: (a.meta && a.meta.owned) || undefined,
|
|
95
|
+
use_hint: (a.meta && a.meta.priceCredits)
|
|
96
|
+
? 'Costs ' + a.meta.priceCredits + ' credits for this account. Confirm with the user, then: import_stock_asset source="' + a.source + '" id="' + a.sourceId + '" ("{TITLE}")'
|
|
97
|
+
: 'Import this stock asset into my media library: import_stock_asset source="' + a.source + '" id="' + a.sourceId + '" ("{TITLE}")'
|
|
88
98
|
};
|
|
89
99
|
});
|
|
90
100
|
return uiResult(UI.mediaGrid, text, {
|
|
@@ -206,7 +216,7 @@ function registerStockLibraryTools(server, client, options = {}) {
|
|
|
206
216
|
// ─── import_stock_asset ───────────────────────────────────────
|
|
207
217
|
server.tool(
|
|
208
218
|
'import_stock_asset',
|
|
209
|
-
"Copy a stock asset into the account's Kolbo media library (downloaded to Kolbo's CDN with a stable URL) so it can be used in projects/generations.
|
|
219
|
+
"Copy a stock asset into the account's Kolbo media library (downloaded to Kolbo's CDN with a stable URL) so it can be used in projects/generations. FREE for images, video and 3D. Kolbo's own MUSIC and SFX are free for subscribers, org members and anyone who already bought the track; for everyone else they COST CREDITS (search results carry the exact price in meta.priceCredits; 0/absent means free for this caller). When a price is shown, tell the user the cost and get agreement BEFORE calling this. Returns the created media library item. Works for Kolbo SFX (source='kolbo-ai', mediaType='sfx') and supported external visual/audio sources. For SYNCI music use import_music_track_to_library; that paid action acquires a clean licensed file.",
|
|
210
220
|
{
|
|
211
221
|
source: z.enum(['kolbo-ai', 'pexels', 'pixabay', 'sketchfab', 'freesound']).describe('The asset source.'),
|
|
212
222
|
id: z.string().describe('The provider asset id (sourceId).'),
|