@kolbo/mcp 1.72.3 → 1.73.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.72.3",
3
+ "version": "1.73.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": {
package/skill/SKILL.md CHANGED
@@ -139,6 +139,19 @@ Passing `visual_dna_ids` is **not enough**. For every DNA in that array you MUST
139
139
 
140
140
  Resolve names with `list_visual_dnas` first. Full binding rules: `references/workflows/visual-dna.md`.
141
141
 
142
+ ## ⚠️ `enhance_prompt` — leave it OFF (HARD RULE)
143
+
144
+ **Never pass `enhance_prompt: true` unless the user asked for it in words.** It is
145
+ not a quality knob you turn on to be helpful — it sends the prompt to a rewriter
146
+ first, so the model renders *different words than the ones the user wrote*.
147
+
148
+ - The user says "make it more cinematic" → that is a request to change **your**
149
+ prompt. Write the better prompt yourself. It is NOT permission to enhance.
150
+ - Only "enhance the prompt" / "improve my prompt" / "expand this prompt" is.
151
+ - Passing it silently is the worst case: the card shows an `enhanced` chip the
152
+ user never asked for, and their own wording never reached the model.
153
+ - The default is `false` in every generation tool. Leave the argument out.
154
+
142
155
  ## ⚠️ Seedance / Elements prompt contract (HARD RULE)
143
156
 
144
157
  `generate_elements`, Seedance 2, and Seedance 2.5 share **one** compile shape — the Locked Intro in `references/models/seedance.md`:
@@ -112,6 +112,7 @@ The match is **literal and case-insensitive**, so:
112
112
  - The `@name` must equal the stored `name` field (e.g. if `name: "esther_model"` → write `@esther_model`, not `@Esther`, not `@אסתר`, not `@the model`).
113
113
  - Any-language characters are supported — if the DNA was created with `name: "אסתר"` you write `@אסתר`. Use the EXACT stored string.
114
114
  - Mentions terminate at punctuation (`.,!?`), double-spaces, another `@`, or end of string. So `@maya, wearing...` matches `maya`.
115
+ - **Never glue a possessive onto the tag.** Write `the face of @maya stays stable`, not `@maya's face stays stable` — the tag must end at the stored name. kolbo-api does strip a trailing `'s` / `’s` before the lookup, so a possessive no longer loses the DNA, but the apostrophe is left sitting in the prompt text the model sees.
115
116
 
116
117
  This composes with `@image1` / `@image2` positional tags for plain reference/source images — see "Reference Tagging" below.
117
118
 
@@ -103,12 +103,46 @@ const BRIDGE_JS = `
103
103
  var card = document.querySelector('.k-card');
104
104
  var rect = card ? card.getBoundingClientRect() : null;
105
105
  var height = rect ? Math.ceil(rect.bottom + 8) : document.documentElement.scrollHeight;
106
- // Never ask the host for more than a viewport of inline height — a card
107
- // taller than the screen overlaps Claude's prompt area.
108
- height = Math.min(height, Math.max(window.innerHeight || 900, 500));
106
+ // Ceiling comes from the SCREEN, never window.innerHeight. Inside an iframe
107
+ // innerHeight IS the height the host already granted, so clamping to it made
108
+ // the request a feedback loop: once the host capped us, we could never ask
109
+ // for more than the cap, and any content past it became an inner scrollbar
110
+ // that no amount of growing could clear. The host clamps too, so this is
111
+ // only a sanity ceiling.
112
+ var ceiling = (window.screen && window.screen.availHeight) || 1200;
113
+ height = Math.min(height, Math.max(ceiling, 500));
109
114
  notify('ui/notifications/size-changed', {
110
115
  width: document.documentElement.scrollWidth, height: height
111
116
  });
117
+ markDraggable();
118
+ }
119
+
120
+ // Every image/video in a widget can be dragged straight into the host's
121
+ // composer. <img> is natively draggable but <video> is not, and neither sets
122
+ // a clean URL, so both get an explicit dragstart carrying text/uri-list —
123
+ // exactly the format the composer's drop handler already reads.
124
+ // Hooked off notifySize because that is the one thing every render path
125
+ // already calls, so new render sites are covered without touching them.
126
+ function markDraggable() {
127
+ var nodes = document.querySelectorAll('img[src], video[src]');
128
+ for (var i = 0; i < nodes.length; i++) {
129
+ var node = nodes[i];
130
+ if (node.getAttribute('data-kolbo-drag')) continue;
131
+ node.setAttribute('data-kolbo-drag', '1');
132
+ node.setAttribute('draggable', 'true');
133
+ node.addEventListener('dragstart', onMediaDragStart);
134
+ }
135
+ }
136
+
137
+ function onMediaDragStart(e) {
138
+ var node = e.currentTarget;
139
+ // Strip the #t=0.05 poster fragment videos carry, or the host would attach
140
+ // a URL the CDN answers differently.
141
+ var url = String(node.currentSrc || node.getAttribute('src') || '').split('#')[0];
142
+ if (!/^https?:/i.test(url) || !e.dataTransfer) return;
143
+ e.dataTransfer.setData('text/uri-list', url);
144
+ e.dataTransfer.setData('text/plain', url);
145
+ e.dataTransfer.effectAllowed = 'copy';
112
146
  }
113
147
 
114
148
  var sizeTimer = null;
@@ -149,6 +183,11 @@ const BRIDGE_JS = `
149
183
  return request('ui/message', { role: 'user', content: [{ type: 'text', text: text }] });
150
184
  },
151
185
  openLink: function (url) { return request('ui/open-link', { url: url }); },
186
+ // Hand a piece of this widget's media to the host's composer. Dragging it
187
+ // out cannot work: a widget is a sandboxed cross-origin iframe, so a native
188
+ // HTML5 drag started in here never delivers its dataTransfer to the host
189
+ // document. Hosts that ignore this method simply do nothing.
190
+ attachMedia: function (url) { return request('ui/attach-media', { url: url }); },
152
191
  updateModelContext: function (text) {
153
192
  return request('ui/update-model-context', { content: [{ type: 'text', text: text }] });
154
193
  },
package/src/apps/theme.js CHANGED
@@ -122,6 +122,12 @@ body {
122
122
  }
123
123
  .k-chip img.k-voice-thumb { width: 18px; height: 18px; border-radius: 999px; margin-left: -3px; }
124
124
  .k-ref-thumb { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-strong); }
125
+ /* Visual DNA chips: the character's face, so you can see WHICH DNA is locked in. */
126
+ .k-dna-face { width: 18px; height: 18px; border-radius: 999px; object-fit: cover; margin-left: -3px; background: var(--border-strong); }
127
+ .k-dna-stack .k-dna-stack-item { display: inline-flex; }
128
+ .k-dna-stack .k-dna-stack-item + .k-dna-stack-item .k-dna-face { margin-left: -9px; }
129
+ .k-dna-stack .k-dna-face { box-shadow: 0 0 0 1.5px var(--card-solid); }
130
+ .k-dna-stack { cursor: default; }
125
131
 
126
132
  /* ---- Generating state ---- */
127
133
  .k-gen-grid { display: grid; gap: 8px; }
@@ -192,6 +198,8 @@ body {
192
198
  }
193
199
  .k-media:hover .k-dl, .k-viewer:hover .k-dl, .k-skel:hover .k-dl { opacity: 1; }
194
200
  .k-dl:hover { background: var(--brand); border-color: var(--brand); }
201
+ /* Attach-to-prompt sits immediately left of Download (30px button + 8px gap). */
202
+ .k-attach { right: 46px; }
195
203
  .k-viewer { position: relative; }
196
204
 
197
205
  /* Keep the whole completed card under the host's iframe height cap (~800px):
@@ -199,7 +207,9 @@ body {
199
207
  or claude.ai adds an inner scrollbar. Click the image to expand in-Claude. */
200
208
  .k-viewer { margin-bottom: 10px; }
