@kolbo/mcp 1.72.1 → 1.72.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.72.1",
3
+ "version": "1.72.3",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  # AUTO-GENERATED — do not edit
2
2
 
3
- This tree is mirrored from kolbo-code@77a84cf, the single source of truth.
3
+ This tree is mirrored from kolbo-code@6d0fcfe, the single source of truth.
4
4
  Canonical source: packages/opencode/skills/kolbo/
5
5
  Distribution: .github/workflows/sync-skill-to-plugin.yml
6
6
 
@@ -16,9 +16,50 @@ Read `.kolbo/production.md` **before** acting on any of these signals:
16
16
 
17
17
  If the file is missing and the user is referencing prior media, ask the user — do not guess from chat.
18
18
 
19
+ ## ⚠️ Approval gates — the user is usually still iterating (READ THIS FIRST)
20
+
21
+ Most media work is an **approval loop**, not a single shot. The user generates, looks, asks for another take, and keeps going until satisfied. This is the normal case for **images, image sets, Visual DNAs, moodboards, and videos** alike.
22
+
23
+ The log records **what the user approved** — not everything you produced. Getting this wrong is expensive in both directions: log too eagerly and take 3 of 7 is enshrined as "the character"; log too late and the approved URL is gone after compaction.
24
+
25
+ **The loop:**
26
+
27
+ 1. **Generate** candidates.
28
+ 2. **Present them so the user can actually judge.** Never ask "approve?" over bare URLs or ids — the user cannot see those. Use the widget-carrying tools:
29
+ - Visual DNAs → `list_visual_dnas` (renders a thumbnail media grid; `create_visual_dna` returns text only, so follow it with this)
30
+ - Moodboards → `list_moodboards`
31
+ - Images / videos / audio → show the returned URLs as markdown images/links
32
+ Say plainly which ones are in play, e.g. "created `@maya` and `@maya_alt` — here they are".
33
+ 3. **Ask for a decision** and name the options ("keep the first, redo the second, or both?").
34
+ 4. **Repeat** until the user is satisfied. Log nothing as approved during this stage.
35
+ 5. **On approval → update `.kolbo/production.md` immediately**, in the same turn, before your next tool call or final reply.
36
+
37
+ **Never write an artifact into the log as approved without the user's approval.**
38
+
39
+ **If the user genuinely doesn't care** — "whatever you think", "you pick", "don't care", or they hand you the whole job — then **you decide**. Choose, say in one line which you picked and why, and log it as usual with `(agent-selected)`. Do not stall a production waiting for an approval the user has already delegated to you.
40
+
41
+ **Don't lose candidate URLs while iterating.** Recording a candidate is not the same as claiming approval, and compaction will eat unlogged URLs. Park in-flight takes under a `#### Candidates (pending approval)` bullet, and on approval promote the winner to a normal entry and mark the rest `(rejected)`:
42
+
43
+ ```md
44
+ 2. **Rainy street walk** — neon reflections, slow dolly
45
+ #### Candidates (pending approval)
46
+ - take 1: https://...02-rain-a.png (flux-2-pro, 2026-08-17)
47
+ - take 2: https://...02-rain-b.png (flux-2-pro, 2026-08-17)
48
+ ```
49
+
50
+ after the user picks take 2:
51
+
52
+ ```md
53
+ 2. **Rainy street walk** — neon reflections, slow dolly
54
+ - still: https://...02-rain-b.png (flux-2-pro · 1K, approved 2026-08-17)
55
+ - take 1: https://...02-rain-a.png (rejected 2026-08-17)
56
+ ```
57
+
58
+ **Re-confirm state after a long loop.** When a session has churned through many takes, restate the approved set before moving on — "so we're locked on: @maya, moodboard #noir, scenes 1-3" — and make the log match. An approval loop that ends without a written-down approved state is how the wrong asset ships.
59
+
19
60
  ## When to WRITE to it
20
61
 
21
- **Immediately after every successful generation tool call**, before your next tool call or your final reply. The runtime will inject a reminder after generation tool results — treat that as a hard rule, not a suggestion.
62
+ **Immediately once an artifact is approved** (see the approval gates above), before your next tool call or your final reply. The runtime will inject a reminder after generation tool results — treat that as a hard rule, not a suggestion. Where there is no approval loop — the user asked for one thing and got it, or delegated the choice to you — approval is implicit and you log right away.
22
63
 
