@kolbo/mcp 1.62.0 → 1.63.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/client.js +48 -7
- package/src/index.js +1 -1
- package/src/tools/_shared.js +14 -0
- package/src/tools/chat.js +1 -1
- package/src/tools/generate.js +129 -60
- 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.
|
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/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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const FormData = require('form-data');
|
|
8
8
|
const { pollUntilDone } = require('../polling');
|
|
9
|
-
const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
|
|
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)
|
|
@@ -135,14 +146,15 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
135
146
|
preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.'),
|
|
136
147
|
cinematic: CINEMATIC_SCHEMA,
|
|
137
148
|
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
|
|
149
|
+
project_id: projectIdField,
|
|
150
|
+
session_id: sessionIdField
|
|
139
151
|
},
|
|
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 }) => {
|
|
152
|
+
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
153
|
if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
|
|
142
154
|
model = await canonicalModelId(client, model, 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
143
155
|
const shared = {
|
|
144
156
|
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
|
|
157
|
+
reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
|
|
146
158
|
};
|
|
147
159
|
|
|
148
160
|
// Batch mode: N different prompts, one widget owning all generation ids.
|
|
@@ -180,6 +192,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
180
192
|
type: 'text',
|
|
181
193
|
text: JSON.stringify({
|
|
182
194
|
...creditFields(result),
|
|
195
|
+
session_id: gen.session_id,
|
|
183
196
|
urls: result.result.urls,
|
|
184
197
|
model: result.result.model,
|
|
185
198
|
prompt_used: result.result.prompt_used,
|
|
@@ -207,13 +220,14 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
207
220
|
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
221
|
cinematic: CINEMATIC_SCHEMA,
|
|
209
222
|
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
|
|
223
|
+
project_id: projectIdField,
|
|
224
|
+
session_id: sessionIdField
|
|
211
225
|
},
|
|
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 }) => {
|
|
226
|
+
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
227
|
model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
214
228
|
const gen = await client.post('/v1/generate/image-edit', {
|
|
215
229
|
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
|
|
230
|
+
visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id, session_id
|
|
217
231
|
});
|
|
218
232
|
|
|
219
233
|
if (ui()) return uiGenerating({
|
|
@@ -240,6 +254,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
240
254
|
type: 'text',
|
|
241
255
|
text: JSON.stringify({
|
|
242
256
|
...creditFields(result),
|
|
257
|
+
session_id: gen.session_id,
|
|
243
258
|
urls: result.result.urls,
|
|
244
259
|
model: result.result.model,
|
|
245
260
|
prompt_used: result.result.prompt_used,
|
|
@@ -419,13 +434,14 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
419
434
|
preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.'),
|
|
420
435
|
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
436
|
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
|
|
437
|
+
project_id: projectIdField,
|
|
438
|
+
session_id: sessionIdField
|
|
423
439
|
},
|
|
424
|
-
async ({ prompt, prompts, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id }) => {
|
|
440
|
+
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
441
|
if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
|
|
426
442
|
model = await canonicalModelId(client, model, 'text_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
427
443
|
const shared = {
|
|
428
|
-
model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id
|
|
444
|
+
model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id, session_id
|
|
429
445
|
};
|
|
430
446
|
|
|
431
447
|
// Batch mode: N different prompts, one widget owning all generation ids.
|
|
@@ -465,6 +481,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
465
481
|
type: 'text',
|
|
466
482
|
text: JSON.stringify({
|
|
467
483
|
...creditFields(result),
|
|
484
|
+
session_id: gen.session_id,
|
|
468
485
|
urls: result.result.urls,
|
|
469
486
|
model: result.result.model,
|
|
470
487
|
duration: result.result.duration,
|
|
@@ -480,10 +497,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
480
497
|
// ─── generate_video_from_image ─────────────────────────────
|
|
481
498
|
server.tool(
|
|
482
499
|
'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.',
|
|
500
|
+
'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
501
|
{
|
|
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")'),
|
|
502
|
+
image_url: z.string().optional().describe('URL of the source image to animate. Required unless `items` is provided.'),
|
|
503
|
+
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.'),
|
|
504
|
+
items: z.array(z.object({
|
|
505
|
+
image_url: z.string().describe('URL of the source image to animate for THIS clip.'),
|
|
506
|
+
prompt: z.string().describe('Text description of the desired MOTION for THIS clip.'),
|
|
507
|
+
})).max(MAX_BATCH_PROMPTS).optional().describe(
|
|
508
|
+
`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.`
|
|
509
|
+
),
|
|
487
510
|
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
511
|
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
512
|
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 +515,34 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
492
515
|
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
516
|
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
517
|
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
|
|
518
|
+
project_id: projectIdField,
|
|
519
|
+
session_id: sessionIdField
|
|
496
520
|
},
|
|
497
|
-
async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id }) => {
|
|
521
|
+
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 }) => {
|
|
522
|
+
if (!(items && items.length) && !(image_url && prompt)) throw new Error('Provide image_url + prompt, or items');
|
|
498
523
|
model = await canonicalModelId(client, model, 'img_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
}
|
|
524
|
+
const shared = {
|
|
525
|
+
model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id, session_id
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// Batch mode: N different stills, one widget owning all generation ids.
|
|
529
|
+
// Same fan-out as generate_video's prompts[], except the varying part is
|
|
530
|
+
// the (image_url, prompt) PAIR — submitBatch carries the prompt as the
|
|
531
|
+
// per-tile caption either way.
|
|
532
|
+
if (items && items.length) {
|
|
533
|
+
const batch = await submitBatch(items, (it) => client.post('/v1/generate/video/from-image', { ...shared, image_url: it.image_url, prompt: it.prompt }));
|
|
534
|
+
if (ui()) return uiGenerating({
|
|
535
|
+
tool: 'generate_video_from_image', kind: 'video', gen: batch.ok[0].gen, client, model,
|
|
536
|
+
count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
|
|
537
|
+
generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
|
|
538
|
+
failed_submissions: batch.failed,
|
|
539
|
+
status_args: { generation_ids: batch.ids, wait: true },
|
|
540
|
+
reference_image: items[0].image_url
|
|
541
|
+
});
|
|
542
|
+
return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 8) * 1000, timeout: 900000 });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const gen = await client.post('/v1/generate/video/from-image', { ...shared, image_url, prompt });
|
|
502
546
|
|
|
503
547
|
if (ui()) return uiGenerating({
|
|
504
548
|
tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
|
|
@@ -520,11 +564,12 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
520
564
|
type: 'text',
|
|
521
565
|
text: JSON.stringify({
|
|
522
566
|
...creditFields(result),
|
|
567
|
+
session_id: gen.session_id,
|
|
523
568
|
urls: result.result.urls,
|
|
524
569
|
model: result.result.model,
|
|
525
570
|
duration: result.result.duration,
|
|
526
571
|
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.'
|
|
572
|
+
_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
573
|
}, null, 2)
|
|
529
574
|
}]
|
|
530
575
|
};
|
|
@@ -555,15 +600,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
555
600
|
use_composition_plan: z.boolean().optional().describe('Suno: enable structured composition planning (verse/chorus structure).'),
|
|
556
601
|
singing_dna_id: z.string().optional().describe('Visual DNA character id whose singing voice to use (must be owned by the caller).'),
|
|
557
602
|
singing_voice_id: z.string().optional().describe('Custom cloned singing-voice id (must be owned by the caller).'),
|
|
558
|
-
project_id: projectIdField
|
|
603
|
+
project_id: projectIdField,
|
|
604
|
+
session_id: sessionIdField
|
|
559
605
|
},
|
|
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 }) => {
|
|
606
|
+
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
607
|
model = await canonicalModelId(client, model, 'music_gen'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
562
608
|
const gen = await client.post('/v1/generate/music', {
|
|
563
609
|
prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags,
|
|
564
610
|
duration_seconds, enhance_prompt, preset_id,
|
|
565
611
|
style_weight, weirdness, audio_weight, persona_id, use_composition_plan,
|
|
566
|
-
singing_dna_id, singing_voice_id, project_id
|
|
612
|
+
singing_dna_id, singing_voice_id, project_id, session_id
|
|
567
613
|
});
|
|
568
614
|
|
|
569
615
|
if (ui()) return uiGenerating({
|
|
@@ -583,6 +629,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
583
629
|
type: 'text',
|
|
584
630
|
text: JSON.stringify({
|
|
585
631
|
...creditFields(result),
|
|
632
|
+
session_id: gen.session_id,
|
|
586
633
|
urls: result.result.urls,
|
|
587
634
|
title: result.result.title,
|
|
588
635
|
duration: result.result.duration,
|
|
@@ -627,9 +674,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
627
674
|
minimax_vol: z.number().optional().describe('MiniMax volume, 0–10. Default 1.'),
|
|
628
675
|
minimax_intensity: z.number().optional().describe('MiniMax voice intensity.'),
|
|
629
676
|
minimax_timbre: z.number().optional().describe('MiniMax voice timbre.'),
|
|
630
|
-
project_id: projectIdField
|
|
677
|
+
project_id: projectIdField,
|
|
678
|
+
session_id: sessionIdField
|
|
631
679
|
},
|
|
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 }) => {
|
|
680
|
+
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
681
|
model = await canonicalModelId(client, model, 'text_to_speech'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
634
682
|
// Resolve the requested voice against the REAL catalog (cached) so the card
|
|
635
683
|
// can show its display name + portrait instead of a raw id, and so an id
|
|
@@ -647,7 +695,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
647
695
|
similarity_boost, style, use_speaker_boost,
|
|
648
696
|
variance, tempo, promptBoost, seed, accentControl, voiceTitle,
|
|
649
697
|
minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre,
|
|
650
|
-
project_id
|
|
698
|
+
project_id, session_id
|
|
651
699
|
});
|
|
652
700
|
|
|
653
701
|
if (ui()) return uiGenerating({
|
|
@@ -669,6 +717,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
669
717
|
type: 'text',
|
|
670
718
|
text: JSON.stringify({
|
|
671
719
|
...creditFields(result),
|
|
720
|
+
session_id: gen.session_id,
|
|
672
721
|
urls: result.result.urls,
|
|
673
722
|
voice: result.result.voice,
|
|
674
723
|
duration: result.result.duration,
|
|
@@ -701,15 +750,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
701
750
|
seed_pitch: z.number().optional().describe('FAL Seed-Audio: pitch shift in semitones.'),
|
|
702
751
|
seed_reference_audio_urls: z.array(z.string()).optional().describe('FAL Seed-Audio: up to 3 reference audio URLs to condition the sound.'),
|
|
703
752
|
seed_reference_image_url: z.string().optional().describe('FAL Seed-Audio: a reference image URL to condition the sound.'),
|
|
704
|
-
project_id: projectIdField
|
|
753
|
+
project_id: projectIdField,
|
|
754
|
+
session_id: sessionIdField
|
|
705
755
|
},
|
|
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 }) => {
|
|
756
|
+
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
757
|
model = await canonicalModelId(client, model, 'text_to_sound'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
708
758
|
const gen = await client.post('/v1/generate/sound', {
|
|
709
759
|
prompt, model, duration, prompt_influence,
|
|
710
760
|
cfg_strength, sound_loop, sound_tempo, sound_key,
|
|
711
761
|
seed_voice, seed_speed, seed_volume, seed_pitch,
|
|
712
|
-
seed_reference_audio_urls, seed_reference_image_url, project_id
|
|
762
|
+
seed_reference_audio_urls, seed_reference_image_url, project_id, session_id
|
|
713
763
|
});
|
|
714
764
|
|
|
715
765
|
if (ui()) return uiGenerating({
|
|
@@ -729,6 +779,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
729
779
|
type: 'text',
|
|
730
780
|
text: JSON.stringify({
|
|
731
781
|
...creditFields(result),
|
|
782
|
+
session_id: gen.session_id,
|
|
732
783
|
urls: result.result.urls,
|
|
733
784
|
duration: result.result.duration
|
|
734
785
|
}, null, 2)
|
|
@@ -917,9 +968,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
917
968
|
image_url: z.string().describe('Public URL of the keyframe image'),
|
|
918
969
|
timestamp_seconds: z.number().describe('Moment on the OUTPUT timeline (seconds, 0 = first frame) where this image is pinned')
|
|
919
970
|
})).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
|
|
971
|
+
project_id: projectIdField,
|
|
972
|
+
session_id: sessionIdField
|
|
921
973
|
},
|
|
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 }) => {
|
|
974
|
+
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
975
|
model = await canonicalModelId(client, model, 'elements'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
924
976
|
if (!prompt) throw new Error('prompt is required');
|
|
925
977
|
|
|
@@ -943,6 +995,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
943
995
|
if (resolution) form.append('resolution', resolution);
|
|
944
996
|
if (keyframes) form.append('keyframes', JSON.stringify(keyframes));
|
|
945
997
|
if (project_id) form.append('project_id', project_id);
|
|
998
|
+
if (session_id) form.append('session_id', session_id);
|
|
946
999
|
for (const f of resolved) {
|
|
947
1000
|
form.append('files', f.buffer, { filename: f.filename, contentType: f.contentType });
|
|
948
1001
|
}
|
|
@@ -950,7 +1003,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
950
1003
|
} else {
|
|
951
1004
|
// URL-only mode: plain JSON.
|
|
952
1005
|
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
|
|
1006
|
+
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
1007
|
});
|
|
955
1008
|
}
|
|
956
1009
|
|
|
@@ -972,6 +1025,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
972
1025
|
type: 'text',
|
|
973
1026
|
text: JSON.stringify({
|
|
974
1027
|
...creditFields(result),
|
|
1028
|
+
session_id: startResponse.session_id,
|
|
975
1029
|
urls: result.result?.urls || [],
|
|
976
1030
|
thumbnail_url: result.result?.thumbnail_url || null,
|
|
977
1031
|
duration: result.result?.duration || null,
|
|
@@ -998,9 +1052,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
998
1052
|
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
1053
|
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
1054
|
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
|
|
1055
|
+
project_id: projectIdField,
|
|
1056
|
+
session_id: sessionIdField
|
|
1002
1057
|
},
|
|
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 }) => {
|
|
1058
|
+
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
1059
|
model = await canonicalModelId(client, model, 'firstlastgenerations'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
1005
1060
|
const urlMode = first_frame_url && last_frame_url;
|
|
1006
1061
|
const fileMode = first_frame && last_frame;
|
|
@@ -1028,10 +1083,11 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1028
1083
|
if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
|
|
1029
1084
|
if (resolution) form.append('resolution', resolution);
|
|
1030
1085
|
if (project_id) form.append('project_id', project_id);
|
|
1086
|
+
if (session_id) form.append('session_id', session_id);
|
|
1031
1087
|
startResponse = await client.postMultipart('/v1/generate/first-last-frame', form);
|
|
1032
1088
|
} else {
|
|
1033
1089
|
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
|
|
1090
|
+
first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, project_id, session_id
|
|
1035
1091
|
});
|
|
1036
1092
|
}
|
|
1037
1093
|
|
|
@@ -1053,6 +1109,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1053
1109
|
type: 'text',
|
|
1054
1110
|
text: JSON.stringify({
|
|
1055
1111
|
...creditFields(result),
|
|
1112
|
+
session_id: startResponse.session_id,
|
|
1056
1113
|
urls: result.result?.urls || [],
|
|
1057
1114
|
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1058
1115
|
duration: result.result?.duration || null,
|
|
@@ -1088,9 +1145,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1088
1145
|
bounding_boxes_url: z.string().optional().describe('URL to a JSON file with per-frame boxes.'),
|
|
1089
1146
|
face_image: z.string().optional().describe('Base64-encoded reference face image.')
|
|
1090
1147
|
}).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
|
|
1148
|
+
project_id: projectIdField,
|
|
1149
|
+
session_id: sessionIdField
|
|
1092
1150
|
},
|
|
1093
|
-
async ({ source, audio, text_prompt, model, bounding_box_target, sync_mode, model_mode, emotion, temperature, occlusion_detection_enabled, active_speaker_detection, project_id }) => {
|
|
1151
|
+
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
1152
|
model = await canonicalModelId(client, model, ['lipsync-image', 'lipsync-video']); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
1095
1153
|
if (!source) throw new Error('source is required (URL or absolute local path to image/video)');
|
|
1096
1154
|
if (!audio) throw new Error('audio is required (URL or absolute local path to audio file)');
|
|
@@ -1114,7 +1172,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1114
1172
|
temperature,
|
|
1115
1173
|
occlusion_detection_enabled,
|
|
1116
1174
|
active_speaker_detection,
|
|
1117
|
-
project_id
|
|
1175
|
+
project_id, session_id
|
|
1118
1176
|
});
|
|
1119
1177
|
} else {
|
|
1120
1178
|
// File mode (or mixed — resolve any local paths, pass URLs through as body fields)
|
|
@@ -1144,6 +1202,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1144
1202
|
if (occlusion_detection_enabled !== undefined) form.append('occlusion_detection_enabled', String(occlusion_detection_enabled));
|
|
1145
1203
|
if (active_speaker_detection) form.append('active_speaker_detection', JSON.stringify(active_speaker_detection));
|
|
1146
1204
|
if (project_id) form.append('project_id', project_id);
|
|
1205
|
+
if (session_id) form.append('session_id', session_id);
|
|
1147
1206
|
startResponse = await client.postMultipart('/v1/generate/lipsync', form);
|
|
1148
1207
|
}
|
|
1149
1208
|
|
|
@@ -1165,6 +1224,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1165
1224
|
type: 'text',
|
|
1166
1225
|
text: JSON.stringify({
|
|
1167
1226
|
...creditFields(result),
|
|
1227
|
+
session_id: startResponse.session_id,
|
|
1168
1228
|
urls: result.result?.urls || [],
|
|
1169
1229
|
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1170
1230
|
duration: result.result?.duration || null,
|
|
@@ -1209,9 +1269,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1209
1269
|
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
1270
|
}).optional(),
|
|
1211
1271
|
}).optional().describe('VEED Subtitles only: style overrides. Any omitted field keeps the preset default. Best supported by Basic presets.'),
|
|
1212
|
-
project_id: projectIdField
|
|
1272
|
+
project_id: projectIdField,
|
|
1273
|
+
session_id: sessionIdField
|
|
1213
1274
|
},
|
|
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 }) => {
|
|
1275
|
+
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
1276
|
model = await canonicalModelId(client, model, 'video_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
1216
1277
|
if (!source_video) throw new Error('source_video is required');
|
|
1217
1278
|
|
|
@@ -1221,7 +1282,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1221
1282
|
startResponse = await client.post('/v1/generate/video-from-video', {
|
|
1222
1283
|
video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution,
|
|
1223
1284
|
reference_images, reference_videos, elements, preset, source_language, translation_language,
|
|
1224
|
-
srt_content, srt_file_url, vocabulary, customization, project_id
|
|
1285
|
+
srt_content, srt_file_url, vocabulary, customization, project_id, session_id
|
|
1225
1286
|
});
|
|
1226
1287
|
} else {
|
|
1227
1288
|
const resolved = await resolveToBuffer(source_video, 'video');
|
|
@@ -1245,6 +1306,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1245
1306
|
if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
|
|
1246
1307
|
if (elements) form.append('elements', JSON.stringify(elements));
|
|
1247
1308
|
if (project_id) form.append('project_id', project_id);
|
|
1309
|
+
if (session_id) form.append('session_id', session_id);
|
|
1248
1310
|
startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
|
|
1249
1311
|
}
|
|
1250
1312
|
|
|
@@ -1267,6 +1329,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1267
1329
|
type: 'text',
|
|
1268
1330
|
text: JSON.stringify({
|
|
1269
1331
|
...creditFields(result),
|
|
1332
|
+
session_id: startResponse.session_id,
|
|
1270
1333
|
urls: result.result?.urls || [],
|
|
1271
1334
|
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1272
1335
|
duration: result.result?.duration || null,
|
|
@@ -1291,15 +1354,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1291
1354
|
words_per_line: z.number().optional().describe('SRT: max words per subtitle line, 1–18. Default: 12.'),
|
|
1292
1355
|
lines_per_subtitle: z.number().optional().describe('SRT: max lines per subtitle cue, 1–4. Default: 2.'),
|
|
1293
1356
|
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
|
|
1357
|
+
project_id: projectIdField,
|
|
1358
|
+
session_id: sessionIdField
|
|
1295
1359
|
},
|
|
1296
|
-
async ({ source, language, diarize, tag_audio_events, remove_punctuation, generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id }) => {
|
|
1360
|
+
async ({ source, language, diarize, tag_audio_events, remove_punctuation, generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id, session_id }) => {
|
|
1297
1361
|
if (!source) throw new Error('source is required (URL or absolute local path)');
|
|
1298
1362
|
|
|
1299
1363
|
// Advanced transcription controls forwarded when provided (undefined keys are dropped by the client).
|
|
1300
1364
|
const opts = {
|
|
1301
1365
|
language, diarize, tag_audio_events, remove_punctuation,
|
|
1302
|
-
generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id
|
|
1366
|
+
generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id, session_id
|
|
1303
1367
|
};
|
|
1304
1368
|
|
|
1305
1369
|
const isUrl = /^https?:\/\//i.test(source);
|
|
@@ -1343,6 +1407,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1343
1407
|
type: 'text',
|
|
1344
1408
|
text: JSON.stringify({
|
|
1345
1409
|
...creditFields(result),
|
|
1410
|
+
session_id: startResponse.session_id,
|
|
1346
1411
|
text: result.result?.text || '',
|
|
1347
1412
|
srt_url: result.result?.srt_url || null,
|
|
1348
1413
|
word_by_word_srt_url: result.result?.word_by_word_srt_url || null,
|
|
@@ -1506,13 +1571,14 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1506
1571
|
ai_optimize: z.boolean().optional()
|
|
1507
1572
|
.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
1573
|
|
|
1509
|
-
project_id: projectIdField
|
|
1574
|
+
project_id: projectIdField,
|
|
1575
|
+
session_id: sessionIdField
|
|
1510
1576
|
},
|
|
1511
1577
|
async ({
|
|
1512
1578
|
image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
|
|
1513
1579
|
mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize = false,
|
|
1514
1580
|
zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
|
|
1515
|
-
project_id
|
|
1581
|
+
project_id, session_id
|
|
1516
1582
|
}) => {
|
|
1517
1583
|
// No `type` argument: these are operation-routed tools (upscale / reframe /
|
|
1518
1584
|
// removebg / …), each operation with its own model family — there is no single
|
|
@@ -1530,7 +1596,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1530
1596
|
image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
|
|
1531
1597
|
mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize,
|
|
1532
1598
|
zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
|
|
1533
|
-
project_id
|
|
1599
|
+
project_id, session_id
|
|
1534
1600
|
});
|
|
1535
1601
|
|
|
1536
1602
|
if (ui()) return uiGenerating({
|
|
@@ -1552,6 +1618,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1552
1618
|
type: 'text',
|
|
1553
1619
|
text: JSON.stringify({
|
|
1554
1620
|
...creditFields(result),
|
|
1621
|
+
session_id: gen.session_id,
|
|
1555
1622
|
urls: result.result?.urls || [],
|
|
1556
1623
|
edit_type: result.result?.edit_type || null,
|
|
1557
1624
|
model: result.result?.model || null
|
|
@@ -1656,7 +1723,8 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1656
1723
|
start_time: z.number().optional()
|
|
1657
1724
|
.describe('Start time in seconds of the segment to retake. Used with operation="retake".'),
|
|
1658
1725
|
|
|
1659
|
-
project_id: projectIdField
|
|
1726
|
+
project_id: projectIdField,
|
|
1727
|
+
session_id: sessionIdField
|
|
1660
1728
|
},
|
|
1661
1729
|
async ({
|
|
1662
1730
|
video_url, operation, model, aspect_ratio, scale, prompt,
|
|
@@ -1668,7 +1736,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1668
1736
|
text_prompt, context,
|
|
1669
1737
|
mask_video_url, object_prompt, video_strength,
|
|
1670
1738
|
start_time,
|
|
1671
|
-
project_id
|
|
1739
|
+
project_id, session_id
|
|
1672
1740
|
}) => {
|
|
1673
1741
|
// No `type` argument: these are operation-routed tools (upscale / reframe /
|
|
1674
1742
|
// removebg / …), each operation with its own model family — there is no single
|
|
@@ -1693,7 +1761,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1693
1761
|
text_prompt, context,
|
|
1694
1762
|
mask_video_url, object_prompt, video_strength,
|
|
1695
1763
|
start_time,
|
|
1696
|
-
project_id
|
|
1764
|
+
project_id, session_id
|
|
1697
1765
|
});
|
|
1698
1766
|
|
|
1699
1767
|
if (ui()) return uiGenerating({
|
|
@@ -1715,6 +1783,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1715
1783
|
type: 'text',
|
|
1716
1784
|
text: JSON.stringify({
|
|
1717
1785
|
...creditFields(result),
|
|
1786
|
+
session_id: gen.session_id,
|
|
1718
1787
|
urls: result.result?.urls || [],
|
|
1719
1788
|
download_url: result.result?.download_url || null,
|
|
1720
1789
|
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).'),
|