@kolbo/mcp 1.72.4 → 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.4",
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.
@@ -174,9 +186,7 @@ function renderChips(sc) {
174
186
  // Ids where we have them (title = the id, so it can be copied / reused),
175
187
  // falling back to the old count/boolean shape for payloads generated before
176
188
  // the ids were carried.
177
- var dnaIds = s.visual_dna_ids || [];
178
- if (dnaIds.length) h += chipT(dnaIds.length + ' Visual DNA', dnaIds.join('\\n'));
179
- else if (s.visual_dna) h += chip(s.visual_dna + ' Visual DNA');
189
+ h += dnaChipsHTML(sc, s);
180
190
  var mbIds = s.moodboard_ids || (s.moodboard_id ? [s.moodboard_id] : []);
181
191
  if (mbIds.length) h += chipT(mbIds.length > 1 ? mbIds.length + ' moodboards' : 'moodboard', mbIds.join('\\n'));
182
192
  else if (s.moodboard) h += chip('moodboard');
@@ -193,12 +203,94 @@ function renderChips(sc) {
193
203
  }
194
204
  if (s.mode) h += chip(esc(s.mode));
195
205
  if (sc.count > 1) h += chip('×' + sc.count);
196
- var refs = Array.isArray(sc.reference_images) && sc.reference_images.length
197
- ? 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 = '';
198
241
  for (var i = 0; i < refs.length; i++) {
199
- 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
+ }
200
254
  }
201
- 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>';
202
294
  }
203
295
  function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
204
296
  // Same chip with a hover title — used to surface the asset id behind a
@@ -618,7 +710,12 @@ function renderLinks(urls) {
618
710
  // Small hover download button attached to a media cell (per-item downloads —
619
711
  // batch grids and CD scenes have no single "current" url for the action row).
620
712
  function dlBtnHTML(u) {
621
- 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>';
622
719
  }
623
720
  function wireDlButtons(root) {
624
721
  Array.prototype.forEach.call((root || document).querySelectorAll('.k-dl[data-dl]'), function (b) {
@@ -627,6 +724,12 @@ function wireDlButtons(root) {
627
724
  window.kolbo.openLink(downloadUrl(b.getAttribute('data-dl')));
628
725
  };
629
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
+ });
630
733
  }
631
734
 
632
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) ───
@@ -224,19 +224,20 @@ function registerGenerateTools(server, client, options = {}) {
224
224
  const result = poll.result;
225
225
 
226
226
  const images = await inlineImageBlocks(result.result.urls, { enabled: inlineImages });
227
- return {
228
- content: [{
229
- type: 'text',
230
- text: JSON.stringify({
231
- ...creditFields(result),
232
- session_id: gen.session_id,
233
- urls: result.result.urls,
234
- model: result.result.model,
235
- prompt_used: result.result.prompt_used,
236
- _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.'
237
- }, null, 2)
238
- }, ...images]
239
- };
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);
240
241
  }
241
242
  );
242
243
 
@@ -311,19 +312,20 @@ function registerGenerateTools(server, client, options = {}) {
311
312
  const result = poll.result;
312
313
 
313
314
  const images = await inlineImageBlocks(result.result.urls, { enabled: inlineImages });
314
- return {
315
- content: [{
316
- type: 'text',
317
- text: JSON.stringify({
318
- ...creditFields(result),
319
- session_id: gen.session_id,
320
- urls: result.result.urls,
321
- model: result.result.model,
322
- prompt_used: result.result.prompt_used,
323
- _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.'
324
- }, null, 2)
325
- }, ...images]
326
- };
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);
327
329
  }
328
330
  );
329
331
 
@@ -538,21 +540,24 @@ function registerGenerateTools(server, client, options = {}) {
538
540
  if (poll.timedOut) return poll.timedOut;
539
541
  const result = poll.result;
540
542
 
541
- return {
542
- content: [{
543
- type: 'text',
544
- text: JSON.stringify({
545
- ...creditFields(result),
546
- session_id: gen.session_id,
547
- urls: result.result.urls,
548
- model: result.result.model,
549
- duration: result.result.duration,
550
- thumbnail_url: result.result.thumbnail_url,
551
- prompt_used: result.result.prompt_used,
552
- _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.'
553
- }, null, 2)
554
- }]
555
- };
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));
556
561
  }
557
562
  );
558
563
 
