@kolbo/mcp 1.58.0 → 1.60.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 +3 -2
- package/src/apps/index.js +111 -17
- package/src/apps/theme.js +1 -0
- package/src/apps/widgets/generation.js +21 -3
- package/src/tools/_shared.js +23 -7
- package/src/tools/generate.js +58 -7
- package/src/tools/models.js +52 -15
- package/src/tools/voices.js +19 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolbo/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.60.0",
|
|
4
4
|
"description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"start": "node src/index.js",
|
|
11
11
|
"smoke": "node scripts/smoke.js",
|
|
12
12
|
"check-parity": "node scripts/check-parity.js",
|
|
13
|
-
"prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js && node scripts/check-widget-fields.js && node scripts/check-widget-render.js && node scripts/check-skill-tools.js && node scripts/check-install.js",
|
|
13
|
+
"prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js && node scripts/check-widget-fields.js && node scripts/check-widget-render.js && node scripts/check-model-catalog.js && node scripts/check-skill-tools.js && node scripts/check-install.js",
|
|
14
|
+
"check-model-catalog": "node scripts/check-model-catalog.js",
|
|
14
15
|
"check-widget-fields": "node scripts/check-widget-fields.js",
|
|
15
16
|
"check-widget-render": "node scripts/check-widget-render.js",
|
|
16
17
|
"check-skill-tools": "node scripts/check-skill-tools.js",
|
package/src/apps/index.js
CHANGED
|
@@ -203,7 +203,10 @@ async function modelInfoMap(client) {
|
|
|
203
203
|
// Real p75 wall-clock estimate mined from production creditUsages —
|
|
204
204
|
// the same source the in-app countdowns use. No estimate → no ETA shown.
|
|
205
205
|
const eta = Number(m.estimatedDurationSeconds || m.estimated_duration_seconds) || null;
|
|
206
|
-
|
|
206
|
+
// `name` is the CLEAN display name ("Google TTS"); it is what widgets show.
|
|
207
|
+
// Without it the model chip fell back to whatever raw string the caller or
|
|
208
|
+
// the status endpoint supplied ("google_tts", "fal-ai/bytedance/omnihuman/v1.5").
|
|
209
|
+
const info = { icon, eta, id: m.identifier || null, name: m.name || null };
|
|
207
210
|
// Display names collide across variants ("Nano Banana 2" names both the
|
|
208
211
|
// t2i model and its editing sibling) — on collision keep the model with
|
|
209
212
|
// the SHORTEST identifier (the base model), deterministically.
|
|
@@ -224,11 +227,51 @@ async function modelInfoMap(client) {
|
|
|
224
227
|
return byKey;
|
|
225
228
|
}
|
|
226
229
|
|
|
227
|
-
/** Resolve one model's { icon, eta }; missing →
|
|
230
|
+
/** Resolve one model's { icon, eta, name }; missing → all null. */
|
|
228
231
|
async function modelInfo(client, modelName) {
|
|
229
|
-
if (!modelName) return { icon: null, eta: null };
|
|
232
|
+
if (!modelName) return { icon: null, eta: null, name: null };
|
|
230
233
|
const map = await modelInfoMap(client);
|
|
231
|
-
return map.get(String(modelName).toLowerCase()) || { icon: null, eta: null };
|
|
234
|
+
return map.get(String(modelName).toLowerCase()) || { icon: null, eta: null, name: null };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ------------------------------------------------------------------ */
|
|
238
|
+
/* Voice lookup (voice_id / display name → { id, name, thumbnail }) */
|
|
239
|
+
/* ------------------------------------------------------------------ */
|
|
240
|
+
|
|
241
|
+
// Same shape and TTL as the model catalog above: the voice catalog is stable,
|
|
242
|
+
// and a per-generation /v1/voices round trip would be paid on every speech card.
|
|
243
|
+
const voiceCache = new Map(); // apiBase → { at, byKey }
|
|
244
|
+
|
|
245
|
+
async function voiceInfoMap(client) {
|
|
246
|
+
const cacheKey = client.apiBase || 'default';
|
|
247
|
+
const hit = voiceCache.get(cacheKey);
|
|
248
|
+
if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit.byKey;
|
|
249
|
+
const byKey = new Map();
|
|
250
|
+
try {
|
|
251
|
+
const res = await client.request('GET', '/v1/voices');
|
|
252
|
+
for (const v of res?.voices || []) {
|
|
253
|
+
if (!v || !v.voice_id) continue;
|
|
254
|
+
// thumbnail/preview come from the catalog record — NEVER templated from
|
|
255
|
+
// the id, so a change to the CDN path scheme cannot silently 404 the card.
|
|
256
|
+
const info = { id: v.voice_id, name: v.name || v.voice_id, thumbnail: v.thumbnail || null };
|
|
257
|
+
byKey.set(String(v.voice_id).toLowerCase(), info);
|
|
258
|
+
// Display names are not unique across locales (the same Gemini voice is
|
|
259
|
+
// catalogued per language). First one wins; the id lookup above is exact.
|
|
260
|
+
const nameKey = String(info.name).toLowerCase();
|
|
261
|
+
if (v.name && !byKey.has(nameKey)) byKey.set(nameKey, info);
|
|
262
|
+
}
|
|
263
|
+
} catch (_) {
|
|
264
|
+
/* fail open — cards fall back to the raw voice string */
|
|
265
|
+
}
|
|
266
|
+
if (byKey.size > 0) voiceCache.set(cacheKey, { at: Date.now(), byKey });
|
|
267
|
+
return byKey;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Resolve a voice id OR display name to { id, name, thumbnail }; null if unknown. */
|
|
271
|
+
async function voiceInfo(client, voice) {
|
|
272
|
+
if (!voice) return null;
|
|
273
|
+
const map = await voiceInfoMap(client);
|
|
274
|
+
return map.get(String(voice).toLowerCase().trim()) || null;
|
|
232
275
|
}
|
|
233
276
|
|
|
234
277
|
/** Back-compat shim (used by uiCompleted and older call sites). */
|
|
@@ -236,29 +279,79 @@ async function modelIcon(client, modelName) {
|
|
|
236
279
|
return (await modelInfo(client, modelName)).icon;
|
|
237
280
|
}
|
|
238
281
|
|
|
282
|
+
// Separator-insensitive key. Catalog keys carry their own punctuation — the
|
|
283
|
+
// NAME is keyed "minimax h3", the IDENTIFIER "flux-2/flash" — so both sides
|
|
284
|
+
// must be flattened before comparing. Normalising only the input (the old
|
|
285
|
+
// `key.replace(/\s+/g, '-')`) is why "flux-2-flash" never found "flux-2/flash".
|
|
286
|
+
const normId = (s) => String(s || '').toLowerCase().replace(/[\s._/-]+/g, '');
|
|
287
|
+
|
|
288
|
+
// The API maps these to Smart Select itself. They are never typos, so they must
|
|
289
|
+
// never be "corrected" or reported as unknown.
|
|
290
|
+
const AUTO_ALIASES = new Set([
|
|
291
|
+
'auto', 'autoselect', 'smartselect', 'kolbosmartselectrouter', 'default', 'none',
|
|
292
|
+
]);
|
|
293
|
+
|
|
239
294
|
/**
|
|
240
295
|
* Lenient model-identifier resolution for LLM-supplied model args.
|
|
241
296
|
* Users say "z-image"; the real identifier is "z-image/turbo" — the backend
|
|
242
297
|
* has no fuzzy matching on generation routes and fails deep in credit
|
|
243
298
|
* reservation. Resolve here: exact name/identifier hit → its identifier;
|
|
244
|
-
* else a
|
|
245
|
-
*
|
|
299
|
+
* else a separator-insensitive hit ("flux-2-flash" → "flux-2/flash"); else a
|
|
300
|
+
* UNIQUE prefix match ("z-image" → "z-image/turbo").
|
|
301
|
+
*
|
|
302
|
+
* Still unresolved: throw with the near misses named. The API answers a bad
|
|
303
|
+
* identifier with a bare INVALID_*_MODEL and no hint, which on 2026-08-09 sent
|
|
304
|
+
* an agent guessing "minimax-hailuo-3" (real id: "minimax-h3") and then
|
|
305
|
+
* substituting a far more expensive model. Only throws when the catalog is
|
|
306
|
+
* healthy AND actually offers candidates — otherwise it passes through
|
|
307
|
+
* unchanged, so identifiers the catalog does not publish (hidden models) still
|
|
308
|
+
* reach the API and it stays the source of truth.
|
|
246
309
|
*/
|
|
247
310
|
async function canonicalModelId(client, input) {
|
|
248
311
|
if (!input || typeof input !== 'string') return input;
|
|
312
|
+
const key = input.toLowerCase().trim();
|
|
313
|
+
const want = normId(key);
|
|
314
|
+
if (!want || AUTO_ALIASES.has(want)) return input;
|
|
315
|
+
|
|
316
|
+
let map;
|
|
249
317
|
try {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
318
|
+
map = await modelInfoMap(client);
|
|
319
|
+
} catch (_) {
|
|
320
|
+
return input; // fail open — never block a generation on a catalog hiccup
|
|
321
|
+
}
|
|
322
|
+
if (!map || map.size === 0) return input;
|
|
323
|
+
|
|
324
|
+
// 1. exact name / identifier hit
|
|
325
|
+
const hit = map.get(key) || map.get(key.replace(/\s+/g, '-'));
|
|
326
|
+
if (hit && hit.id) return hit.id;
|
|
327
|
+
|
|
328
|
+
// 2 + 3. separator-insensitive exact, then unique prefix
|
|
329
|
+
const byNorm = new Map();
|
|
330
|
+
for (const info of map.values()) {
|
|
331
|
+
if (!info.id) continue;
|
|
332
|
+
for (const k of [info.id, info.name]) {
|
|
333
|
+
const n = normId(k);
|
|
334
|
+
if (n && !byNorm.has(n)) byNorm.set(n, info.id);
|
|
258
335
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
336
|
+
}
|
|
337
|
+
if (byNorm.has(want)) return byNorm.get(want);
|
|
338
|
+
const prefixed = new Set();
|
|
339
|
+
for (const [n, id] of byNorm) if (n.startsWith(want)) prefixed.add(id);
|
|
340
|
+
if (prefixed.size === 1) return [...prefixed][0];
|
|
341
|
+
|
|
342
|
+
// 4. unknown — name the near misses instead of dead-ending at the API.
|
|
343
|
+
const stem = normId(key.split(/[\s._/-]+/).filter(Boolean)[0] || key);
|
|
344
|
+
const near = [...new Set(
|
|
345
|
+
[...map.values()]
|
|
346
|
+
.filter((i) => i.id && stem && (normId(i.id).startsWith(stem) || normId(i.name).startsWith(stem)))
|
|
347
|
+
.map((i) => (i.name ? `${i.id} (${i.name})` : i.id))
|
|
348
|
+
)].sort().slice(0, 12);
|
|
349
|
+
if (!near.length) return input;
|
|
350
|
+
throw new Error(
|
|
351
|
+
`Unknown model identifier "${input}". Did you mean: ${near.join(', ')}? `
|
|
352
|
+
+ 'Never guess an identifier — call list_models with the matching `type` and `format: "json"` '
|
|
353
|
+
+ 'to get the exact identifiers and caps.'
|
|
354
|
+
);
|
|
262
355
|
}
|
|
263
356
|
|
|
264
357
|
/* ------------------------------------------------------------------ */
|
|
@@ -341,6 +434,7 @@ module.exports = {
|
|
|
341
434
|
modelIcon,
|
|
342
435
|
modelInfo,
|
|
343
436
|
modelInfoMap,
|
|
437
|
+
voiceInfo,
|
|
344
438
|
canonicalModelId,
|
|
345
439
|
resolveAvatarUrl,
|
|
346
440
|
widgetHtml, // exported for smoke tests
|
package/src/apps/theme.js
CHANGED
|
@@ -111,6 +111,7 @@ body {
|
|
|
111
111
|
color: #fff; font-size: 9px; font-weight: 700; display: inline-flex;
|
|
112
112
|
align-items: center; justify-content: center; font-family: 'Inter', sans-serif;
|
|
113
113
|
}
|
|
114
|
+
.k-chip img.k-voice-thumb { width: 18px; height: 18px; border-radius: 999px; margin-left: -3px; }
|
|
114
115
|
.k-ref-thumb { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-strong); }
|
|
115
116
|
|
|
116
117
|
/* ---- Generating state ---- */
|
|
@@ -105,8 +105,14 @@ function boot(sc) {
|
|
|
105
105
|
window.kolbo.notifySize();
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// model_name / voice_name are the CLEAN catalog names, resolved server-side
|
|
109
|
+
// (src/tools/_shared.js on submit, get_generation_status on completion). The raw
|
|
110
|
+
// ids stay in sc.model / sc.voice for Recreate + model context — never for display.
|
|
111
|
+
function modelLabel(sc) { return sc.model_name || sc.model; }
|
|
112
|
+
function voiceLabel(sc) { return sc.voice_name || sc.voice || (sc.settings || {}).voice; }
|
|
113
|
+
|
|
108
114
|
function renderChips(sc) {
|
|
109
|
-
var h = modelChipHTML(sc
|
|
115
|
+
var h = modelChipHTML(modelLabel(sc), sc.model_icon);
|
|
110
116
|
var s = sc.settings || {};
|
|
111
117
|
if (sc.kind) h += chip(iconFor(sc.kind) + ' ' + sc.kind);
|
|
112
118
|
if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration));
|
|
@@ -120,7 +126,13 @@ function renderChips(sc) {
|
|
|
120
126
|
if (s.preset) h += chip('preset');
|
|
121
127
|
if (s.cinematic) h += chip('cinematic');
|
|
122
128
|
if (s.audio) h += chip(ICONS.sound + ' audio');
|
|
123
|
-
|
|
129
|
+
var voice = voiceLabel(sc);
|
|
130
|
+
if (voice) {
|
|
131
|
+
var face = sc.voice_thumbnail
|
|
132
|
+
? '<img class="k-voice-thumb" src="' + esc(sc.voice_thumbnail) + '" alt="" loading="lazy" onerror="this.style.display=\\'none\\'">'
|
|
133
|
+
: ICONS.mic;
|
|
134
|
+
h += chip(face + ' ' + esc(voice));
|
|
135
|
+
}
|
|
124
136
|
if (s.mode) h += chip(esc(s.mode));
|
|
125
137
|
if (sc.count > 1) h += chip('×' + sc.count);
|
|
126
138
|
if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="" loading="lazy" title="Reference image" onerror="this.style.display=\\'none\\'">';
|
|
@@ -414,6 +426,10 @@ function fillBatchCell(sc, i, g) {
|
|
|
414
426
|
function renderResult(sc) {
|
|
415
427
|
clearTimeout(pollTimer);
|
|
416
428
|
|
|
429
|
+
// Repaint the chips: the generating phase only knew what the CALLER asked for
|
|
430
|
+
// (often nothing → "Smart Select"). The completed status carries the model and
|
|
431
|
+
// voice that actually ran, so the finished card must not keep the guess.
|
|
432
|
+
renderChips(sc);
|
|
417
433
|
setPhaseChip('', false);
|
|
418
434
|
if (sc.batch && sc.scenes && sc.scenes.length) return renderBatchGrid(sc);
|
|
419
435
|
if (sc.kind === 'scenes' && sc.scenes && sc.scenes.length) return renderScenes(sc);
|
|
@@ -482,7 +498,9 @@ function renderAudio(sc, urls) {
|
|
|
482
498
|
(artwork ? '<img class="k-audio-art" src="' + esc(artwork) + '" alt="" loading="lazy">' :
|
|
483
499
|
'<div class="k-audio-art k-audio-placeholder">' + ICONS.audio + '</div>') +
|
|
484
500
|
'<div class="k-audio-meta"><div class="k-audio-title">' + esc(title) + '</div>' +
|
|
485
|
-
|
|
501
|
+
// Resolved name first: a per-track model field is the raw id, and every
|
|
502
|
+
// track in one generation came from the same model anyway.
|
|
503
|
+
'<div class="k-audio-sub">' + esc(modelLabel(sc) || track.model || '') +
|
|
486
504
|
(duration ? ' · ' + fmtDur(duration) : '') + '</div></div>' +
|
|
487
505
|
'<button class="k-btn k-audio-download" data-audio-download="' + esc(u) +
|
|
488
506
|
'" aria-label="Download ' + esc(title) + '">' + ICONS.download + ' Download</button>' +
|
package/src/tools/_shared.js
CHANGED
|
@@ -484,7 +484,22 @@ function buildProjectUrl(projectId, opts = {}) {
|
|
|
484
484
|
// ui://kolbo/generation.html widget takes over: live progress, inline result,
|
|
485
485
|
// action buttons. Text-only hosts never enter this path — their blocking
|
|
486
486
|
// behavior and response bytes are UNCHANGED.
|
|
487
|
-
const { UI, uiResult, appsEnabled,
|
|
487
|
+
const { UI, uiResult, appsEnabled, modelInfo } = require('../apps');
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Chip identity for a model: the CLEAN display name + its icon, resolved from
|
|
491
|
+
* the same /v1/models catalog `list_models` renders. Callers pass whatever the
|
|
492
|
+
* user/LLM supplied (an identifier like `google_tts`, `fal-ai/…/omnihuman/v1.5`,
|
|
493
|
+
* or a display name) — the card must never show the raw id.
|
|
494
|
+
*/
|
|
495
|
+
async function modelChipFields(client, model) {
|
|
496
|
+
const info = await modelInfo(client, model).catch(() => null);
|
|
497
|
+
return {
|
|
498
|
+
model: model || 'Smart Select',
|
|
499
|
+
model_name: (info && info.name) || model || 'Smart Select',
|
|
500
|
+
model_icon: (info && info.icon) || null,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
488
503
|
|
|
489
504
|
/**
|
|
490
505
|
* Build the "submitted — widget is live" tool result for a UI host.
|
|
@@ -494,12 +509,13 @@ const { UI, uiResult, appsEnabled, modelIcon } = require('../apps');
|
|
|
494
509
|
* gen the submit response ({ generation_id, poll_interval_hint })
|
|
495
510
|
* client KolboClient (for model icon lookup)
|
|
496
511
|
* model, prompt, count, settings, reference_image, estimated_seconds
|
|
512
|
+
* voice resolved voice record { name, thumbnail } (speech only)
|
|
497
513
|
* poll_tool widget-side status tool (default 'get_generation_status')
|
|
498
514
|
* status_args args for poll_tool (default { generation_id, wait: true })
|
|
499
515
|
*/
|
|
500
516
|
async function uiGenerating(p) {
|
|
501
517
|
// No ETAs anywhere — just a spinner until the poll flips to completed.
|
|
502
|
-
const
|
|
518
|
+
const chip = await modelChipFields(p.client, p.model);
|
|
503
519
|
const structured = {
|
|
504
520
|
phase: 'generating',
|
|
505
521
|
widget: 'generation',
|
|
@@ -511,8 +527,8 @@ async function uiGenerating(p) {
|
|
|
511
527
|
// wait=true, every open card calls tools/call every few seconds, flooding
|
|
512
528
|
// the host's global progress/context stream and API rate limits.
|
|
513
529
|
status_args: p.status_args || { generation_id: p.gen.generation_id, wait: true },
|
|
514
|
-
|
|
515
|
-
|
|
530
|
+
...chip,
|
|
531
|
+
...(p.voice ? { voice_name: p.voice.name, voice_thumbnail: p.voice.thumbnail } : {}),
|
|
516
532
|
prompt: p.prompt,
|
|
517
533
|
count: p.count || 1,
|
|
518
534
|
settings: p.settings || {},
|
|
@@ -531,6 +547,7 @@ async function uiGenerating(p) {
|
|
|
531
547
|
? { batch: true, generation_ids: p.generation_ids } : {}),
|
|
532
548
|
...(p.failed_submissions && p.failed_submissions.length
|
|
533
549
|
? { failed_submissions: p.failed_submissions } : {}),
|
|
550
|
+
...(p.warning ? { _warning: p.warning } : {}),
|
|
534
551
|
_widget_note: 'A live Kolbo widget is rendering this generation for the user (progress + final result + action buttons). Tell the user it is generating and the card above will update — do NOT poll in a loop. If you need the output URLs (e.g. for a follow-up edit or a report), call get_generation_status ONCE with wait=true — it blocks until done. Tracking several generations? Pass ALL their ids in generation_ids in that one call.',
|
|
535
552
|
}, null, 2);
|
|
536
553
|
return uiResult(UI.generation, text, structured);
|
|
@@ -541,14 +558,13 @@ async function uiGenerating(p) {
|
|
|
541
558
|
* that stay blocking even on UI hosts, e.g. creative director).
|
|
542
559
|
*/
|
|
543
560
|
async function uiCompleted(p, textPayload) {
|
|
544
|
-
const
|
|
561
|
+
const chip = await modelChipFields(p.client, p.model);
|
|
545
562
|
const structured = {
|
|
546
563
|
phase: 'completed',
|
|
547
564
|
widget: 'generation',
|
|
548
565
|
kind: p.kind,
|
|
549
566
|
tool: p.tool,
|
|
550
|
-
|
|
551
|
-
model_icon: icon,
|
|
567
|
+
...chip,
|
|
552
568
|
prompt: p.prompt,
|
|
553
569
|
count: p.count || 1,
|
|
554
570
|
settings: p.settings || {},
|
package/src/tools/generate.js
CHANGED
|
@@ -7,7 +7,7 @@ const { z } = require('zod');
|
|
|
7
7
|
const FormData = require('form-data');
|
|
8
8
|
const { pollUntilDone } = require('../polling');
|
|
9
9
|
const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
|
|
10
|
-
const { UI, uiResult, canonicalModelId } = require('../apps');
|
|
10
|
+
const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
|
|
11
11
|
|
|
12
12
|
// ─── Cinematic Dimensions schema (shared by generate_image + generate_image_edit) ───
|
|
13
13
|
// Kolbo's "Cinema mode": eight independent photographic dimensions, each an OPTIONAL
|
|
@@ -42,9 +42,20 @@ const CINEMATIC_SCHEMA = z.object({
|
|
|
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.
|
|
45
|
+
// Over the cap is a hard rejection, never a truncation: this used to
|
|
46
|
+
// `.slice(0, MAX_BATCH_PROMPTS)`, so a 9-prompt call silently generated 8 and
|
|
47
|
+
// the caller had no way to know which prompt vanished. `promptsField` also caps
|
|
48
|
+
// the array in the schema (so hosts see `maxItems` before calling); the guard
|
|
49
|
+
// below is the choke point EVERY batch tool routes through, and names the count.
|
|
45
50
|
const MAX_BATCH_PROMPTS = 8;
|
|
46
51
|
async function submitBatch(rawPrompts, submitOne) {
|
|
47
|
-
|
|
52
|
+
if (rawPrompts.length > MAX_BATCH_PROMPTS) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Too many prompts: ${rawPrompts.length} received, max ${MAX_BATCH_PROMPTS} per call. ` +
|
|
55
|
+
`Split them across ${Math.ceil(rawPrompts.length / MAX_BATCH_PROMPTS)} calls of at most ${MAX_BATCH_PROMPTS}.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const prompts = rawPrompts.map((s) => String(s).trim()).filter(Boolean);
|
|
48
59
|
const settled = await Promise.allSettled(prompts.map((p) => submitOne(p)));
|
|
49
60
|
const ok = [], failed = [];
|
|
50
61
|
settled.forEach((s, i) => {
|
|
@@ -91,8 +102,8 @@ const imageSettings = (a = {}) => ({
|
|
|
91
102
|
cinematic: a.cinematic ? true : undefined,
|
|
92
103
|
});
|
|
93
104
|
|
|
94
|
-
const promptsField = (what) => z.array(z.string()).optional().describe(
|
|
95
|
-
`BATCH MODE — several DIFFERENT prompts (2–${MAX_BATCH_PROMPTS}) generated concurrently in ONE call and rendered together in ONE combined widget. Whenever the user wants multiple distinct ${what} with their own prompts, ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All prompts share the same model/settings. When set, \`prompt\` is ignored. For N variations of a SINGLE prompt use num_images (image tools); for an AI-planned coherent scene set use generate_creative_director.`
|
|
105
|
+
const promptsField = (what) => z.array(z.string()).max(MAX_BATCH_PROMPTS).optional().describe(
|
|
106
|
+
`BATCH MODE — several DIFFERENT prompts (2–${MAX_BATCH_PROMPTS}) generated concurrently in ONE call and rendered together in ONE combined widget. **Hard cap: ${MAX_BATCH_PROMPTS} prompts per call — more than that is REJECTED with an error (never silently truncated), so split a longer list across several calls of at most ${MAX_BATCH_PROMPTS}.** Whenever the user wants multiple distinct ${what} with their own prompts, ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All prompts share the same model/settings. When set, \`prompt\` is ignored. For N variations of a SINGLE prompt use num_images (image tools); for an AI-planned coherent scene set use generate_creative_director.`
|
|
96
107
|
);
|
|
97
108
|
|
|
98
109
|
function registerGenerateTools(server, client, options = {}) {
|
|
@@ -588,7 +599,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
588
599
|
'Convert text to speech using Kolbo AI. Default provider is ElevenLabs. To pick a specific voice by language/gender, call list_voices first and pass the returned voice_id (or a voice display name — both work). Every voice belongs to a provider (ElevenLabs, DeepDub, MiniMax, Google/Gemini, OpenAI, Zonos) and each provider exposes its own expressive/style controls below — the engine ignores any control that does not apply to the chosen voice\'s provider, so it is safe to pass only what you need. Returns the final audio URL when complete.',
|
|
589
600
|
{
|
|
590
601
|
text: z.string().describe('The text to convert to speech'),
|
|
591
|
-
voice: z.string().optional().describe('Voice ID
|
|
602
|
+
voice: z.string().optional().describe('Voice ID or display name — MUST come from a `list_voices` result, never constructed. Google/Gemini ids in particular are not validated provider-side: an id that is not in the catalog is silently mapped to another voice (or a default one) and the audio comes back in a voice nobody asked for. Do not pattern-match a locale onto an id you saw for another language. Default: "Rachel"'),
|
|
592
603
|
model: z.string().optional().describe('Model identifier. Use list_models type="text_to_speech" to see options. Default: eleven_v3'),
|
|
593
604
|
language: z.string().optional().describe('Language code (e.g., "en-US", "he-IL", "es-ES"). Default: "en-US"'),
|
|
594
605
|
// ── Expressive style / emotion (provider-specific) ──
|
|
@@ -620,6 +631,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
620
631
|
},
|
|
621
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 }) => {
|
|
622
633
|
model = await canonicalModelId(client, model); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
634
|
+
// Resolve the requested voice against the REAL catalog (cached) so the card
|
|
635
|
+
// can show its display name + portrait instead of a raw id, and so an id
|
|
636
|
+
// that does not exist is reported instead of rendering silently: Google
|
|
637
|
+
// voice ids collapse to their last segment provider-side, so a made-up
|
|
638
|
+
// "en-US-Chirp3-HD-<Name>" either speaks as some other catalog entry's
|
|
639
|
+
// voice or falls back to a default one — with the card naming neither.
|
|
640
|
+
const voiceRecord = await voiceInfo(client, voice).catch(() => null);
|
|
641
|
+
const unknownVoice = voice && !voiceRecord && !/^custom_/i.test(voice)
|
|
642
|
+
? `Voice "${voice}" is not in the Kolbo voice catalog. Call list_voices and pass a voice_id it returns — an unrecognised id is NOT rejected, it is silently mapped to a different voice, so the audio will not be the voice you named.`
|
|
643
|
+
: null;
|
|
623
644
|
const gen = await client.post('/v1/generate/speech', {
|
|
624
645
|
text, voice, model, language,
|
|
625
646
|
style_instructions, selected_style, emotion, speaking_speed,
|
|
@@ -631,7 +652,9 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
631
652
|
|
|
632
653
|
if (ui()) return uiGenerating({
|
|
633
654
|
tool: 'generate_speech', kind: 'audio', gen, client, model, prompt: text,
|
|
634
|
-
|
|
655
|
+
voice: voiceRecord,
|
|
656
|
+
settings: { voice: voice || 'Rachel', style: selected_style || emotion || style_instructions },
|
|
657
|
+
warning: unknownVoice
|
|
635
658
|
});
|
|
636
659
|
|
|
637
660
|
const poll = await pollOrTimedOut(client, gen.generation_id, {
|
|
@@ -648,7 +671,8 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
648
671
|
...creditFields(result),
|
|
649
672
|
urls: result.result.urls,
|
|
650
673
|
voice: result.result.voice,
|
|
651
|
-
duration: result.result.duration
|
|
674
|
+
duration: result.result.duration,
|
|
675
|
+
...(unknownVoice ? { _warning: unknownVoice } : {})
|
|
652
676
|
}, null, 2)
|
|
653
677
|
}]
|
|
654
678
|
};
|
|
@@ -713,6 +737,32 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
713
737
|
}
|
|
714
738
|
);
|
|
715
739
|
|
|
740
|
+
// The status endpoint reports RAW ids — `model: "google_tts"`, `voice:
|
|
741
|
+
// "he-IL-Chirp3-HD-Kore"`. That is the model/voice that ACTUALLY ran (for
|
|
742
|
+
// Smart Select it is the only place the choice surfaces), so the card must
|
|
743
|
+
// show it — but with the clean name + icon everything else uses. Resolved
|
|
744
|
+
// here, off the same cached catalogs `list_models` / `list_voices` serve, so
|
|
745
|
+
// the widget adds no round trip per generation. Written INTO `result` because
|
|
746
|
+
// that is the object the widget merges over its generating-phase state.
|
|
747
|
+
async function addDisplayNames(status) {
|
|
748
|
+
const r = status && status.result;
|
|
749
|
+
if (!r || typeof r !== 'object') return;
|
|
750
|
+
// Always WRITE both keys, even on a catalog miss: the widget merges this
|
|
751
|
+
// over its generating-phase state, so a missing key silently keeps the
|
|
752
|
+
// pre-submit guess ("Smart Select") for a model that is not what ran. An
|
|
753
|
+
// unresolvable id is at least honest.
|
|
754
|
+
if (r.model) {
|
|
755
|
+
const info = await modelInfo(client, r.model).catch(() => null);
|
|
756
|
+
r.model_name = (info && info.name) || r.model;
|
|
757
|
+
r.model_icon = (info && info.icon) || null;
|
|
758
|
+
}
|
|
759
|
+
if (r.voice) {
|
|
760
|
+
const v = await voiceInfo(client, r.voice).catch(() => null);
|
|
761
|
+
r.voice_name = (v && v.name) || r.voice;
|
|
762
|
+
r.voice_thumbnail = (v && v.thumbnail) || null;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
716
766
|
// ─── get_generation_status ─────────────────────────────────
|
|
717
767
|
server.tool(
|
|
718
768
|
'get_generation_status',
|
|
@@ -753,6 +803,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
753
803
|
};
|
|
754
804
|
|
|
755
805
|
const results = await Promise.all(ids.map(checkOne));
|
|
806
|
+
await Promise.all(results.map(addDisplayNames));
|
|
756
807
|
|
|
757
808
|
const pending = results.filter(r => r.state !== 'completed' && r.state !== 'failed' && r.state !== 'cancelled');
|
|
758
809
|
const doneHint = 'ALL generations are in a final state — do NOT poll again. Report the results to the user.';
|
package/src/tools/models.js
CHANGED
|
@@ -83,15 +83,26 @@ function buildCatalogStructured(models, type, compact) {
|
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// One row per model — every identifier, nothing else. ~90 bytes/model, so the
|
|
87
|
+
// whole 400+ model catalog fits in a payload an agent can actually read.
|
|
88
|
+
const identifierRow = (m) => ({
|
|
89
|
+
identifier: m.identifier,
|
|
90
|
+
name: m.name,
|
|
91
|
+
types: m.types,
|
|
92
|
+
credit: m.credit,
|
|
93
|
+
...(m.recommended ? { recommended: true } : {}),
|
|
94
|
+
...(m.new_model ? { new_model: true } : {}),
|
|
95
|
+
});
|
|
96
|
+
|
|
86
97
|
function registerModelTools(server, client, options = {}) {
|
|
87
98
|
const ui = () => appsEnabled(server, options);
|
|
88
99
|
// ─── list_models ───────────────────────────────────────────
|
|
89
100
|
server.tool(
|
|
90
101
|
'list_models',
|
|
91
|
-
'List available AI models on Kolbo. Filter by `type` to narrow to a generation type, and pass `format: "json"` to
|
|
102
|
+
'List available AI models on Kolbo. Filter by `type` to narrow to a generation type, and pass `format: "json"` to enumerate the catalog with exact identifiers — `format: "json"` + `type` returns the full raw model documents (every constraint field, for programmatic comparison / cap validation before submitting a generation); `format: "json"` alone returns a compact index of EVERY model and its identifier. Default `format: "text"` returns the human-readable summary. NEVER guess a model identifier: call this tool.',
|
|
92
103
|
{
|
|
93
104
|
type: z.string().optional().describe('Filter by DB type name: "text_to_img", "image_editing", "text_to_video", "img_to_video", "draw_to_video", "video_to_video", "elements", "firstlastgenerations", "lipsync-image", "lipsync-video", "music_gen", "text_to_speech", "text_to_sound", "stt", "text". Legacy aliases also accepted: "image", "image_edit", "video", "video_from_image", "video_from_video", "music", "speech", "sound", "chat", "lipsync" (both lipsync types), "three_d" (all 3D types), "first_last_frame", "transcription". Omit for all models.'),
|
|
94
|
-
format: z.enum(['text', 'json']).optional().describe('Output format. "text" (default) returns a human-readable summary with the most-used caps. "json" returns the raw model documents from the API
|
|
105
|
+
format: z.enum(['text', 'json']).optional().describe('Output format. "text" (default) returns a human-readable summary with the most-used caps. "json" is the source of truth for identifiers and caps: with `type` it returns the raw model documents from the API (identifier, credit, supported_durations, supported_resolutions, supported_aspect_ratios, max_reference_images, max_visual_dna, max_video_duration, …) for EVERY model of that type; without `type` it returns a compact index of every model in the catalog and its exact identifier. Use it whenever you need an identifier you have not seen listed, or must verify a cap before passing a value that might exceed a model-specific limit.'),
|
|
95
106
|
display_catalog: z.boolean().optional().describe('Set true when the USER explicitly asked to see/browse the available models — the visual catalog opens expanded. Leave unset for internal lookups (verifying a model name, checking caps before a generation): the catalog stays collapsed to a single row the user can tap to browse.')
|
|
96
107
|
},
|
|
97
108
|
async ({ type, format, display_catalog }) => {
|
|
@@ -106,14 +117,41 @@ function registerModelTools(server, client, options = {}) {
|
|
|
106
117
|
const path = type ? `/v1/models?type=${encodeURIComponent(type)}` : '/v1/models';
|
|
107
118
|
const result = await client.get(path);
|
|
108
119
|
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
120
|
+
// ⚠️ Hosts that mount this widget (claude.ai, Claude Code desktop) hand the
|
|
121
|
+
// MODEL `structuredContent` and DROP `content[].text`. So every payload the
|
|
122
|
+
// agent needs has to ride in structuredContent — shipping it as text only
|
|
123
|
+
// makes it invisible. That is exactly how `format: "json"` came to return
|
|
124
|
+
// the curated 6-per-group picker instead of the raw documents: v1.53.1
|
|
125
|
+
// (406a51e) flipped `if (ui() && showCatalog)` → `if (ui())` on all three
|
|
126
|
+
// return paths, so the widget payload started shadowing the real answer and
|
|
127
|
+
// the other 43 text_to_video identifiers became undiscoverable by any MCP
|
|
128
|
+
// call. On 2026-08-09 that cost a wrong-model generation (minimax-h3).
|
|
129
|
+
// `extra` (json mode) carries the data as structured fields; without it the
|
|
130
|
+
// full text payload is attached verbatim. The widget ignores both.
|
|
131
|
+
const respond = (text, extra) => (ui()
|
|
132
|
+
? uiResult(UI.catalog, text, {
|
|
133
|
+
...buildCatalogStructured(result.models, type, !showCatalog),
|
|
134
|
+
...(extra || { text }),
|
|
135
|
+
})
|
|
136
|
+
: { content: [{ type: 'text', text }] });
|
|
137
|
+
|
|
138
|
+
// JSON mode — the authoritative shape; every constraint the agent might
|
|
139
|
+
// need to validate a request lives here (durations, reference caps,
|
|
140
|
+
// audio/video min/max, resolution multipliers, supports_* flags,
|
|
141
|
+
// prompt-length limits, etc.).
|
|
113
142
|
if (format === 'json') {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
143
|
+
// Raw documents once `type` narrows the set (~49 docs for a video type).
|
|
144
|
+
// Unfiltered that is 400+ documents / hundreds of KB, so return the
|
|
145
|
+
// complete IDENTIFIER INDEX instead: every model stays enumerable and
|
|
146
|
+
// the full caps are one `type` away.
|
|
147
|
+
const payload = type
|
|
148
|
+
? { count: result.count, models: result.models }
|
|
149
|
+
: {
|
|
150
|
+
count: result.count,
|
|
151
|
+
models: result.models.map(identifierRow),
|
|
152
|
+
note: 'Compact index — every model in the catalog and its exact identifier. Re-call with `type` for the full documents (all caps, credit costs, supported_* fields).',
|
|
153
|
+
};
|
|
154
|
+
return respond(JSON.stringify(payload, null, 2), payload);
|
|
117
155
|
}
|
|
118
156
|
|
|
119
157
|
// Split into auto-selectable (has summary) and named-only (no summary)
|
|
@@ -306,9 +344,9 @@ function registerModelTools(server, client, options = {}) {
|
|
|
306
344
|
+ ' first_last_frame · elements · lipsync · music_gen · text_to_speech ·\n'
|
|
307
345
|
+ ' text_to_sound · stt · three_d · text\n\n'
|
|
308
346
|
+ 'Use the "identifier" value as the "model" parameter in generate tools. '
|
|
309
|
-
+ 'For
|
|
310
|
-
|
|
311
|
-
return
|
|
347
|
+
+ 'For EVERY model + its exact identifier, re-call with format: "json" (compact index of the '
|
|
348
|
+
+ 'whole catalog). Add `type` to that call for the full raw documents with all caps.';
|
|
349
|
+
return respond(text);
|
|
312
350
|
}
|
|
313
351
|
|
|
314
352
|
if (withSummary.length > 0) {
|
|
@@ -318,9 +356,8 @@ function registerModelTools(server, client, options = {}) {
|
|
|
318
356
|
sections.push(`Named-only models (${withoutSummary.length}) — only use if the user explicitly requests by name:\n${withoutSummary.map(formatModel).join('\n')}`);
|
|
319
357
|
}
|
|
320
358
|
|
|
321
|
-
const text = `Available ${type} models (${result.count}):\n\n${sections.join('\n\n')}\n\
|
|
322
|
-
|
|
323
|
-
return { content: [{ type: 'text', text }] };
|
|
359
|
+
const text = `Available ${type} models (${result.count}):\n\n${sections.join('\n\n')}\n\nEvery ${type} model in the catalog is listed above — both sections together are the complete set. Use the "identifier" value as the "model" parameter in generate tools. For the raw documents (programmatic cap validation), re-call with format: "json".`;
|
|
360
|
+
return respond(text);
|
|
324
361
|
}
|
|
325
362
|
);
|
|
326
363
|
|
package/src/tools/voices.js
CHANGED
|
@@ -19,14 +19,29 @@ function registerVoiceTools(server, client, options = {}) {
|
|
|
19
19
|
},
|
|
20
20
|
async ({ language, gender, provider }) => {
|
|
21
21
|
const params = new URLSearchParams();
|
|
22
|
-
if (language) params.set('language', language);
|
|
23
22
|
if (gender) params.set('gender', gender);
|
|
24
23
|
if (provider) params.set('provider', provider);
|
|
24
|
+
const withoutLanguage = `/v1/voices${params.toString() ? '?' + params.toString() : ''}`;
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
|
|
26
|
+
const qs = new URLSearchParams(params);
|
|
27
|
+
if (language) qs.set('language', language);
|
|
28
|
+
const result = await client.get(`/v1/voices${qs.toString() ? '?' + qs.toString() : ''}`);
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
let voices = result.voices || [];
|
|
31
|
+
// The API matches `language` EXACTLY against the voice's language name or
|
|
32
|
+
// its locale code — "hebrew" and "he-IL" both hit, but "he" / "heb" /
|
|
33
|
+
// "Hebrew (Israel)" return nothing. This arg is documented as a partial
|
|
34
|
+
// match, and an empty list reads to the model as "Kolbo has no Google
|
|
35
|
+
// Hebrew voices at all", which is how a whole provider goes missing.
|
|
36
|
+
// Retry ONCE, unfiltered, and match locally — only on the empty path, so
|
|
37
|
+
// the normal call still costs one request.
|
|
38
|
+
if (voices.length === 0 && language) {
|
|
39
|
+
const l = language.toLowerCase();
|
|
40
|
+
const all = await client.get(withoutLanguage);
|
|
41
|
+
voices = (all.voices || []).filter(v =>
|
|
42
|
+
[v.language, v.language_code, v.languageCode]
|
|
43
|
+
.some(x => String(x || '').toLowerCase().includes(l)));
|
|
44
|
+
}
|
|
30
45
|
if (voices.length === 0) {
|
|
31
46
|
return {
|
|
32
47
|
content: [{ type: 'text', text: 'No voices found matching those filters.' }]
|