23
64
  Tools that REQUIRE logging:
24
65
  - `generate_image`, `generate_image_edit`, `edit_image`
@@ -52,6 +93,8 @@ Stub for first creation:
52
93
 
53
94
  **Brief:** <paraphrase of user's overall goal in 1-3 sentences>
54
95
  **Now working on:** <the immediate next step>
96
+ **Approved:** <locked assets — DNAs, moodboards, scenes; "nothing yet" if still iterating>
97
+ **Awaiting approval:** <what you've presented and are waiting on; omit when nothing is pending>
55
98
  **Last updated:** <ISO date>
56
99
 
57
100
  ---
@@ -107,6 +150,8 @@ When a user request supersedes a previous artifact (e.g., "redo scene 2 with mor
107
150
  4. **Do not log failures.** Only successful generations.
108
151
  5. **Resolve user references via the log, not chat history.** If the user says "scene 3," use the URL the log says is scene 3, even if a later tool_result mentioned a different URL.
109
152
  6. **One file per workspace.** Multiple concurrent productions go under separate `## Production: <name>` headings inside the same file.
153
+ 7. **Approved state is user-granted, never assumed.** A generation succeeding is not approval. Only the user's "yes" — or their explicit delegation of the choice to you — promotes a candidate to an approved entry. Silence is not approval; neither is the user moving on to another topic.
154
+ 8. **The `## 🎯 Now` block names what is locked.** Keep an `**Approved:**` line there listing the currently-approved cast, DNAs, moodboards, and scenes, so the approved state survives compaction and is the first thing you read next session.
110
155
 
111
156
  ## Bulk Generation Entry Shape
112
157
 
@@ -207,7 +207,7 @@ Read `max_visual_dna` from `list_models` for the exact cap, AND `supports_visual
207
207
 
208
208
  **Step 1 — Generate both images in parallel (one `generate_image` call each, fire simultaneously):**
209
209
 
210
- 1. **4-angle character sheet** — prompt: `"[character description], character reference sheet showing front view, back view, left side view, right side view, four panels arranged in a 2x2 grid, neutral solid background, full body, photorealistic"`, aspect ratio `16:9`
210
+ 1. **4-angle character sheet** — prompt: `"[character description], character reference sheet showing front view, back view, left side view, right side view, four panels arranged in a 2x2 grid, neutral solid background, full body, photorealistic"`, aspect ratio `16:9` (or `3:2` — always landscape, see the aspect-ratio rule below)
211
211
  2. **Close-up portrait** — prompt: `"[character description], close-up portrait, face and shoulders, neutral solid background, soft studio lighting, photorealistic"`, aspect ratio `1:1`
212
212
 
213
213
  **Step 2 — Call `create_visual_dna`** with:
@@ -250,3 +250,13 @@ Tools: `list_visual_dna_folders`, `create_visual_dna_folder` (`name`, optional h
250
250
  - When the user is about to create a **character** DNA, proactively OFFER it: "want me to generate a character sheet first? It makes the character far more consistent and costs a few credits." Run it only on a yes.
251
251
  - Flow: `generate_character_sheet {image_urls}` → show the sheet → `create_visual_dna {name, images, character_sheet_url: <url>}`.
252
252
  - For non-character DNAs (style/product/environment), skip it.
253
+
254
+ ### ⚠️ Aspect ratio — character sheets and bibles are LANDSCAPE
255
+
256
+ Default every character sheet, turnaround, and character/production **bible** sheet to **`3:2` or `16:9`** unless the user asks for something else.
257
+
258
+ These are multi-panel grids laid out side by side — front, back, left, right, plus detail callouts. A square or portrait frame forces the panels to stack, which shrinks each one and costs the engine the very facial and body detail the sheet exists to capture. Landscape gives each panel usable width.
259
+
260
+ - Character sheet / turnaround / bible sheet → `3:2` or `16:9`
261
+ - Close-up portrait reference → `1:1` (a single panel, so the grid logic doesn't apply)
262
+ - Only deviate when the user explicitly names a different ratio.
package/src/apps/index.js CHANGED
@@ -447,6 +447,10 @@ const TOOL_WIDGETS = {
447
447
  generate_speech: UI.generation,
448
448
  generate_sound: UI.generation,
449
449
  generate_3d: UI.generation,
450
+ // Declared explicitly so Apps hosts prepare the card from tools/list rather
451
+ // than inferring it from the `generate_*` name; result-level _meta alone is
452
+ // not enough for hosts that read the declaration (see the note below).
453
+ generate_character_sheet: UI.generation,
450
454
  edit_image: UI.generation,
451
455
  edit_video: UI.generation,
452
456
  // transcript viewer
package/src/apps/theme.js CHANGED
@@ -85,6 +85,15 @@ body {
85
85
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
86
86
  .k-prompt.k-clamped, .k-caption.k-clamped { cursor: pointer; }
87
87
  .k-prompt.expanded { -webkit-line-clamp: unset; }
88
+ /* @VisualDNA / #Moodboard mentions are load-bearing prompt syntax, not prose —
89
+ the server resolves them to the actual asset. Mark them so a glance at the
90
+ prompt shows which references it pulls in. */
91
+ .k-mention {
92
+ display: inline; padding: 1px 5px; border-radius: 5px;
93
+ background: var(--brand-soft); color: var(--brand);
94
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
95
+ font-size: 0.94em; font-weight: 500;
96
+ }
88
97
  /* Single-line media caption (scene / batch prompt under the viewer) */
89
98
  .k-caption { font-size: 11px; color: var(--text-faint); margin: 2px 2px 0;
90
99
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -127,7 +127,7 @@ function boot(sc) {
127
127
  if (isListPayload(sc, sc.tool)) return renderList(sc);
128
128
  state = sc;
129
129
  el('tool-title').textContent = TOOL_TITLES[sc.tool] || 'Generation';
130
- el('prompt').textContent = sc.prompt || '';
130
+ el('prompt').innerHTML = promptHTML(sc.prompt);
131
131
  el('prompt').style.display = sc.prompt ? '' : 'none';
132
132
  makeExpandable(el('prompt'));
133
133
  renderChips(sc);
@@ -144,6 +144,22 @@ function boot(sc) {
144
144
  function modelLabel(sc) { return sc.model_name || sc.model; }
145
145
  function voiceLabel(sc) { return sc.voice_name || sc.voice || (sc.settings || {}).voice; }
146
146
 
147
+ // @VisualDNA / #Moodboard mentions are the tag syntax the server resolves into
148
+ // real reference assets — rendering them as flat prose hid the single most
149
+ // consequential part of the prompt. Escape FIRST, then wrap: the pattern only
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;
152
+ // #ff8800 / #fff are hex colors, and prompts are full of them. A moodboard tag
153
+ // that happens to be 3 or 6 hex letters loses this coin flip; a grading note
154
+ // mistaken for a moodboard is the worse read.
155
+ var HEX_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
156
+ function promptHTML(text) {
157
+ return esc(text || '').replace(MENTION_RE, function (m, pre, tag) {
158
+ if (HEX_RE.test(tag)) return m;
159
+ return pre + '<span class="k-mention">' + tag + '</span>';
160
+ });
161
+ }
162
+
147
163
  function renderChips(sc) {
148
164
  var h = modelChipHTML(modelLabel(sc), sc.model_icon);
149
165
  var s = sc.settings || {};
@@ -154,9 +170,17 @@ function renderChips(sc) {
154
170
  if (s.quality) h += chip(esc(s.quality) + ' quality');
155
171
  if (s.enhance_prompt) h += chip(ICONS.sparkle + ' enhanced');
156
172
  if (s.web_search) h += chip('web search');
157
- if (s.visual_dna) h += chip(s.visual_dna + ' Visual DNA');
158
- if (s.moodboard) h += chip('moodboard');
159
- if (s.preset) h += chip('preset');
173
+ // Ids where we have them (title = the id, so it can be copied / reused),
174
+ // falling back to the old count/boolean shape for payloads generated before
175
+ // 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');
179
+ var mbIds = s.moodboard_ids || (s.moodboard_id ? [s.moodboard_id] : []);
180
+ if (mbIds.length) h += chipT(mbIds.length > 1 ? mbIds.length + ' moodboards' : 'moodboard', mbIds.join('\\n'));
181
+ else if (s.moodboard) h += chip('moodboard');
182
+ if (s.preset_id) h += chipT('preset', s.preset_id);
183
+ else if (s.preset) h += chip('preset');
160
184
  if (s.cinematic) h += chip('cinematic');
161
185
  if (s.audio) h += chip(ICONS.sound + ' audio');
162
186
  var voice = voiceLabel(sc);
@@ -176,6 +200,11 @@ function renderChips(sc) {
176
200
  el('chips').innerHTML = h;
177
201
  }
178
202
  function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
203
+ // Same chip with a hover title — used to surface the asset id behind a
204
+ // "2 Visual DNA" / "preset" label without spending chip width on it.
205
+ function chipT(inner, title) {
206
+ return '<span class="k-chip" title="' + esc(title) + '">' + inner + '</span>';
207
+ }
179
208
  function iconFor(kind) {
180
209
  switch (kind) {
181
210
  case 'image': return ICONS.image;
@@ -841,8 +870,8 @@ function bootPre(toolName, args) {
841
870
  }
842
871
  el('tool-title').textContent = TOOL_TITLES[toolName] || 'Generation';
843
872
  if (args && (args.prompt || args.text || (Array.isArray(args.prompts) && args.prompts.length))) {
844
- el('prompt').textContent = args.prompt || args.text ||
845
- (args.prompts.length + ' prompts — ' + args.prompts.join(' · '));
873
+ el('prompt').innerHTML = promptHTML(args.prompt || args.text ||
874
+ (args.prompts.length + ' prompts — ' + args.prompts.join(' · ')));
846
875
  el('prompt').style.display = '';
847
876
  makeExpandable(el('prompt'));
848
877
  }
@@ -101,16 +101,35 @@ async function pollBatch(client, batch, { interval, timeout }) {
101
101
  // only values that were really supplied ever surface. This used to be
102
102
  // `{ resolution, aspect_ratio }` only — `quality` (and every knob below it) was
103
103
  // silently dropped, so three calls at low/medium/high rendered identical cards.
104
+ // `visual_dna`/`moodboard`/`preset` used to collapse to a count and two
105
+ // booleans, which told nobody WHICH asset was applied — not the user reading
106
+ // the card, and not the agent reading the tool result, so a follow-up call
107
+ // could not reuse the same DNA or preset without re-listing. Carry the ids.
108
+ const refSettings = (a = {}) => ({
109
+ enhance_prompt: a.enhance_prompt || undefined,
110
+ web_search: a.enable_web_search || undefined,
111
+ visual_dna_ids: (a.visual_dna_ids && a.visual_dna_ids.length) ? a.visual_dna_ids : undefined,
112
+ moodboard_id: a.moodboard_id || undefined,
113
+ moodboard_ids: (a.moodboard_ids && a.moodboard_ids.length) ? a.moodboard_ids : undefined,
114
+ preset_id: a.preset_id || undefined,
115
+ cinematic: a.cinematic ? true : undefined,
116
+ });
117
+
104
118
  const imageSettings = (a = {}) => ({
105
119
  resolution: a.resolution,
106
120
  aspect_ratio: a.aspect_ratio,
107
121
  quality: a.quality,
108
- enhance_prompt: a.enhance_prompt || undefined,
109
- web_search: a.enable_web_search || undefined,
110
- visual_dna: (a.visual_dna_ids && a.visual_dna_ids.length) || undefined,
111
- moodboard: a.moodboard_id ? true : undefined,
112
- preset: a.preset_id ? true : undefined,
113
- cinematic: a.cinematic ? true : undefined,
122
+ ...refSettings(a),
123
+ });
124
+
125
+ // Same block for the video tools, which carried only { duration, resolution,
126
+ // aspect_ratio } — a DNA-anchored video card showed no sign a DNA was in play.
127
+ const videoSettings = (a = {}) => ({
128
+ duration: a.duration,
129
+ resolution: a.resolution,
130
+ aspect_ratio: a.aspect_ratio,
131
+ ...(a.mode ? { mode: a.mode } : {}),
132
+ ...refSettings(a),
114
133
  });
115
134
 
116
135
  const promptsField = (what) => z.array(z.string()).max(MAX_BATCH_PROMPTS).optional().describe(
@@ -220,9 +239,11 @@ function registerGenerateTools(server, client, options = {}) {
220
239
  'generate_image_edit',
221
240
  'THE tool for ANY prompt-driven / content edit of an existing image — changing the scene ("make it night", "change the sky to sunset"), adding/removing/replacing objects, restyling, recoloring, compositing, or any "edit this image to…" request. This is the image-editing equivalent of generate_image and runs on strong dedicated editing models (nano-banana-2, gpt-image-2). Provide the source image URL(s) in `source_images` and the instruction in `prompt`. Supports Visual DNA profiles, moodboards, and Kolbo image-editing presets. PRESET CONTRACT: if the user requests a preset, call list_presets type="image_edit" and pass its exact id as `preset_id`; never silently omit it. Do NOT use `edit_image` for these — that tool is only for mechanical enhancements (upscale/reframe/remove-background/skin). For a brand-new image from scratch, use generate_image. Returns the edited image URL(s) when complete.',
222
241
  {
223
- prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
242
+ prompt: z.string().optional().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset"). Required unless `prompts` is provided.'),
243
+ prompts: promptsField('edits of the SAME source images'),
224
244
  model: z.string().optional().describe('Model identifier — REQUIRED in practice: pick a specific model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Many text-to-image ids double as editors: the server auto-routes a base id to its editing variant when source_images is present (e.g. "gpt-image-2" → gpt-image-2/edit, "nano-banana-2" → nano-banana-2-image-editing) — passing the bare id is fine, no need to hunt for the "/edit" suffix yourself. BUT this only works for models that actually have a registered edit variant. For prompt-driven photoreal photo edits (object removal, keep-this-person/remove-the-rest, crowd cleanup, inpainting) the ONLY auto-pick defaults are "nano-banana-2" or "gpt-image-2" (use GPT Image 2 when the image needs readable text). Do NOT auto-pick Flux 2 / flux-2/edit / Flux Klein — those are generate-from-scratch / style models; use them only if the user names Flux. If unsure, confirm the model appears in `list_models type="image_editing"` and choose by the strengths summary — Flux edit variants are named-only.'),
225
245
  source_images: z.array(z.string()).describe('PIXEL-ACCURATE compositing. Array of source image URLs whose pixel content is composited into the output. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** Three modes the model auto-detects from input shape: (1) Single image → edit/transform that image. (2) Multiple images, one base + others → composite the others into the base. (3) Multiple images with no clear base → generate a new scene that pixel-accurately embeds the supplied images at positions described in the prompt. Mode 3 is the canonical pattern for thumbnails / branded compositions where exact-pixel logo + face fidelity matter. Refer to source images in the prompt by ordinal position ("FIRST source image", "SECOND source image") or use @image1/@image2 tags. Add "composite AS-IS, do not redraw or restyle" to lock pixels.'),
246
+ reference_images: z.array(z.string()).optional().describe('STYLE/COMPOSITION inspiration, alongside `source_images` on the same call — does NOT embed reference pixels. Use when the edit should follow a look sampled from other images ("re-light this shot like these references"). The pixels that must survive the edit go in `source_images`; these only steer the look. **Cap: `source_images` + `reference_images` together must not exceed `max_reference_images` from list_models for the chosen model.**'),
226
247
  aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
227
248
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
228
249
  num_images: z.number().optional().describe('Number of output images. Default: 1'),
@@ -230,23 +251,45 @@ function registerGenerateTools(server, client, options = {}) {
230
251
  moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
231
252
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
232
253
  resolution: z.string().optional().describe('Image resolution tier: "1K" / "2K" / "3K" / "4K". Model-dependent — call list_models and read supported_resolutions. Default: "1K" for most edit models.'),
254
+ quality: z.string().optional().describe('Quality tier for edit models that support it (e.g. "low", "medium", "high", "auto"). Check list_models → supported_qualities on the chosen model. "auto" is normalised to "medium" on gpt-image-2. Omit to use the model default.'),
233
255
  preset_id: z.string().optional().describe('Exact preset ID from list_presets type="image_edit" to apply an image-editing preset. If the user requests a preset, resolve and pass it; do not silently omit it.'),
234
256
  cinematic: CINEMATIC_SCHEMA,
235
257
  skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one edit ungraded.'),
236
258
  project_id: projectIdField,
237
259
  session_id: sessionIdField
238
260
  },
239
- async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id, cinematic, skip_color_palette, project_id, session_id }) => {
261
+ async ({ prompt, prompts, model, source_images, reference_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id }) => {
262
+ if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
240
263
  model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
241
- const gen = await client.post('/v1/generate/image-edit', {
242
- prompt, model, source_images, aspect_ratio, enhance_prompt, num_images,
243
- visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id, cinematic, skip_color_palette, project_id, session_id
244
- });
264
+ const shared = {
265
+ model, source_images, reference_images, aspect_ratio, enhance_prompt,
266
+ visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
267
+ };
268
+ const settings = imageSettings(shared);
269
+
270
+ // Batch mode: N different edit instructions against the SAME source
271
+ // images, one widget owning every generation id. Same contract as
272
+ // generate_image — without it, "give me 4 variations of this edit" came
273
+ // back as four stacked cards.
274
+ if (prompts && prompts.length) {
275
+ const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/image-edit', { ...shared, prompt: p }));
276
+ if (ui()) return uiGenerating({
277
+ tool: 'generate_image_edit', kind: 'image', gen: batch.ok[0].gen, client, model,
278
+ count: batch.ids.length, settings,
279
+ generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
280
+ failed_submissions: batch.failed,
281
+ status_args: { generation_ids: batch.ids, wait: true },
282
+ reference_images: source_images
283
+ });
284
+ return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 240000 });
285
+ }
286
+
287
+ const gen = await client.post('/v1/generate/image-edit', { ...shared, prompt, num_images });
245
288
 
246
289
  if (ui()) return uiGenerating({
247
290
  tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
248
291
  count: num_images,
249
- settings: imageSettings({ resolution, aspect_ratio, enhance_prompt, enable_web_search, visual_dna_ids, moodboard_id, preset_id, cinematic }),
292
+ settings,
250
293
  reference_images: source_images
251
294
  });
252
295
 
@@ -308,7 +351,7 @@ function registerGenerateTools(server, client, options = {}) {
308
351
  if (ui()) return uiGenerating({
309
352
  tool: 'generate_creative_director', kind: 'scenes', gen, client, model, prompt,
310
353
  count: scene_count || 4,
311
- settings: { duration, resolution, aspect_ratio, mode: workflow_type || 'image' },
354
+ settings: videoSettings({ duration, resolution, aspect_ratio, mode: workflow_type || 'image', enhance_prompt, visual_dna_ids, moodboard_id, moodboard_ids }),
312
355
  reference_images,
313
356
  poll_tool: 'get_creative_director_status',
314
357
  status_args: { generation_id: gen.generation_id, wait: true }
@@ -462,7 +505,7 @@ function registerGenerateTools(server, client, options = {}) {
462
505
  const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/video', { ...shared, prompt: p }));
463
506
  if (ui()) return uiGenerating({
464
507
  tool: 'generate_video', kind: 'video', gen: batch.ok[0].gen, client, model,
465
- count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
508
+ count: batch.ids.length, settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
466
509
  generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
467
510
  failed_submissions: batch.failed,
468
511
  status_args: { generation_ids: batch.ids, wait: true },
@@ -475,7 +518,7 @@ function registerGenerateTools(server, client, options = {}) {
475
518
 
476
519
  if (ui()) return uiGenerating({
477
520
  tool: 'generate_video', kind: 'video', gen, client, model, prompt,
478
- settings: { duration, resolution, aspect_ratio },
521
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
479
522
  reference_images
480
523
  });
481
524
 
@@ -546,7 +589,7 @@ function registerGenerateTools(server, client, options = {}) {
546
589
  const batch = await submitBatch(items, (it) => client.post('/v1/generate/video/from-image', { ...shared, image_url: it.image_url, prompt: it.prompt }));
547
590
  if (ui()) return uiGenerating({
548
591
  tool: 'generate_video_from_image', kind: 'video', gen: batch.ok[0].gen, client, model,
549
- count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
592
+ count: batch.ids.length, settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
550
593
  generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
551
594
  failed_submissions: batch.failed,
552
595
  status_args: { generation_ids: batch.ids, wait: true },
@@ -559,7 +602,7 @@ function registerGenerateTools(server, client, options = {}) {
559
602
 
560
603
  if (ui()) return uiGenerating({
561
604
  tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
562
- settings: { duration, resolution, aspect_ratio },
605
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
563
606
  reference_images: [image_url]
564
607
  });
565
608
 
@@ -1050,7 +1093,7 @@ function registerGenerateTools(server, client, options = {}) {
1050
1093
 
1051
1094
  if (ui()) return uiGenerating({
1052
1095
  tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
1053
- settings: { duration, resolution, aspect_ratio },
1096
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id }),
1054
1097
  reference_images: [
1055
1098
  ...(reference_images || []),
1056
1099
  ...(keyframes || []).map((keyframe) => keyframe.image_url),
@@ -1138,7 +1181,7 @@ function registerGenerateTools(server, client, options = {}) {
1138
1181
 
1139
1182
  if (ui()) return uiGenerating({
1140
1183
  tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
1141
- settings: { duration, resolution, aspect_ratio },
1184
+ settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
1142
1185
  reference_images: [first_frame_url || first_frame, last_frame_url || last_frame]
1143
1186
  .filter((source) => /^https?:\/\//i.test(source || ''))
1144
1187
  });
@@ -1359,7 +1402,7 @@ function registerGenerateTools(server, client, options = {}) {
1359
1402
  if (ui()) return uiGenerating({
1360
1403
  tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
1361
1404
  prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
1362
- settings: { duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle' },
1405
+ settings: videoSettings({ duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle', enhance_prompt, visual_dna_ids }),
1363
1406
  reference_images: [...(reference_images || []), ...(elements || [])]
1364
1407
  });
1365
1408
 
@@ -8,6 +8,10 @@ const FormData = require('form-data');
8
8
  const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField, compactList } = require('./_shared');
9
9
  const { UI, uiResult, listResult, appsEnabled } = require('../apps');
10
10
 
11
+ // Reference sheets are a blocking multi-panel render; the 120s client default
12
+ // aborted them mid-flight while the server finished and charged anyway.
13
+ const CHARACTER_SHEET_TIMEOUT_MS = Number(process.env.KOLBO_CHARACTER_SHEET_TIMEOUT_MS) || 600000;
14
+
11
15
  // Visual DNA caps reference media at 25MB per file (stricter than the
12
16
  // default _shared.resolveToBuffer cap — DNA profiles only need enough
13
17
  // source signal to extract features, not full-quality media).
@@ -200,12 +204,33 @@ function registerVisualDnaTools(server, client, options = {}) {
200
204
  sheet_type: z.enum(['character', 'character_headless', 'character_bible', 'product', 'environment', 'style']).optional().describe('Sheet layout. character = front/back/face turnaround. character_headless = wardrobe/body refs with a headless front panel (use when clothing must change without fighting the face sheet). character_bible = denser production model-sheet (turnaround + faces + wardrobe + color swatches). product / environment / style = matching DNA types. Defaults to character.')
201
205
  },
202
206
  async ({ image_urls, sheet_type }) => {
203
- const result = await client.post('/v1/visual-dna/character-sheet', { image_urls, ...(sheet_type ? { sheet_type } : {}) });
207
+ // The endpoint is blocking and a 2K multi-panel sheet routinely runs past the
208
+ // 120s default: the MCP aborted while kolbo-api kept going, finished, and
209
+ // billed — the user saw "Failed" for a sheet they had already paid for.
210
+ const result = await client.post(
211
+ '/v1/visual-dna/character-sheet',
212
+ { image_urls, ...(sheet_type ? { sheet_type } : {}) },
213
+ { timeoutMs: CHARACTER_SHEET_TIMEOUT_MS },
214
+ );
215
+ // `urls` is NOT redundant with character_sheet_url. Every generation-card
216
+ // reader keys on `urls` — the MCP's own widget (apps/widgets/generation.js),
217
+ // kolbo-code's kolbo-operation.ts mediaUrls(), and its operation.js urlsOf().
218
+ // Kolbo Code mounts a generation card for ANY tool named `generate_*`, so
219
+ // returning only character_sheet_url gave the card zero URLs and it rendered
220
+ // "No output received / Failed" on top of a sheet that generated fine and was
221
+ // already billed — and "Try Again" then double-charged. `widget`/`phase` mark
222
+ // the payload as a completed generation so the card stops falling back to the
223
+ // stale `phase: "review"` envelope built before the tool ran.
224
+ const urls = result.character_sheet_url ? [result.character_sheet_url] : [];
204
225
  return {
205
226
  content: [{
206
227
  type: 'text',
207
228
  text: JSON.stringify({
208
229
  character_sheet_url: result.character_sheet_url,
230
+ urls,
231
+ widget: 'generation',
232
+ phase: 'completed',
233
+ kind: 'image',
209
234
  credits_used: result.credits_used,
210
235
  _hint: 'Show the sheet to the user, then pass character_sheet_url to create_visual_dna as that DNA\'s reference.'
211
236
  }, null, 2)