@kolbo/mcp 1.30.1 → 1.30.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.30.1",
3
+ "version": "1.30.3",
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": {
@@ -91,9 +91,13 @@ const BRIDGE_JS = `
91
91
  }).catch(function () { /* host without apps support — widget stays static */ });
92
92
 
93
93
  function notifySize() {
94
- var el = document.documentElement;
94
+ // Measure the widget card itself — documentElement.scrollHeight over-reports
95
+ // in some hosts and leaves a huge empty iframe below the card.
96
+ var card = document.querySelector('.k-card');
97
+ var rect = card ? card.getBoundingClientRect() : null;
98
+ var height = rect ? Math.ceil(rect.bottom + 8) : document.documentElement.scrollHeight;
95
99
  notify('ui/notifications/size-changed', {
96
- width: el.scrollWidth, height: el.scrollHeight
100
+ width: document.documentElement.scrollWidth, height: height
97
101
  });
98
102
  }
99
103
 
package/src/apps/index.js CHANGED
@@ -42,6 +42,26 @@ function widgetHtml(uri) {
42
42
  return htmlCache.get(uri);
43
43
  }
44
44
 
45
+ // Hosts apply a deny-by-default CSP to widget iframes — without this
46
+ // declaration EVERY external asset (generated images/videos on the CDN, model
47
+ // icons, Google Fonts) is silently blocked. resourceDomains maps to
48
+ // img/script/style/font/media-src; connectDomains to connect-src.
49
+ const WIDGET_CSP = {
50
+ resourceDomains: [
51
+ 'https://*.kolbo.ai', // media.kolbo.ai CDN + app.kolbo.ai model icons
52
+ 'https://*.digitaloceanspaces.com', // DO Spaces buckets (all envs)
53
+ 'https://*.cdn.digitaloceanspaces.com', // DO Spaces CDN endpoints
54
+ 'https://fonts.googleapis.com', // Inter / JetBrains Mono stylesheet
55
+ 'https://fonts.gstatic.com', // font files
56
+ 'https://images.pexels.com', // stock thumbnails
57
+ 'https://*.pexels.com',
58
+ 'https://*.pixabay.com',
59
+ 'https://*.sketchfab.com',
60
+ 'https://*.cloudfront.net', // provider-hosted previews
61
+ ],
62
+ connectDomains: [],
63
+ };
64
+
45
65
  /** Register all Kolbo widget resources on an McpServer. */
46
66
  function registerApps(server) {
47
67
  for (const [uri, name] of [
@@ -50,9 +70,16 @@ function registerApps(server) {
50
70
  [UI.catalog, 'Kolbo Model Catalog Widget'],
51
71
  [UI.transcript, 'Kolbo Transcription Widget'],
52
72
  ]) {
53
- registerAppResource(server, name, uri, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
54
- contents: [{ uri, mimeType: RESOURCE_MIME_TYPE, text: widgetHtml(uri) }],
55
- }));
73
+ registerAppResource(
74
+ server, name, uri,
75
+ { mimeType: RESOURCE_MIME_TYPE, _meta: { csp: WIDGET_CSP, ui: { csp: WIDGET_CSP } } },
76
+ async () => ({
77
+ contents: [{
78
+ uri, mimeType: RESOURCE_MIME_TYPE, text: widgetHtml(uri),
79
+ _meta: { csp: WIDGET_CSP, ui: { csp: WIDGET_CSP } },
80
+ }],
81
+ })
82
+ );
56
83
  }
57
84
  }
58
85
 
@@ -96,38 +123,49 @@ function uiResult(uri, text, structured) {
96
123
  /* ------------------------------------------------------------------ */
97
124
 
98
125
  const ICON_TTL_MS = 10 * 60 * 1000;
99
- const iconCache = new Map(); // apiBase → { at, byKey: Map<lowername, url> }
126
+ const infoCache = new Map(); // apiBase → { at, byKey: Map<lowername, {icon, eta}> }
100
127
 
101
- async function modelIconMap(client) {
128
+ async function modelInfoMap(client) {
102
129
  const cacheKey = client.apiBase || 'default';
103
- const hit = iconCache.get(cacheKey);
130
+ const hit = infoCache.get(cacheKey);
104
131
  if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit.byKey;
105
132
  const byKey = new Map();
106
133
  try {
107
134
  const res = await client.request('GET', '/v1/models');
108
135
  const models = res?.models || res?.data?.models || [];
109
136
  for (const m of models) {
110
- if (!m || !m.avatar) continue;
137
+ if (!m) continue;
111
138
  // The API usually resolves avatars to absolute URLs; bare filenames (older
112
139
  // deployments / internal calls) resolve against the app's public icon dir.
113
- const url = /^https?:\/\//i.test(m.avatar)
114
- ? m.avatar
115
- : `https://app.kolbo.ai/models_icons/${encodeURIComponent(m.avatar)}`;
116
- if (m.name) byKey.set(String(m.name).toLowerCase(), url);
117
- if (m.identifier) byKey.set(String(m.identifier).toLowerCase(), url);
140
+ const icon = m.avatar
141
+ ? (/^https?:\/\//i.test(m.avatar)
142
+ ? m.avatar
143
+ : `https://app.kolbo.ai/models_icons/${encodeURIComponent(m.avatar)}`)
144
+ : null;
145
+ // Real p75 wall-clock estimate mined from production creditUsages —
146
+ // the same source the in-app countdowns use. No estimate → no ETA shown.
147
+ const eta = Number(m.estimatedDurationSeconds || m.estimated_duration_seconds) || null;
148
+ const info = { icon, eta };
149
+ if (m.name) byKey.set(String(m.name).toLowerCase(), info);
150
+ if (m.identifier) byKey.set(String(m.identifier).toLowerCase(), info);
118
151
  }
