@kolbo/mcp 1.72.2 → 1.72.4
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 +1 -1
- package/skill/GENERATED.md +1 -1
- package/skill/references/workflows/production-log.md +46 -1
- package/skill/references/workflows/visual-dna.md +11 -1
- package/src/apps/index.js +4 -0
- package/src/apps/theme.js +9 -0
- package/src/apps/widgets/generation.js +37 -7
- package/src/tools/generate.js +75 -21
- package/src/tools/media.js +49 -45
- package/src/tools/moodboards.js +21 -21
- package/src/tools/visual_dna.js +46 -21
package/package.json
CHANGED
package/skill/GENERATED.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AUTO-GENERATED — do not edit
|
|
2
2
|
|
|
3
|
-
This tree is mirrored from kolbo-code@
|
|
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
|
|
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').
|
|
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,19 +144,44 @@ 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 || {};
|
|
150
166
|
if (sc.kind) h += chip(iconFor(sc.kind) + ' ' + sc.kind);
|
|
151
|
-
if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration));
|
|
167
|
+
if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration) + (s.shots > 1 ? ' · ' + s.shots + ' shots' : ''));
|
|
168
|
+
else if (s.shots > 1) h += chip(s.shots + ' shots');
|
|
152
169
|
if (s.resolution) h += chip(esc(s.resolution));
|
|
153
170
|
if (s.aspect_ratio) h += chip(esc(s.aspect_ratio));
|
|
154
171
|
if (s.quality) h += chip(esc(s.quality) + ' quality');
|
|
155
172
|
if (s.enhance_prompt) h += chip(ICONS.sparkle + ' enhanced');
|
|
156
173
|
if (s.web_search) h += chip('web search');
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
174
|
+
// Ids where we have them (title = the id, so it can be copied / reused),
|
|
175
|
+
// falling back to the old count/boolean shape for payloads generated before
|
|
176
|
+
// 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');
|
|
180
|
+
var mbIds = s.moodboard_ids || (s.moodboard_id ? [s.moodboard_id] : []);
|
|
181
|
+
if (mbIds.length) h += chipT(mbIds.length > 1 ? mbIds.length + ' moodboards' : 'moodboard', mbIds.join('\\n'));
|
|
182
|
+
else if (s.moodboard) h += chip('moodboard');
|
|
183
|
+
if (s.preset_id) h += chipT('preset', s.preset_id);
|
|
184
|
+
else if (s.preset) h += chip('preset');
|
|
160
185
|
if (s.cinematic) h += chip('cinematic');
|
|
161
186
|
if (s.audio) h += chip(ICONS.sound + ' audio');
|
|
162
187
|
var voice = voiceLabel(sc);
|
|
@@ -176,6 +201,11 @@ function renderChips(sc) {
|
|
|
176
201
|
el('chips').innerHTML = h;
|
|
177
202
|
}
|
|
178
203
|
function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
|
|
204
|
+
// Same chip with a hover title — used to surface the asset id behind a
|
|
205
|
+
// "2 Visual DNA" / "preset" label without spending chip width on it.
|
|
206
|
+
function chipT(inner, title) {
|
|
207
|
+
return '<span class="k-chip" title="' + esc(title) + '">' + inner + '</span>';
|
|
208
|
+
}
|
|
179
209
|
function iconFor(kind) {
|
|
180
210
|
switch (kind) {
|
|
181
211
|
case 'image': return ICONS.image;
|
|
@@ -841,8 +871,8 @@ function bootPre(toolName, args) {
|
|
|
841
871
|
}
|
|
842
872
|
el('tool-title').textContent = TOOL_TITLES[toolName] || 'Generation';
|
|
843
873
|
if (args && (args.prompt || args.text || (Array.isArray(args.prompts) && args.prompts.length))) {
|
|
844
|
-
el('prompt').
|
|
845
|
-
(args.prompts.length + ' prompts — ' + args.prompts.join(' · '));
|
|
874
|
+
el('prompt').innerHTML = promptHTML(args.prompt || args.text ||
|
|
875
|
+
(args.prompts.length + ' prompts — ' + args.prompts.join(' · ')));
|
|
846
876
|
el('prompt').style.display = '';
|
|
847
877
|
makeExpandable(el('prompt'));
|
|
848
878
|
}
|
package/src/tools/generate.js
CHANGED
|
@@ -101,16 +101,41 @@ 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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
// 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 } : {}),
|
|
135
|
+
resolution: a.resolution,
|
|
136
|
+
aspect_ratio: a.aspect_ratio,
|
|
137
|
+
...(a.mode ? { mode: a.mode } : {}),
|
|
138
|
+
...refSettings(a),
|
|
114
139
|
});
|
|
115
140
|
|
|
116
141
|
const promptsField = (what) => z.array(z.string()).max(MAX_BATCH_PROMPTS).optional().describe(
|
|
@@ -220,9 +245,11 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
220
245
|
'generate_image_edit',
|
|
221
246
|
'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
247
|
{
|
|
223
|
-
prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
|
|
248
|
+
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.'),
|
|
249
|
+
prompts: promptsField('edits of the SAME source images'),
|
|
224
250
|
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
251
|
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.'),
|
|
252
|
+
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
253
|
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
254
|
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
255
|
num_images: z.number().optional().describe('Number of output images. Default: 1'),
|
|
@@ -230,23 +257,45 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
230
257
|
moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
|
|
231
258
|
enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
|
|
232
259
|
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.'),
|
|
260
|
+
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
261
|
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
262
|
cinematic: CINEMATIC_SCHEMA,
|
|
235
263
|
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
264
|
project_id: projectIdField,
|
|
237
265
|
session_id: sessionIdField
|
|
238
266
|
},
|
|
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 }) => {
|
|
267
|
+
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 }) => {
|
|
268
|
+
if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
|
|
240
269
|
model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id, cinematic, skip_color_palette, project_id, session_id
|
|
244
|
-
}
|
|
270
|
+
const shared = {
|
|
271
|
+
model, source_images, reference_images, aspect_ratio, enhance_prompt,
|
|
272
|
+
visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
|
|
273
|
+
};
|
|
274
|
+
const settings = imageSettings(shared);
|
|
275
|
+
|
|
276
|
+
// Batch mode: N different edit instructions against the SAME source
|
|
277
|
+
// images, one widget owning every generation id. Same contract as
|
|
278
|
+
// generate_image — without it, "give me 4 variations of this edit" came
|
|
279
|
+
// back as four stacked cards.
|
|
280
|
+
if (prompts && prompts.length) {
|
|
281
|
+
const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/image-edit', { ...shared, prompt: p }));
|
|
282
|
+
if (ui()) return uiGenerating({
|
|
283
|
+
tool: 'generate_image_edit', kind: 'image', gen: batch.ok[0].gen, client, model,
|
|
284
|
+
count: batch.ids.length, settings,
|
|
285
|
+
generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
|
|
286
|
+
failed_submissions: batch.failed,
|
|
287
|
+
status_args: { generation_ids: batch.ids, wait: true },
|
|
288
|
+
reference_images: source_images
|
|
289
|
+
});
|
|
290
|
+
return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 240000 });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const gen = await client.post('/v1/generate/image-edit', { ...shared, prompt, num_images });
|
|
245
294
|
|
|
246
295
|
if (ui()) return uiGenerating({
|
|
247
296
|
tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
|
|
248
297
|
count: num_images,
|
|
249
|
-
settings
|
|
298
|
+
settings,
|
|
250
299
|
reference_images: source_images
|
|
251
300
|
});
|
|
252
301
|
|
|
@@ -308,7 +357,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
308
357
|
if (ui()) return uiGenerating({
|
|
309
358
|
tool: 'generate_creative_director', kind: 'scenes', gen, client, model, prompt,
|
|
310
359
|
count: scene_count || 4,
|
|
311
|
-
settings: { duration, resolution, aspect_ratio, mode: workflow_type || 'image' },
|
|
360
|
+
settings: videoSettings({ duration, resolution, aspect_ratio, mode: workflow_type || 'image', enhance_prompt, visual_dna_ids, moodboard_id, moodboard_ids }),
|
|
312
361
|
reference_images,
|
|
313
362
|
poll_tool: 'get_creative_director_status',
|
|
314
363
|
status_args: { generation_id: gen.generation_id, wait: true }
|
|
@@ -462,7 +511,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
462
511
|
const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/video', { ...shared, prompt: p }));
|
|
463
512
|
if (ui()) return uiGenerating({
|
|
464
513
|
tool: 'generate_video', kind: 'video', gen: batch.ok[0].gen, client, model,
|
|
465
|
-
count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
|
|
514
|
+
count: batch.ids.length, settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
|
|
466
515
|
generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
|
|
467
516
|
failed_submissions: batch.failed,
|
|
468
517
|
status_args: { generation_ids: batch.ids, wait: true },
|
|
@@ -475,7 +524,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
475
524
|
|
|
476
525
|
if (ui()) return uiGenerating({
|
|
477
526
|
tool: 'generate_video', kind: 'video', gen, client, model, prompt,
|
|
478
|
-
settings: { duration, resolution, aspect_ratio },
|
|
527
|
+
settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
|
|
479
528
|
reference_images
|
|
480
529
|
});
|
|
481
530
|
|
|
@@ -546,7 +595,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
546
595
|
const batch = await submitBatch(items, (it) => client.post('/v1/generate/video/from-image', { ...shared, image_url: it.image_url, prompt: it.prompt }));
|
|
547
596
|
if (ui()) return uiGenerating({
|
|
548
597
|
tool: 'generate_video_from_image', kind: 'video', gen: batch.ok[0].gen, client, model,
|
|
549
|
-
count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
|
|
598
|
+
count: batch.ids.length, settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
|
|
550
599
|
generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
|
|
551
600
|
failed_submissions: batch.failed,
|
|
552
601
|
status_args: { generation_ids: batch.ids, wait: true },
|
|
@@ -559,7 +608,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
559
608
|
|
|
560
609
|
if (ui()) return uiGenerating({
|
|
561
610
|
tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
|
|
562
|
-
settings: { duration, resolution, aspect_ratio },
|
|
611
|
+
settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
|
|
563
612
|
reference_images: [image_url]
|
|
564
613
|
});
|
|
565
614
|
|
|
@@ -1050,7 +1099,12 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1050
1099
|
|
|
1051
1100
|
if (ui()) return uiGenerating({
|
|
1052
1101
|
tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
|
|
1053
|
-
settings: {
|
|
1102
|
+
settings: videoSettings({
|
|
1103
|
+
duration,
|
|
1104
|
+
reported_duration: startResponse?.duration ?? startResponse?.result?.duration,
|
|
1105
|
+
shots: multi_shot_count ?? (Array.isArray(multi_shots) ? multi_shots.length : undefined),
|
|
1106
|
+
resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id,
|
|
1107
|
+
}),
|
|
1054
1108
|
reference_images: [
|
|
1055
1109
|
...(reference_images || []),
|
|
1056
1110
|
...(keyframes || []).map((keyframe) => keyframe.image_url),
|
|
@@ -1138,7 +1192,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1138
1192
|
|
|
1139
1193
|
if (ui()) return uiGenerating({
|
|
1140
1194
|
tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
|
|
1141
|
-
settings: { duration, resolution, aspect_ratio },
|
|
1195
|
+
settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
|
|
1142
1196
|
reference_images: [first_frame_url || first_frame, last_frame_url || last_frame]
|
|
1143
1197
|
.filter((source) => /^https?:\/\//i.test(source || ''))
|
|
1144
1198
|
});
|
|
@@ -1359,7 +1413,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1359
1413
|
if (ui()) return uiGenerating({
|
|
1360
1414
|
tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
|
|
1361
1415
|
prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
|
|
1362
|
-
settings: { duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle' },
|
|
1416
|
+
settings: videoSettings({ duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle', enhance_prompt, visual_dna_ids }),
|
|
1363
1417
|
reference_images: [...(reference_images || []), ...(elements || [])]
|
|
1364
1418
|
});
|
|
1365
1419
|
|
package/src/tools/media.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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,30 +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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
+
});
|
|
290
294
|
|
|
291
295
|
return {
|
|
292
296
|
content: [{
|
package/src/tools/moodboards.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
package/src/tools/visual_dna.js
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
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
|
|
9
|
+
const { UI, uiResult, listResult } = require('../apps');
|
|
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;
|
|
10
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
|
|
@@ -16,7 +20,6 @@ function resolveToBuffer(source, kind) {
|
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
function registerVisualDnaTools(server, client, options = {}) {
|
|
19
|
-
const ui = () => appsEnabled(server, options);
|
|
20
23
|
// ─── create_visual_dna ─────────────────────────────────────
|
|
21
24
|
server.tool(
|
|
22
25
|
'create_visual_dna',
|
|
@@ -129,24 +132,25 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
129
132
|
note: 'Narrow with `search`, `tags`, or `collection`, or pass `page`/`limit` for the rest; get_visual_dna returns one in full.',
|
|
130
133
|
});
|
|
131
134
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
});
|
|
150
154
|
}
|
|
151
155
|
);
|
|
152
156
|
|
|
@@ -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
|
-
|
|
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)
|