@@ -621,20 +626,23 @@ function registerGenerateTools(server, client, options = {}) {
621
626
  if (poll.timedOut) return poll.timedOut;
622
627
  const result = poll.result;
623
628
 
624
- return {
625
- content: [{
626
- type: 'text',
627
- text: JSON.stringify({
628
- ...creditFields(result),
629
- session_id: gen.session_id,
630
- urls: result.result.urls,
631
- model: result.result.model,
632
- duration: result.result.duration,
633
- thumbnail_url: result.result.thumbnail_url,
634
- _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.'
635
- }, null, 2)
636
- }]
637
- };
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));
638
646
  }
639
647
  );
640
648
 
@@ -686,19 +694,21 @@ function registerGenerateTools(server, client, options = {}) {
686
694
  if (poll.timedOut) return poll.timedOut;
687
695
  const result = poll.result;
688
696
 
689
- return {
690
- content: [{
691
- type: 'text',
692
- text: JSON.stringify({
693
- ...creditFields(result),
694
- session_id: gen.session_id,
695
- urls: result.result.urls,
696
- title: result.result.title,
697
- duration: result.result.duration,
698
- lyrics: result.result.lyrics
699
- }, null, 2)
700
- }]
701
- };
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));
702
712
  }
703
713
  );
704
714
 
@@ -788,19 +798,26 @@ function registerGenerateTools(server, client, options = {}) {
788
798
  if (poll.timedOut) return poll.timedOut;
789
799
  const result = poll.result;
790
800
 
791
- return {
792
- content: [{
793
- type: 'text',
794
- text: JSON.stringify({
795
- ...creditFields(result),
796
- session_id: gen.session_id,
797
- urls: result.result.urls,
798
- voice: result.result.voice,
799
- duration: result.result.duration,
800
- ...(unknownVoice ? { _warning: unknownVoice } : {})
801
- }, null, 2)
802
- }]
803
- };
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));
804
821
  }
805
822
  );
806
823
 
@@ -851,17 +868,19 @@ function registerGenerateTools(server, client, options = {}) {
851
868
  if (poll.timedOut) return poll.timedOut;
852
869
  const result = poll.result;
853
870
 
854
- return {
855
- content: [{
856
- type: 'text',
857
- text: JSON.stringify({
858
- ...creditFields(result),
859
- session_id: gen.session_id,
860
- urls: result.result.urls,
861
- duration: result.result.duration
862
- }, null, 2)
863
- }]
864
- };
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));
865
884
  }
866
885
  );
867
886
 
@@ -1109,7 +1128,12 @@ function registerGenerateTools(server, client, options = {}) {
1109
1128
  ...(reference_images || []),
1110
1129
  ...(keyframes || []).map((keyframe) => keyframe.image_url),
1111
1130
  ...(files || []).filter((source) => /^https?:\/\//i.test(source))
1112
- ]
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] : [])]
1113
1137
  });
1114
1138
 
1115
1139
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1119,19 +1143,33 @@ function registerGenerateTools(server, client, options = {}) {
1119
1143
  if (poll.timedOut) return poll.timedOut;
1120
1144
  const result = poll.result;
1121
1145
 
1122
- return {
1123
- content: [{
1124
- type: 'text',
1125
- text: JSON.stringify({
1126
- ...creditFields(result),
1127
- session_id: startResponse.session_id,
1128
- urls: result.result?.urls || [],
1129
- thumbnail_url: result.result?.thumbnail_url || null,
1130
- duration: result.result?.duration || null,
1131
- model: result.result?.model || null
1132
- }, null, 2)
1133
- }]
1134
- };
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));
1135
1173
  }
1136
1174
  );
1137
1175
 
@@ -1204,19 +1242,23 @@ function registerGenerateTools(server, client, options = {}) {
1204
1242
  if (poll.timedOut) return poll.timedOut;
1205
1243
  const result = poll.result;
1206
1244
 
1207
- return {
1208
- content: [{
1209
- type: 'text',
1210
- text: JSON.stringify({
1211
- ...creditFields(result),
1212
- session_id: startResponse.session_id,
1213
- urls: result.result?.urls || [],
1214
- thumbnail_url: result.result?.thumbnail_url || null,
1215
- duration: result.result?.duration || null,
1216
- model: result.result?.model || null
1217
- }, null, 2)
1218
- }]
1219
- };
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));
1220
1262
  }
1221
1263
  );
1222
1264
 