201
209
  .k-viewer img, .k-viewer video { display: block; width: 100%;
202
- max-height: min(340px, 55vh); object-fit: contain;
210
+ /* Fixed px, no vh: vh inside an iframe is the height the host granted, so a
211
+ vh-based cap grew as the iframe grew and the card chased its own tail. */
212
+ max-height: 320px; object-fit: contain;
203
213
  border-radius: 12px; background: #000; border: 1px solid var(--border); cursor: zoom-in; }
204
214
  .k-viewer video { cursor: default; }
205
215
 
@@ -148,7 +148,19 @@ function voiceLabel(sc) { return sc.voice_name || sc.voice || (sc.settings || {}
148
148
  // real reference assets — rendering them as flat prose hid the single most
149
149
  // consequential part of the prompt. Escape FIRST, then wrap: the pattern only
150
150
  // matches after a boundary, so an email or a #fff hex never lights up.
151
- var MENTION_RE = /(^|[\s([{"'>])([@#][A-Za-z][\w-]*)/g;
151
+ // DOUBLE backslashes: this whole file is a JS template literal, so a single
152
+ // backslash-s / backslash-w is eaten before the browser ever sees it. This was
153
+ // emitting /(^|[s([{"'>])([@#][A-Za-z][w-]*)/g — character classes of the
154
+ // LITERAL letters s and w — so "@zohar_apocalypse" highlighted as just "@z",
155
+ // and a mention after a space (rather than at the very start of the prompt)
156
+ // did not highlight at all.
157
+ // The boundary is a NEGATIVE set, not a whitelist of openers. The old
158
+ // whitelist ([\s([{"'>]) meant any other character glued to a mention killed
159
+ // the chip — "×@tel_aviv_invasion" rendered as flat text. Excluding word chars
160
+ // keeps the thing that whitelist was really protecting: an email's "a@b.com"
161
+ // has a word char before the @, so it still never lights up. (. and - are in
162
+ // the set for the same reason: "file.name@host", "co-op@x".)
163
+ var MENTION_RE = /(^|[^\\w@.-])([@#][A-Za-z][\\w-]*)/g;
152
164
  // #ff8800 / #fff are hex colors, and prompts are full of them. A moodboard tag
153
165
  // that happens to be 3 or 6 hex letters loses this coin flip; a grading note
154
166
  // mistaken for a moodboard is the worse read.
@@ -164,7 +176,8 @@ function renderChips(sc) {
164
176
  var h = modelChipHTML(modelLabel(sc), sc.model_icon);
165
177
  var s = sc.settings || {};
166
178
  if (sc.kind) h += chip(iconFor(sc.kind) + ' ' + sc.kind);
167
- if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration));
179
+ if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration) + (s.shots > 1 ? ' · ' + s.shots + ' shots' : ''));
180
+ else if (s.shots > 1) h += chip(s.shots + ' shots');
168
181
  if (s.resolution) h += chip(esc(s.resolution));
169
182
  if (s.aspect_ratio) h += chip(esc(s.aspect_ratio));
170
183
  if (s.quality) h += chip(esc(s.quality) + ' quality');
@@ -173,9 +186,7 @@ function renderChips(sc) {
173
186
  // Ids where we have them (title = the id, so it can be copied / reused),
174
187
  // falling back to the old count/boolean shape for payloads generated before
175
188
  // the ids were carried.
176
- var dnaIds = s.visual_dna_ids || [];
177
- if (dnaIds.length) h += chipT(dnaIds.length + ' Visual DNA', dnaIds.join('\\n'));
178
- else if (s.visual_dna) h += chip(s.visual_dna + ' Visual DNA');
189
+ h += dnaChipsHTML(sc, s);
179
190
  var mbIds = s.moodboard_ids || (s.moodboard_id ? [s.moodboard_id] : []);
180
191
  if (mbIds.length) h += chipT(mbIds.length > 1 ? mbIds.length + ' moodboards' : 'moodboard', mbIds.join('\\n'));
181
192
  else if (s.moodboard) h += chip('moodboard');
@@ -192,12 +203,94 @@ function renderChips(sc) {
192
203
  }
193
204
  if (s.mode) h += chip(esc(s.mode));
194
205
  if (sc.count > 1) h += chip('×' + sc.count);
195
- var refs = Array.isArray(sc.reference_images) && sc.reference_images.length
196
- ? sc.reference_images : (sc.reference_image ? [sc.reference_image] : []);
206
+ h += referenceHTML(sc);
207
+ el('chips').innerHTML = h;
208
+ }
209
+
210
+ var REF_VIDEO_RE = /\\.(mp4|mov|webm|mkv|avi|m4v)(\\?|#|$)/i;
211
+ var REF_AUDIO_RE = /\\.(mp3|wav|m4a|aac|ogg|flac)(\\?|#|$)/i;
212
+
213
+ // Every reference the generation was actually given — images, videos AND audio.
214
+ // Kind is taken from the URL rather than from which field it arrived in: callers
215
+ // legitimately pack a video into reference_images (Elements files, v2v
216
+ // elements), and an <img> pointed at an .mp4 renders nothing, so those
217
+ // references were silently invisible. Extension wins, field is the fallback.
218
+ function refKind(url, fallback) {
219
+ if (REF_VIDEO_RE.test(url)) return 'video';
220
+ if (REF_AUDIO_RE.test(url)) return 'audio';
221
+ return fallback;
222
+ }
223
+ function collectRefs(sc) {
224
+ var out = [];
225
+ var push = function (list, fallback) {
226
+ (Array.isArray(list) ? list : []).forEach(function (url) {
227
+ if (typeof url !== 'string' || !url) return;
228
+ if (out.some(function (r) { return r.url === url; })) return;
229
+ out.push({ url: url, kind: refKind(url, fallback) });
230
+ });
231
+ };
232
+ push(sc.reference_images && sc.reference_images.length ? sc.reference_images
233
+ : (sc.reference_image ? [sc.reference_image] : []), 'image');
234
+ push(sc.reference_videos, 'video');
235
+ push(sc.reference_audio, 'audio');
236
+ return out;
237
+ }
238
+ function referenceHTML(sc) {
239
+ var refs = collectRefs(sc);
240
+ var h = '';
197
241
  for (var i = 0; i < refs.length; i++) {
198
- h += '<img class="k-ref-thumb" src="' + esc(refs[i]) + '" alt="" loading="lazy" title="Reference image ' + (i + 1) + ' of ' + refs.length + '" onerror="this.style.display=\\'none\\'">';
242
+ var url = esc(refs[i].url);
243
+ var title = 'Reference ' + refs[i].kind + ' ' + (i + 1) + ' of ' + refs.length;
244
+ if (refs[i].kind === 'video') {
245
+ // #t=0.1 so the poster is a real frame, not a black canvas.
246
+ h += '<video class="k-ref-thumb" src="' + url + '#t=0.1" muted playsinline preload="metadata" title="'
247
+ + title + '" onerror="this.style.display=\\'none\\'"></video>';
248
+ } else if (refs[i].kind === 'audio') {
249
+ h += '<span class="k-chip" title="' + title + '">' + ICONS.sound + ' audio ref</span>';
250
+ } else {
251
+ h += '<img class="k-ref-thumb" src="' + url + '" alt="" loading="lazy" title="' + title
252
+ + '" onerror="this.style.display=\\'none\\'">';
253
+ }
199
254
  }
200
- el('chips').innerHTML = h;
255
+ return h;
256
+ }
257
+ // Which characters/looks are locked into this generation — by face and name,
258
+ // resolved from visual_dna_ids server-side. "1 Visual DNA" told the user nothing
259
+ // about WHICH DNA. Up to DNA_CHIP_MAX get their own named chip; beyond that they
260
+ // collapse into one stack of overlapping faces whose tooltip lists every name,
261
+ // so a 12-DNA scene can't push the model and aspect chips off the card.
262
+ var DNA_CHIP_MAX = 3;
263
+ function dnaFaceHTML(dna, cls) {
264
+ if (dna.thumbnail) {
265
+ return '<img class="' + cls + '" src="' + esc(dna.thumbnail) + '" alt="" loading="lazy" onerror="this.style.display=\\'none\\'">';
266
+ }
267
+ return '';
268
+ }
269
+ function dnaChipsHTML(sc, s) {
270
+ var dnas = Array.isArray(sc.visual_dnas) ? sc.visual_dnas : [];
271
+ if (!dnas.length) {
272
+ // Payloads from before the ids were resolved (or an offline resolve).
273
+ var ids = s.visual_dna_ids || [];
274
+ if (ids.length) return chipT(ids.length + ' Visual DNA', ids.join('\\n'));
275
+ if (s.visual_dna) return chip(s.visual_dna + ' Visual DNA');
276
+ return '';
277
+ }
278
+ var h = '';
279
+ if (dnas.length <= DNA_CHIP_MAX) {
280
+ for (var i = 0; i < dnas.length; i++) {
281
+ h += '<span class="k-chip" title="' + esc(dnas[i].id) + '">'
282
+ + dnaFaceHTML(dnas[i], 'k-dna-face') + esc(dnas[i].name) + '</span>';
283
+ }
284
+ return h;
285
+ }
286
+ var names = [];
287
+ var stack = '';
288
+ for (var j = 0; j < dnas.length; j++) {
289
+ names.push(dnas[j].name);
290
+ if (j < 4) stack += '<span class="k-dna-stack-item">' + dnaFaceHTML(dnas[j], 'k-dna-face') + '</span>';
291
+ }
292
+ return '<span class="k-chip k-dna-stack" title="' + esc(names.join('\\n')) + '">'
293
+ + stack + dnas.length + ' Visual DNA</span>';
201
294
  }
202
295
  function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
203
296
  // Same chip with a hover title — used to surface the asset id behind a
@@ -617,7 +710,12 @@ function renderLinks(urls) {
617
710
  // Small hover download button attached to a media cell (per-item downloads —
618
711
  // batch grids and CD scenes have no single "current" url for the action row).
619
712
  function dlBtnHTML(u) {
620
- return '<button class="k-dl" data-dl="' + esc(u) + '" title="Download" aria-label="Download">' + ICONS.download + '</button>';
713
+ // Attach sits beside Download on the same hover overlay. It is the reliable
714
+ // route into the composer — see window.kolbo.attachMedia for why dragging the
715
+ // media out of the iframe cannot be made to work.
716
+ return '<button class="k-dl k-attach" data-attach="' + esc(u) + '" title="Attach to prompt" aria-label="Attach to prompt">'
717
+ + ICONS.upload + '</button>'
718
+ + '<button class="k-dl" data-dl="' + esc(u) + '" title="Download" aria-label="Download">' + ICONS.download + '</button>';
621
719
  }
622
720
  function wireDlButtons(root) {
623
721
  Array.prototype.forEach.call((root || document).querySelectorAll('.k-dl[data-dl]'), function (b) {
@@ -626,6 +724,12 @@ function wireDlButtons(root) {
626
724
  window.kolbo.openLink(downloadUrl(b.getAttribute('data-dl')));
627
725
  };
628
726
  });
727
+ Array.prototype.forEach.call((root || document).querySelectorAll('.k-attach[data-attach]'), function (b) {
728
+ b.onclick = function (e) {
729
+ e.stopPropagation();
730
+ window.kolbo.attachMedia(b.getAttribute('data-attach'));
731
+ };
732
+ });
629
733
  }
630
734
 
631
735
  // CD scenes render as the SAME viewer+thumbnail carousel as image batches —
@@ -63,10 +63,15 @@ function boot(sc) {
63
63
  function cellHTML(item, i) {
64
64
  var idx = state.items.indexOf(item);
65
65
  var isVideo = item.media_type === 'video' && item.url;
66
- var media = item.thumbnail
67
- ? '<img src="' + esc(item.thumbnail) + '" loading="lazy" alt="">'
68
- : '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-faint);font-size:22px">' +
66
+ // onerror matters: a thumbnail that 404s / expires / can't be fetched by the
67
+ // host webview left a BLACK tile with no glyph and no label hint — visually
68
+ // identical to "corrupted media". Fall back to the same kind icon a
69
+ // thumbnail-less item gets, so a dead URL degrades instead of looking broken.
70
+ var fallbackCell = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-faint);font-size:22px">' +
69
71
  kindIcon(item.media_type) + '</div>';
72
+ var media = item.thumbnail
73
+ ? '<img src="' + esc(item.thumbnail) + '" loading="lazy" alt="" onerror="this.parentNode.innerHTML=this.getAttribute(\\'data-fb\\')" data-fb="' + esc(fallbackCell) + '">'
74
+ : fallbackCell;
70
75
  return '<div class="k-cell" data-i="' + idx + '">' +
71
76
  '<div class="k-cell-media">' + media +
72
77
  (isVideo ? '<button class="k-play k-cell-play" data-video-play="' + esc(item.url) + '">' + ICONS.play + '</button>' : '') +
@@ -512,6 +512,48 @@ async function modelChipFields(client, model) {
512
512
  };
513
513
  }
514
514
 
515
+ /**
516
+ * Resolve `visual_dna_ids` to {id, name, thumbnail} so the card can show WHICH
517
+ * characters are locked in, not just how many. An id tells the user nothing;
518
+ * the face does.
519
+ *
520
+ * One list fetch per process, cached — the DNA catalog barely moves within a
521
+ * session, and a generation card must never add a round-trip per chip. A miss
522
+ * (id not in the caller's own DNAs) degrades to the bare id, which is exactly
523
+ * what the card showed before.
524
+ */
525
+ const _dnaChipCache = new Map();
526
+ let _dnaChipLoaded = 0;
527
+ const DNA_CHIP_TTL = 5 * 60 * 1000;
528
+
529
+ async function resolveVisualDnas(client, ids) {
530
+ const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id) : [];
531
+ if (!list.length) return [];
532
+
533
+ const stale = Date.now() - _dnaChipLoaded > DNA_CHIP_TTL;
534
+ if (stale || list.some((id) => !_dnaChipCache.has(id))) {
535
+ try {
536
+ const res = await client.get('/v1/visual-dna?scope=mine');
537
+ const rows = res?.visual_dnas || res?.data || [];
538
+ for (const row of rows) {
539
+ const id = row?.id || row?._id;
540
+ if (!id) continue;
541
+ _dnaChipCache.set(String(id), {
542
+ id: String(id),
543
+ name: row.name || String(id),
544
+ // Same hero-image rule as the app: reference sheet, then thumbnail.
545
+ thumbnail: row.sheet_url || row.thumbnail_url || (Array.isArray(row.images) ? row.images[0] : null) || null,
546
+ });
547
+ }
548
+ _dnaChipLoaded = Date.now();
549
+ } catch {
550
+ // Offline / rate-limited — fall through to bare ids.
551
+ }
552
+ }
553
+
554
+ return list.map((id) => _dnaChipCache.get(id) || { id, name: id, thumbnail: null });
555
+ }
556
+
515
557
  /**
516
558
  * Build the "submitted — widget is live" tool result for a UI host.
517
559
  * @param {object} p
@@ -543,6 +585,7 @@ async function uiGenerating(p) {
543
585
  prompt: p.prompt,
544
586
  count: p.count || 1,
545
587
  settings: p.settings || {},
588
+ visual_dnas: await resolveVisualDnas(p.client, (p.settings || {}).visual_dna_ids),
546
589
  // `reference_image` is retained for older widget builds. New widgets render
547
590
  // every browser-loadable image supplied to the generation.
548
591
  reference_images: Array.isArray(p.reference_images)
@@ -573,10 +616,20 @@ async function uiGenerating(p) {
573
616
  }
574
617
 
575
618
  /**
576
- * Wrap an already-completed generation result with the widget (used by tools
577
- * that stay blocking even on UI hosts, e.g. creative director).
619
+ * Wrap an already-completed generation result with the widget.
620
+ *
621
+ * Used by tools that stay blocking even on UI hosts (creative director), AND —
622
+ * since structuredContent costs a text host nothing — by every generation tool
623
+ * on its normal blocking return. That second case is why model names, model
624
+ * avatars and Visual DNA chips were missing in Kolbo Code: it does not advertise
625
+ * MCP Apps, so `ui()` is false, `uiGenerating` never runs, and the host had to
626
+ * rebuild the card from raw text that carries only a model IDENTIFIER. Shipping
627
+ * the resolved payload here fixes every non-Apps host at once, exactly the way
628
+ * the list tools already do it (see listResult).
629
+ *
630
+ * The TEXT is unchanged, so text-only hosts see precisely what they saw before.
578
631
  */
579
- async function uiCompleted(p, textPayload) {
632
+ async function uiCompleted(p, textPayload, extraContent) {
580
633
  const chip = await modelChipFields(p.client, p.model);
581
634
  const structured = {
582
635
  phase: 'completed',
@@ -587,6 +640,7 @@ async function uiCompleted(p, textPayload) {
587
640
  prompt: p.prompt,
588
641
  count: p.count || 1,
589
642
  settings: p.settings || {},
643
+ visual_dnas: await resolveVisualDnas(p.client, (p.settings || {}).visual_dna_ids),
590
644
  reference_images: Array.isArray(p.reference_images)
591
645
  ? p.reference_images.filter(Boolean)
592
646
  : (p.reference_image ? [p.reference_image] : []),
@@ -599,7 +653,11 @@ async function uiCompleted(p, textPayload) {
599
653
  credits_used: p.credits_used,
600
654
  open_url: buildOpenUrl(p.tool, p.gen),
601
655
  };
602
- return uiResult(UI.generation, textPayload, structured);
656
+ const out = uiResult(UI.generation, textPayload, structured);
657
+ if (Array.isArray(extraContent) && extraContent.length) {
658
+ out.content = [...out.content, ...extraContent];
659
+ }
660
+ return out;
603
661
  }
604
662
 
605
663
  // ─── Text-payload budget ─────────────────────────────────────────────────────
@@ -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, appsEnabled } = require('./_shared');
9
+ const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, uiCompleted, appsEnabled } = require('./_shared');
10
10
  const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
11
11
 
12
12
  // ─── Cinematic Dimensions schema (shared by generate_image + generate_image_edit) ───
@@ -125,7 +125,13 @@ const imageSettings = (a = {}) => ({
125
125
  // Same block for the video tools, which carried only { duration, resolution,
126
126
  // aspect_ratio } — a DNA-anchored video card showed no sign a DNA was in play.
127
127
  const videoSettings = (a = {}) => ({
128
- duration: a.duration,
128
+ // Multi-shot elements calls usually set shot COUNT and let the model take the
129
+ // default per-shot length, so `duration` came through undefined and the card
130
+ // showed model/resolution/ratio but never how long the video actually is —
131
+ // the one number the user picked and is paying for. Fall back to what the API
132
+ // echoed back on start, then to per-shot × shots.
133
+ duration: a.duration ?? a.reported_duration ?? (a.shot_duration && a.shots ? a.shot_duration * a.shots : undefined),
134
+ ...(a.shots > 1 ? { shots: a.shots } : {}),
129
135
  resolution: a.resolution,
130
136
  aspect_ratio: a.aspect_ratio,
131
137
  ...(a.mode ? { mode: a.mode } : {}),
@@ -218,19 +224,20 @@ function registerGenerateTools(server, client, options = {}) {
218
224
  const result = poll.result;
219
225
 
220
226
  const images = await inlineImageBlocks(result.result.urls, { enabled: inlineImages });
221
- return {
222
- content: [{
223
- type: 'text',
224
- text: JSON.stringify({
225
- ...creditFields(result),
226
- session_id: gen.session_id,
227
- urls: result.result.urls,
228
- model: result.result.model,
229
- prompt_used: result.result.prompt_used,
230
- _followup_hint: 'If the user asks to edit/change/modify this image next (scene, lighting, objects, style, color — any content edit), pass urls[0] to generate_image_edit. Use edit_image ONLY for mechanical ops (upscale/reframe/removebg/enhance_skin). Do NOT call generate_image again.'
231
- }, null, 2)
232
- }, ...images]
233
- };
227
+ return uiCompleted({
228
+ tool: 'generate_image', kind: 'image', gen, client, model, prompt,
229
+ count: num_images, settings: imageSettings(shared),
230
+ reference_images,
231
+ urls: result.result.urls,
232
+ credits_used: creditFields(result).credits_used,
233
+ }, JSON.stringify({
234
+ ...creditFields(result),
235
+ session_id: gen.session_id,
236
+ urls: result.result.urls,
237
+ model: result.result.model,
238
+ prompt_used: result.result.prompt_used,
239
+ _followup_hint: 'If the user asks to edit/change/modify this image next (scene, lighting, objects, style, color — any content edit), pass urls[0] to generate_image_edit. Use edit_image ONLY for mechanical ops (upscale/reframe/removebg/enhance_skin). Do NOT call generate_image again.'
240
+ }, null, 2), images);
234
241
  }
235
242
  );
236
243
 
@@ -305,19 +312,20 @@ function registerGenerateTools(server, client, options = {}) {
305
312
  const result = poll.result;
306
313
 
307
314
  const images = await inlineImageBlocks(result.result.urls, { enabled: inlineImages });
308
- return {
309
- content: [{
310
- type: 'text',
311
- text: JSON.stringify({
312
- ...creditFields(result),
313
- session_id: gen.session_id,
314
- urls: result.result.urls,
315
- model: result.result.model,
316
- prompt_used: result.result.prompt_used,
317
- _followup_hint: 'If the user asks for another edit on this output, pass urls[0] back into generate_image_edit as source_images. For targeted ops (upscale/reframe/removebg/enhance_skin) use edit_image instead. Do NOT call generate_image from scratch.'
318
- }, null, 2)
319
- }, ...images]
320
- };
315
+ return uiCompleted({
316
+ tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
317
+ count: num_images, settings: imageSettings(shared),
318
+ reference_images: source_images,
319
+ urls: result.result.urls,
320
+ credits_used: creditFields(result).credits_used,
321
+ }, JSON.stringify({
322
+ ...creditFields(result),
323
+ session_id: gen.session_id,
324
+ urls: result.result.urls,
325
+ model: result.result.model,
326
+ prompt_used: result.result.prompt_used,
327
+ _followup_hint: 'If the user asks for another edit on this output, pass urls[0] back into generate_image_edit as source_images. For targeted ops (upscale/reframe/removebg/enhance_skin) use edit_image instead. Do NOT call generate_image from scratch.'
328
+ }, null, 2), images);
321
329
  }
322
330
  );
323
331
 
@@ -532,21 +540,24 @@ function registerGenerateTools(server, client, options = {}) {
532
540
  if (poll.timedOut) return poll.timedOut;
533
541
  const result = poll.result;
534
542
 
535
- return {
536
- content: [{
537
- type: 'text',
538
- text: JSON.stringify({
539
- ...creditFields(result),
540
- session_id: gen.session_id,
541
- urls: result.result.urls,
542
- model: result.result.model,
543
- duration: result.result.duration,
544
- thumbnail_url: result.result.thumbnail_url,
545
- prompt_used: result.result.prompt_used,
546
- _followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video (upscale/reframe/face_swap/extend/generate_audio/lipsync/magic_edit) or generate_video_from_video (restyle). Do NOT call generate_video from scratch.'
547
- }, null, 2)
548
- }]
549
- };
543
+ return uiCompleted({
544
+ tool: 'generate_video', kind: 'video', gen, client, model, prompt,
545
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
546
+ reference_images,
547
+ urls: result.result.urls,
548
+ thumbnail_url: result.result.thumbnail_url,
549
+ duration: result.result.duration,
550
+ credits_used: creditFields(result).credits_used,
551
+ }, JSON.stringify({
552
+ ...creditFields(result),
553
+ session_id: gen.session_id,
554
+ urls: result.result.urls,
555
+ model: result.result.model,
556
+ duration: result.result.duration,
557
+ thumbnail_url: result.result.thumbnail_url,
558
+ prompt_used: result.result.prompt_used,
559
+ _followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video (upscale/reframe/face_swap/extend/generate_audio/lipsync/magic_edit) or generate_video_from_video (restyle). Do NOT call generate_video from scratch.'
560
+ }, null, 2));
550
561
  }
551
562
  );
552
563
 
@@ -615,20 +626,23 @@ function registerGenerateTools(server, client, options = {}) {
615
626
  if (poll.timedOut) return poll.timedOut;
616
627
  const result = poll.result;
617
628
 
618
- return {
619
- content: [{
620
- type: 'text',
621
- text: JSON.stringify({
622
- ...creditFields(result),
623
- session_id: gen.session_id,
624
- urls: result.result.urls,
625
- model: result.result.model,
626
- duration: result.result.duration,
627
- thumbnail_url: result.result.thumbnail_url,
628
- _followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video or generate_video_from_video. Do NOT re-run generate_video_from_image unless they want a fresh animation from a different source image. Animating more shots of THIS same sequence? Pass the session_id above back on each of those calls so they all land in one session.'
629
- }, null, 2)
630
- }]
631
- };
629
+ return uiCompleted({
630
+ tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
631
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
632
+ reference_images: [image_url],
633
+ urls: result.result.urls,
634
+ thumbnail_url: result.result.thumbnail_url,
635
+ duration: result.result.duration,
636
+ credits_used: creditFields(result).credits_used,
637
+ }, JSON.stringify({
638
+ ...creditFields(result),
639
+ session_id: gen.session_id,
640
+ urls: result.result.urls,
641
+ model: result.result.model,
642
+ duration: result.result.duration,
643
+ thumbnail_url: result.result.thumbnail_url,
644
+ _followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video or generate_video_from_video. Do NOT re-run generate_video_from_image unless they want a fresh animation from a different source image. Animating more shots of THIS same sequence? Pass the session_id above back on each of those calls so they all land in one session.'
645
+ }, null, 2));
632
646
  }
633
647
  );
634
648
 
@@ -680,19 +694,21 @@ function registerGenerateTools(server, client, options = {}) {
680
694
  if (poll.timedOut) return poll.timedOut;
681
695
  const result = poll.result;
682
696
 
683
- return {
684
- content: [{
685
- type: 'text',
686
- text: JSON.stringify({
687
- ...creditFields(result),
688
- session_id: gen.session_id,
689
- urls: result.result.urls,
690
- title: result.result.title,
691
- duration: result.result.duration,
692
- lyrics: result.result.lyrics
693
- }, null, 2)
694
- }]
695
- };
697
+ return uiCompleted({
698
+ tool: 'generate_music', kind: 'audio', gen, client, model: model || 'Suno', prompt,
699
+ settings: { mode: instrumental ? 'instrumental' : (style || undefined) },
700
+ urls: result.result.urls,
701
+ title: result.result.title,
702
+ duration: result.result.duration,
703
+ credits_used: creditFields(result).credits_used,
704
+ }, JSON.stringify({
705
+ ...creditFields(result),
706
+ session_id: gen.session_id,
707
+ urls: result.result.urls,
708
+ title: result.result.title,
709
+ duration: result.result.duration,
710
+ lyrics: result.result.lyrics
711
+ }, null, 2));
696
712
  }
697
713
  );
698
714
 
@@ -782,19 +798,26 @@ function registerGenerateTools(server, client, options = {}) {
782
798
  if (poll.timedOut) return poll.timedOut;
783
799
  const result = poll.result;
784
800
 
785
- return {
786
- content: [{
787
- type: 'text',
788
- text: JSON.stringify({
789
- ...creditFields(result),
790
- session_id: gen.session_id,
791
- urls: result.result.urls,
792
- voice: result.result.voice,
793
- duration: result.result.duration,
794
- ...(unknownVoice ? { _warning: unknownVoice } : {})
795
- }, null, 2)
796
- }]
797
- };
801
+ return uiCompleted({
802
+ tool: 'generate_speech', kind: 'audio', gen, client, model, prompt: text,
803
+ voice: voiceRecord,
804
+ settings: {
805
+ voice: voice || 'Rachel',
806
+ style: selected_style || emotion || style_instructions_preset_id || style_instructions,
807
+ speaking_speed,
808
+ language,
809
+ },
810
+ urls: result.result.urls,
811
+ duration: result.result.duration,
812
+ credits_used: creditFields(result).credits_used,
813
+ }, JSON.stringify({
814
+ ...creditFields(result),
815
+ session_id: gen.session_id,
816
+ urls: result.result.urls,
817
+ voice: result.result.voice,
818
+ duration: result.result.duration,
819
+ ...(unknownVoice ? { _warning: unknownVoice } : {})
820
+ }, null, 2));
798
821
  }
799
822
  );
800
823
 
@@ -845,17 +868,19 @@ function registerGenerateTools(server, client, options = {}) {
845
868
  if (poll.timedOut) return poll.timedOut;
846
869
  const result = poll.result;
847
870
 
848
- return {
849
- content: [{
850
- type: 'text',
851
- text: JSON.stringify({
852
- ...creditFields(result),
853
- session_id: gen.session_id,
854
- urls: result.result.urls,
855
- duration: result.result.duration
856
- }, null, 2)
857
- }]
858
- };
871
+ return uiCompleted({
872
+ tool: 'generate_sound', kind: 'audio', gen, client, model, prompt,
873
+ settings: { duration },
874
+ reference_images: seed_reference_image_url ? [seed_reference_image_url] : [],
875
+ urls: result.result.urls,
876
+ duration: result.result.duration,
877
+ credits_used: creditFields(result).credits_used,
878
+ }, JSON.stringify({
879
+ ...creditFields(result),
880
+ session_id: gen.session_id,
881
+ urls: result.result.urls,
882
+ duration: result.result.duration
883
+ }, null, 2));
859
884
  }
860
885
  );
861
886
 
@@ -1093,12 +1118,22 @@ function registerGenerateTools(server, client, options = {}) {
1093
1118
 
1094
1119
  if (ui()) return uiGenerating({
1095
1120
  tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
1096
- settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id }),
1121
+ settings: videoSettings({
1122
+ duration,
1123
+ reported_duration: startResponse?.duration ?? startResponse?.result?.duration,
1124
+ shots: multi_shot_count ?? (Array.isArray(multi_shots) ? multi_shots.length : undefined),
1125
+ resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id,
1126
+ }),
1097
1127
  reference_images: [
1098
1128
  ...(reference_images || []),
1099
1129
  ...(keyframes || []).map((keyframe) => keyframe.image_url),
1100
1130
  ...(files || []).filter((source) => /^https?:\/\//i.test(source))
1101
- ]
1131
+ ],
1132
+ // Elements is the one tool that takes all three modalities. The widget
1133
+ // sorts kind by extension, so a video that arrived via `files` is still
1134
+ // rendered as a video — these two just make sure nothing is dropped.
1135
+ reference_videos: reference_videos || [],
1136
+ reference_audio: [...(reference_audio_urls || []), ...(audio_url ? [audio_url] : [])]
1102
1137
  });
1103
1138
 
1104
1139
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1108,19 +1143,33 @@ function registerGenerateTools(server, client, options = {}) {
1108
1143
  if (poll.timedOut) return poll.timedOut;
1109
1144
  const result = poll.result;
1110
1145
 
1111
- return {
1112
- content: [{
1113
- type: 'text',
1114
- text: JSON.stringify({
1115
- ...creditFields(result),
1116
- session_id: startResponse.session_id,
1117
- urls: result.result?.urls || [],
1118
- thumbnail_url: result.result?.thumbnail_url || null,
1119
- duration: result.result?.duration || null,
1120
- model: result.result?.model || null
1121
- }, null, 2)
1122
- }]
1123
- };
1146
+ return uiCompleted({
1147
+ tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
1148
+ settings: videoSettings({
1149
+ duration,
1150
+ reported_duration: result.result?.duration,
1151
+ shots: multi_shot_count ?? (Array.isArray(multi_shots) ? multi_shots.length : undefined),
1152
+ resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id,
1153
+ }),
1154
+ reference_images: [
1155
+ ...(reference_images || []),
1156
+ ...(keyframes || []).map((keyframe) => keyframe.image_url),
1157
+ ...(files || []).filter((source) => /^https?:\/\//i.test(source))
1158
+ ],
1159
+ reference_videos: reference_videos || [],
1160
+ reference_audio: [...(reference_audio_urls || []), ...(audio_url ? [audio_url] : [])],
1161
+ urls: result.result?.urls || [],
1162
+ thumbnail_url: result.result?.thumbnail_url || null,
1163
+ duration: result.result?.duration || null,
1164
+ credits_used: creditFields(result).credits_used,
1165
+ }, JSON.stringify({
1166
+ ...creditFields(result),
1167
+ session_id: startResponse.session_id,
1168
+ urls: result.result?.urls || [],
1169
+ thumbnail_url: result.result?.thumbnail_url || null,
1170
+ duration: result.result?.duration || null,
1171
+ model: result.result?.model || null
1172
+ }, null, 2));
1124
1173
  }
1125
1174
  );
1126
1175
 
@@ -1193,19 +1242,23 @@ function registerGenerateTools(server, client, options = {}) {
1193
1242
  if (poll.timedOut) return poll.timedOut;
1194
1243
  const result = poll.result;
1195
1244
 
1196
- return {
1197
- content: [{
1198
- type: 'text',
1199
- text: JSON.stringify({
1200
- ...creditFields(result),
1201
- session_id: startResponse.session_id,
1202
- urls: result.result?.urls || [],
1203
- thumbnail_url: result.result?.thumbnail_url || null,
1204
- duration: result.result?.duration || null,
1205
- model: result.result?.model || null
1206
- }, null, 2)
1207
- }]
1208
- };
1245
+ return uiCompleted({
1246
+ tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
1247
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
1248
+ reference_images: [first_frame_url || first_frame, last_frame_url || last_frame]
1249
+ .filter((source) => /^https?:\/\//i.test(source || '')),
1250
+ urls: result.result?.urls || [],
1251
+ thumbnail_url: result.result?.thumbnail_url || null,
1252
+ duration: result.result?.duration || null,
1253
+ credits_used: creditFields(result).credits_used,
1254
+ }, JSON.stringify({
1255
+ ...creditFields(result),
1256
+ session_id: startResponse.session_id,
1257
+ urls: result.result?.urls || [],
1258
+ thumbnail_url: result.result?.thumbnail_url || null,
1259
+ duration: result.result?.duration || null,
1260
+ model: result.result?.model || null
1261
+ }, null, 2));
1209
1262
  }
1210
1263
  );
1211
1264
 
@@ -1308,19 +1361,23 @@ function registerGenerateTools(server, client, options = {}) {
1308
1361
  if (poll.timedOut) return poll.timedOut;
1309
1362
  const result = poll.result;
1310
1363
 
1311
- return {
1312
- content: [{
1313
- type: 'text',
1314
- text: JSON.stringify({
1315
- ...creditFields(result),
1316
- session_id: startResponse.session_id,
1317
- urls: result.result?.urls || [],
1318
- thumbnail_url: result.result?.thumbnail_url || null,
1319
- duration: result.result?.duration || null,
1320
- model: result.result?.model || null
1321
- }, null, 2)
1322
- }]
1323
- };
1364
+ return uiCompleted({
1365
+ tool: 'generate_lipsync', kind: 'video', gen: startResponse, client, model,
1366
+ prompt: text_prompt, settings: { mode: 'lipsync' },
1367
+ reference_images: sourceIsUrl && !/\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? [source] : [],
1368
+ reference_videos: sourceIsUrl && /\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? [source] : [],
1369
+ urls: result.result?.urls || [],
1370
+ thumbnail_url: result.result?.thumbnail_url || null,
1371
+ duration: result.result?.duration || null,
1372
+ credits_used: creditFields(result).credits_used,
1373
+ }, JSON.stringify({
1374
+ ...creditFields(result),
1375
+ session_id: startResponse.session_id,
1376
+ urls: result.result?.urls || [],
1377
+ thumbnail_url: result.result?.thumbnail_url || null,
1378
+ duration: result.result?.duration || null,
1379
+ model: result.result?.model || null
1380
+ }, null, 2));
1324
1381
  }
1325
1382
  );
1326
1383
 
@@ -1403,7 +1460,13 @@ function registerGenerateTools(server, client, options = {}) {
1403
1460
  tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
1404
1461
  prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
1405
1462
  settings: videoSettings({ duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle', enhance_prompt, visual_dna_ids }),
1406
- reference_images: [...(reference_images || []), ...(elements || [])]
1463
+ reference_images: [...(reference_images || []), ...(elements || [])],
1464
+ // The source clip IS the primary reference for a restyle — showing the
1465
+ // extra references while hiding the video being transformed was backwards.
1466
+ reference_videos: [
1467
+ ...(isUrl ? [source_video] : []),
1468
+ ...(reference_videos || [])
1469
+ ]
1407
1470
  });
1408
1471
 
1409
1472
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1413,19 +1476,27 @@ function registerGenerateTools(server, client, options = {}) {
1413
1476
  if (poll.timedOut) return poll.timedOut;
1414
1477
  const result = poll.result;
1415
1478
 
1416
- return {
1417
- content: [{
1418
- type: 'text',
1419
- text: JSON.stringify({
1420
- ...creditFields(result),
1421
- session_id: startResponse.session_id,
1422
- urls: result.result?.urls || [],
1423
- thumbnail_url: result.result?.thumbnail_url || null,
1424
- duration: result.result?.duration || null,
1425
- model: result.result?.model || null
1426
- }, null, 2)
1427
- }]
1428
- };
1479
+ return uiCompleted({
1480
+ tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
1481
+ prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
1482
+ settings: videoSettings({ duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle', enhance_prompt, visual_dna_ids }),
1483
+ reference_images: [...(reference_images || []), ...(elements || [])],
1484
+ reference_videos: [
1485
+ ...(isUrl ? [source_video] : []),
1486
+ ...(reference_videos || [])
1487
+ ],
1488
+ urls: result.result?.urls || [],
1489
+ thumbnail_url: result.result?.thumbnail_url || null,
1490
+ duration: result.result?.duration || null,
1491
+ credits_used: creditFields(result).credits_used,
1492
+ }, JSON.stringify({
1493
+ ...creditFields(result),
1494
+ session_id: startResponse.session_id,
1495
+ urls: result.result?.urls || [],
1496
+ thumbnail_url: result.result?.thumbnail_url || null,
1497
+ duration: result.result?.duration || null,
1498
+ model: result.result?.model || null
1499
+ }, null, 2));
1429
1500
  }
1430
1501
  );
1431
1502
 
@@ -1556,18 +1627,20 @@ function registerGenerateTools(server, client, options = {}) {
1556
1627
  if (poll.timedOut) return poll.timedOut;
1557
1628
  const result = poll.result;
1558
1629
 
1559
- return {
1560
- content: [{
1561
- type: 'text',
1562
- text: JSON.stringify({
1563
- ...creditFields(result),
1564
- urls: result.result?.urls || [],
1565
- thumbnail_url: result.result?.thumbnail_url || null,
1566
- mode: result.result?.mode || null,
1567
- prompt_used: result.result?.prompt_used || null
1568
- }, null, 2)
1569
- }]
1570
- };
1630
+ return uiCompleted({
1631
+ tool: 'generate_3d', kind: '3d', gen: startResponse, client, model, prompt,
1632
+ settings: { mode: mode || (reference_images?.length > 1 ? 'multi' : reference_images?.length === 1 ? 'single' : 'text') },
1633
+ reference_images,
1634
+ urls: result.result?.urls || [],
1635
+ thumbnail_url: result.result?.thumbnail_url || null,
1636
+ credits_used: creditFields(result).credits_used,
1637
+ }, JSON.stringify({
1638
+ ...creditFields(result),
1639
+ urls: result.result?.urls || [],
1640
+ thumbnail_url: result.result?.thumbnail_url || null,
1641
+ mode: result.result?.mode || null,
1642
+ prompt_used: result.result?.prompt_used || null
1643
+ }, null, 2));
1571
1644
  }
1572
1645
  );
1573
1646
  // ─── edit_image ────────────────────────────────────────────
@@ -1702,18 +1775,20 @@ function registerGenerateTools(server, client, options = {}) {
1702
1775
  if (poll.timedOut) return poll.timedOut;
1703
1776
  const result = poll.result;
1704
1777
 
1705
- return {
1706
- content: [{
1707
- type: 'text',
1708
- text: JSON.stringify({
1709
- ...creditFields(result),
1710
- session_id: gen.session_id,
1711
- urls: result.result?.urls || [],
1712
- edit_type: result.result?.edit_type || null,
1713
- model: result.result?.model || null
1714
- }, null, 2)
1715
- }]
1716
- };
1778
+ return uiCompleted({
1779
+ tool: 'edit_image', kind: 'image', gen, client, model,
1780
+ prompt: prompt || operation,
1781
+ settings: { mode: operation, aspect_ratio, scale, resolution },
1782
+ reference_images: [image_url, mask_image_url, ...(additional_images || [])].filter(Boolean),
1783
+ urls: result.result?.urls || [],
1784
+ credits_used: creditFields(result).credits_used,
1785
+ }, JSON.stringify({
1786
+ ...creditFields(result),
1787
+ session_id: gen.session_id,
1788
+ urls: result.result?.urls || [],
1789
+ edit_type: result.result?.edit_type || null,
1790
+ model: result.result?.model || null
1791
+ }, null, 2));
1717
1792
  }
1718
1793
  );
1719
1794
 
@@ -1867,20 +1942,23 @@ function registerGenerateTools(server, client, options = {}) {
1867
1942
  if (poll.timedOut) return poll.timedOut;
1868
1943
  const result = poll.result;
1869
1944
 
1870
- return {
1871
- content: [{
1872
- type: 'text',
1873
- text: JSON.stringify({
1874
- ...creditFields(result),
1875
- session_id: gen.session_id,
1876
- urls: result.result?.urls || [],
1877
- download_url: result.result?.download_url || null,
1878
- edit_type: result.result?.edit_type || null,
1879
- duration: result.result?.duration || null,
1880
- model: result.result?.model || null
1881
- }, null, 2)
1882
- }]
1883
- };
1945
+ return uiCompleted({
1946
+ tool: 'edit_video', kind: 'video', gen, client, model,
1947
+ prompt: prompt || operation,
1948
+ settings: { mode: operation, duration, aspect_ratio, resolution },
1949
+ reference_images: image_url ? [image_url] : [],
1950
+ urls: result.result?.urls || [],
1951
+ duration: result.result?.duration || null,
1952
+ credits_used: creditFields(result).credits_used,
1953
+ }, JSON.stringify({
1954
+ ...creditFields(result),
1955
+ session_id: gen.session_id,
1956
+ urls: result.result?.urls || [],
1957
+ download_url: result.result?.download_url || null,
1958
+ edit_type: result.result?.edit_type || null,
1959
+ duration: result.result?.duration || null,
1960
+ model: result.result?.model || null
1961
+ }, null, 2));
1884
1962
  }
1885
1963
  );
1886
1964
 
@@ -6,7 +6,7 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { resolveToBuffer, DEFAULT_MAX_FILE_MB, compactList } = require('./_shared');
9
- const { UI, uiResult, listResult, appsEnabled } = require('../apps');
9
+ const { UI, uiResult, listResult } = require('../apps');
10
10
 
11
11
  // How many tiles the media grid renders. A rendering limit only — the text
12
12
  // payload always carries the full page, and `total` reports the real library
@@ -65,8 +65,6 @@ function uploadTicketPayload(ticket) {
65
65
  }
66
66
 
67
67
  function registerMediaTools(server, client, options = {}) {
68
- const ui = () => appsEnabled(server, options);
69
-
70
68
  // `opts.apps` is set only by kolbo-api's per-request server (see createServer
71
69
  // in ../index.js), which makes it a TRANSPORT signal — deliberately not
72
70
  // `appsEnabled()`, which also returns true for stdio hosts that advertise UI.
@@ -101,24 +99,27 @@ function registerMediaTools(server, client, options = {}) {
101
99
  expires_in_seconds: ticket.expires_in,
102
100
  };
103
101
 
104
- if (ui()) {
105
- // upload_ui_url: top-level page for Claude iOS/Android in-iframe
106
- // <input type=file> selections are dropped by WebKit (see upload widget).
107
- const uploadUiUrl = ticket.upload_ui_url
108
- || String(ticket.upload_url || '').replace(/\/upload\/?$/, '/upload-ui');
109
- return uiResult(UI.upload, JSON.stringify(info, null, 2), {
110
- widget: 'upload',
111
- title: purpose || 'Upload media',
112
- upload_url: ticket.upload_url,
113
- upload_ui_url: uploadUiUrl,
114
- token: ticket.token,
115
- expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
116
- kinds: media_types && media_types.length ? media_types : undefined,
117
- max_files: Math.min(Math.max(Number(max_files) || 10, 1), 20),
118
- max_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
119
- ...(project_id ? { project_id } : {}),
120
- });
121
- }
102
+ // Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
103
+ // gating the grid payload on appsEnabled() sent it text only; the host then
104
+ // rebuilt items from the compactList text, whose field names are
105
+ // `filename`/`url` not the `title`/`thumbnail` the grid renders — so every
106
+ // tile came out black and unlabelled. Same reasoning as listResult().
107
+ // upload_ui_url: top-level page for Claude iOS/Android — in-iframe
108
+ // <input type=file> selections are dropped by WebKit (see upload widget).
109
+ const uploadUiUrl = ticket.upload_ui_url
110
+ || String(ticket.upload_url || '').replace(/\/upload\/?$/, '/upload-ui');
111
+ return uiResult(UI.upload, JSON.stringify(info, null, 2), {
112
+ widget: 'upload',
113
+ title: purpose || 'Upload media',
114
+ upload_url: ticket.upload_url,
115
+ upload_ui_url: uploadUiUrl,
116
+ token: ticket.token,
117
+ expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
118
+ kinds: media_types && media_types.length ? media_types : undefined,
119
+ max_files: Math.min(Math.max(Number(max_files) || 10, 1), 20),
120
+ max_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
121
+ ...(project_id ? { project_id } : {}),
122
+ });
122
123
 
123
124
  // Text-only host (Claude Code, Codex CLI, Cursor): no iframe to render —
124
125
  // but these are exactly the hosts that CAN reach a filesystem, so hand
@@ -263,37 +264,33 @@ function registerMediaTools(server, client, options = {}) {
263
264
  note: 'Narrow with `type`, `category`, `project_id`, `folder_id`, or `search`; get_media returns one item in full.',
264
265
  });
265
266
 
266
- if (ui()) {
267
- // The SDK envelope reports `total_items` (see sdk/controller.js listMedia);
268
- // reading `total` always came back undefined, so the grid claimed the page
269
- // size was the whole library. Accept either, then fall back.
270
- const totalItems = pagination
271
- ? (pagination.total_items != null ? pagination.total_items : pagination.total)
272
- : null;
273
- const items = media.slice(0, GRID_CAP).map((m) => ({
274
- id: m.id,
275
- title: m.filename,
276
- subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
277
- thumbnail: m.media_type === 'image' ? m.url : (m.thumbnail_url || null),
278
- media_type: m.media_type,
279
- url: m.url,
280
- use_hint: 'Use this media library asset in my next step:\nURL: {URL}\n(id: {ID})'
281
- }));
282
- return uiResult(UI.mediaGrid, text, {
283
- widget: 'media-grid',
284
- title: 'Media Library',
285
- items,
286
- total: totalItems != null ? totalItems : media.length,
287
- shown: Math.min(media.length, GRID_CAP)
288
- });
289
- }
290
-
291
- return {
292
- content: [{
293
- type: 'text',
294
- text
295
- }]
296
- };
267
+ // Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
268
+ // gating the grid payload on appsEnabled() sent it text only; the host then
269
+ // rebuilt items from the compactList text, whose field names are
270
+ // `filename`/`url` not the `title`/`thumbnail` the grid renders so every
271
+ // tile came out black and unlabelled. Same reasoning as listResult().
272
+ // The SDK envelope reports `total_items` (see sdk/controller.js listMedia);
273
+ // reading `total` always came back undefined, so the grid claimed the page
274
+ // size was the whole library. Accept either, then fall back.
275
+ const totalItems = pagination
276
+ ? (pagination.total_items != null ? pagination.total_items : pagination.total)
277
+ : null;
278
+ const items = media.slice(0, GRID_CAP).map((m) => ({
279
+ id: m.id,
280
+ title: m.filename,
281
+ subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
282
+ thumbnail: m.media_type === 'image' ? m.url : (m.thumbnail_url || null),
283
+ media_type: m.media_type,
284
+ url: m.url,
285
+ use_hint: 'Use this media library asset in my next step:\nURL: {URL}\n(id: {ID})'
286
+ }));
287
+ return uiResult(UI.mediaGrid, text, {
288
+ widget: 'media-grid',
289
+ title: 'Media Library',
290
+ items,
291
+ total: totalItems != null ? totalItems : media.length,
292
+ shown: Math.min(media.length, GRID_CAP)
293
+ });
297
294
  }
298
295
  );
299
296
 
@@ -4,11 +4,10 @@
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 { UI, uiResult, appsEnabled } = require('../apps');
7
+ const { UI, uiResult } = require('../apps');
8
8
  const { projectScopeReadField } = require('./_shared');
9
9
 
10
10
  function registerMoodboardTools(server, client, options = {}) {
11
- const ui = () => appsEnabled(server, options);
12
11
  // ─── list_moodboards ───────────────────────────────────────
13
12
  server.tool(
14
13
  'list_moodboards',
@@ -29,25 +28,26 @@ function registerMoodboardTools(server, client, options = {}) {
29
28
  count: result.count || 0
30
29
  }, null, 2);
31
30
 
32
- if (ui()) {
33
- return uiResult(UI.mediaGrid, text, {
34
- widget: 'media-grid',
35
- title: 'Moodboards',
36
- items: moodboards.slice(0, 24).map(mb => ({
37
- id: mb.id,
38
- title: mb.name,
39
- // API returns thumbnail_url + images[] (sdk listMoodboards) — both
40
- // previous keys were wrong, so the fallback never fired either.
41
- thumbnail: mb.thumbnail_url || mb.thumbnail || (Array.isArray(mb.images) ? mb.images[0] : undefined),
42
- media_type: 'image',
43
- use_hint: 'Apply moodboard "{TITLE}" (moodboard_id: {ID}) to my next generation.'
44
- })),
45
- total: result.count || moodboards.length,
46
- has_more: moodboards.length > 24
47
- });
48
- }
49
-
50
- return { content: [{ type: 'text', text }] };
31
+ // Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
32
+ // gating the grid payload on appsEnabled() sent it text only; the host then
33
+ // rebuilt items from the compactList text, whose field names are
34
+ // `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
35
+ // tile came out black and unlabelled. Same reasoning as listResult().
36
+ return uiResult(UI.mediaGrid, text, {
37
+ widget: 'media-grid',
38
+ title: 'Moodboards',
39
+ items: moodboards.slice(0, 24).map(mb => ({
40
+ id: mb.id,
41
+ title: mb.name,
42
+ // API returns thumbnail_url + images[] (sdk listMoodboards) both
43
+ // previous keys were wrong, so the fallback never fired either.
44
+ thumbnail: mb.thumbnail_url || mb.thumbnail || (Array.isArray(mb.images) ? mb.images[0] : undefined),
45
+ media_type: 'image',
46
+ use_hint: 'Apply moodboard "{TITLE}" (moodboard_id: {ID}) to my next generation.'
47
+ })),
48
+ total: result.count || moodboards.length,
49
+ has_more: moodboards.length > 24
50
+ });
51
51
  }
52
52
  );
53
53
 
@@ -6,7 +6,7 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField, compactList } = require('./_shared');
9
- const { UI, uiResult, listResult, appsEnabled } = require('../apps');
9
+ const { UI, uiResult, listResult } = require('../apps');
10
10
 