119
152
  } catch (_) {
120
- /* fail open — widgets fall back to monogram chips */
153
+ /* fail open — widgets fall back to monogram chips, no ETA */
121
154
  }
122
- iconCache.set(cacheKey, { at: Date.now(), byKey });
155
+ infoCache.set(cacheKey, { at: Date.now(), byKey });
123
156
  return byKey;
124
157
  }
125
158
 
126
- /** Resolve one model's icon URL; nullwidget renders a monogram. */
159
+ /** Resolve one model's { icon, eta }; missing{ icon: null, eta: null }. */
160
+ async function modelInfo(client, modelName) {
161
+ if (!modelName) return { icon: null, eta: null };
162
+ const map = await modelInfoMap(client);
163
+ return map.get(String(modelName).toLowerCase()) || { icon: null, eta: null };
164
+ }
165
+
166
+ /** Back-compat shim (used by uiCompleted and older call sites). */
127
167
  async function modelIcon(client, modelName) {
128
- if (!modelName) return null;
129
- const map = await modelIconMap(client);
130
- return map.get(String(modelName).toLowerCase()) || null;
168
+ return (await modelInfo(client, modelName)).icon;
131
169
  }
132
170
 
133
171
  /* ------------------------------------------------------------------ */
@@ -192,6 +230,7 @@ module.exports = {
192
230
  uiResult,
193
231
  appsEnabled,
194
232
  modelIcon,
195
- modelIconMap,
233
+ modelInfo,
234
+ modelInfoMap,
196
235
  widgetHtml, // exported for smoke tests
197
236
  };