@@ -1319,19 +1361,23 @@ function registerGenerateTools(server, client, options = {}) {
1319
1361
  if (poll.timedOut) return poll.timedOut;
1320
1362
  const result = poll.result;
1321
1363
 
1322
- return {
1323
- content: [{
1324
- type: 'text',
1325
- text: JSON.stringify({
1326
- ...creditFields(result),
1327
- session_id: startResponse.session_id,
1328
- urls: result.result?.urls || [],
1329
- thumbnail_url: result.result?.thumbnail_url || null,
1330
- duration: result.result?.duration || null,
1331
- model: result.result?.model || null
1332
- }, null, 2)
1333
- }]
1334
- };
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));
1335
1381
  }
1336
1382
  );
1337
1383
 
@@ -1414,7 +1460,13 @@ function registerGenerateTools(server, client, options = {}) {
1414
1460
  tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
1415
1461
  prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
1416
1462
  settings: videoSettings({ duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle', enhance_prompt, visual_dna_ids }),
1417
- 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
+ ]
1418
1470
  });
1419
1471
 
1420
1472
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1424,19 +1476,27 @@ function registerGenerateTools(server, client, options = {}) {
1424
1476
  if (poll.timedOut) return poll.timedOut;
1425
1477
  const result = poll.result;
1426
1478
 
1427
- return {
1428
- content: [{
1429
- type: 'text',
1430
- text: JSON.stringify({
1431
- ...creditFields(result),
1432
- session_id: startResponse.session_id,
1433
- urls: result.result?.urls || [],
1434
- thumbnail_url: result.result?.thumbnail_url || null,
1435
- duration: result.result?.duration || null,
1436
- model: result.result?.model || null
1437
- }, null, 2)
1438
- }]
1439
- };
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));
1440
1500
  }
1441
1501
  );
1442
1502
 
@@ -1567,18 +1627,20 @@ function registerGenerateTools(server, client, options = {}) {
1567
1627
  if (poll.timedOut) return poll.timedOut;
1568
1628
  const result = poll.result;
1569
1629
 
1570
- return {
1571
- content: [{
1572
- type: 'text',
1573
- text: JSON.stringify({
1574
- ...creditFields(result),
1575
- urls: result.result?.urls || [],
1576
- thumbnail_url: result.result?.thumbnail_url || null,
1577
- mode: result.result?.mode || null,
1578
- prompt_used: result.result?.prompt_used || null
1579
- }, null, 2)
1580
- }]
1581
- };
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));
1582
1644
  }
1583
1645
  );
1584
1646
  // ─── edit_image ────────────────────────────────────────────
@@ -1713,18 +1775,20 @@ function registerGenerateTools(server, client, options = {}) {
1713
1775
  if (poll.timedOut) return poll.timedOut;
1714
1776
  const result = poll.result;
1715
1777
 
1716
- return {
1717
- content: [{
1718
- type: 'text',
1719
- text: JSON.stringify({
1720
- ...creditFields(result),
1721
- session_id: gen.session_id,
1722
- urls: result.result?.urls || [],
1723
- edit_type: result.result?.edit_type || null,
1724
- model: result.result?.model || null
1725
- }, null, 2)
1726
- }]
1727
- };
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));
1728
1792
  }
1729
1793
  );
1730
1794
 
@@ -1878,20 +1942,23 @@ function registerGenerateTools(server, client, options = {}) {
1878
1942
  if (poll.timedOut) return poll.timedOut;
1879
1943
  const result = poll.result;
1880
1944
 
1881
- return {
1882
- content: [{
1883
- type: 'text',
1884
- text: JSON.stringify({
1885
- ...creditFields(result),
1886
- session_id: gen.session_id,
1887
- urls: result.result?.urls || [],
1888
- download_url: result.result?.download_url || null,
1889
- edit_type: result.result?.edit_type || null,
1890
- duration: result.result?.duration || null,
1891
- model: result.result?.model || null
1892
- }, null, 2)
1893
- }]
1894
- };
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));
1895
1962
  }
1896
1963
  );
1897
1964
 
@@ -291,13 +291,6 @@ function registerMediaTools(server, client, options = {}) {
291
291
  total: totalItems != null ? totalItems : media.length,
292
292
  shown: Math.min(media.length, GRID_CAP)
293
293
  });
294
-
295
- return {
296
- content: [{
297
- type: 'text',
298
- text
299
- }]
300
- };
301
294
  }
302
295
  );
303
296