@kolbo/mcp 1.79.7 → 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 +6 -2
- package/package.json +1 -1
- package/src/apps/bridge.js +2 -0
- package/src/apps/html.js +90 -1
- package/src/apps/index.js +153 -5
- package/src/apps/theme.js +42 -4
- package/src/apps/widgets/generation.js +198 -84
- package/src/apps/widgets/list.js +4 -1
- package/src/apps/widgets/mediaGrid.js +10 -3
- package/src/apps/widgets/transcript.js +4 -1
- package/src/client.js +14 -6
- package/src/toolAnnotations.js +5 -2
- package/src/tools/_shared.js +159 -24
- package/src/tools/agents.js +1 -1
- package/src/tools/docs.js +1 -1
- package/src/tools/generate.js +45 -21
- package/src/tools/moodboards.js +3 -2
- package/src/tools/presets.js +37 -36
- package/src/tools/projects.js +148 -5
- package/src/tools/visual_dna.js +126 -9
package/src/tools/presets.js
CHANGED
|
@@ -12,55 +12,56 @@ function registerPresetTools(server, client, options = {}) {
|
|
|
12
12
|
// ─── list_presets ──────────────────────────────────────────
|
|
13
13
|
server.tool(
|
|
14
14
|
'list_presets',
|
|
15
|
-
'
|
|
15
|
+
'Resolve a generation preset and pass its exact id as preset_id. ALWAYS pass `search` when you already know the name (headless, bible, character sheet, a user preset) — that is a silent id lookup, not a catalog to show the user. Omit search only when they asked to browse. Custom instructions live on the preset, so prefer generate_image + preset_id over generate_character_sheet. Never invent an id.',
|
|
16
16
|
{
|
|
17
|
-
type: z.string().optional().describe('Filter by catalog: "image" | "image_edit" | "video" | "music" | "text_to_video". Omit for all.')
|
|
17
|
+
type: z.string().optional().describe('Filter by catalog: "image" | "image_edit" | "video" | "music" | "text_to_video". Omit for all.'),
|
|
18
|
+
search: z.string().optional().describe('Name lookup, e.g. "headless", "bible", "character sheet". Required when resolving a named sheet/style — do not list the whole catalog.')
|
|
18
19
|
},
|
|
19
|
-
async ({ type }) => {
|
|
20
|
+
async ({ type, search }) => {
|
|
20
21
|
const params = new URLSearchParams();
|
|
21
22
|
if (type) params.set('type', type);
|
|
23
|
+
if (search) params.set('search', search);
|
|
22
24
|
const qs = params.toString();
|
|
23
25
|
const result = await client.get(`/v1/presets${qs ? '?' + qs : ''}`);
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
let presets = result.presets || [];
|
|
28
|
+
if (search && !result.search) {
|
|
29
|
+
const q = search.toLowerCase();
|
|
30
|
+
presets = presets.filter((p) => `${p.name || ''} ${p.description || ''} ${p.category || ''}`.toLowerCase().includes(q));
|
|
31
|
+
}
|
|
32
|
+
const lookup = Boolean(search);
|
|
33
|
+
// Full catalog measured 632,919 chars. A named lookup should return a handful of rows.
|
|
28
34
|
const text = compactList(presets, {
|
|
29
35
|
fields: ['id', 'name', 'category', 'type', 'description'],
|
|
30
|
-
cap:
|
|
36
|
+
cap: lookup ? 8 : 12,
|
|
31
37
|
total: result.count || presets.length,
|
|
32
|
-
extra:
|
|
33
|
-
|
|
38
|
+
extra: {
|
|
39
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
40
|
+
...(lookup ? { _lookup: true } : {}),
|
|
41
|
+
},
|
|
42
|
+
note: lookup
|
|
43
|
+
? 'Pick the closest id and pass it as preset_id. Do not show this list to the user.'
|
|
44
|
+
: 'Pass search next time if you already know the name. Pass the chosen exact id as preset_id.',
|
|
34
45
|
});
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
// renders widgets without advertising MCP Apps (Kolbo Code) with text-only rows
|
|
38
|
-
// that carry no thumbnail field at all — and its BY_TOOL map still force-mounts
|
|
39
|
-
// the media grid on them, so the card rendered one broken-file glyph per cell.
|
|
40
|
-
// media.js and listResult() have always done it this way; these five lagged.
|
|
41
|
-
{
|
|
42
|
-
return uiResult(UI.mediaGrid, text, {
|
|
43
|
-
widget: 'media-grid',
|
|
44
|
-
title: 'Presets' + (type ? ' — ' + type : ''),
|
|
45
|
-
items: presets.slice(0, 24).map(p => ({
|
|
46
|
-
id: p.id,
|
|
47
|
-
title: p.name,
|
|
48
|
-
subtitle: p.category,
|
|
49
|
-
// API returns thumbnail_url / audio_url (see sdk listPresets) — NOT
|
|
50
|
-
// thumbnail / audio_preview_url. Reading the wrong key rendered every
|
|
51
|
-
// preset as a blank tile and dropped music previews entirely.
|
|
52
|
-
thumbnail: p.thumbnail_url || p.thumbnail,
|
|
53
|
-
media_type: p.audio_url ? 'audio' : 'image',
|
|
54
|
-
preview_audio: p.audio_url,
|
|
55
|
-
url: p.thumbnail_url || p.thumbnail,
|
|
56
|
-
use_hint: 'Use preset "{TITLE}" (preset_id: {ID}) for my next generation — ask me for the prompt.'
|
|
57
|
-
})),
|
|
58
|
-
total: result.count || presets.length,
|
|
59
|
-
has_more: presets.length > 24
|
|
60
|
-
});
|
|
61
|
-
}
|
|
47
|
+
if (lookup) return { content: [{ type: 'text', text }] };
|
|
62
48
|
|
|
63
|
-
return
|
|
49
|
+
return uiResult(UI.list, text, {
|
|
50
|
+
widget: 'list',
|
|
51
|
+
title: 'Presets' + (type ? ' — ' + type : ''),
|
|
52
|
+
items: presets.slice(0, 8).map(p => ({
|
|
53
|
+
id: p.id,
|
|
54
|
+
title: p.name,
|
|
55
|
+
subtitle: p.category,
|
|
56
|
+
thumbnail: p.thumbnail_url || p.thumbnail,
|
|
57
|
+
media_type: p.audio_url ? 'audio' : 'image',
|
|
58
|
+
preview_audio: p.audio_url,
|
|
59
|
+
url: p.thumbnail_url || p.thumbnail,
|
|
60
|
+
use_hint: 'Use preset "{TITLE}" (preset_id: {ID}) for my next generation — ask me for the prompt.'
|
|
61
|
+
})),
|
|
62
|
+
total: result.count || presets.length,
|
|
63
|
+
has_more: presets.length > 8
|
|
64
|
+
});
|
|
64
65
|
}
|
|
65
66
|
);
|
|
66
67
|
|
package/src/tools/projects.js
CHANGED
|
@@ -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
|
|
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 `
|
|
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.',
|
package/src/tools/visual_dna.js
CHANGED
|
@@ -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.'),
|
|
@@ -153,7 +153,8 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
153
153
|
id: d.id,
|
|
154
154
|
title: d.name,
|
|
155
155
|
subtitle: (d.dna_type || '') + (Array.isArray(d.tags) && d.tags.length ? ' · ' + d.tags.slice(0, 3).join(', ') : ''),
|
|
156
|
-
thumbnail: d.thumbnail_url || d.thumbnail,
|
|
156
|
+
thumbnail: d.sheet_url || d.thumbnail_url || d.thumbnail,
|
|
157
|
+
url: d.sheet_url || d.thumbnail_url || d.thumbnail,
|
|
157
158
|
media_type: 'image',
|
|
158
159
|
use_hint: 'Use Visual DNA "{TITLE}" (id: {ID}) in my next generation for character/style consistency.'
|
|
159
160
|
})),
|
|
@@ -183,10 +184,119 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
183
184
|
}
|
|
184
185
|
);
|
|
185
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
|
+
|
|
186
296
|
// ─── delete_visual_dna ─────────────────────────────────────
|
|
187
297
|
server.tool(
|
|
188
298
|
'delete_visual_dna',
|
|
189
|
-
'
|
|
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.',
|
|
190
300
|
{
|
|
191
301
|
visual_dna_id: z.string().describe('The Visual DNA profile ID to delete')
|
|
192
302
|
},
|
|
@@ -207,18 +317,25 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
207
317
|
// ─── generate_character_sheet ──────────────────────────────
|
|
208
318
|
server.tool(
|
|
209
319
|
'generate_character_sheet',
|
|
210
|
-
'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`.',
|
|
211
321
|
{
|
|
212
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.'),
|
|
213
|
-
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.')
|
|
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.'),
|
|
324
|
+
model: z.string().optional().describe('Image model for the sheet. Default nano-banana-2. Pass gpt-image-2 when the user names it.'),
|
|
325
|
+
resolution: z.enum(['2K', '4K']).optional().describe('Sheet resolution. 2K or 4K only — never 1K. Default 2K. Use 4K for character_bible, high-detail leads, or when the user asks.')
|
|
214
326
|
},
|
|
215
|
-
async ({ image_urls, sheet_type }) => {
|
|
327
|
+
async ({ image_urls, sheet_type, model, resolution }) => {
|
|
216
328
|
// The endpoint is blocking and a 2K multi-panel sheet routinely runs past the
|
|
217
329
|
// 120s default: the MCP aborted while kolbo-api kept going, finished, and
|
|
218
330
|
// billed — the user saw "Failed" for a sheet they had already paid for.
|
|
219
331
|
const result = await client.post(
|
|
220
332
|
'/v1/visual-dna/character-sheet',
|
|
221
|
-
{
|
|
333
|
+
{
|
|
334
|
+
image_urls,
|
|
335
|
+
...(sheet_type ? { sheet_type } : {}),
|
|
336
|
+
...(model ? { model } : {}),
|
|
337
|
+
...(resolution ? { resolution } : {}),
|
|
338
|
+
},
|
|
222
339
|
{ timeoutMs: CHARACTER_SHEET_TIMEOUT_MS },
|
|
223
340
|
);
|
|
224
341
|
// `urls` is NOT redundant with character_sheet_url. Every generation-card
|
|
@@ -241,7 +358,7 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
241
358
|
phase: 'completed',
|
|
242
359
|
kind: 'image',
|
|
243
360
|
credits_used: result.credits_used,
|
|
244
|
-
_hint: 'Show the sheet to the user, then pass character_sheet_url to create_visual_dna
|
|
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.'
|
|
245
362
|
}, null, 2)
|
|
246
363
|
}]
|
|
247
364
|
};
|