@kolbo/mcp 1.69.0 → 1.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -236,7 +236,12 @@ Every generation tool also accepts an optional `project_id` arg that routes the
236
236
  | `list_presets` | Generation presets across image/image-edit/video/music/text-to-video catalogs. Pass the selected exact id as `preset_id`; never claim a preset was applied without it. |
237
237
  | `list_cinematic_presets` | "Cinema mode" presets grouped by dimension (camera, lens, focal_length, aperture, angle, shot_type, color_palette, lighting) — pass ids via the `cinematic` arg on `generate_image` / `generate_image_edit`. Only when the user wants a specific cinematic look |
238
238
  | `list_projects` | List owned + shared projects (id, name, role, is_default) — call first to resolve a project name into the `project_id` you pass to generation tools |
239
- | `move_session` | Move a session (generation, chat, transcription…) and ALL its media to another project |
239
+ | `move_session` | Move ONE session (generation, chat, transcription…) and ALL its generations + media to another project |
240
+ | `bulk_move_sessions` | Move up to 100 sessions into one project in a single call — mixed types allowed, per-session failures reported |
241
+ | `list_session_generations` | A session's generations as complete groups (prompt + all its outputs) — the ids the two organize tools below take |
242
+ | `move_generations_to_session` | Move selected generations (and only THEIR output media) into another existing session |
243
+ | `split_session` | Carve selected generations out into a brand-new named session, atomically |
244
+ | `undo_session_organization` | Reverse a move/split within 15 minutes, using the `operation_id` it returned |
240
245
  | `create_doc` / `list_docs` / `get_doc` / `update_doc` / `share_doc` / `delete_doc` | AI Docs (Magic Pad): author project-scoped HTML documents, edit them, get public share links |
241
246
  | `generate_character_sheet` | Generate a multi-angle character sheet from reference images (credits) → pass URL to create_visual_dna for stronger character consistency |
242
247
  | `list_visual_dna_folders` / `create_visual_dna_folder` / `update_visual_dna_folder` / `delete_visual_dna_folder` / `move_visual_dna_to_folder` | Organize Visual DNA characters into user folders (create/rename/recolor/delete + move DNAs in/out) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.69.0",
3
+ "version": "1.70.1",
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,7 +1,7 @@
1
1
  # AUTO-GENERATED — do not edit
2
2
 
3
3
  This skill/ tree is mirrored from kolbo-code (the single source of truth)
4
- by .github/workflows/sync-skill-to-plugin.yml — synced from kolbo-code@8a08c7e.
4
+ by .github/workflows/sync-skill-to-plugin.yml — synced from kolbo-code@3a7950b.
5
5
 
6
6
  It is the skill that 'npx @kolbo/mcp install' deploys into the user's agent.
7
7
  To change it, edit packages/opencode/skills/kolbo/ in kolbo-code and push;
