@kolbo/mcp 1.80.0 → 1.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,6 +153,7 @@ Every generation tool also accepts an optional `project_id` arg that routes the
153
153
  | Tool | Description |
154
154
  |------|-------------|
155
155
  | `create_visual_dna` | Create a profile from URLs or local files |
156
+ | `update_visual_dna` | Edit name, description, stills, sheet, or type in place (never delete+recreate) |
156
157
  | `list_visual_dnas` | List your profiles |
157
158
  | `get_visual_dna` | Fetch one profile |
158
159
  | `delete_visual_dna` | Delete a profile |
@@ -162,6 +163,7 @@ Every generation tool also accepts an optional `project_id` arg that routes the
162
163
  |------|-------------|
163
164
  | `list_moodboards` | Browse presets + your moodboards |
164
165
  | `get_moodboard` | Fetch one moodboard with all image URLs |
166
+ | `create_moodboard` / `update_moodboard` / `delete_moodboard` | Create / edit in place / delete |
165
167
 
166
168
  **Color DNA** — sticky, account-wide: the ACTIVE palette strict-grades every generation until deactivated. Opt a single generation out with `skip_color_palette`.
167
169
  | Tool | Description |
@@ -238,7 +240,8 @@ Every generation tool also accepts an optional `project_id` arg that routes the
238
240
  | `list_voices` | TTS voices (presets + cloned) |
239
241
  | `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. |
240
242
  | `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 |
241
- | `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 |
243
+ | `list_projects` | List owned + shared projects (id, name, description, role, is_default) — call first to resolve a project name into the `project_id` you pass to generation tools |
244
+ | `get_project` | Full project record including the unclipped description — read this before `update_project` |
242
245
  | `move_session` | Move ONE session (generation, chat, transcription…) and ALL its generations + media to another project |
243
246
  | `bulk_move_sessions` | Move up to 100 sessions into one project in a single call — mixed types allowed, per-session failures reported |
244
247
  | `list_session_generations` | A session's generations as complete groups (prompt + all its outputs) — the ids the two organize tools below take |
@@ -246,7 +249,7 @@ Every generation tool also accepts an optional `project_id` arg that routes the
246
249
  | `split_session` | Carve selected generations out into a brand-new named session, atomically |
247
250
  | `undo_session_organization` | Reverse a move/split within 15 minutes, using the `operation_id` it returned |
248
251
  | `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 |