11
11
  // Reference sheets are a blocking multi-panel render; the 120s client default
12
12
  // aborted them mid-flight while the server finished and charged anyway.
@@ -20,7 +20,6 @@ function resolveToBuffer(source, kind) {
20
20
  }
21
21
 
22
22
  function registerVisualDnaTools(server, client, options = {}) {
23
- const ui = () => appsEnabled(server, options);
24
23
  // ─── create_visual_dna ─────────────────────────────────────
25
24
  server.tool(
26
25
  'create_visual_dna',
@@ -133,24 +132,25 @@ function registerVisualDnaTools(server, client, options = {}) {
133
132
  note: 'Narrow with `search`, `tags`, or `collection`, or pass `page`/`limit` for the rest; get_visual_dna returns one in full.',
134
133
  });
135
134
 
136
- if (ui()) {
137
- return uiResult(UI.mediaGrid, text, {
138
- widget: 'media-grid',
139
- title: 'Visual DNA Profiles',
140
- items: dnas.slice(0, 24).map(d => ({
141
- id: d.id,
142
- title: d.name,
143
- subtitle: (d.dna_type || '') + (Array.isArray(d.tags) && d.tags.length ? ' · ' + d.tags.slice(0, 3).join(', ') : ''),
144
- thumbnail: d.thumbnail_url || d.thumbnail,
145
- media_type: 'image',
146
- use_hint: 'Use Visual DNA "{TITLE}" (id: {ID}) in my next generation for character/style consistency.'
147
- })),
148
- total,
149
- has_more: result.has_more || dnas.length > 24
150
- });
151
- }
152
-
153
- return { content: [{ type: 'text', text }] };
135
+ // Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
136
+ // gating the grid payload on appsEnabled() sent it text only; the host then
137
+ // rebuilt items from the compactList text, whose field names are
138
+ // `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
139
+ // tile came out black and unlabelled. Same reasoning as listResult().
140
+ return uiResult(UI.mediaGrid, text, {
141
+ widget: 'media-grid',
142
+ title: 'Visual DNA Profiles',
143
+ items: dnas.slice(0, 24).map(d => ({
144
+ id: d.id,
145
+ title: d.name,
146
+ subtitle: (d.dna_type || '') + (Array.isArray(d.tags) && d.tags.length ? ' · ' + d.tags.slice(0, 3).join(', ') : ''),
147
+ thumbnail: d.thumbnail_url || d.thumbnail,
148
+ media_type: 'image',
149
+ use_hint: 'Use Visual DNA "{TITLE}" (id: {ID}) in my next generation for character/style consistency.'
150
+ })),
151
+ total,
152
+ has_more: result.has_more || dnas.length > 24
153
+ });
154
154
  }
155
155
  );
156
156