package/skill/SKILL.md CHANGED
@@ -65,6 +65,7 @@ For multi-scene / batch work this pairs with `generate_creative_director` (see b
65
65
  | If the user wants to… | Read first |
66
66
  |---|---|
67
67
  | Generate a **Seedance 2** video | `references/models/seedance.md` |
68
+ | Generate a **Seedance 2.5** video | `references/models/seedance25.md` (also load `seedance.md` for the locked intro + craft layer) |
68
69
  | Generate a **GPT Image 2** image | `references/models/gpt-image.md` |
69
70
  | Generate a **Nano Banana / Gemini** image | `references/models/nano-banana.md` |
70
71
  | Generate a **Veo 3 / 3.1** video | `references/models/veo.md` |
@@ -99,11 +100,11 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
99
100
  | `generate_video` | Text-to-video. Does **not** support Visual DNA — use `generate_elements` for character-consistent video. |
100
101
  | `generate_video_from_image` | Animate a still. Prompt describes motion, not subject. |
101
102
  | `generate_video_from_video` | Restyle/transform an existing video. Keeps original motion. |
102
- | `generate_elements` | Reference-driven video. **Primary route for DNA → video.** Supports `multi_shots` / `multi_shot_count` (badge/metadata/native flag only — never rewrites the prompt) and short `session_name`. Crafted demos: `enhance_prompt:false` + put shot structure in the prompt. |
103
+ | `generate_elements` | Reference-driven video. **Primary route for DNA → video.** |
103
104
  | `generate_first_last_frame` | Keyframe interpolation between two frames. |
104
105
  | `generate_lipsync` | Lipsync audio to an image or video face. |
105
106
  | `generate_music` | Music generation (Suno + variants). |
106
- | `generate_speech` | TTS. Use `list_voices` to pick a voice. Pass provider controls: Google/Gemini `style_instructions_preset_id` (warm/dramatic/whisper/excited/calm/cheerful/serious/storyteller/sad/intimate/british/commercial) or free-form `style_instructions` + `language` as Accent; DeepDub/MiniMax/Cartesia `selected_style`/`emotion`; `speaking_speed`; ElevenLabs `similarity_boost`/`style`; MiniMax `minimax_pitch`/`minimax_vol`/…. |
107
+ | `generate_speech` | TTS. Use `list_voices` to pick a voice. |
107
108
  | `generate_sound` | Sound effects. |
108
109
  | `generate_3d` | 3D models from text / single image / multi-view. Returns GLB/FBX/OBJ/USDZ. |
109
110
 
@@ -125,7 +126,6 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
125
126
  | `clone_voice` / `import_elevenlabs_voice` / `delete_voice` | Custom voices (clone CHARGES CREDITS — confirm first; new voices show in `list_voices`) |
126
127
  | `trim_video` | Frame-accurate trim of a Kolbo-hosted video (tool waits and returns the URL). `edit_video` also gained `remove_background`. |
127
128
  | `create_doc` / `list_docs` / `get_doc` / `update_doc` / `share_doc` / `delete_doc` | AI Docs (Magic Pad): YOU author full HTML documents (plans, briefs, scripts, research) saved into the user's project, editable in the Kolbo app. `share_doc` returns a public link. `update_doc` content replaces the WHOLE doc — `get_doc` first. |
128
- | `create_review_asset` / `list_review_assets` / `get_review_asset` / `update_review_asset` / `add_review_version` / `set_review_status` / `delete_review_asset` / `get_review_storage_usage` / review collection + comment + share-link tools | **Kolbo Review** (Frame.io-style): upload first via `upload_media` / ticket / widget → pass `media_id` to `create_review_asset`. Comments are text + optional timecodes. `create_review_share_link` returns a guest URL. |
129
129
  | `chat_send_message` / `chat_list_conversations` / `chat_get_messages` | Kolbo chat with optional `media_urls` (up to 10 per call) |
130
130
  | `publish_html_artifact` | Publish HTML / SVG / Mermaid to `sites.kolbo.ai`. Server dedupes by content hash. Strict CSP. |
131
131
 
@@ -166,6 +166,7 @@ A user-named tool — in any language — overrides every other rule. Recognized
166
166
  - User named one → use it. Model identifiers resolve leniently — shorthand like `"z-image"` or `"nano banana 2"` auto-resolves to the exact identifier, so don't over-engineer exact-id lookups (`list_models` is still authoritative for constraints, caps, and pricing).
167
167
  - Auto-select → only from "Auto-selectable" section (models with a `summary`). Cheapest fit. Prefer `[RECOMMENDED]` when cost is similar.
168
168
  - Never auto-select from "Named-only" section.
169
+ - **Photoreal photo edits** (object removal, keep one person / remove the crowd, inpainting, "edit this photo") → `generate_image_edit` with **Nano Banana 2** or **GPT Image 2** only. Do not auto-pick Flux 2 / Flux Klein — those are generate-from-scratch / style, named-only for editing.
169
170
  4. **Validate inputs** against model caps — see `references/workflows/cost-and-validation.md`.
170
171
  5. **How calls work**: each tool blocks until generation is fully complete. Images: seconds. Video: minutes. Multiple tool calls in one response run concurrently. On hosts with live widgets the tool instead returns `submitted` instantly — the card updates on its own; you only need `get_generation_status` when a follow-up step needs the output URLs.
171
172
  6. **Checking status — NEVER poll in a loop**: `get_generation_status` takes `wait=true` (blocks server-side until done, ~3 min) and `generation_ids` (check MANY generations in ONE call — returns `all_done` + which are still running). One `wait=true` call replaces any polling loop. If it comes back with some still processing, call it ONCE more with `wait=true` and the remaining ids.
@@ -177,10 +178,9 @@ Model types for `list_models`: `text_to_img`, `image_editing`, `text_to_video`,
177
178
 
178
179
  Everything in Kolbo — sessions, generations, media, docs — lives inside a PROJECT. Getting this wrong is the #1 user complaint ("my work went to the wrong project").
179
180
 
180
- 1. **User names a project** ("in my Acme project", "for the film") → call `list_projects` ONCE to resolve the name to an ObjectId, then pass that id as `project_id` on **EVERY** subsequent `generate_*` / `upload_media` / `create_doc` / `chat_send_message` call in the conversation. It is **per-call, NOT sticky** — any call that omits it silently lands in the default "API Generations" bucket (`is_default: true`).
181
+ 1. **User names a project** ("in my Acme project", "for the film") → call `list_projects` ONCE to resolve the name to an ObjectId, then pass that id as `project_id` on **EVERY** subsequent `generate_*` / `upload_media` / `create_doc` / `chat_send_message` call in the conversation. It is **per-call, NOT sticky** — any call that omits it silently lands in the default "API Generations" bucket (`is_default: true`). Accounts often hold hundreds of projects, so pass `list_projects({ search: "acme" })` rather than listing everything; the list is paginated (50/page) and hides archived projects unless you pass `include_archived: true`.
181
182
  2. **No project mentioned** → omit `project_id`; the default bucket is correct. Don't ask unless intent is ambiguous.
182
183
  3. **Work landed in the wrong project? MOVE it, never regenerate**: `move_session` relocates a whole session + all its media (works for any session type — the `session_id` from generation responses, chats, transcriptions); `move_media` / `bulk_move_media` / `move_folder_contents` relocate individual media items.
183
- 4. **Inside the project, keep a related set in ONE session.** Every generation response returns a `session_id`. When a set needs several calls — shot 2, 3, 4 of a sequence, or more takes of the same idea — pass the FIRST call's `session_id` back on each follow-up. Without it every call opens its own session and the user's sidebar fills with near-identical single-item sessions. Prefer a real batch where one exists (`prompts` on `generate_image` / `generate_video`, `items` on `generate_video_from_image` — up to 8 `{image_url, prompt}` pairs animated in one call and one widget — `num_images`, `generate_creative_director`); use `session_id` for the tools that animate/edit one thing per call (`generate_elements`, `generate_first_last_frame`, `generate_lipsync`, `generate_video_from_video`, `edit_image`, `edit_video`), and across batches when a sequence runs longer than 8. Start a NEW session (omit it) when the next generation is unrelated.
184
184
 
185
185
 
186
186
  ## Cost Awareness — Quick Rules
@@ -228,7 +228,7 @@ You are NOT allowed to:
228
228
 
229
229
  Existing video → modify → **single `generate_video_from_video` call** with source video URL + edit prompt.
230
230
 
231
- **Use a TRUE video-to-video model.** Image-to-video models reject with `WRONG_MODEL_TYPE`. Valid: `wan/2-7-videoedit`, `happyhorse/video-edit`, `kling-video/o3-video-to-video`, `pika/pikadditions/video-to-video`, `pika/pikaswaps/video-to-video`, `pika/pikaffects/video-to-video`, or any model whose DB `type` includes `video_to_video` (use `list_models({ type: "video_to_video" })`).
231
+ **Use a TRUE video-to-video model.** Image-to-video models reject with `WRONG_MODEL_TYPE`. Valid: `wan/2-7-videoedit`, `happyhorse/video-edit`, `kling-video/o3-video-to-video`, or any model whose DB `type` includes `video_to_video` (use `list_models({ type: "video_to_video" })`).
232
232
 
233
233
  **Motion-control / animate-move models invert the inputs**: `reference_images[0]` = the CHARACTER IMAGE to animate, `source_video` = the driving/reference video whose motion is transferred. Omitting the character image returns a `MOTION_CONTROL_INPUTS` error.
234
234
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  Load this file when the user wants a **GPT Image 2 / gpt-image-2** image (OpenAI). For other image models see `models/nano-banana.md`, `models/creative-director.md`, or `models/prompt-copilot.md`.
8
8
 
9
- **Kolbo MCP routing:** call `generate_image` (text-to-image) or `generate_image_edit` (edits with `source_images`). Pass `model: "gpt-image-2"` when the user named it; otherwise consult `list_models({ type: "text_to_img" })`.
9
+ **Kolbo MCP routing:** call `generate_image` (text-to-image) or `generate_image_edit` (edits with `source_images`). Pass `model: "gpt-image-2"` when the user named it. For photoreal photo edits that need identity lock or readable on-image text, default to `gpt-image-2` via `generate_image_edit` — consult `list_models({ type: "image_editing" })`. Do not auto-pick Flux.
10
10
 
11
11
  ## CRITICAL Kolbo Platform Rules
12
12
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  Load this file when the user wants a **Nano Banana 2 (Gemini 3.1 Flash Image)** or **Nano Banana Pro (Gemini 3 Pro Image)** image. For other image models see `models/gpt-image.md`, `models/creative-director.md`, or `models/prompt-copilot.md`.
8
8
 
9
- **Kolbo MCP routing:** call `generate_image` or `generate_image_edit`. Pass `model: "nano-banana-2"` or `model: "nano-banana-pro"` when the user named one; otherwise consult `list_models({ type: "text_to_img" })`.
9
+ **Kolbo MCP routing:** call `generate_image` or `generate_image_edit`. Pass `model: "nano-banana-2"` or `model: "nano-banana-pro"` when the user named one. For prompt-driven photoreal photo edits (object removal, keep subject / remove others), default to `nano-banana-2` via `generate_image_edit` — consult `list_models({ type: "image_editing" })`. Do not auto-pick Flux.
10
10
 
11
11
  ## CRITICAL Kolbo Platform Rules
12
12
 
@@ -123,6 +123,7 @@ If during the conversation it becomes clear the user is actually working with on
123
123
  | User mentions / asks for | Switch to |
124
124
  |---|---|
125
125
  | Seedance / Seedance 2 / Bytedance video | `models/seedance.md` |
126
+ | Seedance 2.5 | `models/seedance25.md` (also load `models/seedance.md`) |
126
127
  | GPT Image 2 / gpt-image-2 / OpenAI image | `models/gpt-image.md` |
127
128
  | Nano Banana / Gemini image / Gemini 3 Pro Image | `models/nano-banana.md` |
128
129
  | Veo / Veo 3 / Veo 3.1 / Google video | `models/veo.md` |
@@ -9,15 +9,13 @@ Load this file when the user wants a **Seedance 2 / Seedance 2.0** (ByteDance) v
9
9
 
10
10
  **Kolbo MCP routing:** Seedance is a video model — call `generate_video` (text-to-video) or `generate_elements` (when video references / Visual DNA / first-last frames are involved). Run `list_models({ type: "text_to_video" })` and pick a Seedance variant by name.
11
11
 
12
- **Multishot (Elements):** Seedance has **no** native provider Multishot switch. Pass `multi_shots: true` on `generate_elements` (optional `multi_shot_count`) for the UI Multishot badge only — it does **not** rewrite or reshape your prompt. **Always send `enhance_prompt: false` for crafted demos** (MCP default) so the prompt stays RAW. Put shot structure in the prompt yourself (`Multishot ON`, `Total: … / N shots`, numbered shots). Optional `session_name` sets a short sidebar title (skips AI auto-name from boilerplate).
13
-
14
12
  ## Universal Rules (apply to EVERY Seedance prompt)
15
13
 
16
14
  - **First line ALWAYS declares shot structure**: total duration, shot count, aspect ratio. Example: `Total: 15s / 6 shots / 16:9`. Put it at the BOTTOM of the prompt too. For connected narrative sequences the proven phrasing is `N connected cinematic shots, 15 seconds total, 16:9, Multishot ON` — use it and keep `Multishot ON` for any multi-shot story.
17
- - **Order inside each shot**: Subject Action Camera Style Constraints (Audio/SFX if relevant).
18
- - **Prompt length**: aim for ~120–280 words TOTAL across all shots combined (not per shot). Shorter than ~120 words = random output. Longer risks the 8000-char cap below and makes the model forget the opening. For 6-shot prompts, keep each shot 1–2 tight sentences.
19
- - **Character lock**: if a character recurs, open with `same character throughout all shots` to stop identity drift.
20
- - **Max 3 shots per single-shot prompt; max 6 shots in a multi-shot montage.** More causes drift.
15
+ - **Then the Locked Intro** `[GLOBAL LOOK]` / `[CAST]` / `[LOCATION]` before any shot. A one-liner `same character throughout` is not a character lock.
16
+ - **Order inside each shot**: Subject Action Camera Constraints (Audio/SFX if relevant). Do NOT restack GLOBAL LOOK style inside the shot.
17
+ - **Prompt length**: simple single-idea pieces ~120–280 words. Locked-intro cinematic typically 400–900 words. Shorter than ~120 words = random output. The 8000-char cap below always wins.
18
+ - **Shot count is user-directed, not capped at 3 or 6.** If the user asks for N shots, deliver exactly N shots in one prompt unless they explicitly ask to split it.
21
19
  - **Always describe at least one camera movement per shot.**
22
20
  - **Tell Seedance what the camera is NOT doing** (e.g. `no cuts, no zoom, natural head movement`) — this is what locks POV.
23
21
  - **Final prompt is always English**, wrapped in a copy-ready code block. Detect intent in any language and reply in the user's language, but the prompt itself is English.
@@ -28,13 +26,42 @@ Load this file when the user wants a **Seedance 2 / Seedance 2.0** (ByteDance) v
28
26
  - **Never** split into multiple prompts, multiple code blocks, or "part 1 / part 2" to evade the cap.
29
27
  - Before outputting, internally count the characters of the final prompt as a single string. If > 8000, rewrite tighter and re-count. Repeat until ≤ 8000. Only then show the user.
30
28
 
29
+ ## Locked Intro (DEFAULT for any multi-shot cinematic)
30
+
31
+ After the Total line, every multi-shot prompt — and any piece with recurring people or a recurring place — opens with three locked blocks. Same camera, same people, same world in every cut. Do not skip them. Do not restack this look as per-shot style.
32
+
33
+ Skip only for: true single-shot POV/orb, 3×3 grid-panel mode, or video-edit tasks. UGC/phone pieces still use the three headers, but GLOBAL LOOK is phone-native (no "cinematic", no film-camera body).
34
+
35
+ ```
36
+ Total: Xs / N shots / AR
37
+ N connected cinematic shots, Xs total, AR, Multishot ON
38
+
39
+ [GLOBAL LOOK – LOCKED, APPLIES TO EVERY SHOT]
40
+ Arricam LT, Cooke S4/i primes, 35mm Kodak Vision3 500T, 1.85:1 spherical, T2.8. Shallow depth of field, halation on highlights, fine organic grain, lifted milky blacks, low contrast, no sharpening, no HDR. [Named] grade: [hero tone], [field colors], [shadows], [bloom]. Handheld with micro-drift, never locked off. Naturalistic performance, real dialogue sync, no music.
41
+
42
+ [CAST – IDENTICAL IN EVERY SHOT]
43
+ NAME: age, build, hair, face, wardrobe, skin, signature details. @Tag when a reference or DNA exists.
44
+ PROP: recurring object.
45
+
46
+ [LOCATION]
47
+ Place in materials + light + color field. Blocking: who sits/stands where. Background LIFE (extras, ambient motion).
48
+
49
+ SHOT 1 — 0:00–0:02 — Medium / camera position
50
+ (director-dictated action — physical verbs, timed acting, quoted dialogue)
51
+ Total: Xs / N shots / AR
52
+ ```
53
+
54
+ Adapt the GLOBAL LOOK package to THIS world — never paste a beach grade onto a night interior. Equipment names (body / lens / stock / stop) live in GLOBAL LOOK only. Never name a real director.
55
+
56
+ **Acting:** shot size → physical action → line → timing. `laughs obnoxiously for half a second, then flat and certain` — unbounded laughs eat 2s. Background LIFE on every wide/medium or the frame reads as a ghost town.
57
+
31
58
  ## The 5 Formats
32
59
 
33
60
  ### 1. Transformations (highest-performing format)
34
61
  - Numbered shots, beat by beat.
35
62
  - Escalation arc: **calm → threat → transformation → aftermath**.
36
63
  - 6 shots / 15s / 16:9 is the proven structure.
37
- - Opening boilerplate: `Montage, multi-shot action Hollywood movie, don't use one camera angle or single cut, cinematic lighting, photorealistic, 35mm film, professional color grading, sharp focus, high detail texture, film grain, depth of field mastery, ARRI ALEXA aesthetic`.
64
+ - Prefer the **Locked Intro** over a style dump. For a quick action montage with no recurring cast, a short GLOBAL LOOK still beats the old ARRI-prefix pile.
38
65
  - **Realism trick**: for monsters/creatures, append `no 3D, no cartoon, no VFX` to force ultra-realism.
39
66
  - **Comedy trick**: append `add a visual gag in the background` and Seedance invents one.
40
67
 
@@ -54,7 +81,7 @@ Load this file when the user wants a **Seedance 2 / Seedance 2.0** (ByteDance) v
54
81
  - Always supply: **clear location, clear power mismatch, defined escalation arc**.
55
82
  - Describe choreography beat by beat — Seedance executes what you write.
56
83
  - Single continuous shot 15s works for two-fighter scenes; describe camera moves between beats (`crests rooftop edge`, `full 360 orbit`, `pulls back to wide`, `descends with them`).
57
- - Use `Guy Ritchie speed-ramping with Snyder impact slow-motion` as the style anchor when comedic/stylized.
84
+ - Use speed-ramping with impact slow-motion as the style anchor when comedic/stylized.
58
85
 
59
86
  ### 5. Animation (3D stylized)
60
87
  - Break the 15s into **timed segments** (`0–3s`, `3–6s`, `6–9s`, `9–12s`, `12–15s`) and describe each explicitly.
@@ -66,10 +93,7 @@ Load this file when the user wants a **Seedance 2 / Seedance 2.0** (ByteDance) v
66
93
 
67
94
  Use whenever the user gives named characters or multiple reference images (`@Image1`, `@Image2`, …) — a tactical unit clearing a bunker, a duel between two referenced characters, a war scene. **This is always an Elements-mode prompt** (route the card to `elements`). Structure:
68
95
 
69
- 1. **Labeled scene header FIRST** (grounds the scene before any shot):
70
- - `Time of day:` — hour + light quality + atmosphere (dust, haze, heavy silence before action).
71
- - `Location:` — the environment in concrete physical detail (materials, wear, light direction, high-contrast blown-out entrance, etc.).
72
- - `Characters:` — ONE line per person: `Name @ImageN — wardrobe, position in frame, what they carry`. End with "All must match their character references exactly."
96
+ 1. **Locked Intro FIRST** (GLOBAL LOOK / CAST / LOCATION) — CAST is one line per person: `Name @ImageN — wardrobe, position in frame, what they carry`. End CAST with "All must match their character references exactly." LOCATION carries time-of-day + materials + light direction.
73
97
  2. **REFERENCE CONSISTENCY block** — map every reference and pin what must NOT change: `Reference Image 1 is <X>. Preserve exact face, hair, anatomy, wardrobe, colors, props.` Add per-character energy/aura color rules, and any already-established story state (e.g. "the gem is already shattered — no intact gem, no red glow"). End with "Do not redesign, morph, recolor, or swap either character, their clothing, anatomy, weapons, or the environment."
74
98
  3. **Shots** — either titled (`Shot 1 — Medium Wide / Tactical Positioning`) or timecoded (`SHOT 1 — 0:00–0:03`); timecodes must sum to the total duration. Under each shot use **Camera → Action → Audio** in that order.
75
99
  4. **Continuity** — to chain a series, open with `Begin as a seamless continuation from <the exact last beat of the previous video>.`
@@ -104,16 +128,9 @@ The model reacts to what can be **seen and measured**, not to mood words. Transl
104
128
  - ❌ "tense scene" → ✅ "man freezes, slowly clenches his fist, light only from the side, half his face in shadow"
105
129
  - ❌ "cool cinematic shot of a car, epic, fast" → ✅ "low tracking shot alongside the car as it powers through a wet curve, headlights glowing, spray off the tyres, hard buffeting camera shake"
106
130
 
107
- ### Style — DISTRIBUTED, not a prefix
108
-
109
- Never pile all style tokens at the top of the prompt. Each aspect lives in the block that already governs it:
131
+ ### Style — LOCKED at the top, not redistributed
110
132
 
111
- - Lighting inside the shot's LIGHTING description
112
- - Lens / FOV → in OPTICS
113
- - Color → either an explicit grade (when strong / stylized) or folded into LOCATION + LIGHTING for naturalistic looks
114
- - Skin / acting → in PERFORMANCE
115
- - Physics → in PHYSICS
116
- - Format / resolution / grain → at the END as a suffix stack (before LOCKS)
133
+ The GLOBAL LOOK block is the prefix. Per-shot style is ONLY what changes (shot size, move, height, this-shot light). Do not re-state film stock / lens / grade per shot. Do not pile a second style dump at the end.
117
134
 
118
135
  ### Shot sizes
119
136
 
@@ -153,7 +170,7 @@ Use only the discrete steps. Not "23°" — use 18° or 29°.
153
170
  - **Emotion through muscle movement**, not labels. ❌ "she looks sad" → ✅ "her eyes drop to the table, jaw tightens, she swallows once before answering."
154
171
  - **WB in Kelvin.** 3200K / 4000K / 5600K / 8500K. Pick ONE for the scene's mood.
155
172
  - **Color as material + light + role**, never a flat list. ❌ "she wears red, he wears blue" → ✅ "crimson silk scarf catching the cold tungsten spill from the corridor".
156
- - **No equipment names**, no director references, no "shot on ARRI / Sigma 85mm / Roger Deakins".
173
+ - **Equipment names live in GLOBAL LOOK only** (body, lens, stock, stop). Never inside shots. **Never name a real director** and never write "in the style of [person]".
157
174
 
158
175
  ### Cuts and timing
159
176
 
@@ -176,18 +193,19 @@ Use only the discrete steps. Not "23°" — use 18° or 29°.
176
193
 
177
194
  ### Camera placement
178
195
 
179
- Place CAMERA in the **3rd position** of each shot's core layers (Subject → Action → Camera → Style → Constraints). FOV gets ignored at the end, conflicts with identity at the front.
196
+ Place CAMERA in the **3rd position** of each shot's core layers (Subject → Action → Camera → Constraints). FOV gets ignored at the end, conflicts with identity at the front. GLOBAL LOOK already owns lens / stock / grade.
180
197
 
181
198
  ### Pre-flight checklist (before output)
182
199
 
183
- - Distributed style (no top-pile)?
200
+ - Locked Intro present (`[GLOBAL LOOK]`, `[CAST]`, `[LOCATION]`) unless a documented skip?
201
+ - GLOBAL LOOK not restated inside shots? Equipment names only there? No director names?
202
+ - Acting timed? Background LIFE on wides/mediums?
184
203
  - One camera movement per time slice?
185
204
  - FOV in degrees from the table (not mm, not arbitrary)?
186
205
  - WB in Kelvin?
187
206
  - Speed in km/h, atmosphere in % or meters?
188
- - Color via material + light + role?
207
+ - Color via material + light + role (grade named as law in GLOBAL LOOK)?
189
208
  - Positive phrasing (no "does not X")?
190
- - No equipment / director names?
191
209
  - Emotion through muscle, not labels?
192
210
  - Multishot: FOV per segment + "no drift mid-segment"?
193
211
  - 8000-char cap honored?
@@ -0,0 +1,65 @@
1
+ <!-- PARITY: this file mirrors getSeedance25PromptSystemPrompt() in
2
+ kolbo-api/src/config/systemPrompt.js.
3
+ Craft layer (formats, optics, grid mode) lives in models/seedance.md —
4
+ load that file too. Locked Intro is the same three blocks on both versions. -->
5
+
6
+ # Seedance 2.5 — Prompt Rules
7
+
8
+ Load this file when the user wants a **Seedance 2.5** video (they said "2.5" / "25", or they need longer than 15s, more than ~10 shots, or a large cast of references). Also load `models/seedance.md` for the shared craft layer.
9
+
10
+ **Kolbo MCP routing:** `generate_video` or `generate_elements` (refs / Visual DNA / first-last). Run `list_models({ type: "text_to_video" })` and pick the Seedance 2.5 variant by name.
11
+
12
+ ## What's NEW in 2.5 (verified — never hedge)
13
+
14
+ - **Duration 4–30 seconds**, whole seconds. 30s IS supported.
15
+ - **Up to 30 shots/cuts in ONE generation.** Deliver exactly N if N ≤ 30.
16
+ - **Prompt cap 10,000 characters** for the entire prompt as one string.
17
+ - **Up to 50 reference medias / Visual DNA mentions** (`@Name`, `@ImageN`, `#Moodboard`). Every referenced asset must be tagged in the prompt text.
18
+ - **Multimodal refs:** images + video clips + audio can all anchor one generation.
19
+
20
+ ## Locked Intro (DEFAULT — same shape as Seedance 2)
21
+
22
+ After the Total line, every multi-shot cinematic opens with:
23
+
24
+ ```
25
+ [GLOBAL LOOK – LOCKED, APPLIES TO EVERY SHOT]
26
+ [CAST – IDENTICAL IN EVERY SHOT]
27
+ [LOCATION]
28
+ ```
29
+
30
+ then timecoded `SHOT N — 0:00–0:02 — Medium / camera position` beats. Full skeleton, acting rules, and optical craft: `models/seedance.md`.
31
+
32
+ 2.5 is where this format earns its keep: 15 shots timed to 30s, ~5k characters, one locked look so every cut matches camera / grade / cast. Do not skip the three blocks. Do not restack GLOBAL LOOK inside shots.
33
+
34
+ UGC / phone vertical: NEVER write "cinematic". GLOBAL LOOK is phone-native. Use `N connected phone shots, Xs total, 9:16, Multishot ON` and restate `9:16 vertical phone frame` inside every shot.
35
+
36
+ ## Prompt length
37
+
38
+ Simple ≤15s ~120–280 words. Locked-intro cinematic 15s typically 400–900 words. Full 30s / 15+ shots typically 700–1200 words / ~4k–9k chars. Hard cap 10,000. Never split into part 1 / part 2.
39
+
40
+ ## Feature-Block (optional, UNDER the Locked Intro)
41
+
42
+ Reach for extra department passes only when the user wants "their best possible 30 seconds" AND the 10k budget still has room after GLOBAL LOOK / CAST / LOCATION. Never replace the Locked Intro.
43
+
44
+ May add above GLOBAL LOOK: **EMOTIONAL INTENT** + **SIGNATURE MOMENT**.
45
+ May add under the shot list: CAMERA timecode pass, SOUND timestamps, PHYSICS contract, EDITING/CONTINUITY, DIRECTORIAL NOTES.
46
+ Skip CORE STYLE / SUBJECT / ENVIRONMENT — the three locked blocks already own those.
47
+
48
+ ## References (ByteDance 2.5)
49
+
50
+ - Limits: up to 30 images (each ≤4K), up to 10 videos (≤30s combined), up to 10 audio clips (≤30s combined); up to 50 materials total.
51
+ - Role mapping is mandatory, one line per material:
52
+ - `@Image N defines <subject>'s <appearance, clothing, structure, or material>.`
53
+ - `@Video N defines <motion, camera movement, or pacing>.`
54
+ - `@Audio N defines <character or sound type>'s <voice, dialogue, ambience, or music>.`
55
+ - Add exclusions when a material's people/background could leak: "Do not use the image background." / "Do not use the people in the image."
56
+
57
+ ## Task-locked parameters
58
+
59
+ - **Video editing:** aspect + duration auto-preserve the source. Do NOT declare AR / duration / shot-count headers. Use `[Edit Goal]` / `[Source Video Role]` / `[Target Material Role]` / `[Edit Scope]` / `[Content to Preserve]`.
60
+ - **First-frame / first-and-last-frame:** AR comes from the FIRST image. Duration CAN be set.
61
+ - **Video extension:** AR auto-preserves the input; duration CAN be set.
62
+
63
+ ## Where to run in Kolbo
64
+
65
+ Same routing as Seedance 2 (`first_last_frame` / `elements` / `image_to_video` / `text_to_video`). Pair Visual DNA with `generate_elements` and tag `@<dna-name>` inside the prompt.
@@ -123,7 +123,7 @@ If the user gives a **product URL** instead of a photo, see `workflows/research-
123
123
 
124
124
  ## Multi-Slot Board Method (structured shot specs + character consistency)
125
125
 
126
- For any multi-shot UGC / review / how-to where the SAME presenter must stay identical across shots, compose the prompt as explicit **slots** and lock identity with a **board-first** pass. Prefer `generate_elements` with `multi_shots: true` (and a short `session_name`) when animating a single multi-cut Seedance spot; board-first still uses `generate_image` + `generate_elements` / `generate_video_from_image` per slot when you need separate clips.
126
+ For any multi-shot UGC / review / how-to where the SAME presenter must stay identical across shots, compose the prompt as explicit **slots** and lock identity with a **board-first** pass. This is a prompt-only convention no special MCP mode; it uses `generate_image` (board) + `generate_elements` / `generate_video_from_image` (per-slot animate) that already exist.
127
127
 
128
128
  ### 1. Structured input slots
129
129
 
@@ -42,7 +42,7 @@ client share a filesystem. Choose by transport:
42
42
  |---|---|
43
43
  | **Local (stdio) install** — `npx @kolbo/mcp` on the same machine | `upload_media` with the absolute path |
44
44
  | **Remote connector + you can run shell commands** (Claude Code, Codex, Cursor, CI) | `create_upload_ticket`, then POST each file to `upload_url` |
45
- | **Remote connector, no filesystem** (claude.ai web/mobile) | `media_upload_widget` — the user picks the file. On Claude iOS/Android the card opens a full-screen uploader (in-chat file pickers are dropped by WebKit); after upload the user pastes the copied CDN URLs back into chat. |
45
+ | **Remote connector, no filesystem** (claude.ai web/mobile) | `media_upload_widget` — the user picks the file |
46
46
 
47
47
  `create_upload_ticket` returns `upload_url` + a short-lived `token`. Upload with
48
48
  multipart field `file` and `Authorization: Bearer <token>`; the stable CDN URL comes
@@ -50,20 +50,9 @@ back at `media.url`. One POST per file, ticket reusable for a batch. Then pass t
50
50
  URLs to any generation tool.
51
51
 
52
52
  ```bash
53
- curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/abs/path/clip.mp3;type=audio/mpeg"
53
+ curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/abs/path/clip.mp3"
54
54
  ```
55
55
 
56
- **Declare the type.** `curl` labels the part from its own mime table and falls back to
57
- `application/octet-stream` for anything missing from it — `.mp3` included — which the
58
- endpoint can reject as an unsupported type. Append `;type=<mime>` (`audio/mpeg`,
59
- `audio/wav`, `video/mp4`, `image/png`, `application/pdf`, …) and it never comes up.
60
-
61
- **Pace a batch.** The upload endpoint is rate limited — the ticket response says by
62
- how much in `rate_limit` (currently 40 uploads per 60s). Firing 55 files back to
63
- back stalls at file 41. Sleep ~2s between files, or read the 429: it carries a
64
- `Retry-After` header and `retry_after_seconds` in the body. Wait exactly that long
65
- and continue — a 429 is a "not yet", not a failed upload, and nothing was charged.
66
-
67
56
  Do **not** fall back to `upload_media`'s `source_base64` for anything but a tiny file —
68
57
  it pushes the whole file through the model's context, twice. And do not reach for
69
58
  cloud credentials or a bucket of your own; the ticket is the sanctioned path.
@@ -16,7 +16,8 @@ const { widgetPage } = require('../html');
16
16
  * model, model_icon, prompt, count,
17
17
  * settings: { duration, resolution, aspect_ratio, quality, audio, voice, mode,
18
18
  * enhance_prompt, web_search, visual_dna, moodboard, preset, cinematic },
19
- * reference_image, // thumbnail URL (optional)
19
+ * reference_images, // all reference thumbnail URLs (optional)
20
+ * reference_image, // legacy single-thumbnail fallback
20
21
  * urls, thumbnail_url, title, duration, credits_used,
21
22
  * tracks: [{ title, duration, thumbnail_url, model }], // optional audio metadata by URL index
22
23
  * scenes: [{ scene_number, title, image_urls, video_urls }],
@@ -135,7 +136,11 @@ function renderChips(sc) {
135
136
  }
136
137
  if (s.mode) h += chip(esc(s.mode));
137
138
  if (sc.count > 1) h += chip('×' + sc.count);
138
- if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="" loading="lazy" title="Reference image" onerror="this.style.display=\\'none\\'">';
139
+ var refs = Array.isArray(sc.reference_images) && sc.reference_images.length
140
+ ? sc.reference_images : (sc.reference_image ? [sc.reference_image] : []);
141
+ for (var i = 0; i < refs.length; i++) {
142
+ 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\\'">';
143
+ }
139
144
  el('chips').innerHTML = h;
140
145
  }
141
146
  function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
package/src/client.js CHANGED
@@ -47,6 +47,26 @@ function isAbortError(err) {
47
47
  return err && (err.name === 'TimeoutError' || err.name === 'AbortError');
48
48
  }
49
49
 
50
+ function composeSignals(signals) {
51
+ const active = signals.filter(Boolean);
52
+ if (active.length === 0) return { signal: undefined, cleanup: () => {} };
53
+ if (active.length === 1) return { signal: active[0], cleanup: () => {} };
54
+
55
+ const controller = new AbortController();
56
+ const onAbort = () => controller.abort();
57
+ for (const item of active) {
58
+ if (item.aborted) {
59
+ controller.abort();
60
+ break;
61
+ }
62
+ item.addEventListener('abort', onAbort, { once: true });
63
+ }
64
+ return {
65
+ signal: controller.signal,
66
+ cleanup: () => active.forEach(item => item.removeEventListener('abort', onAbort))
67
+ };
68
+ }
69
+
50
70
  // ---------------------------------------------------------------------------
51
71
  // 429 handling
52
72
  // ---------------------------------------------------------------------------
@@ -279,18 +299,18 @@ class KolboClient {
279
299
  return false;
280
300
  }
281
301
 
282
- async request(method, reqPath, body = null) {
302
+ async request(method, reqPath, body = null, requestOptions = {}) {
283
303
  if (!this.apiKey) await this._ensureLogin();
284
- const result = await this._doRequest(method, reqPath, body);
304
+ const result = await this._doRequest(method, reqPath, body, requestOptions);
285
305
 
286
306
  // On 401, try re-reading auth store and retry once
287
307
  if (result._status === 401 && this._tryRefreshKey()) {
288
- return this._doRequest(method, reqPath, body);
308
+ return this._doRequest(method, reqPath, body, requestOptions);
289
309
  }
290
310
  return result;
291
311
  }
292
312
 
293
- async _doRequest(method, reqPath, body = null) {
313
+ async _doRequest(method, reqPath, body = null, requestOptions = {}) {
294
314
  const url = `${this.baseUrl}${reqPath}`;
295
315
  const headers = {
296
316
  'X-API-Key': this.apiKey,
@@ -313,21 +333,32 @@ class KolboClient {
313
333
  options.body = JSON.stringify(body);
314
334
  }
315
335
 
316
- options.signal = timeoutSignal(REQUEST_TIMEOUT_MS);
336
+ const callerSignal = requestOptions.ignoreCallerSignal ? null : progress.signal();
337
+ const requestTimeoutMs = requestOptions.timeoutMs || REQUEST_TIMEOUT_MS;
338
+ const composed = composeSignals([callerSignal, timeoutSignal(requestTimeoutMs)]);
339
+ options.signal = composed.signal;
317
340
 
318
341
  let response;
319
342
  try {
320
343
  response = await fetch(url, options);
321
344
  } catch (err) {
322
345
  if (isAbortError(err)) {
346
+ if (callerSignal?.aborted) {
347
+ throw new KolboApiError(
348
+ `Request cancelled by caller: ${method} ${reqPath}`,
349
+ { code: 'REQUEST_CANCELLED', status: 499 }
350
+ );
351
+ }
323
352
  throw new KolboApiError(
324
- `Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s: ${method} ${reqPath}. ` +
353
+ `Request timed out after ${requestTimeoutMs / 1000}s: ${method} ${reqPath}. ` +
325
354
  'The job may still be running server-side — poll get_generation_status before retrying. ' +
326
355
  'Raise KOLBO_HTTP_TIMEOUT_MS if this is a legitimately slow endpoint.',
327
356
  { code: 'REQUEST_TIMEOUT', status: 504 }
328
357
  );
329
358
  }
330
359
  throw err;
360
+ } finally {
361
+ composed.cleanup();
331
362
  }
332
363
 
333
364
  let data;
@@ -367,16 +398,37 @@ class KolboClient {
367
398
  const generationId = data?.generation_id || data?.generationId;
368
399
  if (method === 'POST' && generationId) {
369
400
  await progress.generation(generationId);
401
+ if (!requestOptions.suppressGenerationTracking) {
402
+ progress.trackGeneration(generationId, () => this._cancelAfterCallerAbort(generationId));
403
+ }
370
404
  }
371
405
  return data;
372
406
  }
373
407
 
374
- async post(reqPath, body) {
375
- return this.request('POST', reqPath, body);
408
+ async _cancelAfterCallerAbort(generationId) {
409
+ try {
410
+ await this._doRequest(
411
+ 'POST',
412
+ `/v1/generate/${encodeURIComponent(generationId)}/cancel`,
413
+ {},
414
+ {
415
+ ignoreCallerSignal: true,
416
+ suppressGenerationTracking: true,
417
+ timeoutMs: 30000
418
+ }
419
+ );
420
+ } catch (_) {
421
+ // The host has already cancelled the visible tool call. This is a
422
+ // best-effort cleanup and must never become an unhandled rejection.
423
+ }
424
+ }
425
+
426
+ async post(reqPath, body, requestOptions) {
427
+ return this.request('POST', reqPath, body, requestOptions);
376
428
  }
377
429
 
378
- async get(reqPath) {
379
- return this.request('GET', reqPath);
430
+ async get(reqPath, requestOptions) {
431
+ return this.request('GET', reqPath, null, requestOptions);
380
432
  }
381
433
 
382
434
  async put(reqPath, body = null) {
@@ -426,16 +478,24 @@ class KolboClient {
426
478
  const body = formData.getBuffer();
427
479
  headers['Content-Length'] = String(body.length);
428
480
 
481
+ const callerSignal = progress.signal();
482
+ const composed = composeSignals([callerSignal, timeoutSignal(UPLOAD_TIMEOUT_MS)]);
429
483
  let response;
430
484
  try {
431
485
  response = await fetch(url, {
432
486
  method: 'POST',
433
487
  headers,
434
488
  body,
435
- signal: timeoutSignal(UPLOAD_TIMEOUT_MS)
489
+ signal: composed.signal
436
490
  });
437
491
  } catch (err) {
438
492
  if (isAbortError(err)) {
493
+ if (callerSignal?.aborted) {
494
+ throw new KolboApiError(
495
+ `Upload cancelled by caller: POST ${reqPath}`,
496
+ { code: 'REQUEST_CANCELLED', status: 499 }
497
+ );
498
+ }
439
499
  throw new KolboApiError(
440
500
  `Upload timed out after ${UPLOAD_TIMEOUT_MS / 1000}s: POST ${reqPath} ` +
441
501
  `(${Math.round(body.length / 1024)}KB). Raise KOLBO_UPLOAD_TIMEOUT_MS for slow links.`,
@@ -443,6 +503,8 @@ class KolboClient {
443
503
  );
444
504
  }
445
505
  throw err;
506
+ } finally {
507
+ composed.cleanup();
446
508
  }
447
509
 
448
510
  let data;
@@ -476,6 +538,11 @@ class KolboClient {
476
538
  throw apiError;
477
539
  }
478
540
 
541
+ const generationId = data?.generation_id || data?.generationId;
542
+ if (generationId) {
543
+ await progress.generation(generationId);
544
+ progress.trackGeneration(generationId, () => this._cancelAfterCallerAbort(generationId));
545
+ }
479
546
  return data;
480
547
  }
481
548
  }
package/src/index.js CHANGED
@@ -127,7 +127,7 @@ function createServer(opts = {}) {
127
127
  '7. SESSION CONTINUITY — one task, one session, always: every generation tool returns a `session_id`. For ANY follow-up, refinement, retry, or next step on the SAME task, pass that session_id back — never start fresh. BATCH RULE (critical): when a single user request produces multiple parallel generations (e.g. "animate these 5 images", "generate 3 variants"), do NOT launch them all at once without a session_id. Instead: (1) run the FIRST generation without session_id to create the session, (2) capture the session_id from its response, (3) pass that session_id to ALL remaining generations in the batch. This keeps the entire batch in one session. Exception: only omit session_id and start fresh when the user explicitly starts an unrelated new task.',
128
128
  '8. LOCAL FILES / REFERENCE MEDIA — HOW TO HANDLE EVERY CASE. (A) User has a LOCAL file (audio, video, image, document) on their machine. What matters is WHERE THIS SERVER RUNS, not what your client can do — your own filesystem access is irrelevant if the server is somewhere else. On a LOCAL stdio install (server and client share a machine) → call `upload_media` with the absolute path, or pass the path straight to tools like `transcribe_audio` that accept local paths. Over a REMOTE connector the server cannot see that path no matter how capable you are, so a local path will always fail: if you can run shell commands or issue HTTP requests → call `create_upload_ticket` and POST the file to the returned upload_url yourself (fastest, no user interaction); if you cannot → call `media_upload_widget` IMMEDIATELY, the user uploads, and a `media.kolbo.ai` CDN URL comes back for any follow-up call. (B) You already have a public URL (media.kolbo.ai, any CDN, any direct link) → pass it directly; all Kolbo tools accept public URLs. NEVER search for DO Spaces keys, DigitalOcean credentials, or server-side upload credentials. NEVER ask the user to put the file on Google Drive, Dropbox, or Loom. NEVER invent or guess a URL. NEVER base64 anything but a tiny file — it costs context in proportion to file size; use the ticket or the widget instead.',
129
129
  '9. MODEL SELECTION — ROUTE BY THE STRENGTHS SUMMARY, NEVER BY THE BADGE OR THE PRICE TAG: ALWAYS pass a specific `model` on every generation tool — do NOT omit it (omitting falls back to "Smart Select" auto-routing, which hides the choice from the user; use it ONLY if the user explicitly asks you to auto-pick). To choose: call `list_models` with the matching `type` and read each model\'s STRENGTHS SUMMARY — the "— …" clause printed after the credit cost. That summary IS the routing instruction: match it against what the user actually asked for (subject, style, motion, length, quality bar, speed), then pick the CHEAPEST model whose summary covers the task. `[NEW]` and `[RECOMMENDED]` badges, a high credit number, and "flagship"/"most intelligent" wording are NOT selection signals — never pick a model because it is newest, biggest or most expensive. Escalate to a premium/frontier model only when the user explicitly asks for maximum quality, or when no cheaper summary covers the requirement. Models printed under "Named-only" (no summary) are opt-in: use them only when the user names them. TEXT/CHAT: `chat_send_message` bills PER TOKEN, so the listed credit number is not the cost — a frontier text model (Claude Fable 5, GPT-5.6 Sol, Pro-class) costs 5-30x a mid-tier one per reply. Default ordinary chat (writing, brainstorming, Q&A, summarising) to a balanced mid-tier model and reserve the frontier tier for hard reasoning or long-form code the user asked for.',
130
- '10. IMAGE EDITING: for ANY prompt-driven / content edit of an existing image — "make it night", changing scene/lighting/colors, adding/removing/replacing objects, restyling — use `generate_image_edit` (it runs on strong dedicated editing models, same as image generation). Do NOT use `edit_image` for content edits — `edit_image` is ONLY for mechanical enhancements (upscale, expand/outpaint, remove-background, skin retouch). Its `magic_edit` operation is deprecated in favor of `generate_image_edit`. EXPANDING AN IMAGE: to widen/extend/uncrop an image or fit it into a wider frame while KEEPING the existing artwork, use `edit_image` with operation="zoom_out" (outpainting — original pixels preserved; size it with `zoom_out_percentage` or the `expand_left/right/top/bottom` pixel args). The "reframe" operation is NOT this: it re-generates the whole picture at a new aspect ratio and the subject comes back re-imagined. Only pick "reframe" when the user wants the shot re-taken, never when they want their image extended.',
130
+ '10. IMAGE EDITING: for ANY prompt-driven / content edit of an existing image — "make it night", changing scene/lighting/colors, adding/removing/replacing objects, restyling — use `generate_image_edit`. Auto-pick only Nano Banana 2 (`nano-banana-2` / `nano-banana-2-image-editing`) or GPT Image 2 (`gpt-image-2` / `gpt-image-2/edit`) for photoreal photo edits, object removal, keep-subject/remove-others, or crowd cleanup. Do NOT auto-pick Flux 2 / flux-2/edit / Flux Klein — those are generate-from-scratch / style, named-only for editing. Do NOT use `edit_image` for content edits — `edit_image` is ONLY for mechanical enhancements (upscale, expand/outpaint, remove-background, skin retouch). Its `magic_edit` operation is deprecated in favor of `generate_image_edit`. EXPANDING AN IMAGE: to widen/extend/uncrop an image or fit it into a wider frame while KEEPING the existing artwork, use `edit_image` with operation="zoom_out" (outpainting — original pixels preserved; size it with `zoom_out_percentage` or the `expand_left/right/top/bottom` pixel args). The "reframe" operation is NOT this: it re-generates the whole picture at a new aspect ratio and the subject comes back re-imagined. Only pick "reframe" when the user wants the shot re-taken, never when they want their image extended.',
131
131
  '11. PRESET CONTRACT: if the user asks for a preset, names a preset, or says to use one of their/Kolbo presets, you MUST call `list_presets` with the matching type before generation, resolve the named or closest matching preset, and pass its exact returned `id` as `preset_id`. Use type="image" for generate_image and type="image_edit" for generate_image_edit. Never silently ignore a preset request, never invent an id, and never claim a preset was applied unless `preset_id` was present in the generation call.'
132
132
  ].join('\n')
133
133
  });
package/src/polling.js CHANGED
@@ -80,7 +80,7 @@ async function pollUntilDone(client, generationId, options = {}) {
80
80
  }
81
81
  const backoff = Math.min(interval * Math.pow(1.5, Math.min(transientFailures - 1, 5)), 30000);
82
82
  await progress.tick(); // same keepalive as the normal path — backoff waits up to 30s
83
- await new Promise((resolve) => setTimeout(resolve, backoff));
83
+ await progress.wait(backoff);
84
84
  continue;
85
85
  }
86
86
  // Non-transient (auth, 4xx other than 408/425/429) — bubble up.
@@ -109,7 +109,7 @@ async function pollUntilDone(client, generationId, options = {}) {
109
109
  // cadence could return at 60s). That is the difference between landing
110
110
  // inside the caller's transport window and blowing straight through it.
111
111
  const remaining = timeout - (Date.now() - startTime);
112
- await new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(interval, remaining))));
112
+ await progress.wait(Math.max(0, Math.min(interval, remaining)));
113
113
  }
114
114
  }
115
115
 
package/src/progress.js CHANGED
@@ -2,8 +2,79 @@ const { AsyncLocalStorage } = require('async_hooks');
2
2
 
3
3
  const storage = new AsyncLocalStorage();
4
4
 
5
- function run(extra, fn) {
6
- return storage.run(extra, fn);
5
+ async function run(extra, fn) {
6
+ return storage.run(extra, async () => {
7
+ try {
8
+ return await fn();
9
+ } finally {
10
+ if (extra?.signal && extra.__kolboAbortListener) {
11
+ extra.signal.removeEventListener('abort', extra.__kolboAbortListener);
12
+ }
13
+ if (extra) {
14
+ delete extra.__kolboAbortListener;
15
+ delete extra.__kolboTrackedGenerations;
16
+ }
17
+ }
18
+ });
19
+ }
20
+
21
+ function signal() {
22
+ return storage.getStore()?.signal;
23
+ }
24
+
25
+ function abortError() {
26
+ const error = new Error('The MCP tool call was cancelled by the caller.');
27
+ error.name = 'AbortError';
28
+ return error;
29
+ }
30
+
31
+ /** Wait without keeping a cancelled tool call alive until the next poll tick. */
32
+ function wait(ms) {
33
+ const callerSignal = signal();
34
+ if (!callerSignal) return new Promise(resolve => setTimeout(resolve, ms));
35
+ if (callerSignal.aborted) return Promise.reject(abortError());
36
+
37
+ return new Promise((resolve, reject) => {
38
+ const timer = setTimeout(() => {
39
+ callerSignal.removeEventListener('abort', onAbort);
40
+ resolve();
41
+ }, ms);
42
+ const onAbort = () => {
43
+ clearTimeout(timer);
44
+ callerSignal.removeEventListener('abort', onAbort);
45
+ reject(abortError());
46
+ };
47
+ callerSignal.addEventListener('abort', onAbort, { once: true });
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Associate a submitted backend generation with the current MCP tool call.
53
+ * If Kolbo Code, Claude, or another MCP host cancels the tool while it is
54
+ * polling, every submitted generation is cancelled server-side as well.
55
+ */
56
+ function trackGeneration(id, cancel) {
57
+ const extra = storage.getStore();
58
+ if (!extra?.signal || !id || typeof cancel !== 'function') return;
59
+
60
+ if (!extra.__kolboTrackedGenerations) extra.__kolboTrackedGenerations = new Map();
61
+ extra.__kolboTrackedGenerations.set(String(id), cancel);
62
+
63
+ const cancelTracked = () => {
64
+ const tracked = extra.__kolboTrackedGenerations;
65
+ if (!tracked) return;
66
+ extra.__kolboTrackedGenerations = new Map();
67
+ for (const cancelOne of tracked.values()) {
68
+ Promise.resolve().then(cancelOne).catch(() => {});
69
+ }
70
+ };
71
+
72
+ if (!extra.__kolboAbortListener) {
73
+ extra.__kolboAbortListener = cancelTracked;
74
+ extra.signal.addEventListener('abort', cancelTracked, { once: true });
75
+ }
76
+
77
+ if (extra.signal.aborted) cancelTracked();
7
78
  }
8
79
 
9
80
  async function generation(id) {
@@ -52,4 +123,4 @@ async function sendGenerationProgress(extra, token, id) {
52
123
  });
53
124
  }
54
125
 
55
- module.exports = { run, generation, tick };
126
+ module.exports = { run, generation, tick, signal, wait, trackGeneration };
@@ -518,7 +518,7 @@ async function modelChipFields(client, model) {
518
518
  * kind 'image' | 'video' | 'audio' | '3d' | 'scenes'
519
519
  * gen the submit response ({ generation_id, poll_interval_hint })
520
520
  * client KolboClient (for model icon lookup)
521
- * model, prompt, count, settings, reference_image, estimated_seconds
521
+ * model, prompt, count, settings, reference_images, estimated_seconds
522
522
  * voice resolved voice record { name, thumbnail } (speech only)
523
523
  * poll_tool widget-side status tool (default 'get_generation_status')
524
524
  * status_args args for poll_tool (default { generation_id, wait: true })
@@ -542,7 +542,12 @@ async function uiGenerating(p) {
542
542
  prompt: p.prompt,
543
543
  count: p.count || 1,
544
544
  settings: p.settings || {},
545
- reference_image: p.reference_image,
545
+ // `reference_image` is retained for older widget builds. New widgets render
546
+ // every browser-loadable image supplied to the generation.
547
+ reference_images: Array.isArray(p.reference_images)
548
+ ? p.reference_images.filter(Boolean)
549
+ : (p.reference_image ? [p.reference_image] : []),
550
+ reference_image: p.reference_image || p.reference_images?.find(Boolean),
546
551
  open_url: buildOpenUrl(p.tool, p.gen),
547
552
  };
548
553
  // Batch mode (prompts[] fan-out): ONE widget tracks every id in the set.
@@ -581,7 +586,10 @@ async function uiCompleted(p, textPayload) {
581
586
  prompt: p.prompt,
582
587
  count: p.count || 1,
583
588
  settings: p.settings || {},
584
- reference_image: p.reference_image,
589
+ reference_images: Array.isArray(p.reference_images)
590
+ ? p.reference_images.filter(Boolean)
591
+ : (p.reference_image ? [p.reference_image] : []),
592
+ reference_image: p.reference_image || p.reference_images?.find(Boolean),
585
593
  urls: p.urls,
586
594
  thumbnail_url: p.thumbnail_url,
587
595
  title: p.title,
@@ -178,7 +178,7 @@ function registerGenerateTools(server, client, options = {}) {
178
178
  generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
179
179
  failed_submissions: batch.failed,
180
180
  status_args: { generation_ids: batch.ids, wait: true },
181
- reference_image: reference_images?.[0]
181
+ reference_images
182
182
  });
183
183
  return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 3) * 1000, timeout: 240000 });
184
184
  }
@@ -188,7 +188,7 @@ function registerGenerateTools(server, client, options = {}) {
188
188
  if (ui()) return uiGenerating({
189
189
  tool: 'generate_image', kind: 'image', gen, client, model, prompt,
190
190
  count: num_images, settings: imageSettings(shared),
191
- reference_image: reference_images?.[0]
191
+ reference_images
192
192
  });
193
193
 
194
194
  const poll = await pollOrTimedOut(client, gen.generation_id, {
@@ -221,7 +221,7 @@ function registerGenerateTools(server, client, options = {}) {
221
221
  '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
222
  {
223
223
  prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
224
- 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/edit) — 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 (most flagship models do: gpt-image, nano-banana, flux-2, seedream, qwen, wan, grok-imagine, kling-image families). Models with none (Midjourney, Flux Pro/Ultra, Imagen4, Ideogram, Recraft, Higgsfield Soul, Krea, Dreamina, and others) silently ignore source_images if passed here instead of erroring if unsure, confirm the model appears in `list_models type="image_editing"` before trusting a bare id, or just use a known-safe default: "nano-banana-pro/edit" (best general prompt editor), "gpt-image-2" (photoreal, strong text), or "flux-2/edit".'),
224
+ 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
225
  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.'),
226
226
  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
227
  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.'),
@@ -247,7 +247,7 @@ function registerGenerateTools(server, client, options = {}) {
247
247
  tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
248
248
  count: num_images,
249
249
  settings: imageSettings({ resolution, aspect_ratio, enhance_prompt, enable_web_search, visual_dna_ids, moodboard_id, preset_id, cinematic }),
250
- reference_image: source_images?.[0]
250
+ reference_images: source_images
251
251
  });
252
252
 
253
253
  // Multi-source compositing or DNA-anchored edits routinely exceed 120s
@@ -309,7 +309,7 @@ function registerGenerateTools(server, client, options = {}) {
309
309
  tool: 'generate_creative_director', kind: 'scenes', gen, client, model, prompt,
310
310
  count: scene_count || 4,
311
311
  settings: { duration, resolution, aspect_ratio, mode: workflow_type || 'image' },
312
- reference_image: reference_images?.[0],
312
+ reference_images,
313
313
  poll_tool: 'get_creative_director_status',
314
314
  status_args: { generation_id: gen.generation_id, wait: true }
315
315
  });
@@ -466,7 +466,7 @@ function registerGenerateTools(server, client, options = {}) {
466
466
  generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
467
467
  failed_submissions: batch.failed,
468
468
  status_args: { generation_ids: batch.ids, wait: true },
469
- reference_image: reference_images?.[0]
469
+ reference_images
470
470
  });
471
471
  return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 8) * 1000, timeout: 900000 });
472
472
  }
@@ -476,7 +476,7 @@ function registerGenerateTools(server, client, options = {}) {
476
476
  if (ui()) return uiGenerating({
477
477
  tool: 'generate_video', kind: 'video', gen, client, model, prompt,
478
478
  settings: { duration, resolution, aspect_ratio },
479
- reference_image: reference_images?.[0]
479
+ reference_images
480
480
  });
481
481
 
482
482
  // 15 min — Kling O3 4K, Veo 3.1 at higher durations/resolutions, Hailuo 2.3,
@@ -550,7 +550,7 @@ function registerGenerateTools(server, client, options = {}) {
550
550
  generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
551
551
  failed_submissions: batch.failed,
552
552
  status_args: { generation_ids: batch.ids, wait: true },
553
- reference_image: items[0].image_url
553
+ reference_images: items.map((item) => item.image_url)
554
554
  });
555
555
  return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 8) * 1000, timeout: 900000 });
556
556
  }
@@ -560,7 +560,7 @@ function registerGenerateTools(server, client, options = {}) {
560
560
  if (ui()) return uiGenerating({
561
561
  tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
562
562
  settings: { duration, resolution, aspect_ratio },
563
- reference_image: image_url
563
+ reference_images: [image_url]
564
564
  });
565
565
 
566
566
  // 15 min — same real-world generation times as generate_video (Kling O3 4K,
@@ -791,7 +791,8 @@ function registerGenerateTools(server, client, options = {}) {
791
791
 
792
792
  if (ui()) return uiGenerating({
793
793
  tool: 'generate_sound', kind: 'audio', gen, client, model, prompt,
794
- settings: { duration }
794
+ settings: { duration },
795
+ reference_images: seed_reference_image_url ? [seed_reference_image_url] : []
795
796
  });
796
797
 
797
798
  const poll = await pollOrTimedOut(client, gen.generation_id, {
@@ -1050,7 +1051,11 @@ function registerGenerateTools(server, client, options = {}) {
1050
1051
  if (ui()) return uiGenerating({
1051
1052
  tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
1052
1053
  settings: { duration, resolution, aspect_ratio },
1053
- reference_image: reference_images?.[0]
1054
+ reference_images: [
1055
+ ...(reference_images || []),
1056
+ ...(keyframes || []).map((keyframe) => keyframe.image_url),
1057
+ ...(files || []).filter((source) => /^https?:\/\//i.test(source))
1058
+ ]
1054
1059
  });
1055
1060
 
1056
1061
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1134,7 +1139,8 @@ function registerGenerateTools(server, client, options = {}) {
1134
1139
  if (ui()) return uiGenerating({
1135
1140
  tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
1136
1141
  settings: { duration, resolution, aspect_ratio },
1137
- reference_image: first_frame_url || undefined
1142
+ reference_images: [first_frame_url || first_frame, last_frame_url || last_frame]
1143
+ .filter((source) => /^https?:\/\//i.test(source || ''))
1138
1144
  });
1139
1145
 
1140
1146
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1249,7 +1255,7 @@ function registerGenerateTools(server, client, options = {}) {
1249
1255
  if (ui()) return uiGenerating({
1250
1256
  tool: 'generate_lipsync', kind: 'video', gen: startResponse, client, model,
1251
1257
  prompt: text_prompt, settings: { mode: 'lipsync' },
1252
- reference_image: sourceIsUrl && !/\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? source : undefined,
1258
+ reference_images: sourceIsUrl && !/\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? [source] : [],
1253
1259
  });
1254
1260
 
1255
1261
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1354,7 +1360,7 @@ function registerGenerateTools(server, client, options = {}) {
1354
1360
  tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
1355
1361
  prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
1356
1362
  settings: { duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle' },
1357
- reference_image: reference_images?.[0]
1363
+ reference_images: [...(reference_images || []), ...(elements || [])]
1358
1364
  });
1359
1365
 
1360
1366
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1497,7 +1503,7 @@ function registerGenerateTools(server, client, options = {}) {
1497
1503
  if (ui()) return uiGenerating({
1498
1504
  tool: 'generate_3d', kind: '3d', gen: startResponse, client, model, prompt,
1499
1505
  settings: { mode: mode || (reference_images?.length > 1 ? 'multi' : reference_images?.length === 1 ? 'single' : 'text') },
1500
- reference_image: reference_images?.[0]
1506
+ reference_images
1501
1507
  });
1502
1508
 
1503
1509
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1643,7 +1649,7 @@ function registerGenerateTools(server, client, options = {}) {
1643
1649
  tool: 'edit_image', kind: 'image', gen, client, model,
1644
1650
  prompt: prompt || operation,
1645
1651
  settings: { mode: operation, aspect_ratio, scale, resolution },
1646
- reference_image: image_url
1652
+ reference_images: [image_url, mask_image_url, ...(additional_images || [])].filter(Boolean)
1647
1653
  });
1648
1654
 
1649
1655
  const poll = await pollOrTimedOut(client, gen.generation_id, {
@@ -1808,7 +1814,7 @@ function registerGenerateTools(server, client, options = {}) {
1808
1814
  tool: 'edit_video', kind: 'video', gen, client, model,
1809
1815
  prompt: prompt || operation,
1810
1816
  settings: { mode: operation, duration, aspect_ratio, resolution },
1811
- reference_image: image_url
1817
+ reference_images: image_url ? [image_url] : []
1812
1818
  });
1813
1819
 
1814
1820
  const poll = await pollOrTimedOut(client, gen.generation_id, {
@@ -85,7 +85,6 @@ function registerProjectTools(server, client, options = {}) {
85
85
  };
86
86
  }
87
87
  );
88
-
89
88
  // ─── bulk_move_sessions ────────────────────────────────────
90
89
  server.tool(
91
90
  'bulk_move_sessions',