@@ -34,20 +34,22 @@ function boot(sc) {
34
34
  if (!sc || !sc.groups) return;
35
35
  state = sc;
36
36
  el('title').textContent = sc.title || 'AI Models';
37
- var total = sc.groups.reduce(function (n, g) { return n + g.models.length; }, 0);
37
+ var shown = sc.groups.reduce(function (n, g) { return n + g.models.length; }, 0);
38
38
  el('count-chip').style.display = '';
39
- el('count-chip').textContent = total + ' models';
39
+ el('count-chip').textContent = sc.total_available && sc.total_available > shown
40
+ ? 'top picks · ' + sc.total_available + ' total'
41
+ : shown + ' models';
40
42
  el('stage').innerHTML = sc.groups.map(function (g, gi) {
41
- return '<div style="margin-bottom:14px">' +
42
- '<div style="font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:8px">' + esc(g.name) + '</div>' +
43
+ return '<div style="margin-bottom:10px">' +
44
+ '<div style="font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-faint);margin-bottom:6px">' + esc(g.name) + '</div>' +
43
45
  g.models.map(function (m, mi) {
44
- return '<div class="k-audio-row" data-g="' + gi + '" data-m="' + mi + '" style="cursor:pointer">' +
45
- (m.icon ? '<img class="k-audio-art" style="width:32px;height:32px" src="' + esc(m.icon) + '" onerror="this.outerHTML=monogram(\\'' + esc(m.name).replace(/'/g, '') + '\\')">'
46
+ return '<div class="k-audio-row" data-g="' + gi + '" data-m="' + mi + '" style="cursor:pointer;padding:6px 8px;margin-bottom:4px">' +
47
+ (m.icon ? '<img class="k-audio-art" style="width:24px;height:24px;border-radius:6px" src="' + esc(m.icon) + '" onerror="this.outerHTML=monogram(\\'' + esc(m.name).replace(/'/g, '') + '\\')">'
46
48
  : '<span style="flex:none">' + monogram(m.name) + '</span>') +
47
- '<div class="k-audio-meta"><div class="k-audio-title">' + esc(m.name) + '</div>' +
48
- (m.description ? '<div class="k-audio-sub">' + esc(m.description) + '</div>' : '') + '</div>' +
49
+ '<div class="k-audio-meta"><div class="k-audio-title" style="font-size:12px">' + esc(m.name) + '</div>' +
50
+ (m.description ? '<div class="k-audio-sub" style="font-size:10.5px">' + esc(m.description) + '</div>' : '') + '</div>' +
49
51
  '<div style="display:flex;gap:4px;flex:none">' + (m.chips || []).slice(0, 3).map(function (c) {
50
- return '<span class="k-chip">' + esc(c) + '</span>';
52
+ return '<span class="k-chip" style="padding:2px 7px;font-size:10px">' + esc(c) + '</span>';
51
53
  }).join('') + '</div></div>';
52
54
  }).join('') + '</div>';
53
55
  }).join('');
@@ -12,7 +12,7 @@ const { widgetPage } = require('../html');
12
12
  * tool: 'generate_image', // originating MCP tool name
13
13
  * generation_id, poll_tool, // when phase === 'generating'
14
14
  * status_args, // extra args for the poll tool (optional)
15
- * estimated_seconds, // optional ETA hint
15
+
16
16
  * model, model_icon, prompt, count,
17
17
  * settings: { duration, resolution, aspect_ratio, audio, voice, mode },
18
18
  * reference_image, // thumbnail URL (optional)
@@ -35,10 +35,6 @@ const BODY = `
35
35
  <div class="k-prompt" id="prompt"></div>
36
36
  <div class="k-chips" id="chips"></div>
37
37
  <div id="stage"></div>
38
- <div class="k-progress" id="progress" style="display:none"><i id="progress-fill"></i></div>
39
- <div class="k-status-line" id="status-line" style="display:none">
40
- <span id="status-text">Generating…</span><span id="eta"></span>
41
- </div>
42
38
  <div class="k-prompt-row" id="prompt-row">
43
39
  <input class="k-input" id="action-input" placeholder="">
44
40
  <button class="k-btn primary" id="action-send">Send</button>
@@ -57,8 +53,6 @@ const SCRIPT = `
57
53
  var state = null; // current structuredContent
