@kolbo/mcp 1.81.4 → 1.81.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,12 +58,12 @@ Or add the config by hand — this block is identical for every MCP client and c
58
58
 
59
59
  Restart your app, then ask it to generate something. The first time, a Kolbo login opens in your browser — click **Allow** (no API key to create). _Prefer an API key? Create one at [app.kolbo.ai/developer](https://app.kolbo.ai/developer) and add `"env": { "KOLBO_API_KEY": "kolbo_live_..." }` to the block above._
60
60
 
61
- ### Browser-only (claude.ai / ChatGPT / Codex): connector + zip
61
+ ### Browser-only (claude.ai / ChatGPT / Codex): connector + skill
62
62
 
63
63
  1. Add the custom connector **`https://api.kolbo.ai/mcp`** under Settings → Connectors, then Connect → log in → Allow.
64
- 2. Download **[`kolbo.zip`](https://github.com/Zoharvan12/kolbo-skills/raw/main/kolbo.zip)** and upload it as a skill (Claude.ai: Settings → Features → Skills; ChatGPT/Codex: Settings → Skills). Codex CLI: unzip into `~/.codex/skills/`.
64
+ 2. [Download the Skill](https://kolbo.ai/skills/kolbo.zip) and upload it (Claude.ai: Settings → Features → Skills; ChatGPT/Codex: Settings → Skills). Codex CLI: unzip into `~/.codex/skills/`.
65
65
 
66
- The zip is the canonical routing skill. Without it the tools still work, but the model will not load Seedance / Visual DNA / filmmaking rules.
66
+ The Skill is the canonical routing layer. Without it the tools still work, but the model will not load Seedance / Visual DNA / filmmaking rules.
67
67
 
68
68
  ### Optional upgrade: add the Kolbo skill for slash-commands + smart routing
69
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.81.4",
3
+ "version": "1.81.6",
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": {
@@ -22,6 +22,7 @@
22
22
  * .onThemeChange(fn) — fn(hostContext) on host-context-changed
23
23
  * .callTool(name, args) — Promise<CallToolResult>
24
24
  * .sendMessage(text) — append a user chat message (returns Promise)
25
+ * .insertText(text) — paste into the host composer, do not send
25
26
  * .openLink(url) — open external URL
26
27
  * .copyText(text) — copy via the host clipboard (ui/copy-text)
27
28
  * .notifySize() — report content size to host
@@ -183,6 +184,9 @@ const BRIDGE_JS = `
183
184
  sendMessage: function (text) {
184
185
  return request('ui/message', { role: 'user', content: [{ type: 'text', text: text }] });
185
186
  },
187
+ insertText: function (text) {
188
+ return request('ui/insert-text', { text: text });
189
+ },
186
190
  openLink: function (url) { return request('ui/open-link', { url: url }); },
187
191
  copyText: function (text) { return request('ui/copy-text', { text: text }); },
188
192
  // Hand a piece of this widget's media to the host's composer. Dragging it
package/src/apps/html.js CHANGED
@@ -136,7 +136,8 @@ function modelChipHTML(name, iconUrl) {
136
136
  return '<span class="k-chip brand">' + inner + esc(name) + '</span>';
137
137
  }
138
138
  function monogram(name) {
139
- return '<span class="k-mono-icon">' + esc(String(name).trim().charAt(0).toUpperCase()) + '</span>';
139
+ var ch = String(name || '').replace(/^[@#]+/, '').trim().charAt(0) || '?';
140
+ return '<span class="k-mono-icon">' + esc(ch.toUpperCase()) + '</span>';
140
141
  }
141
142
  // In-widget preview overlay — every card (generation chips, media grid, list
142
143
  // rows) uses this so a DNA / reference / library thumb opens a popup in Kolbo
package/src/apps/index.js CHANGED
@@ -630,6 +630,7 @@ const TOOL_WIDGETS = {
630
630
  list_docs: UI.list,
631
631
  list_media_folders: UI.list,
632
632
  list_visual_dna_folders: UI.list,
633
+ list_project_assets: UI.list,
633
634
  };
634
635
 
635
636
  function attachToolWidgetMeta(server) {
package/src/apps/theme.js CHANGED
@@ -340,7 +340,8 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
340
340
  background: var(--brand-soft); }
341
341
  .k-audio-meta { flex: 1; min-width: 0; }
342
342
  .k-audio-title { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
343
- .k-audio-sub { font-size: 11px; color: var(--text-faint); }
343
+ .k-audio-sub { font-size: 11px; color: var(--text-faint);
344
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
344
345
  .k-generated-audio {
345
346
  display: grid; grid-template-columns: 40px minmax(0, 1fr) auto;
346
347
  align-items: center; gap: 7px 10px;
@@ -67,7 +67,7 @@ function boot(sc) {
67
67
  Array.prototype.forEach.call(document.querySelectorAll('[data-g]'), function (row) {
68
68
  row.onclick = function () {
69
69
  var m = state.groups[+row.getAttribute('data-g')].models[+row.getAttribute('data-m')];
70
- window.kolbo.sendMessage(m.use_hint || ('Generate something with the "' + m.name + '" model — ask me what I want to make.'));
70
+ window.kolbo.insertText(m.identifier || m.name || '');
71
71
  };
72
72
  });
73
73
  window.kolbo.notifySize();
@@ -75,6 +75,34 @@ var TOOL_TITLES = {
75
75
  generate_creative_director: 'Creative Director', edit_image: 'Image Edit', edit_video: 'Video Edit',
76
76
  get_generation_status: 'Generations'
77
77
  };
78
+ var OPEN_ROUTES = {
79
+ generate_image: { path: '/image-tools', tool: 'text-to-image' },
80
+ generate_image_edit: { path: '/image-tools', tool: 'image-editing' },
81
+ edit_image: { path: '/image-tools', tool: 'image-editing' },
82
+ generate_video: { path: '/video-tools', tool: 'text-to-video' },
83
+ generate_video_from_image: { path: '/video-tools', tool: 'image-to-video' },
84
+ generate_elements: { path: '/video-tools', tool: 'image-to-video', mode: 'elements' },
85
+ generate_first_last_frame: { path: '/video-tools', tool: 'image-to-video', mode: 'first-last' },
86
+ generate_video_from_video: { path: '/video-tools', tool: 'video-to-video' },
87
+ generate_lipsync: { path: '/video-tools', tool: 'lipsync' },
88
+ generate_music: { path: '/audio-tools', tool: 'music-generator' },
89
+ generate_speech: { path: '/audio-tools', tool: 'text-to-speech' },
90
+ generate_sound: { path: '/audio-tools', tool: 'text-to-sound' },
91
+ transcribe_audio: { path: '/audio-tools', tool: 'speech-to-text' },
92
+ generate_creative_director: { path: '/creative-director' }
93
+ };
94
+ function kolboUrl(sc) {
95
+ if (sc && typeof sc.open_url === 'string' && sc.open_url) return sc.open_url;
96
+ var sid = sc && (sc.session_id || sc.sessionId);
97
+ var route = OPEN_ROUTES[(sc && sc.tool) || originTool];
98
+ if (!route || !sid) return 'https://app.kolbo.ai';
99
+ var url = 'https://app.kolbo.ai' + route.path + '?session=' + encodeURIComponent(sid);
100
+ if (route.tool) url += '&tool=' + route.tool;
101
+ if (route.mode) url += '&mode=' + route.mode;
102
+ var pid = sc && (sc.project_id || sc.projectId);
103
+ if (pid) url += '&project=' + encodeURIComponent(pid);
104
+ return url;
105
+ }
78
106
 
79
107
  // Long text is clamped by CSS (.k-prompt 2 lines / .k-caption 1 line). Expand
80
108
  // lives on a separate button so the text itself stays selectable.
@@ -488,20 +516,20 @@ function stopNow(sc, spec) {
488
516
  var refund = 0;
489
517
  sts.forEach(function (s) { if (s.credits_refunded) refund += s.credits_refunded; });
490
518
  return {
491
- cancelled: sts.some(function (s) { return s.cancelled !== false; }),
519
+ cancelled: sts.some(function (s) { return s.cancelled === true; }),
492
520
  credits_refunded: refund || undefined
493
521
  };
494
522
  })
495
523
  : window.kolbo.callTool(spec.tool, spec.args).then(function (r) { return structured(r) || {}; });
496
- call.then(function (st) {
497
- if (st.cancelled === false) {
498
- // Already terminal let the normal poll path report the real outcome
499
- // instead of claiming a cancel that did not happen.
500
- cancelRequested = false;
501
- renderStopButton(sc);
502
- return schedulePoll(sc);
503
- }
504
- renderCancelled(st.credits_refunded);
524
+ call.then(function (st) {
525
+ if (!st || st.cancelled !== true) {
526
+ // Empty host ack used to land here as {} and the card painted
527
+ // cancelled while the job kept running. Only an explicit cancel counts.
528
+ cancelRequested = false;
529
+ renderStopButton(sc);
530
+ return schedulePoll(sc);
531
+ }
532
+ renderCancelled(st.credits_refunded);
505
533
  }).catch(function () {
506
534
  cancelRequested = false;
507
535
  renderStopButton(sc);
@@ -608,7 +636,12 @@ function poll(sc) {
608
636
  var done = Object.assign({}, sc, r, {
609
637
  phase: 'completed',
610
638
  urls: r.urls || st.urls || [],
611
- credits_used: st.credits_used != null ? st.credits_used : sc.credits_used
639
+ credits_used: st.credits_used != null ? st.credits_used : sc.credits_used,
640
+ // Status structuredContent used to set open_url:undefined and wipe the
641
+ // session deep-link the generating card already had.
642
+ open_url: (sc && sc.open_url) || r.open_url,
643
+ session_id: (sc && (sc.session_id || sc.sessionId)) || r.session_id || st.session_id,
644
+ project_id: (sc && (sc.project_id || sc.projectId)) || r.project_id || st.project_id
612
645
  });
613
646
  state = done;
614
647
  el('credits').textContent = done.credits_used != null ? fmtCredits(done.credits_used) : '';
@@ -652,6 +685,11 @@ function handleBatchStatus(sc, st) {
652
685
  if (!st.all_done) return schedulePoll(sc);
653
686
 
654
687
  var scenes = [], failedCount = 0, credits = 0, haveCredits = false, allUrls = [];
688
+ // get_generation_status resolves model_name/model_icon into EACH result
689
+ // (addDisplayNames in generate.js) — sc is only the submit-time guess
690
+ // (usually "Smart Select"). Every id in one batch ran the same model, so the
691
+ // first resolved one is enough to replace the guess on the finished card.
692
+ var resolved = {};
655
693
  gens.forEach(function (g, i) {
656
694
  var r = g.result || g;
657
695
  var urls = (r && r.urls) || [];
@@ -659,6 +697,16 @@ function handleBatchStatus(sc, st) {
659
697
  allUrls = allUrls.concat(urls);
660
698
  var c = g.credits_used != null ? g.credits_used : (r.credits_used != null ? r.credits_used : null);
661
699
  if (c != null) { credits += c; haveCredits = true; }
700
+ if (!resolved.model_name && r.model_name) {
701
+ resolved.model = r.model;
702
+ resolved.model_name = r.model_name;
703
+ resolved.model_icon = r.model_icon;
704
+ }
705
+ // Same for the references actually used (reference_details from the
706
+ // server) — every id in the batch shares one reference set.
707
+ if (!resolved.reference_images && r.reference_images && r.reference_images.length) {
708
+ resolved.reference_images = r.reference_images;
709
+ }
662
710
  scenes.push({
663
711
  scene_number: i + 1,
664
712
  title: (sc.prompts && sc.prompts[i]) || '',
@@ -668,7 +716,7 @@ function handleBatchStatus(sc, st) {
668
716
  });
669
717
  if (!scenes.length) return renderError('All ' + gens.length + ' generations failed');
670
718
 
671
- var done = Object.assign({}, sc, {
719
+ var done = Object.assign({}, sc, resolved, {
672
720
  phase: 'completed', kind: 'scenes', batch: true, scenes: scenes, urls: [],
673
721
  credits_used: haveCredits ? credits : sc.credits_used
674
722
  });
@@ -902,10 +950,14 @@ function renderBatchGrid(sc) {
902
950
  dlBtnHTML(it.url) + '</div>';
903
951
  }).join('') + '</div>';
904
952
  wireDlButtons(el('stage'));
953
+ // In-widget popup, not the host round-trip focusMedia() uses — a batch grid
954
+ // tile has no visible full-size image otherwise, so a host that never resolves
955
+ // requestDisplayMode() (or drops window.open after the async round trip eats
956
+ // the click's user-activation window) leaves the click doing nothing at all.
905
957
  Array.prototype.forEach.call(el('stage').querySelectorAll('[data-focus]'), function (cell) {
906
958
  var it = items[+cell.getAttribute('data-focus')];
907
959
  if (it.type !== 'image') return; // <video controls> owns its own clicks
908
- cell.onclick = function () { focusMedia(it.url); };
960
+ cell.onclick = function () { openPeek(it.url, 'image', it.label); };
909
961
  });
910
962
  renderActions(sc);
911
963
  window.kolbo.notifySize();
@@ -949,7 +1001,7 @@ function renderStatusGrid(sc) {
949
1001
  Array.prototype.forEach.call(el('stage').querySelectorAll('[data-focus]'), function (cell) {
950
1002
  var it = items[+cell.getAttribute('data-focus')];
951
1003
  if (it.kind !== 'image') return; // <video controls> owns its own clicks
952
- cell.onclick = function () { focusMedia(it.url); };
1004
+ cell.onclick = function () { openPeek(it.url, 'image', it.title); };
953
1005
  });
954
1006
  renderActions(sc);
955
1007
  window.kolbo.notifySize();
@@ -1030,7 +1082,7 @@ function renderTrackingIssue(msg) {
1030
1082
  (state && state.generation_id ? '\\nGeneration ID: ' + state.generation_id : ''));
1031
1083
  };
1032
1084
  el('tracking-open').onclick = function () {
1033
- window.kolbo.openLink((state && state.open_url) || 'https://app.kolbo.ai');
1085
+ window.kolbo.openLink(kolboUrl(state));
1034
1086
  };
1035
1087
  window.kolbo.notifySize();
1036
1088
  }
@@ -1100,7 +1152,7 @@ function renderActions(sc) {
1100
1152
  el('actions').innerHTML = a.join('');
1101
1153
 
1102
1154
  bind('btn-download', function () { window.kolbo.openLink(downloadUrl(currentUrl())); });
1103
- bind('btn-open', function () { window.kolbo.openLink(state.open_url || 'https://app.kolbo.ai'); });
1155
+ bind('btn-open', function () { window.kolbo.openLink(kolboUrl(state)); });
1104
1156
  bind('btn-recreate', function () {
1105
1157
  window.kolbo.sendMessage('Recreate this with the same settings' +
1106
1158
  (state.model ? '\\nModel: ' + state.model : '') +
@@ -1195,7 +1247,10 @@ function completedFromPlain(sc) {
1195
1247
  aspect_ratio: originArgs.aspect_ratio,
1196
1248
  quality: originArgs.quality
1197
1249
  },
1198
- urls: sc.urls || []
1250
+ urls: sc.urls || [],
1251
+ session_id: sc.session_id || originArgs.session_id,
1252
+ project_id: sc.project_id || originArgs.project_id,
1253
+ open_url: sc.open_url
1199
1254
  });
1200
1255
  }
1201
1256
 
@@ -20,7 +20,7 @@ const { widgetPage } = require('../html');
20
20
  * badge, // small pill, e.g. role/status ("owner", "default", "shared")
21
21
  * meta, // small trailing text, e.g. a date or count
22
22
  * open_url, // optional — renders an "Open" button (openLink)
23
- * use_hint // optionalrow becomes clickable, sends this on click
23
+ * use_hint // unuseda click pastes `id` into the composer
24
24
  * }],
25
25
  * total
26
26
  * }
@@ -72,7 +72,7 @@ function apply(result) {
72
72
  }
73
73
 
74
74
  function itemHTML(item, i) {
75
- var clickable = !!item.use_hint;
75
+ var clickable = !!item.id;
76
76
  var avatar = item.thumbnail
77
77
  ? '<img class="k-audio-art k-peek-hit" src="' + esc(item.thumbnail) + '" alt="" loading="lazy"'
78
78
  + peekAttrs(item.thumbnail, 'image', item.title)
@@ -98,10 +98,10 @@ function wire() {
98
98
  });
99
99
  Array.prototype.forEach.call(document.querySelectorAll('.k-audio-row'), function (row) {
100
100
  var item = state.items[+row.getAttribute('data-i')];
101
- if (!item || !item.use_hint) return;
102
- row.onclick = function () {
103
- var msg = item.use_hint.replace('{TITLE}', item.title || '').replace('{ID}', item.id || '');
104
- window.kolbo.sendMessage(msg);
101
+ if (!item || !item.id) return;
102
+ row.onclick = function (e) {
103
+ if (e.target && e.target.closest && e.target.closest('[data-open],[data-peek]')) return;
104
+ window.kolbo.insertText(String(item.id));
105
105
  };
106
106
  });
107
107
  }
@@ -14,7 +14,7 @@ const { widgetPage } = require('../html');
14
14
  * id, title, subtitle, thumbnail, media_type: 'image'|'video'|'audio'|'3d',
15
15
  * url, // full asset / playback URL
16
16
  * preview_audio, // audio preview URL (voices, music)
17
- * use_hint // message template sent when "Use" clicked, {URL}/{ID}/{TITLE} substituted
17
+ * use_hint // unused Use pastes `id` into the composer
18
18
  * }],
19
19
  * total, has_more,
20
20
  * import_tool_hint // e.g. 'import via import_stock_asset' — shown on Use
@@ -154,11 +154,8 @@ function wire() {
154
154
 
155
155
  function useItem(i) {
156
156
  var item = state.items[i];
157
- if (!item) return;
158
- var msg = item.use_hint
159
- ? item.use_hint.replace('{URL}', item.url || '').replace('{ID}', item.id || '').replace('{TITLE}', item.title || '')
160
- : 'Use this asset: "' + (item.title || item.id) + '"\\nURL: ' + (item.url || '') + (item.id ? '\\nID: ' + item.id : '');
161
- window.kolbo.sendMessage(msg);
157
+ if (!item || !item.id) return;
158
+ window.kolbo.insertText(String(item.id));
162
159
  }
163
160
 
164
161
  window.kolbo.onToolResult(function (result) {
@@ -458,8 +458,8 @@ const OPEN_URL_ROUTES = {
458
458
  edit_image: { path: '/image-tools', tool: 'image-editing' },
459
459
  generate_video: { path: '/video-tools', tool: 'text-to-video' },
460
460
  generate_video_from_image: { path: '/video-tools', tool: 'image-to-video' },
461
- generate_elements: { path: '/video-tools', tool: 'image-to-video' },
462
- generate_first_last_frame: { path: '/video-tools', tool: 'image-to-video' },
461
+ generate_elements: { path: '/video-tools', tool: 'image-to-video', mode: 'elements' },
462
+ generate_first_last_frame: { path: '/video-tools', tool: 'image-to-video', mode: 'first-last' },
463
463
  generate_video_from_video: { path: '/video-tools', tool: 'video-to-video' },
464
464
  generate_lipsync: { path: '/video-tools', tool: 'lipsync' },
465
465
  generate_music: { path: '/audio-tools', tool: 'music-generator' },
@@ -468,6 +468,35 @@ const OPEN_URL_ROUTES = {
468
468
  transcribe_audio: { path: '/audio-tools', tool: 'speech-to-text' },
469
469
  generate_creative_director: { path: '/creative-director' },
470
470
  };
471
+ // SDK tracking `type` on get_generation_status — same session model as the
472
+ // generate_* tool that created the job, so "Open in Kolbo" still deep-links
473
+ // after a status poll overwrites the live card.
474
+ const TYPE_TO_TOOL = {
475
+ image: 'generate_image',
476
+ image_edit: 'generate_image_edit',
477
+ global_image_edit: 'edit_image',
478
+ video: 'generate_video',
479
+ video_from_image: 'generate_video_from_image',
480
+ elements: 'generate_elements',
481
+ first_last_frame: 'generate_first_last_frame',
482
+ video_from_video: 'generate_video_from_video',
483
+ lipsync: 'generate_lipsync',
484
+ music: 'generate_music',
485
+ speech: 'generate_speech',
486
+ sound: 'generate_sound',
487
+ transcription: 'transcribe_audio',
488
+ creative_director: 'generate_creative_director',
489
+ };
490
+
491
+ function sessionOf(gen) {
492
+ if (!gen || typeof gen !== 'object') return undefined;
493
+ return gen.session_id || gen.sessionId || undefined;
494
+ }
495
+
496
+ function projectOf(gen) {
497
+ if (!gen || typeof gen !== 'object') return undefined;
498
+ return gen.project_id || gen.projectId || undefined;
499
+ }
471
500
 
472
501
  /**
473
502
  * Build the "Open in Kolbo" deep link for a generation's actual session.
@@ -476,14 +505,30 @@ const OPEN_URL_ROUTES = {
476
505
  * kolbo-api, shorts render, 3D).
477
506
  */
478
507
  function buildOpenUrl(tool, gen) {
479
- const route = OPEN_URL_ROUTES[tool];
480
- if (!route || !gen || !gen.session_id) return undefined;
481
- let url = `${APP_BASE_URL}${route.path}?session=${encodeURIComponent(gen.session_id)}`;
508
+ const sid = sessionOf(gen);
509
+ if (!sid) return undefined;
510
+ const key = OPEN_URL_ROUTES[tool] ? tool : (TYPE_TO_TOOL[gen && gen.type] || TYPE_TO_TOOL[tool]);
511
+ const route = OPEN_URL_ROUTES[key];
512
+ if (!route) return undefined;
513
+ let url = `${APP_BASE_URL}${route.path}?session=${encodeURIComponent(sid)}`;
482
514
  if (route.tool) url += `&tool=${route.tool}`;
483
- if (gen.project_id) url += `&project=${encodeURIComponent(gen.project_id)}`;
515
+ if (route.mode) url += `&mode=${route.mode}`;
516
+ const pid = projectOf(gen);
517
+ if (pid) url += `&project=${encodeURIComponent(pid)}`;
484
518
  return url;
485
519
  }
486
520
 
521
+ function linkFields(tool, gen) {
522
+ const sid = sessionOf(gen);
523
+ const pid = projectOf(gen);
524
+ const href = buildOpenUrl(tool, gen);
525
+ return {
526
+ ...(sid ? { session_id: String(sid) } : {}),
527
+ ...(pid ? { project_id: String(pid) } : {}),
528
+ ...(href ? { open_url: href } : {}),
529
+ };
530
+ }
531
+
487
532
  /**
488
533
  * Build the "Open in Kolbo" deep link for a PROJECT. Lands on the Media hub
489
534
  * (all-assets view) pre-filtered to this project, where the user sees every
@@ -671,8 +716,11 @@ function mediaRefs(p) {
671
716
  ? p.reference_images.filter(Boolean)
672
717
  : (p.reference_image ? [p.reference_image] : []);
673
718
  return {
674
- reference_images: images,
675
- reference_image: p.reference_image || images[0],
719
+ // Omitted when empty: a live card MERGES status payloads over its own
720
+ // state, so a present-but-empty reference_images (the single-id status
721
+ // path has no input refs of its own) wiped the submit-time thumbnails
722
+ // off the finished card.
723
+ ...(images.length ? { reference_images: images, reference_image: p.reference_image || images[0] } : {}),
676
724
  ...(Array.isArray(p.reference_videos) && p.reference_videos.length
677
725
  ? { reference_videos: p.reference_videos.filter(Boolean) } : {}),
678
726
  ...(Array.isArray(p.reference_audio) && p.reference_audio.length
@@ -723,7 +771,7 @@ async function uiGenerating(p) {
723
771
  // `reference_image` is retained for older widget builds. New widgets render
724
772
  // every browser-loadable image supplied to the generation.
725
773
  ...mediaRefs(p),
726
- open_url: buildOpenUrl(p.tool, p.gen),
774
+ ...linkFields(p.tool, p.gen),
727
775
  };
728
776
  // Batch mode (prompts[] fan-out): ONE widget tracks every id in the set.
729
777
  if (Array.isArray(p.generation_ids) && p.generation_ids.length > 1) {
@@ -741,7 +789,7 @@ async function uiGenerating(p) {
741
789
  ...(p.failed_submissions && p.failed_submissions.length
742
790
  ? { failed_submissions: p.failed_submissions } : {}),
743
791
  ...(p.warning ? { _warning: p.warning } : {}),
744
- _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.',
792
+ _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. CREDIT GUARD: END YOUR TURN now — do not keep Thinking, writing skills/files, or planning while the card spins (that burns coding credits). Do NOT poll in a loop. If you need the output URLs for a follow-up step, call get_generation_status ONCE with wait=true as the only next tool — it blocks until done. Tracking several generations? Pass ALL their ids in generation_ids in that one call.',
745
793
  _paid_note: 'This generation is RUNNING and the user is paying for it. If you now realize the tool, model, or parameters were wrong, call cancel_generation with this generation_id FIRST, then start the replacement — never leave a wrong generation running alongside its retry (the user gets two cards and two charges).',
746
794
  }, null, 2);
747
795
  return uiResult(UI.generation, text, structured);
@@ -819,7 +867,7 @@ async function uiCompleted(p, textPayload, extraContent) {
819
867
  // resolved as completed-with-no-media and painted a red failure card.
820
868
  ...(p.state ? { state: p.state } : {}),
821
869
  credits_used: p.credits_used,
822
- open_url: buildOpenUrl(p.tool, p.gen),
870
+ ...linkFields(p.tool, p.gen),
823
871
  };
824
872
  const out = uiResult(UI.generation, textPayload, structured);
825
873
  if (Array.isArray(extraContent) && extraContent.length) {
@@ -913,6 +961,7 @@ module.exports = {
913
961
  projectScopeReadField,
914
962
  inlineImageBlocks,
915
963
  buildOpenUrl,
964
+ linkFields,
916
965
  buildProjectUrl,
917
966
  uiGenerating,
918
967
  uiCompleted,
@@ -6,7 +6,7 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { pollUntilDone, waitWindowMs } = require('../polling');
9
- const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, uiCompleted, appsEnabled } = require('./_shared');
9
+ const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, linkFields, uiGenerating, uiCompleted, appsEnabled } = require('./_shared');
10
10
  const { ownedUrl } = require('./owned-url');
11
11
  const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo, resolveCatalogAspectRatio } = require('../apps');
12
12
 
@@ -282,7 +282,10 @@ function registerGenerateTools(server, client, options = {}) {
282
282
  return uiCompleted({
283
283
  tool: 'generate_image', kind: 'image', gen, client, model, prompt,
284
284
  count: num_images, settings: imageSettings(shared),
285
- reference_images,
285
+ // The status result reports what the server ACTUALLY conditioned on
286
+ // (reference_details: DNA stills, merged sources, auto-routing) — the
287
+ // input arg is only what the caller happened to name.
288
+ reference_images: result.result.reference_images || reference_images,
286
289
  urls: result.result.urls,
287
290
  credits_used: creditFields(result).credits_used,
288
291
  }, JSON.stringify({
@@ -371,7 +374,8 @@ function registerGenerateTools(server, client, options = {}) {
371
374
  return uiCompleted({
372
375
  tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
373
376
  count: num_images, settings: imageSettings(shared),
374
- reference_images: [...(source_images || []), ...(reference_images || [])],
377
+ reference_images: result.result.reference_images
378
+ || [...(source_images || []), ...(reference_images || [])],
375
379
  urls: result.result.urls,
376
380
  credits_used: creditFields(result).credits_used,
377
381
  }, JSON.stringify({
@@ -1086,10 +1090,21 @@ function registerGenerateTools(server, client, options = {}) {
1086
1090
  // The model that ACTUALLY ran. A single-id check resolves one
1087
1091
  // generation, so 'Generations' here overwrote the real model name on
1088
1092
  // the finished card the moment the live widget merged this payload in.
1089
- model: res.model || 'Generations', gen: { generation_id: single.generation_id },
1093
+ model: res.model || 'Generations',
1094
+ gen: {
1095
+ generation_id: single.generation_id,
1096
+ session_id: single.session_id,
1097
+ project_id: single.project_id,
1098
+ type: single.type,
1099
+ },
1090
1100
  state: single.state,
1091
1101
  urls: done ? urls : undefined,
1092
1102
  thumbnail_url: res.thumbnail_url,
1103
+ // The refs the server actually conditioned on (reference_details) —
1104
+ // the live card merges this payload over its submit-time state, so
1105
+ // the finished card shows every reference, including server-side
1106
+ // additions (DNA stills, merged sources) the tool call never named.
1107
+ reference_images: res.reference_images,
1093
1108
  prompt: res.prompt_used || res.prompt || undefined,
1094
1109
  credits_used: creditFields(single).credits_used,
1095
1110
  items: [{
@@ -1130,10 +1145,27 @@ function registerGenerateTools(server, client, options = {}) {
1130
1145
  // moodboards.js / visual_dna.js / listResult(). uiResult always embeds
1131
1146
  // both text and structuredContent, so hosts without a UI-app renderer
1132
1147
  // are unaffected — only the fallback quality changes.
1148
+ // A live batch card (prompts[] fan-out) merges this payload over its
1149
+ // submit-time state, so a hardcoded 'Generations' model here painted
1150
+ // over the real model chip on every finished batch. When all results
1151
+ // ran the same model (the batch case), name it; only a genuinely mixed
1152
+ // multi-id check falls back to the generic label. Same for the
1153
+ // references actually used — one shared set per batch.
1154
+ const modelsRan = [...new Set(results.map(r => r.result && r.result.model).filter(Boolean))];
1155
+ const actualRefs = results
1156
+ .map(r => r.result && r.result.reference_images)
1157
+ .find(refs => Array.isArray(refs) && refs.length);
1133
1158
  return uiCompleted({
1134
1159
  tool: 'get_generation_status', kind: 'status', client,
1135
- model: 'Generations', gen: { generation_id: ids[0] },
1160
+ model: modelsRan.length === 1 ? modelsRan[0] : 'Generations',
1161
+ gen: {
1162
+ generation_id: ids[0],
1163
+ session_id: results[0] && results[0].session_id,
1164
+ project_id: results[0] && results[0].project_id,
1165
+ type: results[0] && results[0].type,
1166
+ },
1136
1167
  settings: {},
1168
+ reference_images: actualRefs,
1137
1169
  items: results.map(r => {
1138
1170
  const res = r.result || {};
1139
1171
  return {
@@ -1786,7 +1818,7 @@ function registerGenerateTools(server, client, options = {}) {
1786
1818
  poll_tool: 'get_generation_status',
1787
1819
  status_args: { generation_id: startResponse.generation_id, wait: true },
1788
1820
  audio_url: isUrl ? source : undefined,
1789
- open_url: buildOpenUrl('transcribe_audio', startResponse),
1821
+ ...linkFields('transcribe_audio', startResponse),
1790
1822
  });
1791
1823
  }
1792
1824
 
@@ -2056,7 +2088,7 @@ function registerGenerateTools(server, client, options = {}) {
2056
2088
  'inpaint', 'retake'
2057
2089
  ]).describe([
2058
2090
  'Edit operation:',
2059
- '"upscale" — boost to 4K/2K resolution (use `scale` for factor, `resolution` for target, `target_fps` for frame rate).',
2091
+ '"upscale" — boost to 4K/2K resolution (use `scale` for factor, `resolution` for target, `target_fps` for frame rate). On model "blackforestlabs/flux-video-upscale" (Flux Video Upscale): `scale` is 1.5-3x (no named `resolution` target — billed on the OUTPUT tier it lands in), `mode` is "precise" (source-faithful, default) or "creative" (reimagines detail — a pricier tier), and `prompt` optionally guides the creative-mode enhancement.',
2060
2092
  '"reframe" — change aspect ratio (requires `aspect_ratio`; use `grid_position_x`/`grid_position_y` to control where the original sits).',
2061
2093
  '"generate_audio" — add AI-generated audio from `prompt`. Optionally split into `sound_effect_prompt` and `background_music_prompt`. Set `original_sound=true` to keep original audio alongside.',
2062
2094
  '"remove_watermark" — AI-powered watermark removal.',
@@ -2074,9 +2106,9 @@ function registerGenerateTools(server, client, options = {}) {
2074
2106
 
2075
2107
  // ── upscale ────────────────────────────────────────────
2076
2108
  scale: z.number().optional()
2077
- .describe('Upscale factor (e.g. 2, 4). Used with operation="upscale".'),
2109
+ .describe('Upscale factor (e.g. 2, 4). Used with operation="upscale". On "blackforestlabs/flux-video-upscale" the valid range is 1.5-3 (default 2).'),
2078
2110
  resolution: z.string().optional()
2079
- .describe('Target resolution (e.g. "4k", "2k", "1080p"). Used with "upscale" and "reframe".'),
2111
+ .describe('Target resolution (e.g. "4k", "2k", "1080p"). Used with "upscale" and "reframe". Not used by "blackforestlabs/flux-video-upscale" — use `scale` instead.'),
2080
2112
  target_fps: z.number().optional()
2081
2113
  .describe('Target frame rate (e.g. 24, 30, 60). Used with operation="upscale".'),
2082
2114
  enhancement_model: z.string().optional()
@@ -2092,7 +2124,7 @@ function registerGenerateTools(server, client, options = {}) {
2092
2124
 
2093
2125
  // ── generate_audio ─────────────────────────────────────
2094
2126
  prompt: z.string().optional()
2095
- .describe('Text prompt. Required for "magic_edit". Used with "generate_audio", "extend", "inpaint", "retake", and optionally "lipsync" (for text-to-speech instead of audio_url).'),
2127
+ .describe('Text prompt. Required for "magic_edit". Used with "generate_audio", "extend", "inpaint", "retake", optionally "lipsync" (for text-to-speech instead of audio_url), and optionally "upscale" on model "blackforestlabs/flux-video-upscale" (guides creative-mode detail enhancement).'),
2096
2128
  sound_effect_prompt: z.string().optional()
2097
2129
  .describe('Separate prompt for sound effects layer. Used with operation="generate_audio" when you want to specify SFX and music independently.'),
2098
2130
  background_music_prompt: z.string().optional()
@@ -2110,7 +2142,7 @@ function registerGenerateTools(server, client, options = {}) {
2110
2142
  duration: z.number().optional()
2111
2143
  .describe('Seconds of content to generate. Used with "extend" (required) and "retake" (optional). Typical range: 1–20.'),
2112
2144
  mode: z.string().optional()
2113
- .describe('Extension direction: "start" or "end". Used with "extend". For "retake": replacement mode (default: "replace_audio_and_video"). Default: "end".'),
2145
+ .describe('Extension direction: "start" or "end". Used with "extend". For "retake": replacement mode (default: "replace_audio_and_video"). For "upscale" on model "blackforestlabs/flux-video-upscale": "precise" (source-faithful, default) or "creative" (reimagines detail — pricier). Default: "end".'),
2114
2146
  context: z.string().optional()
2115
2147
  .describe('Additional context to guide what gets generated in the extended segment. Used with "extend".'),
2116
2148
 
@@ -4,9 +4,15 @@
4
4
  * new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
5
5
 
6
6
  const { z } = require('zod');
7
- const { buildProjectUrl } = require('./_shared');
7
+ const { buildProjectUrl, compactList } = require('./_shared');
8
8
  const { listResult } = require('../apps');
9
9
 
10
+ function blurb(text) {
11
+ const s = String(text || '').replace(/\s+/g, ' ').trim();
12
+ if (s.length <= 96) return s;
13
+ return s.slice(0, 96).replace(/\s+\S*$/, '') + '…';
14
+ }
15
+
10
16
  function registerProjectTools(server, client) {
11
17
  // ─── list_projects ─────────────────────────────────────────
12
18
  server.tool(
@@ -465,11 +471,33 @@ function registerProjectTools(server, client) {
465
471
  const result = await client.get(`/v1/projects/${encodeURIComponent(project_id)}/assets`);
466
472
  const dnas = result.visual_dnas || [];
467
473
  const moodboards = result.moodboards || [];
468
- const text = JSON.stringify({
469
- visual_dnas: dnas,
470
- moodboards,
471
- _hint: 'Edit a DNA\'s identity description with update_project_asset (description) or update_visual_dna. The `note` is project-scoped purpose (update_project_asset note). Link missing assets with link_project_asset — do not delete+recreate.'
472
- }, null, 2);
474
+ const text = compactList(
475
+ [
476
+ ...dnas.map((d) => ({
477
+ id: d.id,
478
+ name: d.name,
479
+ dna_type: d.dna_type,
480
+ thumbnail: d.thumbnail_url || d.thumbnail,
481
+ note: blurb(d.note),
482
+ description: blurb(d.description),
483
+ })),
484
+ ...moodboards.map((m) => ({
485
+ id: m.id,
486
+ name: m.name,
487
+ dna_type: 'moodboard',
488
+ thumbnail: m.thumbnail_url || m.thumbnail,
489
+ note: blurb(m.note),
490
+ description: blurb(m.summary),
491
+ })),
492
+ ],
493
+ {
494
+ fields: ['id', 'name', 'dna_type', 'thumbnail', 'note', 'description'],
495
+ cap: 80,
496
+ total: dnas.length + moodboards.length,
497
+ extra: { visual_dna_count: dnas.length, moodboard_count: moodboards.length },
498
+ note: 'Edit identity text with update_project_asset (description) or update_visual_dna. `note` is project-scoped purpose. Link missing assets with link_project_asset — do not delete+recreate.',
499
+ },
500
+ );
473
501
  return listResult(text, {
474
502
  widget: 'list',
475
503
  title: 'Project cast',
@@ -477,13 +505,17 @@ function registerProjectTools(server, client) {
477
505
  ...dnas.map((d) => ({
478
506
  id: d.id,
479
507
  title: '@' + (d.name || d.id),
480
- subtitle: [d.dna_type, d.note || d.description].filter(Boolean).join(' · ')
508
+ subtitle: blurb(d.note || d.description),
509
+ thumbnail: d.thumbnail_url || d.thumbnail,
510
+ badge: d.dna_type,
481
511
  })),
482
512
  ...moodboards.map((m) => ({
483
513
  id: m.id,
484
514
  title: '#' + (m.name || m.id),
485
- subtitle: ['moodboard', m.note || m.summary].filter(Boolean).join(' · ')
486
- }))
515
+ subtitle: blurb(m.note || m.summary),
516
+ thumbnail: m.thumbnail_url || m.thumbnail,
517
+ badge: 'moodboard',
518
+ })),
487
519
  ],
488
520
  total: dnas.length + moodboards.length
489
521
  });