@kolbo/mcp 1.53.1 → 1.55.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 +3 -14
- package/package.json +5 -3
- package/skill/GENERATED.md +1 -1
- package/skill/SKILL.md +16 -28
- package/skill/VERSION +1 -1
- package/skill/references/models/music.md +1 -1
- package/skill/references/workflows/media-library.md +29 -1
- package/skill/references/workflows/troubleshooting.md +13 -0
- package/src/client.js +60 -6
- package/src/index.js +32 -3
- package/src/install.js +35 -3
- package/src/polling.js +7 -0
- package/src/progress.js +55 -0
- package/src/tools/_shared.js +137 -11
- package/src/tools/artifacts.js +23 -5
- package/src/tools/chat.js +3 -3
- package/src/tools/generate.js +44 -24
- package/src/tools/media.js +104 -12
- package/src/tools/models.js +55 -3
- package/src/tools/moodboards.js +1 -1
- package/src/tools/music_library.js +31 -4
- package/src/tools/presets.js +56 -17
- package/src/tools/projects.js +1 -1
- package/src/tools/stock_library.js +23 -3
- package/src/tools/visual_dna.js +9 -5
- package/src/tools/voices.js +10 -1
- package/skill/references/models/voice-tts.md +0 -85
- package/skill/references/workflows/app-builder.md +0 -160
- package/src/tools/app_builder.js +0 -253
- package/src/tools/shorts_creator.js +0 -404
package/src/tools/media.js
CHANGED
|
@@ -5,16 +5,70 @@
|
|
|
5
5
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const FormData = require('form-data');
|
|
8
|
-
const { resolveToBuffer } = require('./_shared');
|
|
8
|
+
const { resolveToBuffer, DEFAULT_MAX_FILE_MB, compactList } = require('./_shared');
|
|
9
9
|
const { UI, uiResult, appsEnabled } = require('../apps');
|
|
10
10
|
|
|
11
|
+
// How many tiles the media grid renders. A rendering limit only — the text
|
|
12
|
+
// payload always carries the full page, and `total` reports the real library
|
|
13
|
+
// count, so a capped grid can never be mistaken for "that's everything".
|
|
14
|
+
const GRID_CAP = 24;
|
|
15
|
+
|
|
16
|
+
async function mintUploadTicket(client) {
|
|
17
|
+
const ticket = await client.post('/v1/media/upload-ticket', {});
|
|
18
|
+
if (!ticket || !ticket.token) throw new Error('Could not create an upload ticket — try again.');
|
|
19
|
+
return ticket;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Shape a /v1/media/upload-ticket response for a TEXT consumer (an agent that
|
|
23
|
+
// will POST the file itself). The widget path needs different keys (`expires_at`
|
|
24
|
+
// as an absolute ms timestamp for the countdown), so it builds its own payload.
|
|
25
|
+
//
|
|
26
|
+
// This carries the full recipe, not a pointer to it: both callers are agents
|
|
27
|
+
// holding a token they must use immediately, and telling one of them to go call
|
|
28
|
+
// another tool to learn the POST shape would spend the round trip this whole
|
|
29
|
+
// path exists to remove.
|
|
30
|
+
function uploadTicketPayload(ticket) {
|
|
31
|
+
return {
|
|
32
|
+
upload_url: ticket.upload_url,
|
|
33
|
+
token: ticket.token,
|
|
34
|
+
expires_in_seconds: ticket.expires_in,
|
|
35
|
+
max_file_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
|
|
36
|
+
accepted: ticket.accepted,
|
|
37
|
+
how_to_upload: {
|
|
38
|
+
example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3"',
|
|
39
|
+
optional_fields: ['project_id', 'description'],
|
|
40
|
+
response: 'JSON — the stable CDN URL is at media.url. One POST per file; reuse the ticket for a batch.',
|
|
41
|
+
// Git Bash hands curl a POSIX-style /c/Users/... path that Windows curl
|
|
42
|
+
// cannot open (exit 26). Real trap — it cost a round trip to find.
|
|
43
|
+
windows_note: 'Give curl a native path (C:/Users/...) — a Git Bash /c/Users/... path fails to open.',
|
|
44
|
+
},
|
|
45
|
+
// The one thing the server cannot detect: a caller with no shell that got
|
|
46
|
+
// here anyway. Without this it is left holding a token it can never use.
|
|
47
|
+
if_you_cannot_run_shell_or_http: 'Discard this ticket and call `media_upload_widget` instead so the user can pick the file.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
11
51
|
function registerMediaTools(server, client, options = {}) {
|
|
12
52
|
const ui = () => appsEnabled(server, options);
|
|
13
53
|
|
|
54
|
+
// `opts.apps` is set only by kolbo-api's per-request server (see createServer
|
|
55
|
+
// in ../index.js), which makes it a TRANSPORT signal — deliberately not
|
|
56
|
+
// `appsEnabled()`, which also returns true for stdio hosts that advertise UI.
|
|
57
|
+
// Transport is what decides whether a local path can resolve at all, so state
|
|
58
|
+
// it in the descriptions rather than making the model infer it. What the
|
|
59
|
+
// server still cannot know is whether the CALLER has a shell (one connector
|
|
60
|
+
// serves both claude.ai and Claude Code), hence the fallback hint in the
|
|
61
|
+
// ticket payload.
|
|
62
|
+
const isRemoteConnector = options.apps === true;
|
|
63
|
+
|
|
64
|
+
const ticketRouting = isRemoteConnector
|
|
65
|
+
? 'You are reached over a REMOTE connector: this server cannot read the caller\'s disk, so `upload_media` with a local path will always fail here — do not try it. If you can run shell commands or issue HTTP requests yourself, this tool is the right path. If you cannot (claude.ai web/mobile), ignore this tool and call `media_upload_widget` so the user picks the file.'
|
|
66
|
+
: 'You are a LOCAL (stdio) install: server and client share a filesystem, so for an ordinary local file prefer `upload_media` with the absolute path — one call, no ticket needed. Use this tool only when you specifically want to stream files up yourself (large batches, CI, an external uploader).';
|
|
67
|
+
|
|
14
68
|
// ─── media_upload_widget ───────────────────────────────────
|
|
15
69
|
server.tool(
|
|
16
70
|
'media_upload_widget',
|
|
17
|
-
'Open an interactive file-upload card in the chat so the user can upload LOCAL files (images, videos, audio, documents) into their Kolbo media library. USE THIS IMMEDIATELY whenever a claude.ai (browser/mobile) user wants to use a local file, or references a file they attached to the chat — remote MCP tools CANNOT read chat attachments, so the user must re-upload through this widget; do not ask them to re-attach the file in chat. Each uploaded file gets a stable Kolbo CDN URL that arrives in a follow-up user message — then pass those URLs to generation tools (generate_image_edit, generate_video_from_image, generate_lipsync, transcribe_audio, visual DNA, etc.).
|
|
71
|
+
'Open an interactive file-upload card in the chat so the user can upload LOCAL files (images, videos, audio, documents) into their Kolbo media library. USE THIS IMMEDIATELY whenever a claude.ai (browser/mobile) user wants to use a local file, or references a file they attached to the chat — remote MCP tools CANNOT read chat attachments, so the user must re-upload through this widget; do not ask them to re-attach the file in chat. Each uploaded file gets a stable Kolbo CDN URL that arrives in a follow-up user message — then pass those URLs to generation tools (generate_image_edit, generate_video_from_image, generate_lipsync, transcribe_audio, visual DNA, etc.). ROUTING depends on where the SERVER runs, not on which client you are: `upload_media` with a local path only works on a LOCAL (stdio) install, where server and client share a filesystem. Over a remote connector a local path is unreachable however capable the client is — there, if you can run shell commands, call `create_upload_ticket` and POST the file yourself (no user interaction needed); use this widget when you cannot reach the filesystem (claude.ai web/mobile) or when the user should choose the file.',
|
|
18
72
|
{
|
|
19
73
|
purpose: z.string().optional().describe('Short title shown on the card, e.g. "Upload the photo to animate". Helps the user know what to drop.'),
|
|
20
74
|
media_types: z.array(z.enum(['image', 'video', 'audio', 'document'])).optional().describe('Restrict which file kinds the widget accepts. Omit to accept all types.'),
|
|
@@ -22,8 +76,7 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
22
76
|
project_id: z.string().optional().describe('Project ObjectId to file the uploads into (resolve names via `list_projects`).')
|
|
23
77
|
},
|
|
24
78
|
async ({ purpose, media_types, max_files, project_id }) => {
|
|
25
|
-
const ticket = await client
|
|
26
|
-
if (!ticket || !ticket.token) throw new Error('Could not create an upload ticket — try again.');
|
|
79
|
+
const ticket = await mintUploadTicket(client);
|
|
27
80
|
|
|
28
81
|
const info = {
|
|
29
82
|
status: 'upload_widget_opened',
|
|
@@ -41,18 +94,41 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
41
94
|
expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
|
|
42
95
|
kinds: media_types && media_types.length ? media_types : undefined,
|
|
43
96
|
max_files: Math.min(Math.max(Number(max_files) || 10, 1), 20),
|
|
44
|
-
max_mb: ticket.max_file_mb ||
|
|
97
|
+
max_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
|
|
45
98
|
...(project_id ? { project_id } : {}),
|
|
46
99
|
});
|
|
47
100
|
}
|
|
48
101
|
|
|
49
|
-
// Text-only host: no iframe to render —
|
|
102
|
+
// Text-only host (Claude Code, Codex CLI, Cursor): no iframe to render —
|
|
103
|
+
// but these are exactly the hosts that CAN reach a filesystem, so hand
|
|
104
|
+
// back the ticket already minted instead of dead-ending. Additive fields
|
|
105
|
+
// only; `status` is unchanged for any existing consumer.
|
|
50
106
|
return {
|
|
51
107
|
content: [{
|
|
52
108
|
type: 'text',
|
|
53
109
|
text: JSON.stringify({
|
|
54
110
|
status: 'widget_unavailable',
|
|
55
|
-
hint: 'This host cannot render the upload
|
|
111
|
+
hint: 'This host cannot render the upload card, but it can usually reach the filesystem. Upload the file yourself by POSTing it to upload_url with this ticket — the recipe is below. On a LOCAL stdio install you can also just call upload_media with the absolute path.',
|
|
112
|
+
...uploadTicketPayload(ticket),
|
|
113
|
+
}, null, 2)
|
|
114
|
+
}]
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// ─── create_upload_ticket ──────────────────────────────────
|
|
120
|
+
server.tool(
|
|
121
|
+
'create_upload_ticket',
|
|
122
|
+
'Get a short-lived ticket for uploading LOCAL files straight into the user\'s Kolbo media library, with NO upload card and no user interaction. ' + ticketRouting + ' Why it exists: when the server cannot read the caller\'s disk, the only other ways in are making the user click an upload card (`media_upload_widget`) or inlining the file as base64 via `upload_media` — base64 is slow and burns context in proportion to file size, so do not use it for anything but a tiny file. Returns `upload_url` + `token`; POST each file as multipart field `file` with header `Authorization: Bearer <token>` and read the CDN URL from `media.url` in the response. One POST per file; the ticket is reusable until it expires. Then pass those URLs to any generation tool (transcribe_audio, generate_image_edit, generate_video_from_image, generate_lipsync, visual DNA, …).',
|
|
123
|
+
{},
|
|
124
|
+
async () => {
|
|
125
|
+
const ticket = await mintUploadTicket(client);
|
|
126
|
+
return {
|
|
127
|
+
content: [{
|
|
128
|
+
type: 'text',
|
|
129
|
+
text: JSON.stringify({
|
|
130
|
+
status: 'upload_ticket_created',
|
|
131
|
+
...uploadTicketPayload(ticket),
|
|
56
132
|
}, null, 2)
|
|
57
133
|
}]
|
|
58
134
|
};
|
|
@@ -128,7 +204,7 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
128
204
|
'list_media',
|
|
129
205
|
'Browse the user\'s Kolbo media library — both uploaded files AND AI-generated outputs they have saved. Powerful filtering: scope to a single project (`project_id`), a user folder (`folder_id`), a "section" / category (`category`: ai / uploaded / edited / favorites / training-lab), a media type (`type`: image / video / audio), or generation provenance (`source_type`). Combine filters freely. Use this to discover what the user already has before generating something new, to retrieve a specific past creation, or to list everything in a project for downstream batch work.',
|
|
130
206
|
{
|
|
131
|
-
project_id: z.string().optional().describe('Restrict to a single project (Mongo ObjectId). Use `list_projects` to discover IDs
|
|
207
|
+
project_id: z.string().optional().describe('Restrict to a single project (Mongo ObjectId). Use `list_projects` to discover IDs. Omit to list across all the user\'s media.'),
|
|
132
208
|
folder_id: z.string().optional().describe('Restrict to a user folder (Mongo ObjectId). Discover folder IDs via `list_media_folders`. Takes precedence over project_id when both are set.'),
|
|
133
209
|
type: z.enum(['image', 'video', 'audio', 'all']).optional().describe('Filter by media type. Default: all types.'),
|
|
134
210
|
category: z.enum(['ai', 'uploaded', 'edited', 'favorites', 'training-lab', 'all']).optional().describe('Filter by "section" (matches the Kolbo desktop app sidebar): `ai` = AI-generated, `uploaded` = files the user uploaded, `edited` = AI-edited variants, `favorites` = items the user starred, `training-lab` = training-lab assets. Default: all sections.'),
|
|
@@ -155,10 +231,25 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
155
231
|
|
|
156
232
|
const media = result.media || [];
|
|
157
233
|
const pagination = result.pagination || null;
|
|
158
|
-
|
|
234
|
+
// A default page of 50 items measured 119,847 chars — every row carries a
|
|
235
|
+
// full metadata object and the original prompt. Keep what identifies and
|
|
236
|
+
// locates an item; get_media returns one in full.
|
|
237
|
+
const text = compactList(media, {
|
|
238
|
+
fields: ['id', 'filename', 'media_type', 'url', 'thumbnail_url', 'size', 'project_id', 'created_at'],
|
|
239
|
+
cap: 50,
|
|
240
|
+
total: pagination ? (pagination.total_items != null ? pagination.total_items : pagination.total) : media.length,
|
|
241
|
+
extra: pagination ? { pagination } : undefined,
|
|
242
|
+
note: 'Narrow with `type`, `category`, `project_id`, `folder_id`, or `search`; get_media returns one item in full.',
|
|
243
|
+
});
|
|
159
244
|
|
|
160
245
|
if (ui()) {
|
|
161
|
-
|
|
246
|
+
// The SDK envelope reports `total_items` (see sdk/controller.js listMedia);
|
|
247
|
+
// reading `total` always came back undefined, so the grid claimed the page
|
|
248
|
+
// size was the whole library. Accept either, then fall back.
|
|
249
|
+
const totalItems = pagination
|
|
250
|
+
? (pagination.total_items != null ? pagination.total_items : pagination.total)
|
|
251
|
+
: null;
|
|
252
|
+
const items = media.slice(0, GRID_CAP).map((m) => ({
|
|
162
253
|
id: m.id,
|
|
163
254
|
title: m.filename,
|
|
164
255
|
subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
|
|
@@ -171,7 +262,8 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
171
262
|
widget: 'media-grid',
|
|
172
263
|
title: 'Media Library',
|
|
173
264
|
items,
|
|
174
|
-
total:
|
|
265
|
+
total: totalItems != null ? totalItems : media.length,
|
|
266
|
+
shown: Math.min(media.length, GRID_CAP)
|
|
175
267
|
});
|
|
176
268
|
}
|
|
177
269
|
|
|
@@ -409,7 +501,7 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
409
501
|
'Move a media item to a different project. Caller must own the item AND have access to the target project. Items in shared projects from other members cannot be moved by you. Use this when the user says "move this to project X" or wants to reorganize.',
|
|
410
502
|
{
|
|
411
503
|
media_id: z.string().describe('MediaLibraryItem id to move.'),
|
|
412
|
-
project_id: z.string().describe('Target project id (use `list_projects` to discover ids
|
|
504
|
+
project_id: z.string().describe('Target project id (use `list_projects` to discover ids).')
|
|
413
505
|
},
|
|
414
506
|
async ({ media_id, project_id }) => {
|
|
415
507
|
const result = await client.patch(
|
package/src/tools/models.js
CHANGED
|
@@ -271,10 +271,46 @@ function registerModelTools(server, client, options = {}) {
|
|
|
271
271
|
return parts.length ? `\n ${parts.join(' | ')}` : '';
|
|
272
272
|
};
|
|
273
273
|
|
|
274
|
+
// The FULL catalog with every spec line measured 140,590 chars — past what
|
|
275
|
+
// hosts accept, on the one discovery tool the skill tells the model to call
|
|
276
|
+
// when it is unsure. Unfiltered, emit the one-line form (enough to choose a
|
|
277
|
+
// model); once `type` narrows it, the set is small enough for full specs.
|
|
278
|
+
const detailed = !!type;
|
|
279
|
+
// Summaries run to a paragraph each; across the whole catalog that alone
|
|
280
|
+
// is most of the payload. Unfiltered, one clause is enough to choose by.
|
|
281
|
+
const brief = (s) => {
|
|
282
|
+
if (!s) return '';
|
|
283
|
+
const flat = String(s).replace(/\s+/g, ' ').trim();
|
|
284
|
+
return flat.length > 130 ? flat.slice(0, 127).trimEnd() + '…' : flat;
|
|
285
|
+
};
|
|
274
286
|
const formatModel = m =>
|
|
275
|
-
`${m.identifier} (${m.name}) - ${m.credit} credits${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${m.summary}` : ''}${formatSpecs(m)}`;
|
|
287
|
+
`${m.identifier} (${m.name}) - ${m.credit} credits${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${detailed ? m.summary : brief(m.summary)}` : ''}${detailed ? formatSpecs(m) : ''}`;
|
|
276
288
|
|
|
277
289
|
const sections = [];
|
|
290
|
+
|
|
291
|
+
if (!detailed) {
|
|
292
|
+
// The catalog is ~428 models. Listing all of them is both far past the
|
|
293
|
+
// text budget AND useless to choose from — so unfiltered, surface the
|
|
294
|
+
// curated picks and make the model narrow by `type` for the rest. This
|
|
295
|
+
// matches the connector rule of steering to a CONCRETE model.
|
|
296
|
+
const picks = result.models.filter(m => m.recommended || m.new_model);
|
|
297
|
+
if (picks.length) {
|
|
298
|
+
sections.push(`Recommended & new (${picks.length}):\n${picks.map(formatModel).join('\n')}`);
|
|
299
|
+
}
|
|
300
|
+
const text = `Kolbo model catalog — ${result.count} models total.\n\n`
|
|
301
|
+
+ `${sections.join('\n\n')}\n\n`
|
|
302
|
+
+ 'This is the curated shortlist, not the full catalog. To see everything in a '
|
|
303
|
+
+ 'category (with per-model resolutions, durations, aspect ratios and reference-image '
|
|
304
|
+
+ 'caps), re-call with `type`:\n'
|
|
305
|
+
+ ' text_to_img · image_editing · text_to_video · img_to_video · video_to_video ·\n'
|
|
306
|
+
+ ' first_last_frame · elements · lipsync · music_gen · text_to_speech ·\n'
|
|
307
|
+
+ ' text_to_sound · stt · three_d · text\n\n'
|
|
308
|
+
+ 'Use the "identifier" value as the "model" parameter in generate tools. '
|
|
309
|
+
+ 'For raw documents (programmatic cap validation), re-call with format: "json".';
|
|
310
|
+
if (ui()) return uiResult(UI.catalog, text, buildCatalogStructured(result.models, type, !showCatalog));
|
|
311
|
+
return { content: [{ type: 'text', text }] };
|
|
312
|
+
}
|
|
313
|
+
|
|
278
314
|
if (withSummary.length > 0) {
|
|
279
315
|
sections.push(`Auto-selectable models (${withSummary.length}) — safe to pick based on quality + cost:\n${withSummary.map(formatModel).join('\n')}`);
|
|
280
316
|
}
|
|
@@ -282,7 +318,7 @@ function registerModelTools(server, client, options = {}) {
|
|
|
282
318
|
sections.push(`Named-only models (${withoutSummary.length}) — only use if the user explicitly requests by name:\n${withoutSummary.map(formatModel).join('\n')}`);
|
|
283
319
|
}
|
|
284
320
|
|
|
285
|
-
const text = `Available models (${result.count}):\n\n${sections.join('\n\n')}\n\nUse the "identifier" value as the "model" parameter in generate tools. For programmatic cap validation, re-call with format: "json".`;
|
|
321
|
+
const text = `Available ${type} models (${result.count}):\n\n${sections.join('\n\n')}\n\nUse the "identifier" value as the "model" parameter in generate tools. For programmatic cap validation, re-call with format: "json".`;
|
|
286
322
|
if (ui()) return uiResult(UI.catalog, text, buildCatalogStructured(result.models, type, !showCatalog));
|
|
287
323
|
return { content: [{ type: 'text', text }] };
|
|
288
324
|
}
|
|
@@ -312,9 +348,25 @@ function registerModelTools(server, client, options = {}) {
|
|
|
312
348
|
// X credits in this app session" instead of estimating from base credits.
|
|
313
349
|
server.tool(
|
|
314
350
|
'get_session_usage',
|
|
315
|
-
'Fetch real, multiplier-adjusted credit spend for the current Kolbo Code app session. Use when the user asks "how much did I spend?" or before/after a large bulk job so you can quote actual cost (not an estimate from base credits). Returns total + per-tool breakdown + per-model breakdown + a recent list. The caller-session-id is forwarded automatically by the MCP HTTP client.',
|
|
351
|
+
'Fetch real, multiplier-adjusted credit spend for the current Kolbo Code app session. Use when the user asks "how much did I spend?" or before/after a large bulk job so you can quote actual cost (not an estimate from base credits). Returns total + per-tool breakdown + per-model breakdown + a recent list. The caller-session-id is forwarded automatically by the MCP HTTP client. ONLY works when running under Kolbo Code — on the claude.ai connector and other hosts there is no per-app session to scope to; use check_credits there instead.',
|
|
316
352
|
{},
|
|
317
353
|
async () => {
|
|
354
|
+
// Session scoping needs KOLBO_CALLER_SESSION_ID, which only the Kolbo Code
|
|
355
|
+
// parent process sets. The remote connector serves every user from one
|
|
356
|
+
// process, so it is never present there — calling anyway just returns a 400
|
|
357
|
+
// telling the user to reconfigure a process they do not control.
|
|
358
|
+
if (!process.env.KOLBO_CALLER_SESSION_ID) {
|
|
359
|
+
return {
|
|
360
|
+
content: [{
|
|
361
|
+
type: 'text',
|
|
362
|
+
text: JSON.stringify({
|
|
363
|
+
unavailable: 'Per-session usage is only tracked when running under Kolbo Code.',
|
|
364
|
+
reason: 'This host does not scope tool calls to an app session, so there is no session to total.',
|
|
365
|
+
use_instead: 'check_credits for the current balance, or the Usage page at https://app.kolbo.ai.'
|
|
366
|
+
})
|
|
367
|
+
}]
|
|
368
|
+
};
|
|
369
|
+
}
|
|
318
370
|
try {
|
|
319
371
|
const r = await client.get('/credit-usage/by-caller-session');
|
|
320
372
|
// The endpoint returns { message, data: { total, count, by_tool, by_model, recent[] } }
|
package/src/tools/moodboards.js
CHANGED
|
@@ -75,7 +75,7 @@ function registerMoodboardTools(server, client, options = {}) {
|
|
|
75
75
|
'Create a moodboard from 1–15 image URLs. The server analyzes the images and synthesizes a reusable master style prompt — then pass the returned moodboard id as `moodboard_id` on generation tools to apply the style. Use Kolbo URLs (generated images or `upload_media` output) or any public image URL. Typical flow: generate/upload reference images → create_moodboard → generate with moodboard_id.',
|
|
76
76
|
{
|
|
77
77
|
name: z.string().describe('Moodboard name (1–100 chars).'),
|
|
78
|
-
image_urls: z.array(z.string()).min(1).max(15).describe('1–15 public image URLs. For local files,
|
|
78
|
+
image_urls: z.array(z.string()).min(1).max(15).describe('1–15 public image URLs. For local files, get URLs first via the LOCAL FILE route in this tool\'s description.'),
|
|
79
79
|
style_guide: z.string().optional().describe('Optional style notes (max 500 chars) that steer the analysis, e.g. "focus on the color grading, not the subjects".')
|
|
80
80
|
},
|
|
81
81
|
async ({ name, image_urls, style_guide }) => {
|
|
@@ -124,11 +124,38 @@ function registerMusicLibraryTools(server, client, options = {}) {
|
|
|
124
124
|
|
|
125
125
|
server.tool(
|
|
126
126
|
'get_music_library_facets',
|
|
127
|
-
'List SYNCI genres, moods, instruments, BPM, and duration filters.'
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
'List SYNCI genres, moods, instruments, BPM, and duration filters. Returns the most-used values ' +
|
|
128
|
+
'per facet (ranked by track count); raise `limit` if you need deeper coverage. Any value not ' +
|
|
129
|
+
'listed still works as a free-text `query` on search_music_library.',
|
|
130
|
+
{
|
|
131
|
+
limit: z.number().optional().describe(
|
|
132
|
+
'How many values to return per facet, ranked by track count. Default: 40. Max: 200.'
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
async ({ limit }) => {
|
|
130
136
|
const result = await client.get('/v1/music-library/facets');
|
|
131
|
-
|
|
137
|
+
|
|
138
|
+
// The raw response carries 169 genres and 1000 each of moods/instruments — ~128K
|
|
139
|
+
// chars, which exceeds what hosts accept, so this tool used to fail outright. The
|
|
140
|
+
// tail is single-digit-count noise; the head is what anyone actually filters on.
|
|
141
|
+
const cap = Math.min(Math.max(Number(limit) || 40, 1), 200);
|
|
142
|
+
const trim = (arr) => (Array.isArray(arr) ? arr.slice(0, cap) : arr);
|
|
143
|
+
const omitted = (arr) => (Array.isArray(arr) ? Math.max(arr.length - cap, 0) : 0);
|
|
144
|
+
|
|
145
|
+
const payload = {
|
|
146
|
+
...result,
|
|
147
|
+
genres: trim(result.genres),
|
|
148
|
+
moods: trim(result.moods),
|
|
149
|
+
instruments: trim(result.instruments),
|
|
150
|
+
_truncated: {
|
|
151
|
+
genres: omitted(result.genres),
|
|
152
|
+
moods: omitted(result.moods),
|
|
153
|
+
instruments: omitted(result.instruments),
|
|
154
|
+
hint: 'Values beyond these are long-tail. Pass a higher `limit`, or just use `query` on ' +
|
|
155
|
+
'search_music_library — free text matches values not listed here.',
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
132
159
|
},
|
|
133
160
|
);
|
|
134
161
|
|
package/src/tools/presets.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const { UI, uiResult, appsEnabled } = require('../apps');
|
|
8
|
+
const { compactList } = require('./_shared');
|
|
8
9
|
|
|
9
10
|
function registerPresetTools(server, client, options = {}) {
|
|
10
11
|
const ui = () => appsEnabled(server, options);
|
|
@@ -22,11 +23,15 @@ function registerPresetTools(server, client, options = {}) {
|
|
|
22
23
|
const result = await client.get(`/v1/presets${qs ? '?' + qs : ''}`);
|
|
23
24
|
|
|
24
25
|
const presets = result.presets || [];
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
// Full catalog measured 632,919 chars — the single biggest payload in the
|
|
27
|
+
// tool surface. The model only needs enough to pick a preset_id.
|
|
28
|
+
const text = compactList(presets, {
|
|
29
|
+
fields: ['id', 'name', 'category', 'type', 'description'],
|
|
30
|
+
cap: 60,
|
|
31
|
+
total: result.count || presets.length,
|
|
32
|
+
extra: result.warning ? { warning: result.warning } : undefined,
|
|
33
|
+
note: 'Filter with `type` (image | video | music | text_to_video) to see a focused set.',
|
|
34
|
+
});
|
|
30
35
|
|
|
31
36
|
if (ui()) {
|
|
32
37
|
return uiResult(UI.mediaGrid, text, {
|
|
@@ -64,22 +69,56 @@ function registerPresetTools(server, client, options = {}) {
|
|
|
64
69
|
'specific cinematic look; then pass the chosen ids via the `cinematic` arg of generate_image / ' +
|
|
65
70
|
'generate_image_edit — at most one id per dimension. "Auto" is the absence of a selection: omit a ' +
|
|
66
71
|
'dimension (or the whole `cinematic` object) to let the enhancer decide. For an ordinary generation ' +
|
|
67
|
-
'do not call this at all. Never hardcode ids — dimensions and presets change; always fetch here.'
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
'do not call this at all. Never hardcode ids — dimensions and presets change; always fetch here. ' +
|
|
73
|
+
'Call with no args for a compact id+name index of every dimension, then pass `dimension` to get the ' +
|
|
74
|
+
'full descriptions for just the one you are choosing from.',
|
|
75
|
+
{
|
|
76
|
+
dimension: z.string().optional().describe(
|
|
77
|
+
'Return full detail (incl. descriptions) for ONE dimension only — e.g. "lighting", "camera", ' +
|
|
78
|
+
'"looks". Omit for the compact index of all dimensions.'
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
async ({ dimension }) => {
|
|
70
82
|
const result = await client.get('/v1/cinematic-presets');
|
|
71
83
|
// The public route serves the raw grouped map ({ camera:[...], lens:[...] });
|
|
72
84
|
// the SDK envelope wraps it as { dimensions:{...} }. Accept either shape.
|
|
73
85
|
const dimensions = (result && result.dimensions) || result || {};
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
const names = Object.keys(dimensions);
|
|
87
|
+
|
|
88
|
+
// The full catalog pretty-printed is ~63K chars — past what hosts accept, so the
|
|
89
|
+
// tool used to fail outright. thumbnail_url is dead weight on a text surface, and
|
|
90
|
+
// descriptions are only needed for the dimension actually being chosen from.
|
|
91
|
+
const slim = (p) => ({ id: p.id, name: p.name });
|
|
92
|
+
const full = (p) => ({
|
|
93
|
+
id: p.id,
|
|
94
|
+
name: p.name,
|
|
95
|
+
description: p.description,
|
|
96
|
+
...(p.bundle ? { bundle: p.bundle } : {}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
let payload;
|
|
100
|
+
if (dimension && dimensions[dimension]) {
|
|
101
|
+
payload = {
|
|
102
|
+
dimension,
|
|
103
|
+
presets: (dimensions[dimension] || []).map(full),
|
|
104
|
+
available_dimensions: names,
|
|
105
|
+
};
|
|
106
|
+
} else {
|
|
107
|
+
if (dimension) payload = { _note: `Unknown dimension "${dimension}" — showing the index.` };
|
|
108
|
+
payload = {
|
|
109
|
+
...payload,
|
|
110
|
+
dimensions: Object.fromEntries(names.map((k) => [k, (dimensions[k] || []).map(slim)])),
|
|
111
|
+
available_dimensions: names,
|
|
112
|
+
_detail_hint: 'Names only. Call again with `dimension: "<name>"` for descriptions.',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
payload._usage_hint = 'Include ONLY the dimensions the user actually wants; pass their ids as the ' +
|
|
117
|
+
'`cinematic` arg on generate_image / generate_image_edit, e.g. {"camera":"<id>","lighting":"<id>"}. ' +
|
|
118
|
+
'Every omitted/null dimension is Auto — the enhancer completes the look in the spirit of the ones ' +
|
|
119
|
+
'you set. Omit the whole object for a non-cinematic generation. Ids are validated per-dimension server-side.';
|
|
120
|
+
|
|
121
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
83
122
|
}
|
|
84
123
|
);
|
|
85
124
|
}
|
package/src/tools/projects.js
CHANGED
|
@@ -12,7 +12,7 @@ function registerProjectTools(server, client, options = {}) {
|
|
|
12
12
|
// ─── list_projects ─────────────────────────────────────────
|
|
13
13
|
server.tool(
|
|
14
14
|
'list_projects',
|
|
15
|
-
'List the user\'s platform projects (owned + shared with edit/full/owner permission). Use this to resolve a project NAME the user mentioned ("put this in my Acme Campaign project") into the project ObjectId you pass back as `project_id` on generation / chat / upload / move tools. Whenever the user mentions a project by name OR location, you MUST call this first — those tools accept only ObjectIds, not names — and then pass the resolved `project_id` on EVERY subsequent call in the conversation (it is per-call, not sticky; omitting it drops work into the default bucket). Returns id, name, role, and is_default. The project flagged `is_default: true` is the auto-created "API Generations" bucket every SDK generation lands in when project_id is omitted.
|
|
15
|
+
'List the user\'s platform projects (owned + shared with edit/full/owner permission). Use this to resolve a project NAME the user mentioned ("put this in my Acme Campaign project") into the project ObjectId you pass back as `project_id` on generation / chat / upload / move tools. Whenever the user mentions a project by name OR location, you MUST call this first — those tools accept only ObjectIds, not names — and then pass the resolved `project_id` on EVERY subsequent call in the conversation (it is per-call, not sticky; omitting it drops work into the default bucket). Returns id, name, role, and is_default. The project flagged `is_default: true` is the auto-created "API Generations" bucket every SDK generation lands in when project_id is omitted.',
|
|
16
16
|
{},
|
|
17
17
|
async () => {
|
|
18
18
|
const result = await client.get('/v1/projects');
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const { z } = require('zod');
|
|
5
5
|
const { UI, uiResult, appsEnabled } = require('../apps');
|
|
6
|
+
const { compactList } = require('./_shared');
|
|
6
7
|
|
|
7
8
|
// Compact one-line render of a normalized stock asset.
|
|
8
9
|
function assetLine(a) {
|
|
@@ -62,7 +63,15 @@ function registerStockLibraryTools(server, client, options = {}) {
|
|
|
62
63
|
const assets = result.assets || [];
|
|
63
64
|
if (!assets.length) return { content: [{ type: 'text', text: 'No assets found. Try a broader query, a different source/mediaType, or call get_stock_sources.' }] };
|
|
64
65
|
const head = `Found ${assets.length} asset${assets.length === 1 ? '' : 's'}${result.total ? ` (≈${result.total} total)` : ''}${result.hasMore ? ' — more available (increment page)' : ''}:`;
|
|
65
|
-
|
|
66
|
+
// A wide `source=all` search interleaves providers and overran the text
|
|
67
|
+
// budget at ~27K. Cap the rendered rows; `page` still reaches the rest.
|
|
68
|
+
const ASSET_CAP = 30;
|
|
69
|
+
const shownAssets = assets.slice(0, ASSET_CAP);
|
|
70
|
+
const moreAssets = assets.length - shownAssets.length;
|
|
71
|
+
const moreHint = moreAssets > 0
|
|
72
|
+
? `\n\n…and ${moreAssets} more on this page — narrow with \`source\` / \`mediaType\`, or use \`page\`.`
|
|
73
|
+
: '';
|
|
74
|
+
const text = `${head}\n\n${shownAssets.map(assetLine).join('\n\n')}${moreHint}\n\nUse [source:sourceId] with get_stock_asset for full variants, or import_stock_asset to copy it into the media library.`;
|
|
66
75
|
|
|
67
76
|
if (ui()) {
|
|
68
77
|
const items = assets.slice(0, 24).map((a) => {
|
|
@@ -113,7 +122,14 @@ function registerStockLibraryTools(server, client, options = {}) {
|
|
|
113
122
|
async (args) => {
|
|
114
123
|
const q = buildQuery(args);
|
|
115
124
|
const result = await client.get(`/v1/stock/categories${q ? '?' + q : ''}`);
|
|
116
|
-
|
|
125
|
+
// Full category list measured 257,817 chars (Kolbo SFX alone has 77 groups
|
|
126
|
+
// + 623 sub-filters). The model needs the label and the param to pass back.
|
|
127
|
+
return { content: [{ type: 'text', text: compactList(result.categories, {
|
|
128
|
+
fields: ['key', 'label', 'mediaType', 'source', 'group', 'paramType', 'providerParam'],
|
|
129
|
+
cap: 120,
|
|
130
|
+
total: result.count,
|
|
131
|
+
note: 'Narrow with `source` and `mediaType` to see a focused set.',
|
|
132
|
+
}) }] };
|
|
117
133
|
}
|
|
118
134
|
);
|
|
119
135
|
|
|
@@ -128,7 +144,11 @@ function registerStockLibraryTools(server, client, options = {}) {
|
|
|
128
144
|
async (args) => {
|
|
129
145
|
const q = buildQuery(args);
|
|
130
146
|
const result = await client.get(`/v1/stock/collections${q ? '?' + q : ''}`);
|
|
131
|
-
const text =
|
|
147
|
+
const text = compactList(result.collections, {
|
|
148
|
+
fields: ['id', 'name', 'slug', 'mediaType', 'source', 'itemCount'],
|
|
149
|
+
cap: 60,
|
|
150
|
+
total: result.count,
|
|
151
|
+
});
|
|
132
152
|
|
|
133
153
|
if (ui()) {
|
|
134
154
|
const collections = result.collections || [];
|
package/src/tools/visual_dna.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const FormData = require('form-data');
|
|
8
|
-
const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField } = require('./_shared');
|
|
8
|
+
const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField, compactList } = require('./_shared');
|
|
9
9
|
const { UI, uiResult, appsEnabled } = require('../apps');
|
|
10
10
|
|
|
11
11
|
// Visual DNA caps reference media at 25MB per file (stricter than the
|
|
@@ -110,10 +110,14 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
110
110
|
const qs = params.toString();
|
|
111
111
|
const result = await client.get(`/v1/visual-dna${qs ? '?' + qs : ''}`);
|
|
112
112
|
const dnas = result.visual_dnas || [];
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
// Full profiles measured 74,310 chars — the embedded analysis/description
|
|
114
|
+
// blobs are large and the model only needs enough to pick an id.
|
|
115
|
+
const text = compactList(dnas, {
|
|
116
|
+
fields: ['id', 'name', 'type', 'folder_id', 'tags', 'thumbnail'],
|
|
117
|
+
cap: 60,
|
|
118
|
+
total: result.count || dnas.length,
|
|
119
|
+
note: 'Narrow with `search`, `tags`, or `collection`; get_visual_dna returns one in full.',
|
|
120
|
+
});
|
|
117
121
|
|
|
118
122
|
if (ui()) {
|
|
119
123
|
return uiResult(UI.mediaGrid, text, {
|
package/src/tools/voices.js
CHANGED
|
@@ -40,7 +40,16 @@ function registerVoiceTools(server, client, options = {}) {
|
|
|
40
40
|
return `${v.voice_id} — ${v.name} (${v.provider})${v3}\n ${tags}${styles}${v.description ? `\n ${v.description}` : ''}`;
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// The unfiltered catalog measured 190,286 chars — past what hosts accept.
|
|
44
|
+
// Cap the listing and tell the model how to narrow, rather than handing
|
|
45
|
+
// back a blob the host truncates at an arbitrary byte.
|
|
46
|
+
const VOICE_CAP = 60;
|
|
47
|
+
const shown = lines.slice(0, VOICE_CAP);
|
|
48
|
+
const more = voices.length - shown.length;
|
|
49
|
+
const narrowHint = more > 0
|
|
50
|
+
? `\n\n…and ${more} more. Filter by \`language\`, \`gender\`, or \`provider\` to narrow.`
|
|
51
|
+
: '';
|
|
52
|
+
const text = `Available voices (showing ${shown.length} of ${voices.length}):\n\n${shown.join('\n\n')}${narrowHint}\n\nUse the "voice_id" value in generate_speech calls.`;
|
|
44
53
|
|
|
45
54
|
if (ui()) {
|
|
46
55
|
return uiResult(UI.mediaGrid, text, {
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
# Voice / Text-to-Speech (`generate_speech`) — full style & option reference
|
|
2
|
-
|
|
3
|
-
`generate_speech` turns text into spoken audio. Every voice belongs to a
|
|
4
|
-
**provider** (ElevenLabs, DeepDub, MiniMax, Google/Gemini, OpenAI, Zonos). Each
|
|
5
|
-
provider exposes its own expressive controls. You may pass any control on any
|
|
6
|
-
call — **the engine silently ignores controls that don't apply to the chosen
|
|
7
|
-
voice's provider**, so you never need to branch on provider yourself.
|
|
8
|
-
|
|
9
|
-
## Pick the voice first
|
|
10
|
-
Call `list_voices` (filter by `provider`, `language`, `gender`) and pass the
|
|
11
|
-
returned `voice_id` — or a display name like `"Rachel"`. Cloned/custom voices
|
|
12
|
-
resolve by name too. The voice determines the provider, which determines which
|
|
13
|
-
controls below take effect.
|
|
14
|
-
|
|
15
|
-
## Core params (all providers)
|
|
16
|
-
| Param | Type | Notes |
|
|
17
|
-
|---|---|---|
|
|
18
|
-
| `text` | string (required) | The words to speak. |
|
|
19
|
-
| `voice` | string | Voice id or display name. Default `"Rachel"`. |
|
|
20
|
-
| `model` | string | From `list_models type="text_to_speech"`. Default `eleven_v3`. Usually inferred from the voice — only needed to force a specific engine. |
|
|
21
|
-
| `language` | string | BCP-47 code, e.g. `"en-US"`, `"he-IL"`, `"es-ES"`. |
|
|
22
|
-
| `speaking_speed` | number | `0.5` (slow) – `2.0` (fast). Default `1.0`. Applies to ElevenLabs / OpenAI / Google. |
|
|
23
|
-
| `project_id` | string | Scope into a project (see Projects rules). |
|
|
24
|
-
|
|
25
|
-
## Expressive style / emotion (provider-specific)
|
|
26
|
-
| Param | Provider(s) | Values / notes |
|
|
27
|
-
|---|---|---|
|
|
28
|
-
| `style_instructions` | **Google / Gemini** | Free-form natural-language direction, e.g. `"whisper conspiratorially, slightly amused"`, `"excited sports announcer"`. Max 500 chars. |
|
|
29
|
-
| `selected_style` | **DeepDub**, MiniMax | Preset style. DeepDub: `reading`, `conversational`, `angry`, `breathy`, `panic`, `amused`, `sad`, `whisper`, `singing`, `shout`, `scream`, `mumbling`, `excited`. |
|
|
30
|
-
| `emotion` | **MiniMax** | `happy`, `sad`, `angry`, `fearful`, `disgusted`, `surprised`, `calm`, `fluent`, `whisper`. |
|
|
31
|
-
|
|
32
|
-
## ElevenLabs voice settings
|
|
33
|
-
| Param | Range | Default | Effect |
|
|
34
|
-
|---|---|---|---|
|
|
35
|
-
| `similarity_boost` | 0–1 | 0.75 | Higher hews closer to the source voice. |
|
|
36
|
-
| `style` | 0–1 | 0.5 | Style exaggeration — higher is more expressive/dramatic. |
|
|
37
|
-
| `use_speaker_boost` | bool | true | Speaker-clarity boost. |
|
|
38
|
-
|
|
39
|
-
## DeepDub controls
|
|
40
|
-
| Param | Range | Default | Effect |
|
|
41
|
-
|---|---|---|---|
|
|
42
|
-
| `variance` | 0–1 | 0.2 | More variation / takes. |
|
|
43
|
-
| `tempo` | 0–2 | 1.0 | Pacing multiplier. |
|
|
44
|
-
| `promptBoost` | bool | true | Higher fidelity to the text. |
|
|
45
|
-
| `seed` | int | — | Reproducibility (same seed + inputs → same output). Also honored by Zonos. |
|
|
46
|
-
| `accentControl` | object | — | `{ accentBaseLocale, accentLocale, accentRatio }` — blend an accent. Provide BOTH `accentBaseLocale` (e.g. `"en-US"`) and `accentLocale` (e.g. `"en-GB"`); `accentRatio` 0–1 (default 0.5). |
|
|
47
|
-
| `voiceTitle` | string | — | Display title for a custom/cloned voice. |
|
|
48
|
-
|
|
49
|
-
## MiniMax fine controls
|
|
50
|
-
| Param | Range | Default | Effect |
|
|
51
|
-
|---|---|---|---|
|
|
52
|
-
| `minimax_pitch` | −12 … 12 | 0 | Pitch shift. |
|
|
53
|
-
| `minimax_vol` | 0–10 | 1 | Volume. |
|
|
54
|
-
| `minimax_intensity` | — | — | Voice intensity. |
|
|
55
|
-
| `minimax_timbre` | — | — | Voice timbre. |
|
|
56
|
-
|
|
57
|
-
## Examples
|
|
58
|
-
Neutral ElevenLabs read:
|
|
59
|
-
```
|
|
60
|
-
generate_speech(text="Welcome to Kolbo.", voice="Rachel")
|
|
61
|
-
```
|
|
62
|
-
Whispered, conspiratorial Gemini delivery:
|
|
63
|
-
```
|
|
64
|
-
generate_speech(text="Meet me at midnight.", voice="Kore",
|
|
65
|
-
style_instructions="whisper conspiratorially, slow and breathy")
|
|
66
|
-
```
|
|
67
|
-
Angry DeepDub take, faster:
|
|
68
|
-
```
|
|
69
|
-
generate_speech(text="Get out of my house!", voice="<deepdub voice>",
|
|
70
|
-
selected_style="angry", tempo=1.2)
|
|
71
|
-
```
|
|
72
|
-
Excited MiniMax with pitch/volume tweaks:
|
|
73
|
-
```
|
|
74
|
-
generate_speech(text="We won the championship!", voice="<minimax voice>",
|
|
75
|
-
emotion="happy", minimax_pitch=3, minimax_vol=6)
|
|
76
|
-
```
|
|
77
|
-
British-accented DeepDub blend:
|
|
78
|
-
```
|
|
79
|
-
generate_speech(text="Good evening.", voice="<deepdub voice>",
|
|
80
|
-
accentControl={ accentBaseLocale: "en-US", accentLocale: "en-GB", accentRatio: 0.7 })
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
## Credits
|
|
84
|
-
~5 credits per 100 characters for most TTS models (Zonos ~3; voice design/clone
|
|
85
|
-
~30 flat). Charged only on success. Use `check_credits` once per conversation.
|