58
54
  var selected = 0; // selected result index
59
55
  var pollTimer = null;
60
- var progressTimer = null;
61
- var startedAt = Date.now();
62
56
 
63
57
  el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
64
58
  el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai'); };
@@ -98,7 +92,7 @@ function renderChips(sc) {
98
92
  if (s.voice) h += chip('🎤 ' + esc(s.voice));
99
93
  if (s.mode) h += chip(esc(s.mode));
100
94
  if (sc.count > 1) h += chip('×' + sc.count);
101
- if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="ref" title="Reference image">';
95
+ if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="" title="Reference image" onerror="this.style.display=\'none\'">';
102
96
  el('chips').innerHTML = h;
103
97
  }
104
98
  function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
@@ -117,24 +111,9 @@ function renderGenerating(sc) {
117
111
  (i === 0 ? '<span class="k-gen-badge"><span class="k-spin"></span>Generating</span>' : '') + '</div>';
118
112
  }
119
113
  el('stage').innerHTML = '<div class="k-gen-grid n' + n + '">' + cells + '</div>';
120
- el('progress').style.display = '';
121
- el('status-line').style.display = '';
122
114
  el('actions').innerHTML = '';
123
- startProgress(sc.estimated_seconds || defaultEta(sc.kind));
124
115
  schedulePoll(sc);
125
116
  }
126
- function defaultEta(kind) { return { image: 25, video: 120, audio: 45, '3d': 300, scenes: 240 }[kind] || 60; }
127
-
128
- function startProgress(etaSec) {
129
- clearInterval(progressTimer);
130
- progressTimer = setInterval(function () {
131
- var t = (Date.now() - startedAt) / 1000;
132
- var pct = Math.min(92, 100 * (1 - Math.exp(-t / (etaSec * 0.55))));
133
- el('progress-fill').style.width = pct.toFixed(1) + '%';
134
- var remain = Math.max(0, etaSec - t);
135
- el('eta').textContent = remain > 1 ? '~' + fmtDur(remain) + ' left' : 'finishing…';
136
- }, 500);
137
- }
138
117
 