249
- | `generate_character_sheet` | Generate a multi-angle character sheet from reference images (credits) → pass URL to create_visual_dna for stronger character consistency |
252
+ | `generate_character_sheet` | Generate a multi-angle character sheet from reference images (credits) → pass URL to create_visual_dna or update_visual_dna |
250
253
  | `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) |
251
254
  | `create_project` / `update_project` / `archive_project` / `unarchive_project` | Project lifecycle (create/rename/describe/archive; deletion stays in-app) |
252
255
  | `list_agents` / `create_agent` / `update_agent` / `delete_agent` | Custom chat agents (reusable named personas; `description` is the system instruction) |
@@ -254,6 +257,7 @@ Every generation tool also accepts an optional `project_id` arg that routes the
254
257
  | `list_sessions` | Enumerate sessions across all types, filterable by project, `type`, and `types[]` |
255
258
  | `rename_session` / `delete_session` / `restore_session` | Rename a session; soft-delete leftovers after a move; restore from trash |
256
259
  | `add_project_context` / `list_project_context` / `delete_project_context` / `get_project_profile` / `regenerate_project_profile` | Project knowledge base (RAG): feed scripts/URLs/notes, read the synthesized living brief |
260
+ | `list_project_assets` / `link_project_asset` / `unlink_project_asset` / `update_project_asset` | Project cast: tag Visual DNAs / moodboards onto a project, write each DNA's description and purpose note |
257
261
  | `create_moodboard` / `update_moodboard` / `delete_moodboard` | Build/edit moodboards from image URLs (AI style analysis → master prompt) |
258
262
  | `clone_voice` / `import_elevenlabs_voice` / `delete_voice` | Custom voices: clone from an audio sample, import by ElevenLabs ID, delete |
259
263
  | `trim_video` | Frame-accurate server-side trim of a Kolbo-hosted video (async job, tool waits) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.80.0",
3
+ "version": "1.81.0",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  # AUTO-GENERATED — do not edit
2
2
 
3
- This tree is mirrored from kolbo-code@b39d555, the single source of truth.
3
+ This tree is mirrored from kolbo-code@fccda44, 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
 
package/src/client.js CHANGED
@@ -451,17 +451,25 @@ class KolboClient {
451
451
  // poll loop, which does its own capped backoff in polling.js) still surfaces
452
452
  // the 429 straight to the caller, now with the wait spelled out in the message.
453
453
  async postMultipart(reqPath, formData) {
454
+ return this._multipart('POST', reqPath, formData);
455
+ }
456
+
457
+ async putMultipart(reqPath, formData) {
458
+ return this._multipart('PUT', reqPath, formData);
459
+ }
460
+
461
+ async _multipart(method, reqPath, formData) {
454
462
  if (!this.apiKey) await this._ensureLogin();
455
463
  return retryOnce429(async () => {
456
- const result = await this._doMultipart(reqPath, formData);
464
+ const result = await this._doMultipart(method, reqPath, formData);
457
465
  if (result._status === 401 && this._tryRefreshKey()) {
458
- return this._doMultipart(reqPath, formData);
466
+ return this._doMultipart(method, reqPath, formData);
459
467
  }
460
468
  return result;
461
469
  });
462
470
  }
463
471
 
464
- async _doMultipart(reqPath, formData) {
472
+ async _doMultipart(method, reqPath, formData) {
465
473
  const url = `${this.baseUrl}${reqPath}`;
466
474
  const headers = {
467
475
  'X-API-Key': this.apiKey,
@@ -484,7 +492,7 @@ class KolboClient {
484
492
  let response;
485
493
  try {
486
494
  response = await fetch(url, {
487
- method: 'POST',
495
+ method,
488
496
  headers,
489
497
  body,
490
498
  signal: composed.signal
@@ -493,12 +501,12 @@ class KolboClient {
493
501
  if (isAbortError(err)) {
494
502
  if (callerSignal?.aborted) {
495
503
  throw new KolboApiError(
496
- `Upload cancelled by caller: POST ${reqPath}`,
504
+ `Upload cancelled by caller: ${method} ${reqPath}`,
497
505
  { code: 'REQUEST_CANCELLED', status: 499 }
498
506
  );
499
507
  }
500
508
  throw new KolboApiError(
501
- `Upload timed out after ${UPLOAD_TIMEOUT_MS / 1000}s: POST ${reqPath} ` +
509
+ `Upload timed out after ${UPLOAD_TIMEOUT_MS / 1000}s: ${method} ${reqPath} ` +
502
510
  `(${Math.round(body.length / 1024)}KB). Raise KOLBO_UPLOAD_TIMEOUT_MS for slow links.`,
503
511
  { code: 'UPLOAD_TIMEOUT', status: 504 }
504
512
  );
@@ -17,7 +17,8 @@ const READ_ONLY = [
17
17
  'list_color_palettes', 'analyze_color_palette',
18
18
  'list_media', 'list_media_folders', 'get_media', 'get_media_stats',
19
19
  'list_presets', 'list_cinematic_presets',
20
- 'list_projects', 'list_sessions', 'list_project_context', 'get_project_profile',
20
+ 'list_projects', 'get_project', 'list_sessions', 'list_project_context', 'get_project_profile',
21
+ 'list_project_assets',
21
22
  'list_session_generations',
22
23
  'list_agents', 'list_docs', 'get_doc',
23
24
  'get_review_storage_usage', 'list_review_assets', 'get_review_asset',
@@ -47,6 +48,7 @@ const PRIVATE_WRITE = [
47
48
  'rename_session', 'restore_session',
48
49
  'create_project', 'update_project',
49
50
  'archive_project', 'unarchive_project', 'add_project_context',
51
+ 'link_project_asset', 'unlink_project_asset',
50
52
  'create_agent',
51
53
  'create_doc',
52
54
  'create_review_asset', 'update_review_asset', 'add_review_version',
@@ -69,7 +71,7 @@ const DESTRUCTIVE_WRITE = [
69
71
  'separate_audio_stems', 'clean_dialogue_leftovers', 'separate_ambience',
70
72
 
71
73
  // Deletes and whole-value replacement updates are conservatively destructive.
72
- 'delete_voice', 'delete_visual_dna', 'delete_visual_dna_folder',
74
+ 'delete_voice', 'update_visual_dna', 'delete_visual_dna', 'delete_visual_dna_folder',
73
75
  'update_moodboard', 'delete_moodboard',
74
76
  'update_color_palette', 'delete_color_palette',
75
77
  'delete_media_folder', 'delete_media', 'permanently_delete_media',
@@ -77,6 +79,7 @@ const DESTRUCTIVE_WRITE = [
77
79
  'unshare_media_folder',
78
80
  'delete_session',
79
81
  'delete_project_context', 'regenerate_project_profile',
82
+ 'update_project_asset',
80
83
  'update_agent', 'delete_agent', 'update_doc', 'delete_doc',
81
84
  'delete_review_asset', 'delete_review_collection',
82
85
  'edit_review_comment', 'delete_review_comment',
@@ -53,8 +53,8 @@ const FILE_INPUT_TOOLS = [
53
53
  'generate_video', 'generate_video_from_image', 'generate_video_from_video',
54
54
  'generate_elements', 'generate_first_last_frame', 'generate_lipsync',
55
55
  'generate_3d', 'edit_image', 'edit_video', 'transcribe_audio',
56
- 'create_visual_dna', 'generate_character_sheet', 'clone_voice',
57
- 'chat_send_message', 'create_moodboard',
56
+ 'create_visual_dna', 'update_visual_dna', 'generate_character_sheet', 'clone_voice',
57
+ 'chat_send_message', 'create_moodboard', 'update_moodboard',
58
58
  'separate_audio_stems', 'clean_dialogue_leftovers', 'separate_ambience'
59
59
  ];
60
60
 
@@ -59,7 +59,7 @@ function registerAgentTools(server, client) {
59
59
  // ─── update_agent ──────────────────────────────────────────
60
60
  server.tool(
61
61
  'update_agent',
62
- 'Update a custom chat agent\'s name, description (persona/instructions), or emoji/thumbnail. Only personal agents you own can be edited — global preset agents are protected. Resolve the agent id with list_agents first.',
62
+ 'Edit a custom chat agent in place: name, description (persona/instructions), or emoji/thumbnail. NEVER delete and recreate an agent to change its persona — conversations already reference this agent_id. Only personal agents you own can be edited — global preset agents are protected. Resolve the agent id with list_agents first.',
63
63
  {
64
64
  agent_id: z.string().describe('Agent id (from list_agents).'),
65
65
  name: z.string().optional().describe('New name.'),
package/src/tools/docs.js CHANGED
@@ -84,7 +84,7 @@ function registerDocTools(server, client) {
84
84
  // ─── update_doc ────────────────────────────────────────────
85
85
  server.tool(
86
86
  'update_doc',
87
- 'Update an AI Doc\'s title and/or content. Content REPLACES the whole document — call `get_doc` first, apply the user\'s edits to the full HTML, and send the complete result back.',
87
+ 'Edit an AI Doc in place: title and/or content. NEVER delete and recreate a doc to change its text — existing share links and the sidebar entry stay on this id. Content REPLACES the whole document — call `get_doc` first, apply the user\'s edits to the full HTML, and send the complete result back.',
88
88
  {
89
89
  doc_id: z.string().describe('The doc ObjectId to update.'),
90
90
  title: z.string().optional().describe('New title. Omit to keep the current one.'),
@@ -93,7 +93,7 @@ function registerMoodboardTools(server, client, options = {}) {
93
93
  // ─── update_moodboard ──────────────────────────────────────
94
94
  server.tool(
95
95
  'update_moodboard',
96
- 'Update a moodboard\'s name, style guide, and/or images. Providing `image_urls` REPLACES the whole image set and re-analyzes the style (master prompt regenerates). Owner only.',
96
+ 'Edit a moodboard in place: name, style guide, and/or images. NEVER delete and recreate a board to rename it or swap stills — existing generations already reference this id as moodboard_id / #Name. Providing `image_urls` REPLACES the whole image set and re-analyzes the style (master prompt regenerates). Owner only.',
97
97
  {
98
98
  moodboard_id: z.string().describe('Moodboard id (from list_moodboards).'),
99
99
  name: z.string().optional().describe('New name.'),
@@ -113,7 +113,7 @@ function registerMoodboardTools(server, client, options = {}) {
113
113
  // ─── delete_moodboard ──────────────────────────────────────
114
114
  server.tool(
115
115
  'delete_moodboard',
116
- 'Permanently delete a moodboard (owner only; system presets cannot be deleted). The underlying image files stay in storage — only the board is removed. Confirm with the user before deleting boards they did not just create.',
116
+ 'Permanently delete a moodboard (owner only; system presets cannot be deleted). Do NOT use this to rename or change images — that is `update_moodboard`. The underlying image files stay in storage — only the board is removed. Confirm with the user before deleting boards they did not just create.',
117
117
  { moodboard_id: z.string().describe('Moodboard id to delete.') },
118
118
  async ({ moodboard_id }) => {
119
119
  const result = await client.delete(`/v1/moodboards/${encodeURIComponent(moodboard_id)}`);
@@ -29,6 +29,7 @@ function registerProjectTools(server, client) {
29
29
  const projects = (result.projects || []).map(p => ({
30
30
  id: p.id,
31
31
  name: p.name,
32
+ description: p.description || null,
32
33
  role: p.role,
33
34
  is_default: !!p.is_default,
34
35
  is_archived: !!p.is_archived,
@@ -48,7 +49,7 @@ function registerProjectTools(server, client) {
48
49
  items: projects.map(p => ({
49
50
  id: p.id,
50
51
  title: p.name,
51
- subtitle: p.role + (p.is_default ? ' · default' : '') + (p.is_archived ? ' · archived' : ''),
52
+ subtitle: (p.description || p.role) + (p.is_default ? ' · default' : '') + (p.is_archived ? ' · archived' : ''),
52
53
  thumbnail: p.thumbnail_url,
53
54
  open_url: p.open_url,
54
55
  use_hint: 'Use my "{TITLE}" project (project_id: {ID}) for what I do next.'
@@ -240,14 +241,27 @@ function registerProjectTools(server, client) {
240
241
  }
241
242
  );
242
243
 
244
+ // ─── get_project ───────────────────────────────────────────
245
+ server.tool(
246
+ 'get_project',
247
+ 'Fetch one project by id including its FULL description (list_projects clips descriptions to ~400 chars). Call this before `update_project` when you need to edit the brief, logline, or notes — read, apply the user\'s edits, send the complete description back.',
248
+ {
249
+ project_id: z.string().describe('Project ObjectId from list_projects.')
250
+ },
251
+ async ({ project_id }) => {
252
+ const result = await client.get(`/v1/projects/${encodeURIComponent(project_id)}`);
253
+ return { content: [{ type: 'text', text: JSON.stringify(result.project || result, null, 2) }] };
254
+ }
255
+ );
256
+
243
257
  // ─── update_project ────────────────────────────────────────
244
258
  server.tool(
245
259
  'update_project',
246
- 'Rename a project and/or update its description. Changing the description also refreshes the project\'s AI profile in the background.',
260
+ 'Rename a project and/or replace its description. NEVER delete a project or create a new one just to change the brief — this edits in place. Description REPLACES the whole text: call `get_project` first, apply the user\'s edits, send the complete result. Changing the description also refreshes the project\'s AI profile in the background.',
247
261
  {
248
- project_id: z.string().describe('Project ObjectId (from list_projects).'),
262
+ project_id: z.string().describe('Project ObjectId (from list_projects / get_project).'),
249
263
  name: z.string().optional().describe('New name.'),
250
- description: z.string().optional().describe('New description (replaces the old one).')
264
+ description: z.string().optional().describe('New description (replaces the old one; max 10k chars, markdown OK).')
251
265
  },
252
266
  async ({ project_id, name, description }) => {
253
267
  const body = {};
@@ -331,7 +345,7 @@ function registerProjectTools(server, client) {
331
345
 
332
346
  server.tool(
333
347
  'rename_session',
334
- 'Rename a session the user can see in the Kolbo sidebar. Use after `list_sessions` when they say "call this Hero Sequence" or leftover API daily names should become human titles. Does not move the session or its media.',
348
+ 'Rename a session the user can see in the Kolbo sidebar. Use this to edit a session title in place — never delete and recreate a session just to change its name. Call this immediately after the first generate of a plan bucket so the title matches the production plan (`Cast`, `Locations`, `Scene 03 rooftop chase`) instead of an API daily name. Also use when the user says "call this Hero Sequence". Does not move the session or its media.',
335
349
  {
336
350
  session_id: z.string().describe('Session ObjectId from `list_sessions` or a generate_* result.'),
337
351
  name: z.string().describe('New sidebar title (1–200 characters).'),
@@ -443,6 +457,135 @@ function registerProjectTools(server, client) {
443
457
  }
444
458
  );
445
459
 
460
+ server.tool(
461
+ 'list_project_assets',
462
+ 'List the Visual DNAs and moodboards tagged onto a project\'s CAST roster — the @Name / #Name assets this project actually uses. Each DNA row includes its stored description plus a project-scoped `note` ("what this asset is for here"). Call this before editing the cast, writing DNA descriptions, or generating against a named project. Does not list the user\'s whole library — only what is tagged on THIS project.',
463
+ { project_id: z.string().describe('Project ObjectId from list_projects.') },
464
+ async ({ project_id }) => {
465
+ const result = await client.get(`/v1/projects/${encodeURIComponent(project_id)}/assets`);
466
+ const dnas = result.visual_dnas || [];
467
+ const moodboards = result.moodboards || [];
468
+ const text = JSON.stringify({
469
+ visual_dnas: dnas,
470
+ moodboards,
471
+ _hint: 'Edit a DNA\'s identity description with update_project_asset (description) or update_visual_dna. The `note` is project-scoped purpose (update_project_asset note). Link missing assets with link_project_asset — do not delete+recreate.'
472
+ }, null, 2);
473
+ return listResult(text, {
474
+ widget: 'list',
475
+ title: 'Project cast',
476
+ items: [
477
+ ...dnas.map((d) => ({
478
+ id: d.id,
479
+ title: '@' + (d.name || d.id),
480
+ subtitle: [d.dna_type, d.note || d.description].filter(Boolean).join(' · ')
481
+ })),
482
+ ...moodboards.map((m) => ({
483
+ id: m.id,
484
+ title: '#' + (m.name || m.id),
485
+ subtitle: ['moodboard', m.note || m.summary].filter(Boolean).join(' · ')
486
+ }))
487
+ ],
488
+ total: dnas.length + moodboards.length
489
+ });
490
+ }
491
+ );
492
+
493
+ server.tool(
494
+ 'link_project_asset',
495
+ 'Tag an existing Visual DNA or moodboard onto a project\'s cast roster so it shows up as @Name / #Name for this project (and in the AI cast list). Does NOT copy or recreate the asset. After linking a DNA, write its description with update_project_asset. Optional `note` is the project-scoped purpose ("hero, dark-bg logo").',
496
+ {
497
+ project_id: z.string().describe('Project ObjectId.'),
498
+ asset_type: z.enum(['visual_dna', 'moodboard']).describe('Kind of asset to tag.'),
499
+ asset_id: z.string().describe('Visual DNA id or moodboard id (from list_visual_dnas / list_moodboards).'),
500
+ note: z.string().optional().describe('Optional project-scoped purpose note (max 1000 chars).')
501
+ },
502
+ async ({ project_id, asset_type, asset_id, note }) => {
503
+ const result = await client.post(`/v1/projects/${encodeURIComponent(project_id)}/assets/link`, {
504
+ asset_type,
505
+ asset_id
506
+ });
507
+ let savedNote = null;
508
+ if (note !== undefined) {
509
+ const n = await client.put(
510
+ `/v1/projects/${encodeURIComponent(project_id)}/assets/${encodeURIComponent(asset_type)}/${encodeURIComponent(asset_id)}/note`,
511
+ { note }
512
+ );
513
+ savedNote = n.note;
514
+ }
515
+ return {
516
+ content: [{
517
+ type: 'text',
518
+ text: JSON.stringify({
519
+ asset: result.asset,
520
+ note: savedNote,
521
+ _hint: asset_type === 'visual_dna'
522
+ ? 'DNA is on the cast. Write its identity description with update_project_asset (description) — do not delete and recreate.'
523
+ : 'Moodboard is on the cast. Set a purpose note with update_project_asset if needed.'
524
+ }, null, 2)
525
+ }]
526
+ };
527
+ }
528
+ );
529
+
530
+ server.tool(
531
+ 'unlink_project_asset',
532
+ 'Remove a Visual DNA or moodboard from a project\'s cast roster. The asset itself stays in the user\'s library — this only untags it from the project. Do NOT use this to edit a description; that is update_project_asset / update_visual_dna.',
533
+ {
534
+ project_id: z.string().describe('Project ObjectId.'),
535
+ asset_type: z.enum(['visual_dna', 'moodboard']).describe('Kind of asset to untag.'),
536
+ asset_id: z.string().describe('Asset id from list_project_assets.')
537
+ },
538
+ async ({ project_id, asset_type, asset_id }) => {
539
+ const result = await client.delete(
540
+ `/v1/projects/${encodeURIComponent(project_id)}/assets/${encodeURIComponent(asset_type)}/${encodeURIComponent(asset_id)}`
541
+ );
542
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
543
+ }
544
+ );
545
+
546
+ server.tool(
547
+ 'update_project_asset',
548
+ 'Edit a tagged project-cast asset in place. `description` writes the Visual DNA\'s identity text (the thing @Name injects — same as update_visual_dna prompt_helper). `note` is the project-scoped purpose ("what this asset is for HERE", fed into the AI cast roster). Pass either or both. NEVER unlink+relink or delete+recreate a DNA to change its description. Moodboards: `note` only here; use update_moodboard for style/images.',
549
+ {
550
+ project_id: z.string().describe('Project ObjectId.'),
551
+ asset_type: z.enum(['visual_dna', 'moodboard']).describe('Kind of tagged asset.'),
552
+ asset_id: z.string().describe('Asset id from list_project_assets.'),
553
+ description: z.string().optional().describe('For visual_dna: new identity description (replaces prompt_helper on the DNA itself). Ignored for moodboards.'),
554
+ note: z.string().optional().describe('Project-scoped purpose note (max 1000 chars). Pass "" to clear.')
555
+ },
556
+ async ({ project_id, asset_type, asset_id, description, note }) => {
557
+ if (description === undefined && note === undefined) {
558
+ throw new Error('Provide description and/or note');
559
+ }
560
+ const out = {};
561
+ if (note !== undefined) {
562
+ const n = await client.put(
563
+ `/v1/projects/${encodeURIComponent(project_id)}/assets/${encodeURIComponent(asset_type)}/${encodeURIComponent(asset_id)}/note`,
564
+ { note }
565
+ );
566
+ out.note = n.note;
567
+ }
568
+ if (description !== undefined) {
569
+ if (asset_type !== 'visual_dna') {
570
+ throw new Error('description is only valid for asset_type=visual_dna — use update_moodboard for moodboard style notes');
571
+ }
572
+ const dna = await client.put(`/v1/visual-dna/${encodeURIComponent(asset_id)}`, {
573
+ prompt_helper: description
574
+ });
575
+ out.visual_dna = dna.visual_dna || dna;
576
+ }
577
+ return {
578
+ content: [{
579
+ type: 'text',
580
+ text: JSON.stringify({
581
+ ...out,
582
+ _hint: 'Cast asset updated in place. Keep using the same id and @Name.'
583
+ }, null, 2)
584
+ }]
585
+ };
586
+ }
587
+ );
588
+
446
589
  server.tool(
447
590
  'regenerate_project_profile',
448
591
  'Force-regenerate a project\'s AI profile from its current context sources (also clears any manual-edit lock). Use after adding several new sources when the user wants the brief refreshed now.',
@@ -23,12 +23,12 @@ function registerVisualDnaTools(server, client, options = {}) {
23
23
  // ─── create_visual_dna ─────────────────────────────────────
24
24
  server.tool(
25
25
  'create_visual_dna',
26
- 'Create a Visual DNA profile from reference media. Each item in images/video/audio can be a public URL or an absolute local file path. Max 4 images, 1 video, 1 audio. Files capped at 25MB each. For EVERY DNA type, a reference sheet dramatically improves consistency (character turnaround / product details / location angles / style board) — offer to generate one with `generate_character_sheet` (matching `sheet_type`) first, then pass its URL as `character_sheet_url` here (see that tool).',
26
+ 'Create a Visual DNA profile from reference media. Each item in images/video/audio can be a public URL or an absolute local file path. Max 4 images, 1 video, 1 audio. Files capped at 25MB each. Those stills are ALL packed into later generations (every image slot the model has, or a white grid if only one leftover slot remains) — so they must share one identity and one vibe. Character DNA: only that person (anonymous crowd OK, no second hero). Environment/location DNA: the place only — empty or anonymous crowd, NEVER a main character or recognizable hero face. Product: only that product. Style: one art direction. Separate states (day/night, clean/bloody) = separate DNAs. For EVERY DNA type, a reference sheet dramatically improves consistency (character turnaround / product details / location angles / style board) — offer to generate one with `generate_character_sheet` (matching `sheet_type`) first, then pass its URL as `character_sheet_url` here (see that tool).',
27
27
  {
28
28
  name: z.string().describe('Name of the Visual DNA profile. **Pick a short, lowercase, no-space single token** (e.g. `maya`, `tokyo_neon`, `brand_red`, `esther_model`) — never names with spaces (`Sarah Johnson` ❌). The user/LLM types this as `@<name>` inside generation prompts, and the @ parser stops at the first space, so `@Sarah Johnson` matches only `Sarah` and the binding silently drops. Multi-word concepts should use underscores or be a single token. Names are case-insensitive on lookup, but **reserved** values rejected on creation: `Image1`, `Image2`, …, `Video1`, …, `Audio1`, … (any-language characters allowed; max 100 chars).'),
29
29
  dna_type: z.string().optional().describe('Type: "character", "style", "product", "scene", "environment". Default: "character"'),
30
30
  prompt_helper: z.string().optional().describe('Optional description/notes to guide DNA extraction'),
31
- images: z.array(z.string()).optional().describe('Array of image sources (URLs or absolute local paths). Max 4.'),
31
+ images: z.array(z.string()).optional().describe('Array of image sources (URLs or absolute local paths). Max 4. All of them can reach the model on later gens — same subject, same vibe only; no extra heroes on character DNAs, no main characters on environment DNAs.'),
32
32
  video: z.string().optional().describe('Optional video source (URL or absolute local path)'),
33
33
  audio: z.string().optional().describe('Optional audio source (URL or absolute local path) — the character\'s voice, 5-30s of clean speech. Stored on the DNA and used two ways: (1) as REFERENCE AUDIO in video generation — attaching this DNA to an image-to-video generation on a model with audio slots (Seedance 2.x, Wan 3.0) auto-attaches the clip and tells the model it is that character\'s voice; (2) as the source for a real speaking voice, but ONLY when you ask for one — see `voice_source`.'),
34
34
  voice_source: z.enum(['none', 'clone', 'assign', 'design']).optional().describe('What to do about a SPEAKING voice. **Pass "none" when the audio is just a reference clip** (the usual case for video work) — the clip is stored and usable as video reference audio, and nothing else happens. "clone" mints an ElevenLabs voice from the uploaded audio, which consumes a voice slot and may EVICT another of the user\'s voices to free one; it also makes the DNA addressable as `dna_<id>` in text-to-speech. "assign" points at an existing voice (pass `assigned_voice_id`). "design" generates a voice from the character\'s look. ⚠️ Omitting this while passing `audio` keeps the legacy behaviour and CLONES — pass "none" explicitly unless the user asked for a voice.'),
@@ -184,10 +184,119 @@ function registerVisualDnaTools(server, client, options = {}) {
184
184
  }
185
185
  );
186
186
 
187
+ // ─── update_visual_dna ─────────────────────────────────────
188
+ server.tool(
189
+ 'update_visual_dna',
190
+ 'Edit an existing Visual DNA in place — name, description, type, character sheet, stills, video, audio, or character attributes. NEVER delete and recreate a DNA to change any of those: the old id is what generations, sessions, and @Name bindings already point at. Providing `images` REPLACES the whole still set (max 4) and re-analyzes the profile. Omit images to keep current stills. Owner only; global presets cannot be edited (import first).',
191
+ {
192
+ visual_dna_id: z.string().describe('Visual DNA id from list_visual_dnas / create_visual_dna.'),
193
+ name: z.string().optional().describe('New name. Same no-space single-token rule as create_visual_dna — @Name binding stops at the first space.'),
194
+ dna_type: z.string().optional().describe('Type: "character", "style", "product", "scene", "environment". Changing type re-analyzes the profile.'),
195
+ prompt_helper: z.string().optional().describe('New description / intent notes. Replaces the old ones and re-synthesizes the DNA analysis when the text actually changes. Pass "" to clear.'),
196
+ images: z.array(z.string()).optional().describe('Full replacement still set (URLs or absolute local paths). Max 4. Omit to keep current stills. Same purity rules as create: one identity, one vibe.'),
197
+ video: z.string().optional().describe('Replacement video source (URL or absolute local path).'),
198
+ audio: z.string().optional().describe('Replacement audio source (URL or absolute local path).'),
199
+ character_sheet_url: z.string().optional().describe('New reference sheet URL (from generate_character_sheet). Sets the DNA\'s primary sheet without replacing stills.'),
200
+ remove_character_sheet: z.boolean().optional().describe('If true, clears the stored character sheet. Do not combine with character_sheet_url.'),
201
+ gender: z.string().optional().describe('Character attribute (character DNAs).'),
202
+ ethnicity: z.string().optional().describe('Character attribute (character DNAs).'),
203
+ body_type: z.string().optional().describe('Character attribute (character DNAs).'),
204
+ hair_color: z.string().optional().describe('Character attribute (character DNAs).'),
205
+ eye_color: z.string().optional().describe('Character attribute (character DNAs).'),
206
+ skin_tone: z.string().optional().describe('Character attribute (character DNAs).'),
207
+ age_range: z.string().optional().describe('Character attribute (character DNAs).'),
208
+ specific_age: z.number().optional().describe('Character attribute (character DNAs).')
209
+ },
210
+ async ({
211
+ visual_dna_id, name, dna_type, prompt_helper, images, video, audio,
212
+ character_sheet_url, remove_character_sheet,
213
+ gender, ethnicity, body_type, hair_color, eye_color, skin_tone, age_range, specific_age
214
+ }) => {
215
+ const imageList = Array.isArray(images) ? images.filter(Boolean) : [];
216
+ if (imageList.length > 4) throw new Error('Maximum 4 images allowed');
217
+ const hasMedia = imageList.length > 0 || !!video || !!audio;
218
+ const hasMeta = name !== undefined || dna_type !== undefined || prompt_helper !== undefined
219
+ || character_sheet_url !== undefined || remove_character_sheet !== undefined
220
+ || gender !== undefined || ethnicity !== undefined || body_type !== undefined
221
+ || hair_color !== undefined || eye_color !== undefined || skin_tone !== undefined
222
+ || age_range !== undefined || specific_age !== undefined;
223
+ if (!hasMedia && !hasMeta) {
224
+ throw new Error('Provide at least one field to update');
225
+ }
226
+
227
+ const attrs = {
228
+ ...(gender !== undefined ? { gender } : {}),
229
+ ...(ethnicity !== undefined ? { ethnicity } : {}),
230
+ ...(body_type !== undefined ? { body_type } : {}),
231
+ ...(hair_color !== undefined ? { hair_color } : {}),
232
+ ...(eye_color !== undefined ? { eye_color } : {}),
233
+ ...(skin_tone !== undefined ? { skin_tone } : {}),
234
+ ...(age_range !== undefined ? { age_range } : {}),
235
+ ...(specific_age !== undefined ? { specific_age } : {})
236
+ };
237
+ const path = `/v1/visual-dna/${encodeURIComponent(visual_dna_id)}`;
238
+
239
+ if (!hasMedia) {
240
+ const body = { ...attrs };
241
+ if (name !== undefined) body.name = name;
242
+ if (dna_type !== undefined) body.dna_type = dna_type;
243
+ if (prompt_helper !== undefined) body.prompt_helper = prompt_helper;
244
+ if (character_sheet_url !== undefined) body.character_sheet_url = character_sheet_url;
245
+ if (remove_character_sheet !== undefined) body.remove_character_sheet = remove_character_sheet;
246
+ const result = await client.put(path, body);
247
+ return {
248
+ content: [{
249
+ type: 'text',
250
+ text: JSON.stringify({
251
+ visual_dna: result.visual_dna || result,
252
+ _hint: 'DNA updated in place — keep using this same id and the new stored name in @tags.'
253
+ }, null, 2)
254
+ }]
255
+ };
256
+ }
257
+
258
+ const [imageFiles, videoFile, audioFile] = await Promise.all([
259
+ Promise.all(imageList.map(src => resolveToBuffer(src, 'image'))),
260
+ video ? resolveToBuffer(video, 'video') : Promise.resolve(null),
261
+ audio ? resolveToBuffer(audio, 'audio') : Promise.resolve(null)
262
+ ]);
263
+
264
+ const form = new FormData();
265
+ if (name !== undefined) form.append('name', name);
266
+ if (dna_type) form.append('dnaType', dna_type);
267
+ if (prompt_helper !== undefined) form.append('promptHelper', prompt_helper);
268
+ if (character_sheet_url) form.append('characterSheetUrl', character_sheet_url);
269
+ if (remove_character_sheet !== undefined) {
270
+ form.append('removeCharacterSheet', remove_character_sheet ? 'true' : 'false');
271
+ }
272
+ for (const [k, v] of Object.entries(attrs)) form.append(k, String(v));
273
+ for (const f of imageFiles) {
274
+ form.append('images', f.buffer, { filename: f.filename, contentType: f.contentType });
275
+ }
276
+ if (videoFile) {
277
+ form.append('videos', videoFile.buffer, { filename: videoFile.filename, contentType: videoFile.contentType });
278
+ }
279
+ if (audioFile) {
280
+ form.append('audio', audioFile.buffer, { filename: audioFile.filename, contentType: audioFile.contentType });
281
+ }
282
+
283
+ const result = await client.putMultipart(path, form);
284
+ return {
285
+ content: [{
286
+ type: 'text',
287
+ text: JSON.stringify({
288
+ visual_dna: result.visual_dna || result,
289
+ _hint: 'DNA updated in place — keep using this same id and the new stored name in @tags.'
290
+ }, null, 2)
291
+ }]
292
+ };
293
+ }
294
+ );
295
+
187
296
  // ─── delete_visual_dna ─────────────────────────────────────
188
297
  server.tool(
189
298
  'delete_visual_dna',
190
- 'Delete a Visual DNA profile by ID. Only the owner can delete.',
299
+ 'Permanently delete a Visual DNA profile. Only the owner can delete. Do NOT use this to rename, restyle, swap stills, or change a description — that is `update_visual_dna`. Confirm with the user before deleting a DNA they did not just create.',
191
300
  {
192
301
  visual_dna_id: z.string().describe('The Visual DNA profile ID to delete')
193
302
  },
@@ -208,7 +317,7 @@ function registerVisualDnaTools(server, client, options = {}) {
208
317
  // ─── generate_character_sheet ──────────────────────────────
209
318
  server.tool(
210
319
  'generate_character_sheet',
211
- 'STANDARD FIRST STEP OF THE ASSET PASS for any film/ad/scene: inventory the characters, locations and props the script needs, generate a sheet for each, create its Visual DNA from that sheet, confirm the whole set with the user, and only THEN generate video. Sheets for cinematic environments and invented characters run well on `mirage-film-2` (3cr); use `nano-banana-2` (10cr) or `gpt-image-2` (12cr) when reference fidelity or legible text matters. Generate a reference sheet for a Visual DNA from 1+ reference image URLs — the same step the in-app Visual DNA wizard offers, for EVERY DNA type via `sheet_type`: character = multi-angle turnaround, product = angles + branding/material/construction close-ups, environment = location angles + one signature detail, style = a style board (the same look applied to six varied subjects). The sheet is the single strongest consistency booster for a DNA, and it always preserves the reference\'s original art style (2D stays 2D, photo stays photo). CHARGES CREDITS, so when the user is about to create a DNA, OFFER this first ("want me to generate a reference sheet for stronger consistency? it costs a few credits") and only run it on a yes. Returns `character_sheet_url` — pass it as `character_sheet_url` to `create_visual_dna` with the matching `dna_type`.',
320
+ 'STANDARD FIRST STEP OF THE ASSET PASS for any film/ad/scene: inventory the characters, locations and props the script needs, generate a sheet for each, create its Visual DNA from that sheet, confirm the whole set with the user, and only THEN generate video. Sheets for cinematic environments and invented characters run well on `mirage-film-2` (3cr); use `nano-banana-2` (10cr) or `gpt-image-2` (12cr) when reference fidelity or legible text matters. Generate a reference sheet for a Visual DNA from 1+ reference image URLs — the same step the in-app Visual DNA wizard offers, for EVERY DNA type via `sheet_type`: character = multi-angle turnaround, product = angles + branding/material/construction close-ups, environment = location angles + one signature detail (NO main character / recognizable hero face in an environment sheet — anonymous crowd is OK; those stills are packed into every later gen), style = a style board (the same look applied to six varied subjects). Keep every source image the same identity and vibe. The sheet is the single strongest consistency booster for a DNA, and it always preserves the reference\'s original art style (2D stays 2D, photo stays photo). CHARGES CREDITS, so when the user is about to create a DNA, OFFER this first ("want me to generate a reference sheet for stronger consistency? it costs a few credits") and only run it on a yes. Returns `character_sheet_url` — pass it as `character_sheet_url` to `create_visual_dna` with the matching `dna_type`.',
212
321
  {
213
322
  image_urls: z.array(z.string()).min(1).describe('Reference image URLs of the subject (for characters: front/side/varied angles work best). Use generated-image URLs or upload_media output.'),
214
323
  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. This IS the Character Sheet / Headless / Bible preset — do not call list_presets for those names.'),
@@ -249,7 +358,7 @@ function registerVisualDnaTools(server, client, options = {}) {
249
358
  phase: 'completed',
250
359
  kind: 'image',
251
360
  credits_used: result.credits_used,
252
- _hint: 'Show the sheet to the user, then pass character_sheet_url to create_visual_dna as that DNA\'s reference.'
361
+ _hint: 'Show the sheet to the user, then pass character_sheet_url to create_visual_dna (new DNA) or update_visual_dna (existing DNA). Never delete and recreate.'
253
362
  }, null, 2)
254
363
  }]
255
364
  };