139
118
  function schedulePoll(sc) {
140
119
  clearTimeout(pollTimer);
@@ -147,7 +126,7 @@ function poll(sc) {
147
126
  var stateName = st.state || st.phase || st.status;
148
127
  if (stateName === 'completed') {
149
128
  var r = st.result || st;
150
- finishProgress();
129
+
151
130
  var done = Object.assign({}, sc, r, {
152
131
  phase: 'completed',
153
132
  urls: r.urls || st.urls || [],
@@ -166,21 +145,15 @@ function poll(sc) {
166
145
  } else if (stateName === 'failed' || stateName === 'error' || stateName === 'cancelled') {
167
146
  renderError(st.error || 'Generation ' + stateName);
168
147
  } else {
169
- if (st.progress != null) el('status-text').textContent = 'Generating… ' + Math.round(st.progress) + '%';
170
148
  schedulePoll(sc);
171
149
  }
172
150
  }).catch(function () { schedulePoll(sc); });
173
151
  }
174
- function finishProgress() {
175
- clearInterval(progressTimer);
176
- el('progress-fill').style.width = '100%';
177
- setTimeout(function () { el('progress').style.display = 'none'; el('status-line').style.display = 'none'; }, 450);
178
- }
179
152
 
180
153
  /* ---------- results ---------- */
181
154
  function renderResult(sc) {
182
- clearTimeout(pollTimer); clearInterval(progressTimer);
183
- el('progress').style.display = 'none'; el('status-line').style.display = 'none';
155
+ clearTimeout(pollTimer);
156
+
184
157
  setPhaseChip('', false);
185
158
  if (sc.kind === 'scenes' && sc.scenes && sc.scenes.length) return renderScenes(sc);
186
159
  var urls = sc.urls || [];
@@ -196,7 +169,10 @@ function renderResult(sc) {
196
169
 
197
170
  function renderImages(sc, urls) {
198
171
  selected = Math.min(selected, urls.length - 1);
199
- var viewer = '<div class="k-viewer"><img id="main-img" src="' + esc(urls[selected]) + '" alt=""></div>';
172
+ // If the host CSP still blocks the image, degrade to open-in-browser rows
173
+ // instead of a broken empty viewer.
174
+ var viewer = '<div class="k-viewer"><img id="main-img" src="' + esc(urls[selected]) + '" alt="" onerror="window.__imgFail && window.__imgFail()"></div>';
175
+ window.__imgFail = function () { renderLinks(urls); window.kolbo.notifySize(); };
200
176
  var thumbs = '';
201
177
  if (urls.length > 1) {
202
178
  thumbs = '<div class="k-thumbs">' + urls.map(function (u, i) {
@@ -270,8 +246,8 @@ function renderScenes(sc) {
270
246
  }
271
247
 
272
248
  function renderError(msg) {
273
- clearTimeout(pollTimer); clearInterval(progressTimer);
274
- el('progress').style.display = 'none'; el('status-line').style.display = 'none';
249
+ clearTimeout(pollTimer);
250
+
275
251
  setPhaseChip('Failed', false);
276
252
  el('stage').innerHTML = '<div class="k-error">⚠ ' + esc(msg) + '</div>';
277
253
  el('actions').innerHTML = '<button class="k-btn" id="retry-btn">↻ Try Again</button>';
@@ -311,6 +311,7 @@ const { UI, uiResult, appsEnabled, modelIcon } = require('../apps');
311
311
  * status_args args for poll_tool (default { generation_id })
312
312
  */
313
313
  async function uiGenerating(p) {
314
+ // No ETAs anywhere — just a spinner until the poll flips to completed.
314
315
  const icon = await modelIcon(p.client, p.model).catch(() => null);
315
316
  const structured = {
316
317
  phase: 'generating',
@@ -326,7 +327,6 @@ async function uiGenerating(p) {
326
327
  count: p.count || 1,
327
328
  settings: p.settings || {},
328
329
  reference_image: p.reference_image,
329
- estimated_seconds: p.estimated_seconds,
330
330
  };
331
331
  const text = JSON.stringify({
332
332
  status: 'submitted',
@@ -45,7 +45,7 @@ function registerGenerateTools(server, client, options = {}) {
45
45
  if (ui()) return uiGenerating({
46
46
  tool: 'generate_image', kind: 'image', gen, client, model, prompt,
47
47
  count: num_images, settings: { resolution, aspect_ratio },
48
- reference_image: reference_images?.[0], estimated_seconds: 25
48
+ reference_image: reference_images?.[0]
49
49
  });
50
50
 
51
51
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -95,7 +95,7 @@ function registerGenerateTools(server, client, options = {}) {
95
95
  if (ui()) return uiGenerating({
96
96
  tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
97
97
  count: num_images, settings: { resolution, aspect_ratio },
98
- reference_image: source_images?.[0], estimated_seconds: 40
98
+ reference_image: source_images?.[0]
99
99
  });
100
100
 
101
101
  // Multi-source compositing or DNA-anchored edits routinely exceed 120s
@@ -211,7 +211,7 @@ function registerGenerateTools(server, client, options = {}) {
211
211
  if (ui()) return uiGenerating({
212
212
  tool: 'generate_video', kind: 'video', gen, client, model, prompt,
213
213
  settings: { duration, resolution, aspect_ratio },
214
- reference_image: reference_images?.[0], estimated_seconds: 120
214
+ reference_image: reference_images?.[0]
215
215
  });
216
216
 
217
217
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -259,7 +259,7 @@ function registerGenerateTools(server, client, options = {}) {
259
259
  if (ui()) return uiGenerating({
260
260
  tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
261
261
  settings: { duration, resolution, aspect_ratio },
262
- reference_image: image_url, estimated_seconds: 120
262
+ reference_image: image_url
263
263
  });
264
264
 
265
265
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -306,7 +306,6 @@ function registerGenerateTools(server, client, options = {}) {
306
306
  if (ui()) return uiGenerating({
307
307
  tool: 'generate_music', kind: 'audio', gen, client, model: model || 'Suno', prompt,
308
308
  settings: { mode: instrumental ? 'instrumental' : (style || undefined) },
309
- estimated_seconds: 90
310
309
  });
311
310
 
312
311
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -347,7 +346,7 @@ function registerGenerateTools(server, client, options = {}) {
347
346
 
348
347
  if (ui()) return uiGenerating({
349
348
  tool: 'generate_speech', kind: 'audio', gen, client, model, prompt: text,
350
- settings: { voice: voice || 'Rachel' }, estimated_seconds: 20
349
+ settings: { voice: voice || 'Rachel' }
351
350
  });
352
351
 
353
352
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -387,7 +386,7 @@ function registerGenerateTools(server, client, options = {}) {
387
386
 
388
387
  if (ui()) return uiGenerating({
389
388
  tool: 'generate_sound', kind: 'audio', gen, client, model, prompt,
390
- settings: { duration }, estimated_seconds: 20
389
+ settings: { duration }
391
390
  });
392
391
 
393
392
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -486,7 +485,7 @@ function registerGenerateTools(server, client, options = {}) {
486
485
  if (ui()) return uiGenerating({
487
486
  tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
488
487
  settings: { duration, resolution, aspect_ratio },
489
- reference_image: reference_images?.[0], estimated_seconds: 180
488
+ reference_image: reference_images?.[0]
490
489
  });
491
490
 
492
491
  const result = await pollUntilDone(client, startResponse.generation_id, {
@@ -564,7 +563,7 @@ function registerGenerateTools(server, client, options = {}) {
564
563
  if (ui()) return uiGenerating({
565
564
  tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
566
565
  settings: { duration, resolution, aspect_ratio },
567
- reference_image: first_frame_url || undefined, estimated_seconds: 120
566
+ reference_image: first_frame_url || undefined
568
567
  });
569
568
 
570
569
  const result = await pollUntilDone(client, startResponse.generation_id, {
@@ -674,7 +673,6 @@ function registerGenerateTools(server, client, options = {}) {
674
673
  tool: 'generate_lipsync', kind: 'video', gen: startResponse, client, model,
675
674
  prompt: text_prompt, settings: { mode: 'lipsync' },
676
675
  reference_image: sourceIsUrl && !/\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? source : undefined,
677
- estimated_seconds: 180
678
676
  });
679
677
 
680
678
  const result = await pollUntilDone(client, startResponse.generation_id, {
@@ -773,7 +771,7 @@ function registerGenerateTools(server, client, options = {}) {
773
771
  tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
774
772
  prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
775
773
  settings: { duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle' },
776
- reference_image: reference_images?.[0], estimated_seconds: 240
774
+ reference_image: reference_images?.[0]
777
775
  });
778
776
 
779
777
  const result = await pollUntilDone(client, startResponse.generation_id, {
@@ -890,7 +888,7 @@ function registerGenerateTools(server, client, options = {}) {
890
888
  if (ui()) return uiGenerating({
891
889
  tool: 'generate_3d', kind: '3d', gen: startResponse, client, model, prompt,
892
890
  settings: { mode: mode || (reference_images?.length > 1 ? 'multi' : reference_images?.length === 1 ? 'single' : 'text') },
893
- reference_image: reference_images?.[0], estimated_seconds: 300
891
+ reference_image: reference_images?.[0]
894
892
  });
895
893
 
896
894
  const result = await pollUntilDone(client, startResponse.generation_id, {
@@ -940,7 +938,7 @@ function registerGenerateTools(server, client, options = {}) {
940
938
  tool: 'edit_image', kind: 'image', gen, client, model,
941
939
  prompt: prompt || operation,
942
940
  settings: { mode: operation, aspect_ratio },
943
- reference_image: image_url, estimated_seconds: 40
941
+ reference_image: image_url
944
942
  });
945
943
 
946
944
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -997,7 +995,7 @@ function registerGenerateTools(server, client, options = {}) {
997
995
  tool: 'edit_video', kind: 'video', gen, client, model,
998
996
  prompt: prompt || operation,
999
997
  settings: { mode: operation, duration, aspect_ratio },
1000
- reference_image: image_url, estimated_seconds: 180
998
+ reference_image: image_url
1001
999
  });
1002
1000
 
1003
1001
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -43,14 +43,34 @@ function modelChips(m) {
43
43
  }
44
44
 
45
45
  // structuredContent for ui://kolbo/catalog.html — see src/apps/widgets/catalog.js
46
+ // Deliberately CURATED, not exhaustive: the widget is a picker, not a database.
47
+ // Smart Select is pinned first, each group shows the recommended/new models
48
+ // (max 6), and the total count chip tells the user how many exist overall.
46
49
  function buildCatalogStructured(models, type) {
47
50
  const groups = [];
48
51
  const byName = new Map();
49
- for (const m of models) {
52
+ const isAuto = (m) => /^auto$|smart.select/i.test(String(m.name || '')) || /smart-select|k_auto/i.test(String(m.identifier || ''));
53
+
54
+ // Pinned Smart Select entry (replaces the confusing "Other / Auto" row).
55
+ const smartSelect = {
56
+ name: 'Smart Select',
57
+ icon: null,
58
+ description: 'Recommended — automatically routes to the best model for your prompt, quality and cost.',
59
+ chips: ['AUTO'],
60
+ use_hint: 'Generate with Smart Select (omit the model) — ask me what I want to create first.',
61
+ };
62
+
63
+ // Recommended + new models float to the top of each group.
64
+ const ranked = [...models].filter((m) => !isAuto(m)).sort((a, b) => {
65
+ const score = (m) => (m.recommended ? 2 : 0) + (m.new_model || m.newModel ? 1 : 0);
66
+ return score(b) - score(a);
67
+ });
68
+
69
+ for (const m of ranked) {
50
70
  const name = groupNameFor(m);
51
71
  let g = byName.get(name);
52
72
  if (!g) { g = { name, models: [] }; byName.set(name, g); groups.push(g); }
53
- if (g.models.length >= 12) continue; // cap per group (≤60 total across groups)
73
+ if (g.models.length >= 6) continue; // curated cap full list lives in the text payload
54
74
  g.models.push({
55
75
  name: m.name,
56
76
  icon: m.avatar
@@ -61,10 +81,12 @@ function buildCatalogStructured(models, type) {
61
81
  use_hint: `Generate with the "${m.name}" model — ask me what I want to create first.`,
62
82
  });
63
83
  }
84
+ groups.sort((a, b) => (a.name === 'Other' ? 1 : b.name === 'Other' ? -1 : 0));
64
85
  return {
65
86
  widget: 'catalog',
66
87
  title: 'Kolbo AI Models' + (type ? ' — ' + type : ''),
67
- groups,
88
+ total_available: models.length,
89
+ groups: [{ name: 'Recommended', models: [smartSelect] }, ...groups],
68
90
  };
69
91
  }
70
92
 
@@ -263,7 +263,6 @@ function registerShortsCreatorTools(server, client, options = {}) {
263
263
  settings: { mode: 'shorts' },
264
264
  poll_tool: 'shorts_status',
265
265
  status_args: { job_id },
266
- estimated_seconds: 240
267
266
  });
268
267
 
269
268
  let job;