@kolbo/mcp 1.87.11 → 1.87.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/apps/index.js +6 -5
- package/src/apps/theme.js +444 -446
- package/src/apps/widgets/plans.js +164 -113
- package/src/index.js +264 -257
- package/src/toolAnnotations.js +149 -145
- package/src/tools/_shared.js +1213 -1191
- package/src/tools/models.js +531 -527
package/src/tools/_shared.js
CHANGED
|
@@ -1,1191 +1,1213 @@
|
|
|
1
|
-
/* Shared helpers for MCP tools. No server.tool() registrations here.
|
|
2
|
-
*
|
|
3
|
-
* This file centralizes the URL-or-local-path → Buffer resolver used by
|
|
4
|
-
* every tool that accepts file-ish arguments (visual_dna, elements,
|
|
5
|
-
* first_last_frame, lipsync, video_from_video, transcription, media upload,
|
|
6
|
-
* future additions). It also owns the SSRF guard applied to any URL we
|
|
7
|
-
* fetch on the user's local machine.
|
|
8
|
-
*
|
|
9
|
-
* SSRF defense in depth:
|
|
10
|
-
* 1. Only http: / https: protocols.
|
|
11
|
-
* 2. Block IP literals in private / loopback / link-local / multicast /
|
|
12
|
-
* reserved ranges (IPv4 and IPv6).
|
|
13
|
-
* 3. Block common internal hostnames (localhost, *.local, *.internal,
|
|
14
|
-
* metadata.google.internal, metadata.goog).
|
|
15
|
-
* 4. Manual redirect following so every hop is re-validated (a crafted
|
|
16
|
-
* public URL could 302 to 169.254.169.254 — global fetch would follow
|
|
17
|
-
* silently).
|
|
18
|
-
*
|
|
19
|
-
* If you add a new tool that fetches URLs, import resolveToBuffer from here
|
|
20
|
-
* rather than reinventing the guard.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
const fs = require('fs');
|
|
24
|
-
const path = require('path');
|
|
25
|
-
const net = require('net');
|
|
26
|
-
const dns = require('dns').promises;
|
|
27
|
-
const { Agent, fetch: undiciFetch } = require('undici');
|
|
28
|
-
|
|
29
|
-
const MAX_FILE_BYTES = 500 * 1024 * 1024; // 500 MB — larger than visual_dna because
|
|
30
|
-
// lipsync/v2v/transcription accept full
|
|
31
|
-
// videos and long audio tracks.
|
|
32
|
-
const VISUAL_DNA_MAX_BYTES = 25 * 1024 * 1024; // kept for visual_dna backward-compat
|
|
33
|
-
const REMOTE_FETCH_MAX_BYTES = 100 * 1024 * 1024;
|
|
34
|
-
const MAX_REDIRECTS = 5;
|
|
35
|
-
|
|
36
|
-
// THE single statement of how a local file gets into Kolbo. It is repeated to
|
|
37
|
-
// the model on several surfaces — the server `instructions` block (src/index.js),
|
|
38
|
-
// the media tool descriptions, and both local-path errors below — so it lives
|
|
39
|
-
// here and is imported, not retyped. It was previously pasted in five places and
|
|
40
|
-
// a change to it updated only two, leaving `instructions` teaching the opposite.
|
|
41
|
-
const LOCAL_FILE_ROUTING =
|
|
42
|
-
'If you are using Kolbo over a remote connector (e.g. claude.ai), local files are not reachable. ' +
|
|
43
|
-
'DO NOT upload the file yourself with cloud credentials or a shell command — Kolbo has a tool for this. ' +
|
|
44
|
-
'If you can run shell commands, call `create_upload_ticket` and POST the file to the returned upload_url. ' +
|
|
45
|
-
'Otherwise call `media_upload_widget` to have the user pick the file, or `upload_media` ' +
|
|
46
|
-
'when the file IS reachable from where the MCP server runs, then pass the returned https:// URL here. ' +
|
|
47
|
-
'A URL from `list_media` also works if the asset is already in the library.';
|
|
48
|
-
|
|
49
|
-
// Every tool that takes user media as INPUT. Their descriptions promise "URL or
|
|
50
|
-
// absolute local path" — true on a stdio install, a lie over a remote connector,
|
|
51
|
-
// where the model would read it, see no filesystem, and tell the user Kolbo
|
|
52
|
-
// cannot take their file at all. attachFileInputHints() below appends the route
|
|
53
|
-
// that actually works for the current transport, so the refusal never happens.
|
|
54
|
-
const FILE_INPUT_TOOLS = [
|
|
55
|
-
'generate_image', 'generate_image_edit', 'generate_creative_director',
|
|
56
|
-
'generate_video', 'generate_video_from_image', 'generate_video_from_video',
|
|
57
|
-
'generate_elements', 'generate_first_last_frame', 'generate_lipsync',
|
|
58
|
-
'generate_3d', 'edit_image', 'edit_video', 'transcribe_audio',
|
|
59
|
-
'create_visual_dna', 'update_visual_dna', 'generate_character_sheet', 'clone_voice',
|
|
60
|
-
'chat_send_message', 'create_moodboard', 'update_moodboard',
|
|
61
|
-
'separate_audio_stems', 'clean_dialogue_leftovers', 'separate_ambience',
|
|
62
|
-
'analyze_video'
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
const REMOTE_FILE_HINT =
|
|
66
|
-
' LOCAL FILE (on the user\'s machine, or attached to this chat)? NEVER reply that you cannot upload files — Kolbo uploads it for you. ' +
|
|
67
|
-
'Call `media_upload_widget` so the user picks the file (claude.ai web/mobile), or `create_upload_ticket` and POST the file yourself if you can run shell/HTTP commands; ' +
|
|
68
|
-
'either way you get an https:// URL to pass here. Ignore any "absolute local path" wording in the args below — this server cannot read the caller\'s disk, so a path will fail.';
|
|
69
|
-
|
|
70
|
-
const LOCAL_FILE_HINT =
|
|
71
|
-
' LOCAL FILE? Absolute local paths work here (server and client share a filesystem). ' +
|
|
72
|
-
'Never reply that you cannot upload files — for a file you will reference more than once, call `upload_media` first and reuse the returned https:// URL. ' +
|
|
73
|
-
'IMAGE/VIDEO PASTED OR ATTACHED IN THIS CHAT with no path? You can see it but Kolbo cannot — NEVER substitute a text description of it for the file. ' +
|
|
74
|
-
'Call `media_upload_widget` so the user hands Kolbo the file (or ask for its path), then pass the returned URL — for "make this image X" use generate_image_edit with it in `source_images`.';
|
|
75
|
-
const REMOTE_TEXT_FILE_HINT =
|
|
76
|
-
' REMOTE FILE INPUT: This client cannot send local filesystem paths or render Kolbo\'s upload widget. ' +
|
|
77
|
-
'Use an existing public https:// URL. If the attachment has no public URL, ask the user to upload it in the Kolbo Media Library and paste the resulting URL; never invent a URL or claim a local path is usable here.';
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Append the transport-correct local-file route to every media-input tool's
|
|
81
|
-
* description, post-registration (same pattern as attachToolWidgetMeta).
|
|
82
|
-
* `options.remote === true` is set only by kolbo-api's remote per-request
|
|
83
|
-
* server. Do not use `appsEnabled()` as the transport signal: stdio hosts can
|
|
84
|
-
* render widgets while still sharing a filesystem with this process.
|
|
85
|
-
*/
|
|
86
|
-
function attachFileInputHints(server, options = {}) {
|
|
87
|
-
const hint = options.asyncGenerations
|
|
88
|
-
? REMOTE_TEXT_FILE_HINT
|
|
89
|
-
: (options.remote === true || options.apps === true ? REMOTE_FILE_HINT : LOCAL_FILE_HINT);
|
|
90
|
-
const registered = server._registeredTools || {};
|
|
91
|
-
for (const name of FILE_INPUT_TOOLS) {
|
|
92
|
-
const t = registered[name];
|
|
93
|
-
if (t && typeof t.description === 'string' && !t.description.includes(hint)) {
|
|
94
|
-
t.description += hint;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Per-kind upload caps advertised to callers when the ticket endpoint omits them.
|
|
100
|
-
// Mirrors MAX_MB in kolbo-api src/modules/mcpConnector/upload.js.
|
|
101
|
-
const DEFAULT_MAX_FILE_MB = { image: 50, video: 500, audio: 200, document: 50 };
|
|
102
|
-
|
|
103
|
-
function isHttpUrl(s) {
|
|
104
|
-
return typeof s === 'string' && /^https?:\/\//i.test(s);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function isPrivateIPv4(ip) {
|
|
108
|
-
const parts = ip.split('.').map(Number);
|
|
109
|
-
if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) return true;
|
|
110
|
-
const [a, b] = parts;
|
|
111
|
-
if (a === 10) return true;
|
|
112
|
-
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
113
|
-
if (a === 127) return true;
|
|
114
|
-
if (a === 0) return true;
|
|
115
|
-
if (a === 169 && b === 254) return true; // includes 169.254.169.254 cloud metadata
|
|
116
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
117
|
-
if (a === 192 && b === 168) return true;
|
|
118
|
-
if (a === 192 && b === 0 && parts[2] === 0) return true;
|
|
119
|
-
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
120
|
-
if (a >= 224) return true;
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function isPrivateIPv6(ip) {
|
|
125
|
-
const lower = ip.toLowerCase();
|
|
126
|
-
if (lower === '::' || lower === '::1') return true;
|
|
127
|
-
if (lower.startsWith('fe80:') || lower.startsWith('fe8') ||
|
|
128
|
-
lower.startsWith('fe9') || lower.startsWith('fea') ||
|
|
129
|
-
lower.startsWith('feb')) return true;
|
|
130
|
-
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
|
|
131
|
-
if (lower.startsWith('ff')) return true;
|
|
132
|
-
// IPv4-mapped / compat in dotted form: ::ffff:1.2.3.4 or ::1.2.3.4
|
|
133
|
-
const mappedDot = lower.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/);
|
|
134
|
-
if (mappedDot) return isPrivateIPv4(mappedDot[1]);
|
|
135
|
-
// IPv4-mapped in pure hex form: ::ffff:7f00:1 (Node normalizes
|
|
136
|
-
// ::ffff:127.0.0.1 → ::ffff:7f00:1). Extract last 2 hextets → 4 bytes.
|
|
137
|
-
const mappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
138
|
-
if (mappedHex) {
|
|
139
|
-
const hi = parseInt(mappedHex[1], 16);
|
|
140
|
-
const lo = parseInt(mappedHex[2], 16);
|
|
141
|
-
const dotted = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
142
|
-
return isPrivateIPv4(dotted);
|
|
143
|
-
}
|
|
144
|
-
return false;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function isBlockedHostname(hostname) {
|
|
148
|
-
// new URL('http://[::1]/').hostname returns "[::1]" (brackets kept).
|
|
149
|
-
// Strip them so net.isIP and our private-range checks see the bare address.
|
|
150
|
-
let host = hostname.toLowerCase();
|
|
151
|
-
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
|
152
|
-
const blockedNames = new Set([
|
|
153
|
-
'localhost',
|
|
154
|
-
'ip6-localhost',
|
|
155
|
-
'ip6-loopback',
|
|
156
|
-
'metadata.google.internal',
|
|
157
|
-
'metadata.goog'
|
|
158
|
-
]);
|
|
159
|
-
if (blockedNames.has(host)) return true;
|
|
160
|
-
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost')) return true;
|
|
161
|
-
const ipFamily = net.isIP(host);
|
|
162
|
-
if (ipFamily === 4 && isPrivateIPv4(host)) return true;
|
|
163
|
-
if (ipFamily === 6 && isPrivateIPv6(host)) return true;
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function assertSafeUrl(rawUrl) {
|
|
168
|
-
let u;
|
|
169
|
-
try { u = new URL(rawUrl); }
|
|
170
|
-
catch (_) { throw new Error(`Invalid URL: ${rawUrl}`); }
|
|
171
|
-
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
172
|
-
throw new Error(`Unsupported URL protocol "${u.protocol}" — only http/https allowed`);
|
|
173
|
-
}
|
|
174
|
-
if (isBlockedHostname(u.hostname)) {
|
|
175
|
-
throw new Error(`Refusing to fetch from private / loopback / metadata host: ${u.hostname}`);
|
|
176
|
-
}
|
|
177
|
-
return u;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async function resolvePublicAddresses(hostname) {
|
|
181
|
-
let host = hostname.toLowerCase();
|
|
182
|
-
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
|
183
|
-
const literalFamily = net.isIP(host);
|
|
184
|
-
if (literalFamily) return [{ address: host, family: literalFamily }];
|
|
185
|
-
|
|
186
|
-
let timer;
|
|
187
|
-
const timeout = new Promise((_, reject) => {
|
|
188
|
-
timer = setTimeout(() => reject(new Error(`DNS lookup timed out for ${host}`)), 3000);
|
|
189
|
-
});
|
|
190
|
-
let rows;
|
|
191
|
-
try {
|
|
192
|
-
rows = await Promise.race([dns.lookup(host, { all: true, verbatim: true }), timeout]);
|
|
193
|
-
} finally {
|
|
194
|
-
clearTimeout(timer);
|
|
195
|
-
}
|
|
196
|
-
if (!rows.length) throw new Error(`DNS lookup returned no addresses for ${host}`);
|
|
197
|
-
for (const row of rows) {
|
|
198
|
-
const blocked = row.family === 4 ? isPrivateIPv4(row.address) : isPrivateIPv6(row.address);
|
|
199
|
-
if (blocked) throw new Error(`Refusing private / loopback / metadata DNS target for ${host}`);
|
|
200
|
-
}
|
|
201
|
-
return rows;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function pinnedDispatcher(addresses) {
|
|
205
|
-
let cursor = 0;
|
|
206
|
-
return new Agent({
|
|
207
|
-
connect: {
|
|
208
|
-
lookup(_hostname, options, callback) {
|
|
209
|
-
if (options?.all) return callback(null, addresses);
|
|
210
|
-
const row = addresses[cursor++ % addresses.length];
|
|
211
|
-
return callback(null, row.address, row.family);
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
async function safeFetch(rawUrl, opts = {}) {
|
|
218
|
-
let current = rawUrl;
|
|
219
|
-
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
|
220
|
-
const url = assertSafeUrl(current);
|
|
221
|
-
const addresses = await resolvePublicAddresses(url.hostname);
|
|
222
|
-
const dispatcher = pinnedDispatcher(addresses);
|
|
223
|
-
let res;
|
|
224
|
-
try {
|
|
225
|
-
res = await undiciFetch(current, { redirect: 'manual', signal: opts.signal, dispatcher });
|
|
226
|
-
} catch (err) {
|
|
227
|
-
await dispatcher.close().catch(() => {});
|
|
228
|
-
throw err;
|
|
229
|
-
}
|
|
230
|
-
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
231
|
-
const next = new URL(res.headers.get('location'), current).toString();
|
|
232
|
-
await res.body?.cancel().catch(() => {});
|
|
233
|
-
await dispatcher.close().catch(() => {});
|
|
234
|
-
current = next;
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
// close() waits for this response body to be consumed, so schedule it but
|
|
238
|
-
// do not await it before returning the Response to the caller.
|
|
239
|
-
dispatcher.close().catch(() => {});
|
|
240
|
-
return res;
|
|
241
|
-
}
|
|
242
|
-
throw new Error(`Too many redirects fetching ${rawUrl}`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
async function discardResponse(res) {
|
|
246
|
-
try { await res?.body?.cancel(); } catch (_) {}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function readResponseBuffer(res, maxBytes) {
|
|
250
|
-
if (!res?.body || typeof res.body.getReader !== 'function') {
|
|
251
|
-
throw new Error('Remote response has no readable body');
|
|
252
|
-
}
|
|
253
|
-
const reader = res.body.getReader();
|
|
254
|
-
const chunks = [];
|
|
255
|
-
let total = 0;
|
|
256
|
-
try {
|
|
257
|
-
while (true) {
|
|
258
|
-
const { done, value } = await reader.read();
|
|
259
|
-
if (done) break;
|
|
260
|
-
const chunk = Buffer.from(value);
|
|
261
|
-
total += chunk.length;
|
|
262
|
-
if (total > maxBytes) {
|
|
263
|
-
await reader.cancel().catch(() => {});
|
|
264
|
-
throw new Error(`Remote response exceeds ${maxBytes}-byte limit`);
|
|
265
|
-
}
|
|
266
|
-
chunks.push(chunk);
|
|
267
|
-
}
|
|
268
|
-
} finally {
|
|
269
|
-
try { reader.releaseLock(); } catch (_) {}
|
|
270
|
-
}
|
|
271
|
-
return Buffer.concat(chunks, total);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function guessFilename(source, fallbackExt) {
|
|
275
|
-
if (isHttpUrl(source)) {
|
|
276
|
-
try {
|
|
277
|
-
const u = new URL(source);
|
|
278
|
-
const base = path.basename(u.pathname) || `upload${fallbackExt}`;
|
|
279
|
-
return base.includes('.') ? base : `${base}${fallbackExt}`;
|
|
280
|
-
} catch (_) {
|
|
281
|
-
return `upload${fallbackExt}`;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
return path.basename(source);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function guessContentType(filename) {
|
|
288
|
-
const ext = path.extname(filename).toLowerCase();
|
|
289
|
-
const map = {
|
|
290
|
-
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
291
|
-
'.webp': 'image/webp', '.gif': 'image/gif', '.bmp': 'image/bmp',
|
|
292
|
-
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
293
|
-
'.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',
|
|
294
|
-
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
|
295
|
-
'.m4a': 'audio/mp4', '.flac': 'audio/flac', '.aac': 'audio/aac'
|
|
296
|
-
};
|
|
297
|
-
return map[ext] || 'application/octet-stream';
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Resolve a URL or absolute local path into an in-memory Buffer.
|
|
302
|
-
* - URLs: fetched via safeFetch (SSRF-guarded, manual redirect handling)
|
|
303
|
-
* - Local paths: read via fs.readFileSync (must be absolute)
|
|
304
|
-
*
|
|
305
|
-
* @param {string} source - URL or absolute local path
|
|
306
|
-
* @param {'image'|'video'|'audio'} kind - hint for default filename extension
|
|
307
|
-
* @param {Object} [opts]
|
|
308
|
-
* @param {number} [opts.maxBytes] - override the default size cap
|
|
309
|
-
* @returns {Promise<{buffer: Buffer, filename: string, contentType: string, size: number}>}
|
|
310
|
-
*/
|
|
311
|
-
async function resolveToBuffer(source, kind, opts = {}) {
|
|
312
|
-
const requestedMaxBytes = opts.maxBytes || MAX_FILE_BYTES;
|
|
313
|
-
const maxBytes = opts.allowLocalFiles === false
|
|
314
|
-
? Math.min(requestedMaxBytes, REMOTE_FETCH_MAX_BYTES)
|
|
315
|
-
: requestedMaxBytes;
|
|
316
|
-
const defaultExt = kind === 'image' ? '.png' : kind === 'video' ? '.mp4' : '.mp3';
|
|
317
|
-
|
|
318
|
-
if (isHttpUrl(source)) {
|
|
319
|
-
const res = await safeFetch(source);
|
|
320
|
-
if (!res.ok) {
|
|
321
|
-
await discardResponse(res);
|
|
322
|
-
throw new Error(`Failed to fetch ${source}: ${res.status} ${res.statusText}`);
|
|
323
|
-
}
|
|
324
|
-
const contentLen = parseInt(res.headers.get('content-length') || '0', 10);
|
|
325
|
-
if (contentLen && contentLen > maxBytes) {
|
|
326
|
-
await discardResponse(res);
|
|
327
|
-
throw new Error(`File at ${source} (${contentLen} bytes) exceeds ${maxBytes}-byte limit`);
|
|
328
|
-
}
|
|
329
|
-
const buffer = await readResponseBuffer(res, maxBytes);
|
|
330
|
-
const filename = guessFilename(source, defaultExt);
|
|
331
|
-
return {
|
|
332
|
-
buffer,
|
|
333
|
-
filename,
|
|
334
|
-
contentType: res.headers.get('content-type') || guessContentType(filename),
|
|
335
|
-
size: buffer.length
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
if (opts.allowLocalFiles === false) {
|
|
340
|
-
throw new Error(
|
|
341
|
-
'This remote connector accepts public https:// URLs only. Upload the file to the Kolbo Media Library and pass its public URL.'
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (!path.isAbsolute(source)) {
|
|
346
|
-
// `path.isAbsolute` is platform-specific: on a POSIX server (every remote
|
|
347
|
-
// connector deployment) a valid Windows path like `C:\Users\...` or
|
|
348
|
-
// `\\server\share\...` returns false, so "must be absolute" is a lie that
|
|
349
|
-
// sends the caller off retrying slash variants. `path.win32.isAbsolute`
|
|
350
|
-
// answers the same question with Node's own grammar.
|
|
351
|
-
throw new Error(
|
|
352
|
-
(path.win32.isAbsolute(source)
|
|
353
|
-
? `This Kolbo server cannot read files off the calling machine, so the Windows path ${source} is unreachable from here. `
|
|
354
|
-
: `Local file paths must be absolute: ${source}. `) +
|
|
355
|
-
LOCAL_FILE_ROUTING
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
let stat;
|
|
359
|
-
try {
|
|
360
|
-
stat = fs.statSync(source);
|
|
361
|
-
} catch (err) {
|
|
362
|
-
throw new Error(
|
|
363
|
-
`Local file not found or unreadable: ${source}. ` +
|
|
364
|
-
LOCAL_FILE_ROUTING +
|
|
365
|
-
(err && err.code ? ` [${err.code}]` : '')
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
if (stat.size > maxBytes) {
|
|
369
|
-
throw new Error(`File ${source} (${stat.size} bytes) exceeds ${maxBytes}-byte limit`);
|
|
370
|
-
}
|
|
371
|
-
const buffer = fs.readFileSync(source);
|
|
372
|
-
const filename = path.basename(source);
|
|
373
|
-
return {
|
|
374
|
-
buffer,
|
|
375
|
-
filename,
|
|
376
|
-
contentType: guessContentType(filename),
|
|
377
|
-
size: buffer.length
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// ─── Universal graceful-timeout convention ───────────────────────────────────
|
|
382
|
-
// pollUntilDone throws PollingTimeoutError (err.timedOut === true) when the
|
|
383
|
-
// CLIENT-SIDE poll window elapses — the generation is almost always still
|
|
384
|
-
// running server-side (or already done). Originally only
|
|
385
|
-
// generate_creative_director caught this and returned a non-throwing
|
|
386
|
-
// "_timed_out" result; every other tool let it propagate, so the MCP SDK
|
|
387
|
-
// wrapped it as isError:true and recovery depended entirely on the calling
|
|
388
|
-
// LLM reading hint text. pollOrTimedOut() is the one place that decision now
|
|
389
|
-
// lives — every generation/chat tool routes its pollUntilDone call through
|
|
390
|
-
// it instead of duplicating the try/catch. Genuine failures (state
|
|
391
|
-
// failed/cancelled → GenerationFailedError) are NOT caught here; they still
|
|
392
|
-
// throw and surface as a real tool error.
|
|
393
|
-
//
|
|
394
|
-
// CRITICAL: when the poll window times out, we UNTRACK the generation so that
|
|
395
|
-
// when the MCP host eventually aborts the tool call (e.g., at ~180s), it does
|
|
396
|
-
// NOT cancel the server-side generation. The generation keeps running, and the
|
|
397
|
-
// caller can collect it later with get_generation_status.
|
|
398
|
-
const { pollUntilDone } = require('../polling');
|
|
399
|
-
const progress = require('../progress');
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* @returns {Promise<{result: object}|{timedOut: object}>}
|
|
403
|
-
* Callers do: `const poll = await pollOrTimedOut(...); if (poll.timedOut) return poll.timedOut; const result = poll.result;`
|
|
404
|
-
*/
|
|
405
|
-
async function pollOrTimedOut(client, generationId, pollOpts) {
|
|
406
|
-
try {
|
|
407
|
-
return { result: await pollUntilDone(client, generationId, pollOpts) };
|
|
408
|
-
} catch (err) {
|
|
409
|
-
if (!err || !err.timedOut) throw err;
|
|
410
|
-
// Untrack so the MCP host aborting this tool call does NOT cancel the generation.
|
|
411
|
-
progress.untrackGeneration(generationId);
|
|
412
|
-
return {
|
|
413
|
-
timedOut: {
|
|
414
|
-
content: [{
|
|
415
|
-
type: 'text',
|
|
416
|
-
text: JSON.stringify({
|
|
417
|
-
state: 'processing',
|
|
418
|
-
generation_id: generationId,
|
|
419
|
-
_timed_out: true,
|
|
420
|
-
_hint: `Still running on the server after the poll window — this is NOT a failure, no credits were lost. Call get_generation_status with generation_id="${generationId}" (wait=true) to keep checking until state="completed". Do NOT re-run this tool.`
|
|
421
|
-
}, null, 2)
|
|
422
|
-
}]
|
|
423
|
-
}
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Extract real, multiplier-adjusted credit cost from a polled getStatus
|
|
430
|
-
* response. kolbo-api returns `credits_used` (final number deducted) and
|
|
431
|
-
* `credits_breakdown` (per-CreditUsage detail) when the generation is
|
|
432
|
-
* complete. Returns `{}` when the API didn't include them so spreading
|
|
433
|
-
* the result into a tool's response object is a no-op (forward-compatible
|
|
434
|
-
* with old kolbo-api versions).
|
|
435
|
-
*
|
|
436
|
-
* Usage in every generation tool:
|
|
437
|
-
* return {
|
|
438
|
-
* content: [{ type: 'text', text: JSON.stringify({
|
|
439
|
-
* urls: result.result.urls,
|
|
440
|
-
* model: result.result.model,
|
|
441
|
-
* ...creditFields(result), // adds credits_used + credits_breakdown
|
|
442
|
-
* _followup_hint: '...',
|
|
443
|
-
* }, null, 2) }]
|
|
444
|
-
* };
|
|
445
|
-
*/
|
|
446
|
-
function creditFields(polledResult) {
|
|
447
|
-
if (!polledResult) return {};
|
|
448
|
-
const out = {};
|
|
449
|
-
if (typeof polledResult.credits_used === 'number') {
|
|
450
|
-
out.credits_used = polledResult.credits_used;
|
|
451
|
-
}
|
|
452
|
-
if (Array.isArray(polledResult.credits_breakdown) && polledResult.credits_breakdown.length) {
|
|
453
|
-
out.credits_breakdown = polledResult.credits_breakdown;
|
|
454
|
-
}
|
|
455
|
-
return out;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Shared zod schema for the optional `project_id` arg every generation tool
|
|
459
|
-
// accepts. Keep this in one place so the description never drifts across the
|
|
460
|
-
// 17 tools that use it. When omitted, the generation lands in the user's
|
|
461
|
-
// auto-created "API Generations" project. Call `list_projects` first to
|
|
462
|
-
// resolve a name → ObjectId.
|
|
463
|
-
const { z } = require('zod');
|
|
464
|
-
const projectIdField = z.string().optional().describe(
|
|
465
|
-
'Project ObjectId to drop this generation into. Call `list_projects` to discover IDs (the API has no concept of project names — only ObjectIds). IMPORTANT: this is per-call, NOT sticky — once the user has named a working project, pass its id on EVERY generation call in the conversation; any call that omits it silently lands in the default "API Generations" project instead. Requires owner / edit / full permission on the project; view-only is rejected.'
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
// Shared zod schema for the optional `session_id` arg on generation tools.
|
|
469
|
-
// WHY IT EXISTS: without it, each generation call gets its own session, so a
|
|
470
|
-
// set of related clips lands in the app's left rail as a stack of near-identical
|
|
471
|
-
// single-item sessions. (The server has a per-day fallback bucket, but it is not
|
|
472
|
-
// something a caller can rely on — see kolbo-api sdkSessionManager.) Threading
|
|
473
|
-
// the id returned by the FIRST call is the deterministic way to group a batch.
|
|
474
|
-
const sessionIdField = z.string().optional().describe(
|
|
475
|
-
'Existing session to add this generation to, so a related set lands in ONE session instead of a stack of single-item sessions in the Kolbo sidebar. HOW TO USE: omit it on the FIRST call of a PLAN BUCKET (Cast, Locations, Props, or one Scene NN), read `session_id` off that result, `rename_session` to the plan name, then pass that SAME value on every follow-up in that bucket (another character, shot 2, a retake, "make it darker"). Only omit it again when the plan starts a NEW scene or NEW concept — never per take or per tool call. Image tools and video tools cannot share an id. `list_sessions` also returns ids. When set, `project_id` is ignored — the session\'s own project wins.'
|
|
476
|
-
);
|
|
477
|
-
|
|
478
|
-
// Read-scope variant for list/get tools that can surface a SHARED project's
|
|
479
|
-
// assets (a teammate's Visual DNAs / moodboards). Pass a project id you have
|
|
480
|
-
// edit+ on to also see that project owner's shared assets; omit to see only your
|
|
481
|
-
// own + global/org. View-only members and non-members get nothing extra.
|
|
482
|
-
const projectScopeReadField = z.string().optional().describe(
|
|
483
|
-
"Project ObjectId (from `list_projects`) to ALSO surface that shared project's assets (a teammate's, when the project is shared with you). Requires edit / full / owner on the project — view-only members and non-members get only their own. Omit to see just your own + global/org."
|
|
484
|
-
);
|
|
485
|
-
|
|
486
|
-
// ─── Optional inline-image content blocks ────────────────────────────────────
|
|
487
|
-
// When a host opts in (the remote HTTP connector sets inlineImages:true), turn
|
|
488
|
-
// generated IMAGE urls into MCP `image` content blocks so clients render them
|
|
489
|
-
// inline instead of a "Show Image" link. Strictly gated + bounded:
|
|
490
|
-
// - only runs when opts.enabled is true (stdio/Kolbo Code never enables it,
|
|
491
|
-
// so their behavior is byte-identical: text URL only);
|
|
492
|
-
// - caps the number of images and the bytes per image;
|
|
493
|
-
// - ONLY embeds responses whose content-type is image/* — a video/audio URL
|
|
494
|
-
// can never be base64-embedded even if mistakenly passed in;
|
|
495
|
-
// - any fetch/decoding failure silently falls back to URL-only.
|
|
496
|
-
const INLINE_IMG_MAX_COUNT = 4;
|
|
497
|
-
// Cap kept conservative on purpose: a base64 image rides inside the JSON-RPC
|
|
498
|
-
// tool result, and chat clients (claude.ai etc.) drop the WHOLE result if it's
|
|
499
|
-
// too large — which looks like "no image at all". Anything over the cap is left
|
|
500
|
-
// to the URL in the text payload (clients render a "Show Image" affordance from
|
|
501
|
-
// it), so a big image degrades to a click instead of vanishing.
|
|
502
|
-
const INLINE_IMG_MAX_BYTES = 1.5 * 1024 * 1024; // 1.5 MB per image
|
|
503
|
-
const INLINE_IMG_FETCH_TIMEOUT_MS = 8000; // never hang the tool response on a slow CDN
|
|
504
|
-
|
|
505
|
-
async function inlineImageBlocks(urls, opts = {}) {
|
|
506
|
-
if (!opts || !opts.enabled) return [];
|
|
507
|
-
if (!Array.isArray(urls) || urls.length === 0) return [];
|
|
508
|
-
// Fetch the (≤4) images in parallel — they're independent, the cap already
|
|
509
|
-
// bounds concurrency, and this sits on the connector response path right
|
|
510
|
-
// after generation. Order is preserved by map-then-filter; any failure (size,
|
|
511
|
-
// type, timeout, network) returns null and falls back to URL-only.
|
|
512
|
-
const maxCount = Math.min(INLINE_IMG_MAX_COUNT, Math.max(1, Number(opts.maxCount) || INLINE_IMG_MAX_COUNT));
|
|
513
|
-
const maxBytes = Math.min(INLINE_IMG_MAX_BYTES, Math.max(1, Number(opts.maxBytes) || INLINE_IMG_MAX_BYTES));
|
|
514
|
-
const fetchTimeoutMs = Math.min(INLINE_IMG_FETCH_TIMEOUT_MS, Math.max(1, Number(opts.fetchTimeoutMs) || INLINE_IMG_FETCH_TIMEOUT_MS));
|
|
515
|
-
const blocks = await Promise.all(
|
|
516
|
-
urls.slice(0, maxCount).map(async (url) => {
|
|
517
|
-
const controller = new AbortController();
|
|
518
|
-
const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
|
519
|
-
try {
|
|
520
|
-
if (typeof url !== 'string' || !isHttpUrl(url)) return null;
|
|
521
|
-
const res = await safeFetch(url, { signal: controller.signal });
|
|
522
|
-
if (!res.ok) {
|
|
523
|
-
await discardResponse(res);
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
const contentType = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
527
|
-
if (!contentType.startsWith('image/')) {
|
|
528
|
-
await discardResponse(res);
|
|
529
|
-
return null; // never embed non-images
|
|
530
|
-
}
|
|
531
|
-
const declaredLen = Number(res.headers.get('content-length') || 0);
|
|
532
|
-
if (declaredLen && declaredLen > maxBytes) {
|
|
533
|
-
await discardResponse(res);
|
|
534
|
-
return null;
|
|
535
|
-
}
|
|
536
|
-
const buffer = await readResponseBuffer(res, maxBytes);
|
|
537
|
-
return { type: 'image', data: buffer.toString('base64'), mimeType: contentType };
|
|
538
|
-
} catch (_) {
|
|
539
|
-
return null;
|
|
540
|
-
} finally {
|
|
541
|
-
clearTimeout(timer);
|
|
542
|
-
}
|
|
543
|
-
})
|
|
544
|
-
);
|
|
545
|
-
return blocks.filter(Boolean);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// ─── "Open in Kolbo" deep links ───────────────────────────────────────────────
|
|
549
|
-
// kolbo-api submit responses include `session_id` + `project_id`. Map each MCP
|
|
550
|
-
// tool to the frontend page + tool slug whose session view can RESUME that
|
|
551
|
-
// session (mirrors kolbo-map src/constants/sessionTypes.js resumeUrl map — the
|
|
552
|
-
// route must match the SESSION MODEL the SDK created, per sdkSessionManager):
|
|
553
|
-
// ImageSession (image AND image_edit) → /image-tools?tool=create-image
|
|
554
|
-
// imgEditSession (edit_image / global_image_edit — the Canvas) → /image-tools?tool=canvas
|
|
555
|
-
// imgToVideoSession (video, video_from_image, elements, first_last_frame)
|
|
556
|
-
// → /video-tools?tool=create-video
|
|
557
|
-
// videoToVideoSession → /video-tools?tool=video-to-video
|
|
558
|
-
// lipsyncSession → /video-tools?tool=lipsync
|
|
559
|
-
// MusicGeneratorSession / TextToSpeechSession / textToSoundSession /
|
|
560
|
-
// speechToTextSession → /audio-tools with the matching slug
|
|
561
|
-
// CreativeDirectorSession → /creative-director?session=... (no tool param)
|
|
562
|
-
// RETIRED — do NOT reintroduce as separate destinations. "Image Editing" folded
|
|
563
|
-
// into Create Image and "Text to Video" folded into Create Video (a mode inside
|
|
564
|
-
// it); sdkSessionManager already routes image_edit → ImageSession and video →
|
|
565
|
-
// imgToVideoSession. The old ?tool=image-editing / ?tool=text-to-video slugs
|
|
566
|
-
// still redirect, but nothing new should emit them.
|
|
567
|
-
// Intentionally ABSENT (no deep-linkable session page — widget falls back to
|
|
568
|
-
// plain https://app.kolbo.ai): edit_video (GlobalVideoEditSession has no
|
|
569
|
-
// session deep-link), generate_3d (project-scoped, no session), shorts render.
|
|
570
|
-
const APP_BASE_URL = 'https://app.kolbo.ai';
|
|
571
|
-
const OPEN_URL_ROUTES = {
|
|
572
|
-
generate_image: { path: '/image-tools', tool: 'create-image' },
|
|
573
|
-
generate_image_edit: { path: '/image-tools', tool: 'create-image' },
|
|
574
|
-
edit_image: { path: '/image-tools', tool: 'canvas' },
|
|
575
|
-
generate_video: { path: '/video-tools', tool: 'create-video' },
|
|
576
|
-
generate_video_from_image: { path: '/video-tools', tool: 'create-video' },
|
|
577
|
-
generate_elements: { path: '/video-tools', tool: 'create-video', mode: 'elements' },
|
|
578
|
-
generate_first_last_frame: { path: '/video-tools', tool: 'create-video', mode: 'first-last' },
|
|
579
|
-
generate_video_from_video: { path: '/video-tools', tool: 'video-to-video' },
|
|
580
|
-
generate_lipsync: { path: '/video-tools', tool: 'lipsync' },
|
|
581
|
-
generate_music: { path: '/audio-tools', tool: 'music-generator' },
|
|
582
|
-
generate_speech: { path: '/audio-tools', tool: 'text-to-speech' },
|
|
583
|
-
generate_sound: { path: '/audio-tools', tool: 'text-to-sound' },
|
|
584
|
-
transcribe_audio: { path: '/audio-tools', tool: 'speech-to-text' },
|
|
585
|
-
generate_creative_director: { path: '/creative-director' },
|
|
586
|
-
};
|
|
587
|
-
// SDK tracking `type` on get_generation_status — same session model as the
|
|
588
|
-
// generate_* tool that created the job, so "Open in Kolbo" still deep-links
|
|
589
|
-
// after a status poll overwrites the live card.
|
|
590
|
-
const TYPE_TO_TOOL = {
|
|
591
|
-
image: 'generate_image',
|
|
592
|
-
image_edit: 'generate_image_edit',
|
|
593
|
-
global_image_edit: 'edit_image',
|
|
594
|
-
video: 'generate_video',
|
|
595
|
-
video_from_image: 'generate_video_from_image',
|
|
596
|
-
elements: 'generate_elements',
|
|
597
|
-
first_last_frame: 'generate_first_last_frame',
|
|
598
|
-
video_from_video: 'generate_video_from_video',
|
|
599
|
-
lipsync: 'generate_lipsync',
|
|
600
|
-
music: 'generate_music',
|
|
601
|
-
speech: 'generate_speech',
|
|
602
|
-
sound: 'generate_sound',
|
|
603
|
-
transcription: 'transcribe_audio',
|
|
604
|
-
creative_director: 'generate_creative_director',
|
|
605
|
-
};
|
|
606
|
-
|
|
607
|
-
function sessionOf(gen) {
|
|
608
|
-
if (!gen || typeof gen !== 'object') return undefined;
|
|
609
|
-
return gen.session_id || gen.sessionId || undefined;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
function projectOf(gen) {
|
|
613
|
-
if (!gen || typeof gen !== 'object') return undefined;
|
|
614
|
-
return gen.project_id || gen.projectId || undefined;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* Build the "Open in Kolbo" deep link for a generation's actual session.
|
|
619
|
-
* Returns undefined (widget falls back to app.kolbo.ai) when the tool has no
|
|
620
|
-
* deep-linkable page or the submit response carried no session_id (older
|
|
621
|
-
* kolbo-api, shorts render, 3D).
|
|
622
|
-
*/
|
|
623
|
-
function buildOpenUrl(tool, gen) {
|
|
624
|
-
const sid = sessionOf(gen);
|
|
625
|
-
if (!sid) return undefined;
|
|
626
|
-
const key = OPEN_URL_ROUTES[tool] ? tool : (TYPE_TO_TOOL[gen && gen.type] || TYPE_TO_TOOL[tool]);
|
|
627
|
-
const route = OPEN_URL_ROUTES[key];
|
|
628
|
-
if (!route) return undefined;
|
|
629
|
-
let url = `${APP_BASE_URL}${route.path}?session=${encodeURIComponent(sid)}`;
|
|
630
|
-
if (route.tool) url += `&tool=${route.tool}`;
|
|
631
|
-
if (route.mode) url += `&mode=${route.mode}`;
|
|
632
|
-
const pid = projectOf(gen);
|
|
633
|
-
if (pid) url += `&project=${encodeURIComponent(pid)}`;
|
|
634
|
-
return url;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function linkFields(tool, gen) {
|
|
638
|
-
const sid = sessionOf(gen);
|
|
639
|
-
const pid = projectOf(gen);
|
|
640
|
-
const href = buildOpenUrl(tool, gen);
|
|
641
|
-
return {
|
|
642
|
-
...(sid ? { session_id: String(sid) } : {}),
|
|
643
|
-
...(pid ? { project_id: String(pid) } : {}),
|
|
644
|
-
...(href ? { open_url: href } : {}),
|
|
645
|
-
};
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Build the "Open in Kolbo" deep link for a PROJECT. Lands on the Media hub
|
|
650
|
-
* (all-assets view) pre-filtered to this project, where the user sees every
|
|
651
|
-
* generation/media item in it and can switch projects via the in-page selector.
|
|
652
|
-
* The media page reads `?project=<id>` on load and selects it. Returns undefined
|
|
653
|
-
* for a missing id or the default "API Generations" bucket (no useful landing —
|
|
654
|
-
* it is the catch-all, not a real workspace the user navigates to).
|
|
655
|
-
*/
|
|
656
|
-
function buildProjectUrl(projectId, opts = {}) {
|
|
657
|
-
if (!projectId || opts.is_default) return undefined;
|
|
658
|
-
return `${APP_BASE_URL}/media?project=${encodeURIComponent(projectId)}`;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// ─── MCP Apps generation widget helpers ──────────────────────────────────────
|
|
662
|
-
// When the host renders MCP Apps (claude.ai via the remote connector, Claude
|
|
663
|
-
// Desktop over stdio), generation tools return IMMEDIATELY after submit and the
|
|
664
|
-
// ui://kolbo/generation.html widget takes over: live progress, inline result,
|
|
665
|
-
// action buttons. Text-only hosts never enter this path — their blocking
|
|
666
|
-
// behavior and response bytes are UNCHANGED.
|
|
667
|
-
const { UI, uiResult, appsEnabled, modelInfo } = require('../apps');
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* Chip identity for a model: the CLEAN display name + its icon, resolved from
|
|
671
|
-
* the same /v1/models catalog `list_models` renders. Callers pass whatever the
|
|
672
|
-
* user/LLM supplied (an identifier like `google_tts`, `fal-ai/…/omnihuman/v1.5`,
|
|
673
|
-
* or a display name) — the card must never show the raw id.
|
|
674
|
-
*/
|
|
675
|
-
async function modelChipFields(client, model) {
|
|
676
|
-
const info = await modelInfo(client, model).catch(() => null);
|
|
677
|
-
return {
|
|
678
|
-
model: model || 'Smart Select',
|
|
679
|
-
model_name: (info && info.name) || model || 'Smart Select',
|
|
680
|
-
model_icon: (info && info.icon) || null,
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* Resolve `visual_dna_ids` to {id, name, thumbnail} so the card can show WHICH
|
|
686
|
-
* characters are locked in, not just how many. An id tells the user nothing;
|
|
687
|
-
* the face does.
|
|
688
|
-
*
|
|
689
|
-
* One list fetch per process, cached — the DNA catalog barely moves within a
|
|
690
|
-
* session, and a generation card must never add a round-trip per chip. A miss
|
|
691
|
-
* (id not in the caller's own DNAs) degrades to the bare id, which is exactly
|
|
692
|
-
* what the card showed before.
|
|
693
|
-
*/
|
|
694
|
-
const _dnaChipCache = new Map();
|
|
695
|
-
let _dnaChipLoaded = 0;
|
|
696
|
-
const DNA_CHIP_TTL = 5 * 60 * 1000;
|
|
697
|
-
|
|
698
|
-
function dnaThumb(row) {
|
|
699
|
-
if (!row || typeof row !== 'object') return null;
|
|
700
|
-
const first = Array.isArray(row.images) ? row.images[0] : null;
|
|
701
|
-
return row.sheet_url
|
|
702
|
-
|| row.thumbnail_url
|
|
703
|
-
|| row.characterSheet
|
|
704
|
-
|| (typeof first === 'string' ? first : first && first.url)
|
|
705
|
-
|| null;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
function rememberDna(row, fallbackId) {
|
|
709
|
-
if (!row || typeof row !== 'object') return null;
|
|
710
|
-
const id = String(row.id || row._id || fallbackId || '');
|
|
711
|
-
if (!id) return null;
|
|
712
|
-
const rec = { id, name: row.name || id, thumbnail: dnaThumb(row) };
|
|
713
|
-
_dnaChipCache.set(id, rec);
|
|
714
|
-
return rec;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
async function resolveVisualDnas(client, ids) {
|
|
718
|
-
const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id) : [];
|
|
719
|
-
if (!list.length) return [];
|
|
720
|
-
|
|
721
|
-
const stale = Date.now() - _dnaChipLoaded > DNA_CHIP_TTL;
|
|
722
|
-
if (stale || list.some((id) => !_dnaChipCache.has(id))) {
|
|
723
|
-
try {
|
|
724
|
-
const res = await client.get('/v1/visual-dna?scope=mine');
|
|
725
|
-
const rows = res?.visual_dnas || res?.data || [];
|
|
726
|
-
for (const row of rows) rememberDna(row);
|
|
727
|
-
_dnaChipLoaded = Date.now();
|
|
728
|
-
} catch {
|
|
729
|
-
// Offline / rate-limited — fall through to per-id fetch.
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// scope=mine misses global / shared / teammate DNAs. The generating card
|
|
734
|
-
// then printed "1 Visual DNA" with no face. Fetch the missing ids directly.
|
|
735
|
-
const missing = list.filter((id) => !_dnaChipCache.has(id));
|
|
736
|
-
if (missing.length) {
|
|
737
|
-
await Promise.all(missing.map(async (id) => {
|
|
738
|
-
try {
|
|
739
|
-
const res = await client.get(`/v1/visual-dna/${encodeURIComponent(id)}`);
|
|
740
|
-
rememberDna(res && res.visual_dna ? res.visual_dna : res, id);
|
|
741
|
-
} catch {
|
|
742
|
-
// Leave the bare id — the chip still names it.
|
|
743
|
-
}
|
|
744
|
-
}));
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
return list.map((id) => _dnaChipCache.get(id) || { id, name: id, thumbnail: null });
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
const _presetCache = new Map();
|
|
751
|
-
let _presetLoaded = 0;
|
|
752
|
-
const PRESET_TTL = 10 * 60 * 1000;
|
|
753
|
-
|
|
754
|
-
async function resolvePreset(client, presetId) {
|
|
755
|
-
if (!presetId) return null;
|
|
756
|
-
const key = String(presetId);
|
|
757
|
-
const stale = Date.now() - _presetLoaded > PRESET_TTL;
|
|
758
|
-
if (!stale && _presetCache.has(key)) return _presetCache.get(key);
|
|
759
|
-
if (stale || _presetCache.size === 0) {
|
|
760
|
-
try {
|
|
761
|
-
const res = await client.get('/v1/presets');
|
|
762
|
-
for (const row of res?.presets || res?.data || []) {
|
|
763
|
-
const id = row?.id || row?._id || row?.identifier;
|
|
764
|
-
if (!id) continue;
|
|
765
|
-
_presetCache.set(String(id), {
|
|
766
|
-
id: String(id),
|
|
767
|
-
name: row.name || String(id),
|
|
768
|
-
thumbnail: row.thumbnail_url || row.thumbnail || null,
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
_presetLoaded = Date.now();
|
|
772
|
-
} catch {
|
|
773
|
-
// Offline — the chip keeps the word "preset".
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
return _presetCache.get(key) || null;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
async function decorateSettings(client, settings) {
|
|
780
|
-
const s = { ...(settings || {}) };
|
|
781
|
-
if (!s.preset_id) return s;
|
|
782
|
-
const preset = await resolvePreset(client, s.preset_id);
|
|
783
|
-
if (!preset) return s;
|
|
784
|
-
return {
|
|
785
|
-
...s,
|
|
786
|
-
preset_name: preset.name,
|
|
787
|
-
...(preset.thumbnail ? { preset_thumbnail: preset.thumbnail } : {}),
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const _mbChipCache = new Map();
|
|
792
|
-
let _mbChipLoaded = 0;
|
|
793
|
-
|
|
794
|
-
function rememberMoodboard(row, fallbackId) {
|
|
795
|
-
if (!row || typeof row !== 'object') return null;
|
|
796
|
-
const id = String(row.id || row._id || fallbackId || '');
|
|
797
|
-
if (!id) return null;
|
|
798
|
-
const rec = {
|
|
799
|
-
id,
|
|
800
|
-
name: row.name || id,
|
|
801
|
-
thumbnail: row.thumbnail_url || row.thumbnail || row.cover_url || (Array.isArray(row.images) ? row.images[0] : null) || null,
|
|
802
|
-
};
|
|
803
|
-
_mbChipCache.set(id, rec);
|
|
804
|
-
return rec;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
async function resolveMoodboards(client, ids) {
|
|
808
|
-
const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id) : [];
|
|
809
|
-
if (!list.length) return [];
|
|
810
|
-
const stale = Date.now() - _mbChipLoaded > DNA_CHIP_TTL;
|
|
811
|
-
if (stale || list.some((id) => !_mbChipCache.has(id))) {
|
|
812
|
-
try {
|
|
813
|
-
const res = await client.get('/v1/moodboards');
|
|
814
|
-
for (const row of res?.moodboards || res?.data || []) rememberMoodboard(row);
|
|
815
|
-
_mbChipLoaded = Date.now();
|
|
816
|
-
} catch { /* fall through to per-id */ }
|
|
817
|
-
}
|
|
818
|
-
const missing = list.filter((id) => !_mbChipCache.has(id));
|
|
819
|
-
if (missing.length) {
|
|
820
|
-
await Promise.all(missing.map(async (id) => {
|
|
821
|
-
try {
|
|
822
|
-
const res = await client.get(`/v1/moodboards/${encodeURIComponent(id)}`);
|
|
823
|
-
rememberMoodboard(res && res.moodboard ? res.moodboard : res, id);
|
|
824
|
-
} catch { /* bare id */ }
|
|
825
|
-
}));
|
|
826
|
-
}
|
|
827
|
-
return list.map((id) => _mbChipCache.get(id) || { id, name: id, thumbnail: null });
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
function mediaRefs(p) {
|
|
831
|
-
const images = Array.isArray(p.reference_images)
|
|
832
|
-
? p.reference_images.filter(Boolean)
|
|
833
|
-
: (p.reference_image ? [p.reference_image] : []);
|
|
834
|
-
return {
|
|
835
|
-
// Omitted when empty: a live card MERGES status payloads over its own
|
|
836
|
-
// state, so a present-but-empty reference_images (the single-id status
|
|
837
|
-
// path has no input refs of its own) wiped the submit-time thumbnails
|
|
838
|
-
// off the finished card.
|
|
839
|
-
...(images.length ? { reference_images: images, reference_image: p.reference_image || images[0] } : {}),
|
|
840
|
-
...(Array.isArray(p.reference_videos) && p.reference_videos.length
|
|
841
|
-
? { reference_videos: p.reference_videos.filter(Boolean) } : {}),
|
|
842
|
-
...(Array.isArray(p.reference_audio) && p.reference_audio.length
|
|
843
|
-
? { reference_audio: p.reference_audio.filter(Boolean) } : {}),
|
|
844
|
-
};
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function moodboardIds(settings) {
|
|
848
|
-
const s = settings || {};
|
|
849
|
-
if (Array.isArray(s.moodboard_ids) && s.moodboard_ids.length) return s.moodboard_ids;
|
|
850
|
-
return s.moodboard_id ? [s.moodboard_id] : [];
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/**
|
|
854
|
-
* Build the "submitted — widget is live" tool result for a UI host.
|
|
855
|
-
* @param {object} p
|
|
856
|
-
* tool MCP tool name (e.g. 'generate_image')
|
|
857
|
-
* kind 'image' | 'video' | 'audio' | '3d' | 'scenes'
|
|
858
|
-
* gen the submit response ({ generation_id, poll_interval_hint })
|
|
859
|
-
* client KolboClient (for model icon lookup)
|
|
860
|
-
* model, prompt, count, settings, reference_images, estimated_seconds
|
|
861
|
-
* voice resolved voice record { name, thumbnail } (speech only)
|
|
862
|
-
* poll_tool widget-side status tool (default 'get_generation_status')
|
|
863
|
-
* status_args args for poll_tool (default { generation_id, wait: true })
|
|
864
|
-
*/
|
|
865
|
-
async function uiGenerating(p) {
|
|
866
|
-
// No ETAs anywhere — just a spinner until the poll flips to completed.
|
|
867
|
-
const chip = await modelChipFields(p.client, p.model);
|
|
868
|
-
const settings = await decorateSettings(p.client, p.settings || {});
|
|
869
|
-
const structured = {
|
|
870
|
-
phase: 'generating',
|
|
871
|
-
widget: 'generation',
|
|
872
|
-
kind: p.kind,
|
|
873
|
-
tool: p.tool,
|
|
874
|
-
generation_id: p.gen.generation_id,
|
|
875
|
-
poll_tool: p.poll_tool || 'get_generation_status',
|
|
876
|
-
// Keep at most one long-wait status call in flight per widget. Without
|
|
877
|
-
// wait=true, every open card calls tools/call every few seconds, flooding
|
|
878
|
-
// the host's global progress/context stream and API rate limits.
|
|
879
|
-
status_args: p.status_args || { generation_id: p.gen.generation_id, wait: true },
|
|
880
|
-
...chip,
|
|
881
|
-
...(p.voice ? { voice_name: p.voice.name, voice_thumbnail: p.voice.thumbnail } : {}),
|
|
882
|
-
prompt: p.prompt,
|
|
883
|
-
count: p.count || 1,
|
|
884
|
-
settings,
|
|
885
|
-
visual_dnas: await resolveVisualDnas(p.client, settings.visual_dna_ids),
|
|
886
|
-
moodboards: await resolveMoodboards(p.client, moodboardIds(settings)),
|
|
887
|
-
// `reference_image` is retained for older widget builds. New widgets render
|
|
888
|
-
// every browser-loadable image supplied to the generation.
|
|
889
|
-
...mediaRefs(p),
|
|
890
|
-
...linkFields(p.tool, p.gen),
|
|
891
|
-
};
|
|
892
|
-
// Batch mode (prompts[] fan-out): ONE widget tracks every id in the set.
|
|
893
|
-
if (Array.isArray(p.generation_ids) && p.generation_ids.length > 1) {
|
|
894
|
-
structured.generation_ids = p.generation_ids;
|
|
895
|
-
structured.prompts = p.prompts;
|
|
896
|
-
}
|
|
897
|
-
const text = JSON.stringify({
|
|
898
|
-
status: 'submitted',
|
|
899
|
-
generation_id: p.gen.generation_id,
|
|
900
|
-
// The session this landed in. Pass it back as `session_id` on the next call
|
|
901
|
-
// of the same set to keep the whole set in one session (see sessionIdField).
|
|
902
|
-
session_id: p.gen.session_id,
|
|
903
|
-
...(Array.isArray(p.generation_ids) && p.generation_ids.length > 1
|
|
904
|
-
? { batch: true, generation_ids: p.generation_ids } : {}),
|
|
905
|
-
...(p.failed_submissions && p.failed_submissions.length
|
|
906
|
-
? { failed_submissions: p.failed_submissions } : {}),
|
|
907
|
-
...(p.warning ? { _warning: p.warning } : {}),
|
|
908
|
-
_widget_note: 'A live Kolbo widget is rendering this generation for the user (progress + final result + action buttons). Tell the user it is generating and the card above will update. CREDIT GUARD: END YOUR TURN now — do not keep Thinking, writing skills/files, or planning while the card spins (that burns coding credits). Do NOT poll in a loop. If you need the output URLs for a follow-up step, call get_generation_status ONCE with wait=true as the only next tool — it blocks until done. Tracking several generations? Pass ALL their ids in generation_ids in that one call.',
|
|
909
|
-
_paid_note: 'This generation is RUNNING and the user is paying for it. If you now realize the tool, model, or parameters were wrong, call cancel_generation with this generation_id FIRST, then start the replacement — never leave a wrong generation running alongside its retry (the user gets two cards and two charges).',
|
|
910
|
-
}, null, 2);
|
|
911
|
-
return uiResult(UI.generation, text, structured);
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
/**
|
|
915
|
-
* Return a paid generation immediately for hosts that cannot render MCP Apps
|
|
916
|
-
* and enforce short tool-call timeouts (for example Manus custom MCP). This is
|
|
917
|
-
* deliberately plain MCP content: no ui:// resource and no promise of a card.
|
|
918
|
-
* The existing get_generation_status tool is the durable status endpoint.
|
|
919
|
-
*/
|
|
920
|
-
function asyncGenerating(p) {
|
|
921
|
-
const ids = Array.isArray(p.generation_ids) && p.generation_ids.length
|
|
922
|
-
? p.generation_ids
|
|
923
|
-
: [p.gen.generation_id].filter(Boolean);
|
|
924
|
-
const defaultStatusArgs = ids.length > 1
|
|
925
|
-
? { generation_ids: ids, wait: false }
|
|
926
|
-
: { generation_id: ids[0], wait: false };
|
|
927
|
-
const pollTool = p.poll_tool || 'get_generation_status';
|
|
928
|
-
const statusArgs = { ...(p.status_args || defaultStatusArgs), wait: false };
|
|
929
|
-
const structured = {
|
|
930
|
-
status: 'submitted',
|
|
931
|
-
generation_id: p.gen.generation_id,
|
|
932
|
-
session_id: p.gen.session_id,
|
|
933
|
-
...(ids.length > 1 ? { batch: true, generation_ids: ids } : {}),
|
|
934
|
-
...(p.failed_submissions && p.failed_submissions.length
|
|
935
|
-
? { failed_submissions: p.failed_submissions } : {}),
|
|
936
|
-
...(p.warning ? { warning: p.warning } : {}),
|
|
937
|
-
poll_tool: pollTool,
|
|
938
|
-
status_args: statusArgs,
|
|
939
|
-
next_action: `The job is running. Do not submit it again. When the result is needed, call ${pollTool} with the supplied status_args. If it is still processing, tell the user and check again later; do not poll in a tight loop.`,
|
|
940
|
-
paid_job_notice: ids.length > 1
|
|
941
|
-
? `This is a paid batch. Only if the user asks to stop or approves a replacement, cancel every running generation first: ${ids.join(', ')}.`
|
|
942
|
-
: 'This is a paid generation. Only if the user asks to stop or approves a replacement, cancel this generation before starting the replacement.',
|
|
943
|
-
};
|
|
944
|
-
return {
|
|
945
|
-
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
946
|
-
structuredContent: structured,
|
|
947
|
-
};
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* Wrap an already-completed generation result with the widget.
|
|
952
|
-
*
|
|
953
|
-
* Used by tools that stay blocking even on UI hosts (creative director), AND —
|
|
954
|
-
* since structuredContent costs a text host nothing — by every generation tool
|
|
955
|
-
* on its normal blocking return. That second case is why model names, model
|
|
956
|
-
* avatars and Visual DNA chips were missing in Kolbo Code: it does not advertise
|
|
957
|
-
* MCP Apps, so `ui()` is false, `uiGenerating` never runs, and the host had to
|
|
958
|
-
* rebuild the card from raw text that carries only a model IDENTIFIER. Shipping
|
|
959
|
-
* the resolved payload here fixes every non-Apps host at once, exactly the way
|
|
960
|
-
* the list tools already do it (see listResult).
|
|
961
|
-
*
|
|
962
|
-
* The TEXT is unchanged, so text-only hosts see precisely what they saw before.
|
|
963
|
-
*/
|
|
964
|
-
function preferOwnedUrls(urls) {
|
|
965
|
-
const list = Array.isArray(urls) ? urls.filter((item) => typeof item === 'string' && item) : [];
|
|
966
|
-
const ours = list.filter((item) => {
|
|
967
|
-
try {
|
|
968
|
-
const host = new URL(item).hostname;
|
|
969
|
-
return /(?:^|\.)kolbo\.ai$/.test(host) || /digitaloceanspaces\.com$/.test(host);
|
|
970
|
-
} catch {
|
|
971
|
-
return false;
|
|
972
|
-
}
|
|
973
|
-
});
|
|
974
|
-
return ours.length ? ours : (list.length ? list : urls);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
async function uiCompleted(p, textPayload, extraContent) {
|
|
978
|
-
const chip = await modelChipFields(p.client, p.model);
|
|
979
|
-
const settings = p.settings ? await decorateSettings(p.client, p.settings) : undefined;
|
|
980
|
-
const structured = {
|
|
981
|
-
phase: 'completed',
|
|
982
|
-
widget: 'generation',
|
|
983
|
-
kind: p.kind,
|
|
984
|
-
tool: p.tool,
|
|
985
|
-
...chip,
|
|
986
|
-
prompt: p.prompt,
|
|
987
|
-
count: p.count || 1,
|
|
988
|
-
// Omitted entirely when the caller has none. A live generation card merges
|
|
989
|
-
// an incoming status payload over its own state, so an empty-but-present
|
|
990
|
-
// `settings` wiped the resolution / aspect / DNA chips off the finished
|
|
991
|
-
// card. Every reader already does `sc.settings || {}`.
|
|
992
|
-
...(settings ? { settings } : {}),
|
|
993
|
-
visual_dnas: await resolveVisualDnas(p.client, (settings || {}).visual_dna_ids),
|
|
994
|
-
moodboards: await resolveMoodboards(p.client, moodboardIds(settings)),
|
|
995
|
-
...mediaRefs(p),
|
|
996
|
-
urls: preferOwnedUrls(p.urls),
|
|
997
|
-
thumbnail_url: p.thumbnail_url,
|
|
998
|
-
title: p.title,
|
|
999
|
-
duration: p.duration,
|
|
1000
|
-
scenes: p.scenes,
|
|
1001
|
-
// Independent per-item results (get_generation_status checking several ids
|
|
1002
|
-
// at once) — each one carries its OWN state/media, unlike `scenes`/`urls`
|
|
1003
|
-
// above which assume everything finished together. Only set when the
|
|
1004
|
-
// caller actually has this shape; every existing caller is unaffected.
|
|
1005
|
-
...(Array.isArray(p.items) ? { items: p.items } : {}),
|
|
1006
|
-
// Transcription payload. get_generation_status is the ONLY way the live
|
|
1007
|
-
// transcript widget learns its result, and it reads text/srt_url/txt_url off
|
|
1008
|
-
// this object — but uiCompleted is shaped for the generation card and
|
|
1009
|
-
// dropped every one, so a finished transcription rendered "(empty
|
|
1010
|
-
// transcript)" with no SRT/TXT buttons while the text sat in the status
|
|
1011
|
-
// response. structuredContent SHADOWS the text block on widget hosts, so
|
|
1012
|
-
// omitting a field here is the same as deleting it.
|
|
1013
|
-
...(typeof p.text === 'string' ? { text: p.text } : {}),
|
|
1014
|
-
...(p.srt_url ? { srt_url: p.srt_url } : {}),
|
|
1015
|
-
...(p.word_by_word_srt_url ? { word_by_word_srt_url: p.word_by_word_srt_url } : {}),
|
|
1016
|
-
...(p.txt_url ? { txt_url: p.txt_url } : {}),
|
|
1017
|
-
...(p.audio_url ? { audio_url: p.audio_url } : {}),
|
|
1018
|
-
// The voice, by name and portrait. uiGenerating has carried this since the
|
|
1019
|
-
// chips were introduced; uiCompleted never did, so it silently dropped a
|
|
1020
|
-
// resolved voice its caller had already looked up — every FINISHED speech
|
|
1021
|
-
// card fell back to `settings.voice`, printing a raw ElevenLabs id where the
|
|
1022
|
-
// name belongs and rendering the generic note placeholder instead of the
|
|
1023
|
-
// voice's face. Exactly the "never show a raw id on a card" rule, broken on
|
|
1024
|
-
// the one path the user actually ends up looking at.
|
|
1025
|
-
...(p.voice ? { voice_name: p.voice.name, voice_thumbnail: p.voice.thumbnail } : {}),
|
|
1026
|
-
// The RAW generation state, when the caller has one. `phase` above is
|
|
1027
|
-
// hardcoded 'completed' — it means "this tool call finished", not "the
|
|
1028
|
-
// generation finished" — so a status check on a still-running job looked
|
|
1029
|
-
// done to every reader. A live generation card polls get_generation_status
|
|
1030
|
-
// from its own iframe and reads `state` FIRST; without it, a processing job
|
|
1031
|
-
// resolved as completed-with-no-media and painted a red failure card.
|
|
1032
|
-
...(p.state ? { state: p.state } : {}),
|
|
1033
|
-
credits_used: p.credits_used,
|
|
1034
|
-
...linkFields(p.tool, p.gen),
|
|
1035
|
-
};
|
|
1036
|
-
const out = uiResult(UI.generation, textPayload, structured);
|
|
1037
|
-
if (Array.isArray(extraContent) && extraContent.length) {
|
|
1038
|
-
out.content = [...out.content, ...extraContent];
|
|
1039
|
-
}
|
|
1040
|
-
return out;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
// ─── Text-payload budget ─────────────────────────────────────────────────────
|
|
1044
|
-
//
|
|
1045
|
-
// Whatever a tool returns as TEXT is what the model actually reads, and hosts
|
|
1046
|
-
// reject or spill-to-disk anything much past this. A list tool that dumps every
|
|
1047
|
-
// field of every row blows it instantly: list_presets measured 632,919 chars,
|
|
1048
|
-
// get_stock_categories 257,817, list_voices 190,286 — all pretty-printed with
|
|
1049
|
-
// `JSON.stringify(x, null, 2)` and no cap. The widget path already slims rows to
|
|
1050
|
-
// id/title/thumbnail; the text path has to do the same or the tool is unusable
|
|
1051
|
-
// on exactly the text hosts (Claude Code, Cursor, Codex) that depend on it.
|
|
1052
|
-
const MAX_TEXT_CHARS = 20000;
|
|
1053
|
-
|
|
1054
|
-
/**
|
|
1055
|
-
* Build a compact text payload for a list-shaped result.
|
|
1056
|
-
*
|
|
1057
|
-
* @param {object[]} items rows from the API
|
|
1058
|
-
* @param {object} opts
|
|
1059
|
-
* @param {string[]} opts.fields keys to keep per row, in order (others dropped)
|
|
1060
|
-
* @param {number} [opts.cap] max rows to include (default 50)
|
|
1061
|
-
* @param {number} [opts.total] true total, so the model knows more exist
|
|
1062
|
-
* @param {object} [opts.extra] extra top-level keys to merge in
|
|
1063
|
-
* @param {string} [opts.note] guidance on how to fetch the rest
|
|
1064
|
-
*/
|
|
1065
|
-
function compactList(items, { fields, cap = 50, total, extra, note } = {}) {
|
|
1066
|
-
const rows = Array.isArray(items) ? items : [];
|
|
1067
|
-
const kept = rows.slice(0, cap).map((row) => {
|
|
1068
|
-
if (!row || typeof row !== 'object') return row;
|
|
1069
|
-
const out = {};
|
|
1070
|
-
for (const f of fields || Object.keys(row)) {
|
|
1071
|
-
// Drop empties — a row of nulls costs tokens and tells the model nothing.
|
|
1072
|
-
if (row[f] !== undefined && row[f] !== null && row[f] !== '') out[f] = row[f];
|
|
1073
|
-
}
|
|
1074
|
-
return out;
|
|
1075
|
-
});
|
|
1076
|
-
|
|
1077
|
-
const payload = { ...(extra || {}), items: kept, count: kept.length };
|
|
1078
|
-
if (total != null) payload.total = total;
|
|
1079
|
-
const omitted = Math.max(rows.length - kept.length, 0);
|
|
1080
|
-
if (omitted > 0 || (total != null && total > kept.length)) {
|
|
1081
|
-
payload._truncated = {
|
|
1082
|
-
omitted_from_this_page: omitted,
|
|
1083
|
-
hint: note || 'Narrow with the tool\'s filter args, or page for more.',
|
|
1084
|
-
};
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
let text = JSON.stringify(payload);
|
|
1088
|
-
if (text.length > MAX_TEXT_CHARS) {
|
|
1089
|
-
// Still too big even trimmed (very long descriptions). Halve until it fits
|
|
1090
|
-
// rather than returning something the host will truncate at a random byte.
|
|
1091
|
-
let n = kept.length;
|
|
1092
|
-
while (n > 1 && text.length > MAX_TEXT_CHARS) {
|
|
1093
|
-
n = Math.floor(n / 2);
|
|
1094
|
-
payload.items = kept.slice(0, n);
|
|
1095
|
-
payload.count = n;
|
|
1096
|
-
payload._truncated = {
|
|
1097
|
-
omitted_from_this_page: rows.length - n,
|
|
1098
|
-
hint: note || 'Result was too large for one response; narrow with filter args.',
|
|
1099
|
-
};
|
|
1100
|
-
text = JSON.stringify(payload);
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
return text;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
/**
|
|
1107
|
-
* Turn a 403 INSUFFICIENT_CREDITS into the plans/upgrade card.
|
|
1108
|
-
*
|
|
1109
|
-
* Without this the user just sees the raw API sentence and has to go find the
|
|
1110
|
-
* pricing page themselves. The card states the shortfall, shows live
|
|
1111
|
-
* promo-adjusted plans, and links to app.kolbo.ai/pricing.
|
|
1112
|
-
*
|
|
1113
|
-
* Returns null for any other error so callers can rethrow untouched. The plan
|
|
1114
|
-
* fetch is best-effort: if it fails we still return a card carrying the
|
|
1115
|
-
* balance/shortfall and the pricing link, because the ONE thing this path must
|
|
1116
|
-
* never do is swallow the reason the generation did not run.
|
|
1117
|
-
*/
|
|
1118
|
-
async function insufficientCreditsResult(client, err) {
|
|
1119
|
-
if (!err || err.code !== 'INSUFFICIENT_CREDITS') return null;
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1
|
+
/* Shared helpers for MCP tools. No server.tool() registrations here.
|
|
2
|
+
*
|
|
3
|
+
* This file centralizes the URL-or-local-path → Buffer resolver used by
|
|
4
|
+
* every tool that accepts file-ish arguments (visual_dna, elements,
|
|
5
|
+
* first_last_frame, lipsync, video_from_video, transcription, media upload,
|
|
6
|
+
* future additions). It also owns the SSRF guard applied to any URL we
|
|
7
|
+
* fetch on the user's local machine.
|
|
8
|
+
*
|
|
9
|
+
* SSRF defense in depth:
|
|
10
|
+
* 1. Only http: / https: protocols.
|
|
11
|
+
* 2. Block IP literals in private / loopback / link-local / multicast /
|
|
12
|
+
* reserved ranges (IPv4 and IPv6).
|
|
13
|
+
* 3. Block common internal hostnames (localhost, *.local, *.internal,
|
|
14
|
+
* metadata.google.internal, metadata.goog).
|
|
15
|
+
* 4. Manual redirect following so every hop is re-validated (a crafted
|
|
16
|
+
* public URL could 302 to 169.254.169.254 — global fetch would follow
|
|
17
|
+
* silently).
|
|
18
|
+
*
|
|
19
|
+
* If you add a new tool that fetches URLs, import resolveToBuffer from here
|
|
20
|
+
* rather than reinventing the guard.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const net = require('net');
|
|
26
|
+
const dns = require('dns').promises;
|
|
27
|
+
const { Agent, fetch: undiciFetch } = require('undici');
|
|
28
|
+
|
|
29
|
+
const MAX_FILE_BYTES = 500 * 1024 * 1024; // 500 MB — larger than visual_dna because
|
|
30
|
+
// lipsync/v2v/transcription accept full
|
|
31
|
+
// videos and long audio tracks.
|
|
32
|
+
const VISUAL_DNA_MAX_BYTES = 25 * 1024 * 1024; // kept for visual_dna backward-compat
|
|
33
|
+
const REMOTE_FETCH_MAX_BYTES = 100 * 1024 * 1024;
|
|
34
|
+
const MAX_REDIRECTS = 5;
|
|
35
|
+
|
|
36
|
+
// THE single statement of how a local file gets into Kolbo. It is repeated to
|
|
37
|
+
// the model on several surfaces — the server `instructions` block (src/index.js),
|
|
38
|
+
// the media tool descriptions, and both local-path errors below — so it lives
|
|
39
|
+
// here and is imported, not retyped. It was previously pasted in five places and
|
|
40
|
+
// a change to it updated only two, leaving `instructions` teaching the opposite.
|
|
41
|
+
const LOCAL_FILE_ROUTING =
|
|
42
|
+
'If you are using Kolbo over a remote connector (e.g. claude.ai), local files are not reachable. ' +
|
|
43
|
+
'DO NOT upload the file yourself with cloud credentials or a shell command — Kolbo has a tool for this. ' +
|
|
44
|
+
'If you can run shell commands, call `create_upload_ticket` and POST the file to the returned upload_url. ' +
|
|
45
|
+
'Otherwise call `media_upload_widget` to have the user pick the file, or `upload_media` ' +
|
|
46
|
+
'when the file IS reachable from where the MCP server runs, then pass the returned https:// URL here. ' +
|
|
47
|
+
'A URL from `list_media` also works if the asset is already in the library.';
|
|
48
|
+
|
|
49
|
+
// Every tool that takes user media as INPUT. Their descriptions promise "URL or
|
|
50
|
+
// absolute local path" — true on a stdio install, a lie over a remote connector,
|
|
51
|
+
// where the model would read it, see no filesystem, and tell the user Kolbo
|
|
52
|
+
// cannot take their file at all. attachFileInputHints() below appends the route
|
|
53
|
+
// that actually works for the current transport, so the refusal never happens.
|
|
54
|
+
const FILE_INPUT_TOOLS = [
|
|
55
|
+
'generate_image', 'generate_image_edit', 'generate_creative_director',
|
|
56
|
+
'generate_video', 'generate_video_from_image', 'generate_video_from_video',
|
|
57
|
+
'generate_elements', 'generate_first_last_frame', 'generate_lipsync',
|
|
58
|
+
'generate_3d', 'edit_image', 'edit_video', 'transcribe_audio',
|
|
59
|
+
'create_visual_dna', 'update_visual_dna', 'generate_character_sheet', 'clone_voice',
|
|
60
|
+
'chat_send_message', 'create_moodboard', 'update_moodboard',
|
|
61
|
+
'separate_audio_stems', 'clean_dialogue_leftovers', 'separate_ambience',
|
|
62
|
+
'analyze_video'
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const REMOTE_FILE_HINT =
|
|
66
|
+
' LOCAL FILE (on the user\'s machine, or attached to this chat)? NEVER reply that you cannot upload files — Kolbo uploads it for you. ' +
|
|
67
|
+
'Call `media_upload_widget` so the user picks the file (claude.ai web/mobile), or `create_upload_ticket` and POST the file yourself if you can run shell/HTTP commands; ' +
|
|
68
|
+
'either way you get an https:// URL to pass here. Ignore any "absolute local path" wording in the args below — this server cannot read the caller\'s disk, so a path will fail.';
|
|
69
|
+
|
|
70
|
+
const LOCAL_FILE_HINT =
|
|
71
|
+
' LOCAL FILE? Absolute local paths work here (server and client share a filesystem). ' +
|
|
72
|
+
'Never reply that you cannot upload files — for a file you will reference more than once, call `upload_media` first and reuse the returned https:// URL. ' +
|
|
73
|
+
'IMAGE/VIDEO PASTED OR ATTACHED IN THIS CHAT with no path? You can see it but Kolbo cannot — NEVER substitute a text description of it for the file. ' +
|
|
74
|
+
'Call `media_upload_widget` so the user hands Kolbo the file (or ask for its path), then pass the returned URL — for "make this image X" use generate_image_edit with it in `source_images`.';
|
|
75
|
+
const REMOTE_TEXT_FILE_HINT =
|
|
76
|
+
' REMOTE FILE INPUT: This client cannot send local filesystem paths or render Kolbo\'s upload widget. ' +
|
|
77
|
+
'Use an existing public https:// URL. If the attachment has no public URL, ask the user to upload it in the Kolbo Media Library and paste the resulting URL; never invent a URL or claim a local path is usable here.';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Append the transport-correct local-file route to every media-input tool's
|
|
81
|
+
* description, post-registration (same pattern as attachToolWidgetMeta).
|
|
82
|
+
* `options.remote === true` is set only by kolbo-api's remote per-request
|
|
83
|
+
* server. Do not use `appsEnabled()` as the transport signal: stdio hosts can
|
|
84
|
+
* render widgets while still sharing a filesystem with this process.
|
|
85
|
+
*/
|
|
86
|
+
function attachFileInputHints(server, options = {}) {
|
|
87
|
+
const hint = options.asyncGenerations
|
|
88
|
+
? REMOTE_TEXT_FILE_HINT
|
|
89
|
+
: (options.remote === true || options.apps === true ? REMOTE_FILE_HINT : LOCAL_FILE_HINT);
|
|
90
|
+
const registered = server._registeredTools || {};
|
|
91
|
+
for (const name of FILE_INPUT_TOOLS) {
|
|
92
|
+
const t = registered[name];
|
|
93
|
+
if (t && typeof t.description === 'string' && !t.description.includes(hint)) {
|
|
94
|
+
t.description += hint;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Per-kind upload caps advertised to callers when the ticket endpoint omits them.
|
|
100
|
+
// Mirrors MAX_MB in kolbo-api src/modules/mcpConnector/upload.js.
|
|
101
|
+
const DEFAULT_MAX_FILE_MB = { image: 50, video: 500, audio: 200, document: 50 };
|
|
102
|
+
|
|
103
|
+
function isHttpUrl(s) {
|
|
104
|
+
return typeof s === 'string' && /^https?:\/\//i.test(s);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isPrivateIPv4(ip) {
|
|
108
|
+
const parts = ip.split('.').map(Number);
|
|
109
|
+
if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) return true;
|
|
110
|
+
const [a, b] = parts;
|
|
111
|
+
if (a === 10) return true;
|
|
112
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
113
|
+
if (a === 127) return true;
|
|
114
|
+
if (a === 0) return true;
|
|
115
|
+
if (a === 169 && b === 254) return true; // includes 169.254.169.254 cloud metadata
|
|
116
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
117
|
+
if (a === 192 && b === 168) return true;
|
|
118
|
+
if (a === 192 && b === 0 && parts[2] === 0) return true;
|
|
119
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
120
|
+
if (a >= 224) return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isPrivateIPv6(ip) {
|
|
125
|
+
const lower = ip.toLowerCase();
|
|
126
|
+
if (lower === '::' || lower === '::1') return true;
|
|
127
|
+
if (lower.startsWith('fe80:') || lower.startsWith('fe8') ||
|
|
128
|
+
lower.startsWith('fe9') || lower.startsWith('fea') ||
|
|
129
|
+
lower.startsWith('feb')) return true;
|
|
130
|
+
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
|
|
131
|
+
if (lower.startsWith('ff')) return true;
|
|
132
|
+
// IPv4-mapped / compat in dotted form: ::ffff:1.2.3.4 or ::1.2.3.4
|
|
133
|
+
const mappedDot = lower.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/);
|
|
134
|
+
if (mappedDot) return isPrivateIPv4(mappedDot[1]);
|
|
135
|
+
// IPv4-mapped in pure hex form: ::ffff:7f00:1 (Node normalizes
|
|
136
|
+
// ::ffff:127.0.0.1 → ::ffff:7f00:1). Extract last 2 hextets → 4 bytes.
|
|
137
|
+
const mappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
138
|
+
if (mappedHex) {
|
|
139
|
+
const hi = parseInt(mappedHex[1], 16);
|
|
140
|
+
const lo = parseInt(mappedHex[2], 16);
|
|
141
|
+
const dotted = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
142
|
+
return isPrivateIPv4(dotted);
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isBlockedHostname(hostname) {
|
|
148
|
+
// new URL('http://[::1]/').hostname returns "[::1]" (brackets kept).
|
|
149
|
+
// Strip them so net.isIP and our private-range checks see the bare address.
|
|
150
|
+
let host = hostname.toLowerCase();
|
|
151
|
+
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
|
152
|
+
const blockedNames = new Set([
|
|
153
|
+
'localhost',
|
|
154
|
+
'ip6-localhost',
|
|
155
|
+
'ip6-loopback',
|
|
156
|
+
'metadata.google.internal',
|
|
157
|
+
'metadata.goog'
|
|
158
|
+
]);
|
|
159
|
+
if (blockedNames.has(host)) return true;
|
|
160
|
+
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost')) return true;
|
|
161
|
+
const ipFamily = net.isIP(host);
|
|
162
|
+
if (ipFamily === 4 && isPrivateIPv4(host)) return true;
|
|
163
|
+
if (ipFamily === 6 && isPrivateIPv6(host)) return true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function assertSafeUrl(rawUrl) {
|
|
168
|
+
let u;
|
|
169
|
+
try { u = new URL(rawUrl); }
|
|
170
|
+
catch (_) { throw new Error(`Invalid URL: ${rawUrl}`); }
|
|
171
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
172
|
+
throw new Error(`Unsupported URL protocol "${u.protocol}" — only http/https allowed`);
|
|
173
|
+
}
|
|
174
|
+
if (isBlockedHostname(u.hostname)) {
|
|
175
|
+
throw new Error(`Refusing to fetch from private / loopback / metadata host: ${u.hostname}`);
|
|
176
|
+
}
|
|
177
|
+
return u;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function resolvePublicAddresses(hostname) {
|
|
181
|
+
let host = hostname.toLowerCase();
|
|
182
|
+
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
|
183
|
+
const literalFamily = net.isIP(host);
|
|
184
|
+
if (literalFamily) return [{ address: host, family: literalFamily }];
|
|
185
|
+
|
|
186
|
+
let timer;
|
|
187
|
+
const timeout = new Promise((_, reject) => {
|
|
188
|
+
timer = setTimeout(() => reject(new Error(`DNS lookup timed out for ${host}`)), 3000);
|
|
189
|
+
});
|
|
190
|
+
let rows;
|
|
191
|
+
try {
|
|
192
|
+
rows = await Promise.race([dns.lookup(host, { all: true, verbatim: true }), timeout]);
|
|
193
|
+
} finally {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
}
|
|
196
|
+
if (!rows.length) throw new Error(`DNS lookup returned no addresses for ${host}`);
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
const blocked = row.family === 4 ? isPrivateIPv4(row.address) : isPrivateIPv6(row.address);
|
|
199
|
+
if (blocked) throw new Error(`Refusing private / loopback / metadata DNS target for ${host}`);
|
|
200
|
+
}
|
|
201
|
+
return rows;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pinnedDispatcher(addresses) {
|
|
205
|
+
let cursor = 0;
|
|
206
|
+
return new Agent({
|
|
207
|
+
connect: {
|
|
208
|
+
lookup(_hostname, options, callback) {
|
|
209
|
+
if (options?.all) return callback(null, addresses);
|
|
210
|
+
const row = addresses[cursor++ % addresses.length];
|
|
211
|
+
return callback(null, row.address, row.family);
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function safeFetch(rawUrl, opts = {}) {
|
|
218
|
+
let current = rawUrl;
|
|
219
|
+
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
|
220
|
+
const url = assertSafeUrl(current);
|
|
221
|
+
const addresses = await resolvePublicAddresses(url.hostname);
|
|
222
|
+
const dispatcher = pinnedDispatcher(addresses);
|
|
223
|
+
let res;
|
|
224
|
+
try {
|
|
225
|
+
res = await undiciFetch(current, { redirect: 'manual', signal: opts.signal, dispatcher });
|
|
226
|
+
} catch (err) {
|
|
227
|
+
await dispatcher.close().catch(() => {});
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
231
|
+
const next = new URL(res.headers.get('location'), current).toString();
|
|
232
|
+
await res.body?.cancel().catch(() => {});
|
|
233
|
+
await dispatcher.close().catch(() => {});
|
|
234
|
+
current = next;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// close() waits for this response body to be consumed, so schedule it but
|
|
238
|
+
// do not await it before returning the Response to the caller.
|
|
239
|
+
dispatcher.close().catch(() => {});
|
|
240
|
+
return res;
|
|
241
|
+
}
|
|
242
|
+
throw new Error(`Too many redirects fetching ${rawUrl}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function discardResponse(res) {
|
|
246
|
+
try { await res?.body?.cancel(); } catch (_) {}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function readResponseBuffer(res, maxBytes) {
|
|
250
|
+
if (!res?.body || typeof res.body.getReader !== 'function') {
|
|
251
|
+
throw new Error('Remote response has no readable body');
|
|
252
|
+
}
|
|
253
|
+
const reader = res.body.getReader();
|
|
254
|
+
const chunks = [];
|
|
255
|
+
let total = 0;
|
|
256
|
+
try {
|
|
257
|
+
while (true) {
|
|
258
|
+
const { done, value } = await reader.read();
|
|
259
|
+
if (done) break;
|
|
260
|
+
const chunk = Buffer.from(value);
|
|
261
|
+
total += chunk.length;
|
|
262
|
+
if (total > maxBytes) {
|
|
263
|
+
await reader.cancel().catch(() => {});
|
|
264
|
+
throw new Error(`Remote response exceeds ${maxBytes}-byte limit`);
|
|
265
|
+
}
|
|
266
|
+
chunks.push(chunk);
|
|
267
|
+
}
|
|
268
|
+
} finally {
|
|
269
|
+
try { reader.releaseLock(); } catch (_) {}
|
|
270
|
+
}
|
|
271
|
+
return Buffer.concat(chunks, total);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function guessFilename(source, fallbackExt) {
|
|
275
|
+
if (isHttpUrl(source)) {
|
|
276
|
+
try {
|
|
277
|
+
const u = new URL(source);
|
|
278
|
+
const base = path.basename(u.pathname) || `upload${fallbackExt}`;
|
|
279
|
+
return base.includes('.') ? base : `${base}${fallbackExt}`;
|
|
280
|
+
} catch (_) {
|
|
281
|
+
return `upload${fallbackExt}`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return path.basename(source);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function guessContentType(filename) {
|
|
288
|
+
const ext = path.extname(filename).toLowerCase();
|
|
289
|
+
const map = {
|
|
290
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
291
|
+
'.webp': 'image/webp', '.gif': 'image/gif', '.bmp': 'image/bmp',
|
|
292
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
293
|
+
'.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',
|
|
294
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
|
295
|
+
'.m4a': 'audio/mp4', '.flac': 'audio/flac', '.aac': 'audio/aac'
|
|
296
|
+
};
|
|
297
|
+
return map[ext] || 'application/octet-stream';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Resolve a URL or absolute local path into an in-memory Buffer.
|
|
302
|
+
* - URLs: fetched via safeFetch (SSRF-guarded, manual redirect handling)
|
|
303
|
+
* - Local paths: read via fs.readFileSync (must be absolute)
|
|
304
|
+
*
|
|
305
|
+
* @param {string} source - URL or absolute local path
|
|
306
|
+
* @param {'image'|'video'|'audio'} kind - hint for default filename extension
|
|
307
|
+
* @param {Object} [opts]
|
|
308
|
+
* @param {number} [opts.maxBytes] - override the default size cap
|
|
309
|
+
* @returns {Promise<{buffer: Buffer, filename: string, contentType: string, size: number}>}
|
|
310
|
+
*/
|
|
311
|
+
async function resolveToBuffer(source, kind, opts = {}) {
|
|
312
|
+
const requestedMaxBytes = opts.maxBytes || MAX_FILE_BYTES;
|
|
313
|
+
const maxBytes = opts.allowLocalFiles === false
|
|
314
|
+
? Math.min(requestedMaxBytes, REMOTE_FETCH_MAX_BYTES)
|
|
315
|
+
: requestedMaxBytes;
|
|
316
|
+
const defaultExt = kind === 'image' ? '.png' : kind === 'video' ? '.mp4' : '.mp3';
|
|
317
|
+
|
|
318
|
+
if (isHttpUrl(source)) {
|
|
319
|
+
const res = await safeFetch(source);
|
|
320
|
+
if (!res.ok) {
|
|
321
|
+
await discardResponse(res);
|
|
322
|
+
throw new Error(`Failed to fetch ${source}: ${res.status} ${res.statusText}`);
|
|
323
|
+
}
|
|
324
|
+
const contentLen = parseInt(res.headers.get('content-length') || '0', 10);
|
|
325
|
+
if (contentLen && contentLen > maxBytes) {
|
|
326
|
+
await discardResponse(res);
|
|
327
|
+
throw new Error(`File at ${source} (${contentLen} bytes) exceeds ${maxBytes}-byte limit`);
|
|
328
|
+
}
|
|
329
|
+
const buffer = await readResponseBuffer(res, maxBytes);
|
|
330
|
+
const filename = guessFilename(source, defaultExt);
|
|
331
|
+
return {
|
|
332
|
+
buffer,
|
|
333
|
+
filename,
|
|
334
|
+
contentType: res.headers.get('content-type') || guessContentType(filename),
|
|
335
|
+
size: buffer.length
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (opts.allowLocalFiles === false) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
'This remote connector accepts public https:// URLs only. Upload the file to the Kolbo Media Library and pass its public URL.'
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!path.isAbsolute(source)) {
|
|
346
|
+
// `path.isAbsolute` is platform-specific: on a POSIX server (every remote
|
|
347
|
+
// connector deployment) a valid Windows path like `C:\Users\...` or
|
|
348
|
+
// `\\server\share\...` returns false, so "must be absolute" is a lie that
|
|
349
|
+
// sends the caller off retrying slash variants. `path.win32.isAbsolute`
|
|
350
|
+
// answers the same question with Node's own grammar.
|
|
351
|
+
throw new Error(
|
|
352
|
+
(path.win32.isAbsolute(source)
|
|
353
|
+
? `This Kolbo server cannot read files off the calling machine, so the Windows path ${source} is unreachable from here. `
|
|
354
|
+
: `Local file paths must be absolute: ${source}. `) +
|
|
355
|
+
LOCAL_FILE_ROUTING
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
let stat;
|
|
359
|
+
try {
|
|
360
|
+
stat = fs.statSync(source);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Local file not found or unreadable: ${source}. ` +
|
|
364
|
+
LOCAL_FILE_ROUTING +
|
|
365
|
+
(err && err.code ? ` [${err.code}]` : '')
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
if (stat.size > maxBytes) {
|
|
369
|
+
throw new Error(`File ${source} (${stat.size} bytes) exceeds ${maxBytes}-byte limit`);
|
|
370
|
+
}
|
|
371
|
+
const buffer = fs.readFileSync(source);
|
|
372
|
+
const filename = path.basename(source);
|
|
373
|
+
return {
|
|
374
|
+
buffer,
|
|
375
|
+
filename,
|
|
376
|
+
contentType: guessContentType(filename),
|
|
377
|
+
size: buffer.length
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ─── Universal graceful-timeout convention ───────────────────────────────────
|
|
382
|
+
// pollUntilDone throws PollingTimeoutError (err.timedOut === true) when the
|
|
383
|
+
// CLIENT-SIDE poll window elapses — the generation is almost always still
|
|
384
|
+
// running server-side (or already done). Originally only
|
|
385
|
+
// generate_creative_director caught this and returned a non-throwing
|
|
386
|
+
// "_timed_out" result; every other tool let it propagate, so the MCP SDK
|
|
387
|
+
// wrapped it as isError:true and recovery depended entirely on the calling
|
|
388
|
+
// LLM reading hint text. pollOrTimedOut() is the one place that decision now
|
|
389
|
+
// lives — every generation/chat tool routes its pollUntilDone call through
|
|
390
|
+
// it instead of duplicating the try/catch. Genuine failures (state
|
|
391
|
+
// failed/cancelled → GenerationFailedError) are NOT caught here; they still
|
|
392
|
+
// throw and surface as a real tool error.
|
|
393
|
+
//
|
|
394
|
+
// CRITICAL: when the poll window times out, we UNTRACK the generation so that
|
|
395
|
+
// when the MCP host eventually aborts the tool call (e.g., at ~180s), it does
|
|
396
|
+
// NOT cancel the server-side generation. The generation keeps running, and the
|
|
397
|
+
// caller can collect it later with get_generation_status.
|
|
398
|
+
const { pollUntilDone } = require('../polling');
|
|
399
|
+
const progress = require('../progress');
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @returns {Promise<{result: object}|{timedOut: object}>}
|
|
403
|
+
* Callers do: `const poll = await pollOrTimedOut(...); if (poll.timedOut) return poll.timedOut; const result = poll.result;`
|
|
404
|
+
*/
|
|
405
|
+
async function pollOrTimedOut(client, generationId, pollOpts) {
|
|
406
|
+
try {
|
|
407
|
+
return { result: await pollUntilDone(client, generationId, pollOpts) };
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if (!err || !err.timedOut) throw err;
|
|
410
|
+
// Untrack so the MCP host aborting this tool call does NOT cancel the generation.
|
|
411
|
+
progress.untrackGeneration(generationId);
|
|
412
|
+
return {
|
|
413
|
+
timedOut: {
|
|
414
|
+
content: [{
|
|
415
|
+
type: 'text',
|
|
416
|
+
text: JSON.stringify({
|
|
417
|
+
state: 'processing',
|
|
418
|
+
generation_id: generationId,
|
|
419
|
+
_timed_out: true,
|
|
420
|
+
_hint: `Still running on the server after the poll window — this is NOT a failure, no credits were lost. Call get_generation_status with generation_id="${generationId}" (wait=true) to keep checking until state="completed". Do NOT re-run this tool.`
|
|
421
|
+
}, null, 2)
|
|
422
|
+
}]
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Extract real, multiplier-adjusted credit cost from a polled getStatus
|
|
430
|
+
* response. kolbo-api returns `credits_used` (final number deducted) and
|
|
431
|
+
* `credits_breakdown` (per-CreditUsage detail) when the generation is
|
|
432
|
+
* complete. Returns `{}` when the API didn't include them so spreading
|
|
433
|
+
* the result into a tool's response object is a no-op (forward-compatible
|
|
434
|
+
* with old kolbo-api versions).
|
|
435
|
+
*
|
|
436
|
+
* Usage in every generation tool:
|
|
437
|
+
* return {
|
|
438
|
+
* content: [{ type: 'text', text: JSON.stringify({
|
|
439
|
+
* urls: result.result.urls,
|
|
440
|
+
* model: result.result.model,
|
|
441
|
+
* ...creditFields(result), // adds credits_used + credits_breakdown
|
|
442
|
+
* _followup_hint: '...',
|
|
443
|
+
* }, null, 2) }]
|
|
444
|
+
* };
|
|
445
|
+
*/
|
|
446
|
+
function creditFields(polledResult) {
|
|
447
|
+
if (!polledResult) return {};
|
|
448
|
+
const out = {};
|
|
449
|
+
if (typeof polledResult.credits_used === 'number') {
|
|
450
|
+
out.credits_used = polledResult.credits_used;
|
|
451
|
+
}
|
|
452
|
+
if (Array.isArray(polledResult.credits_breakdown) && polledResult.credits_breakdown.length) {
|
|
453
|
+
out.credits_breakdown = polledResult.credits_breakdown;
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Shared zod schema for the optional `project_id` arg every generation tool
|
|
459
|
+
// accepts. Keep this in one place so the description never drifts across the
|
|
460
|
+
// 17 tools that use it. When omitted, the generation lands in the user's
|
|
461
|
+
// auto-created "API Generations" project. Call `list_projects` first to
|
|
462
|
+
// resolve a name → ObjectId.
|
|
463
|
+
const { z } = require('zod');
|
|
464
|
+
const projectIdField = z.string().optional().describe(
|
|
465
|
+
'Project ObjectId to drop this generation into. Call `list_projects` to discover IDs (the API has no concept of project names — only ObjectIds). IMPORTANT: this is per-call, NOT sticky — once the user has named a working project, pass its id on EVERY generation call in the conversation; any call that omits it silently lands in the default "API Generations" project instead. Requires owner / edit / full permission on the project; view-only is rejected.'
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
// Shared zod schema for the optional `session_id` arg on generation tools.
|
|
469
|
+
// WHY IT EXISTS: without it, each generation call gets its own session, so a
|
|
470
|
+
// set of related clips lands in the app's left rail as a stack of near-identical
|
|
471
|
+
// single-item sessions. (The server has a per-day fallback bucket, but it is not
|
|
472
|
+
// something a caller can rely on — see kolbo-api sdkSessionManager.) Threading
|
|
473
|
+
// the id returned by the FIRST call is the deterministic way to group a batch.
|
|
474
|
+
const sessionIdField = z.string().optional().describe(
|
|
475
|
+
'Existing session to add this generation to, so a related set lands in ONE session instead of a stack of single-item sessions in the Kolbo sidebar. HOW TO USE: omit it on the FIRST call of a PLAN BUCKET (Cast, Locations, Props, or one Scene NN), read `session_id` off that result, `rename_session` to the plan name, then pass that SAME value on every follow-up in that bucket (another character, shot 2, a retake, "make it darker"). Only omit it again when the plan starts a NEW scene or NEW concept — never per take or per tool call. Image tools and video tools cannot share an id. `list_sessions` also returns ids. When set, `project_id` is ignored — the session\'s own project wins.'
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
// Read-scope variant for list/get tools that can surface a SHARED project's
|
|
479
|
+
// assets (a teammate's Visual DNAs / moodboards). Pass a project id you have
|
|
480
|
+
// edit+ on to also see that project owner's shared assets; omit to see only your
|
|
481
|
+
// own + global/org. View-only members and non-members get nothing extra.
|
|
482
|
+
const projectScopeReadField = z.string().optional().describe(
|
|
483
|
+
"Project ObjectId (from `list_projects`) to ALSO surface that shared project's assets (a teammate's, when the project is shared with you). Requires edit / full / owner on the project — view-only members and non-members get only their own. Omit to see just your own + global/org."
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
// ─── Optional inline-image content blocks ────────────────────────────────────
|
|
487
|
+
// When a host opts in (the remote HTTP connector sets inlineImages:true), turn
|
|
488
|
+
// generated IMAGE urls into MCP `image` content blocks so clients render them
|
|
489
|
+
// inline instead of a "Show Image" link. Strictly gated + bounded:
|
|
490
|
+
// - only runs when opts.enabled is true (stdio/Kolbo Code never enables it,
|
|
491
|
+
// so their behavior is byte-identical: text URL only);
|
|
492
|
+
// - caps the number of images and the bytes per image;
|
|
493
|
+
// - ONLY embeds responses whose content-type is image/* — a video/audio URL
|
|
494
|
+
// can never be base64-embedded even if mistakenly passed in;
|
|
495
|
+
// - any fetch/decoding failure silently falls back to URL-only.
|
|
496
|
+
const INLINE_IMG_MAX_COUNT = 4;
|
|
497
|
+
// Cap kept conservative on purpose: a base64 image rides inside the JSON-RPC
|
|
498
|
+
// tool result, and chat clients (claude.ai etc.) drop the WHOLE result if it's
|
|
499
|
+
// too large — which looks like "no image at all". Anything over the cap is left
|
|
500
|
+
// to the URL in the text payload (clients render a "Show Image" affordance from
|
|
501
|
+
// it), so a big image degrades to a click instead of vanishing.
|
|
502
|
+
const INLINE_IMG_MAX_BYTES = 1.5 * 1024 * 1024; // 1.5 MB per image
|
|
503
|
+
const INLINE_IMG_FETCH_TIMEOUT_MS = 8000; // never hang the tool response on a slow CDN
|
|
504
|
+
|
|
505
|
+
async function inlineImageBlocks(urls, opts = {}) {
|
|
506
|
+
if (!opts || !opts.enabled) return [];
|
|
507
|
+
if (!Array.isArray(urls) || urls.length === 0) return [];
|
|
508
|
+
// Fetch the (≤4) images in parallel — they're independent, the cap already
|
|
509
|
+
// bounds concurrency, and this sits on the connector response path right
|
|
510
|
+
// after generation. Order is preserved by map-then-filter; any failure (size,
|
|
511
|
+
// type, timeout, network) returns null and falls back to URL-only.
|
|
512
|
+
const maxCount = Math.min(INLINE_IMG_MAX_COUNT, Math.max(1, Number(opts.maxCount) || INLINE_IMG_MAX_COUNT));
|
|
513
|
+
const maxBytes = Math.min(INLINE_IMG_MAX_BYTES, Math.max(1, Number(opts.maxBytes) || INLINE_IMG_MAX_BYTES));
|
|
514
|
+
const fetchTimeoutMs = Math.min(INLINE_IMG_FETCH_TIMEOUT_MS, Math.max(1, Number(opts.fetchTimeoutMs) || INLINE_IMG_FETCH_TIMEOUT_MS));
|
|
515
|
+
const blocks = await Promise.all(
|
|
516
|
+
urls.slice(0, maxCount).map(async (url) => {
|
|
517
|
+
const controller = new AbortController();
|
|
518
|
+
const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
|
519
|
+
try {
|
|
520
|
+
if (typeof url !== 'string' || !isHttpUrl(url)) return null;
|
|
521
|
+
const res = await safeFetch(url, { signal: controller.signal });
|
|
522
|
+
if (!res.ok) {
|
|
523
|
+
await discardResponse(res);
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
const contentType = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
527
|
+
if (!contentType.startsWith('image/')) {
|
|
528
|
+
await discardResponse(res);
|
|
529
|
+
return null; // never embed non-images
|
|
530
|
+
}
|
|
531
|
+
const declaredLen = Number(res.headers.get('content-length') || 0);
|
|
532
|
+
if (declaredLen && declaredLen > maxBytes) {
|
|
533
|
+
await discardResponse(res);
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
const buffer = await readResponseBuffer(res, maxBytes);
|
|
537
|
+
return { type: 'image', data: buffer.toString('base64'), mimeType: contentType };
|
|
538
|
+
} catch (_) {
|
|
539
|
+
return null;
|
|
540
|
+
} finally {
|
|
541
|
+
clearTimeout(timer);
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
);
|
|
545
|
+
return blocks.filter(Boolean);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ─── "Open in Kolbo" deep links ───────────────────────────────────────────────
|
|
549
|
+
// kolbo-api submit responses include `session_id` + `project_id`. Map each MCP
|
|
550
|
+
// tool to the frontend page + tool slug whose session view can RESUME that
|
|
551
|
+
// session (mirrors kolbo-map src/constants/sessionTypes.js resumeUrl map — the
|
|
552
|
+
// route must match the SESSION MODEL the SDK created, per sdkSessionManager):
|
|
553
|
+
// ImageSession (image AND image_edit) → /image-tools?tool=create-image
|
|
554
|
+
// imgEditSession (edit_image / global_image_edit — the Canvas) → /image-tools?tool=canvas
|
|
555
|
+
// imgToVideoSession (video, video_from_image, elements, first_last_frame)
|
|
556
|
+
// → /video-tools?tool=create-video
|
|
557
|
+
// videoToVideoSession → /video-tools?tool=video-to-video
|
|
558
|
+
// lipsyncSession → /video-tools?tool=lipsync
|
|
559
|
+
// MusicGeneratorSession / TextToSpeechSession / textToSoundSession /
|
|
560
|
+
// speechToTextSession → /audio-tools with the matching slug
|
|
561
|
+
// CreativeDirectorSession → /creative-director?session=... (no tool param)
|
|
562
|
+
// RETIRED — do NOT reintroduce as separate destinations. "Image Editing" folded
|
|
563
|
+
// into Create Image and "Text to Video" folded into Create Video (a mode inside
|
|
564
|
+
// it); sdkSessionManager already routes image_edit → ImageSession and video →
|
|
565
|
+
// imgToVideoSession. The old ?tool=image-editing / ?tool=text-to-video slugs
|
|
566
|
+
// still redirect, but nothing new should emit them.
|
|
567
|
+
// Intentionally ABSENT (no deep-linkable session page — widget falls back to
|
|
568
|
+
// plain https://app.kolbo.ai): edit_video (GlobalVideoEditSession has no
|
|
569
|
+
// session deep-link), generate_3d (project-scoped, no session), shorts render.
|
|
570
|
+
const APP_BASE_URL = 'https://app.kolbo.ai';
|
|
571
|
+
const OPEN_URL_ROUTES = {
|
|
572
|
+
generate_image: { path: '/image-tools', tool: 'create-image' },
|
|
573
|
+
generate_image_edit: { path: '/image-tools', tool: 'create-image' },
|
|
574
|
+
edit_image: { path: '/image-tools', tool: 'canvas' },
|
|
575
|
+
generate_video: { path: '/video-tools', tool: 'create-video' },
|
|
576
|
+
generate_video_from_image: { path: '/video-tools', tool: 'create-video' },
|
|
577
|
+
generate_elements: { path: '/video-tools', tool: 'create-video', mode: 'elements' },
|
|
578
|
+
generate_first_last_frame: { path: '/video-tools', tool: 'create-video', mode: 'first-last' },
|
|
579
|
+
generate_video_from_video: { path: '/video-tools', tool: 'video-to-video' },
|
|
580
|
+
generate_lipsync: { path: '/video-tools', tool: 'lipsync' },
|
|
581
|
+
generate_music: { path: '/audio-tools', tool: 'music-generator' },
|
|
582
|
+
generate_speech: { path: '/audio-tools', tool: 'text-to-speech' },
|
|
583
|
+
generate_sound: { path: '/audio-tools', tool: 'text-to-sound' },
|
|
584
|
+
transcribe_audio: { path: '/audio-tools', tool: 'speech-to-text' },
|
|
585
|
+
generate_creative_director: { path: '/creative-director' },
|
|
586
|
+
};
|
|
587
|
+
// SDK tracking `type` on get_generation_status — same session model as the
|
|
588
|
+
// generate_* tool that created the job, so "Open in Kolbo" still deep-links
|
|
589
|
+
// after a status poll overwrites the live card.
|
|
590
|
+
const TYPE_TO_TOOL = {
|
|
591
|
+
image: 'generate_image',
|
|
592
|
+
image_edit: 'generate_image_edit',
|
|
593
|
+
global_image_edit: 'edit_image',
|
|
594
|
+
video: 'generate_video',
|
|
595
|
+
video_from_image: 'generate_video_from_image',
|
|
596
|
+
elements: 'generate_elements',
|
|
597
|
+
first_last_frame: 'generate_first_last_frame',
|
|
598
|
+
video_from_video: 'generate_video_from_video',
|
|
599
|
+
lipsync: 'generate_lipsync',
|
|
600
|
+
music: 'generate_music',
|
|
601
|
+
speech: 'generate_speech',
|
|
602
|
+
sound: 'generate_sound',
|
|
603
|
+
transcription: 'transcribe_audio',
|
|
604
|
+
creative_director: 'generate_creative_director',
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
function sessionOf(gen) {
|
|
608
|
+
if (!gen || typeof gen !== 'object') return undefined;
|
|
609
|
+
return gen.session_id || gen.sessionId || undefined;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function projectOf(gen) {
|
|
613
|
+
if (!gen || typeof gen !== 'object') return undefined;
|
|
614
|
+
return gen.project_id || gen.projectId || undefined;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Build the "Open in Kolbo" deep link for a generation's actual session.
|
|
619
|
+
* Returns undefined (widget falls back to app.kolbo.ai) when the tool has no
|
|
620
|
+
* deep-linkable page or the submit response carried no session_id (older
|
|
621
|
+
* kolbo-api, shorts render, 3D).
|
|
622
|
+
*/
|
|
623
|
+
function buildOpenUrl(tool, gen) {
|
|
624
|
+
const sid = sessionOf(gen);
|
|
625
|
+
if (!sid) return undefined;
|
|
626
|
+
const key = OPEN_URL_ROUTES[tool] ? tool : (TYPE_TO_TOOL[gen && gen.type] || TYPE_TO_TOOL[tool]);
|
|
627
|
+
const route = OPEN_URL_ROUTES[key];
|
|
628
|
+
if (!route) return undefined;
|
|
629
|
+
let url = `${APP_BASE_URL}${route.path}?session=${encodeURIComponent(sid)}`;
|
|
630
|
+
if (route.tool) url += `&tool=${route.tool}`;
|
|
631
|
+
if (route.mode) url += `&mode=${route.mode}`;
|
|
632
|
+
const pid = projectOf(gen);
|
|
633
|
+
if (pid) url += `&project=${encodeURIComponent(pid)}`;
|
|
634
|
+
return url;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function linkFields(tool, gen) {
|
|
638
|
+
const sid = sessionOf(gen);
|
|
639
|
+
const pid = projectOf(gen);
|
|
640
|
+
const href = buildOpenUrl(tool, gen);
|
|
641
|
+
return {
|
|
642
|
+
...(sid ? { session_id: String(sid) } : {}),
|
|
643
|
+
...(pid ? { project_id: String(pid) } : {}),
|
|
644
|
+
...(href ? { open_url: href } : {}),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Build the "Open in Kolbo" deep link for a PROJECT. Lands on the Media hub
|
|
650
|
+
* (all-assets view) pre-filtered to this project, where the user sees every
|
|
651
|
+
* generation/media item in it and can switch projects via the in-page selector.
|
|
652
|
+
* The media page reads `?project=<id>` on load and selects it. Returns undefined
|
|
653
|
+
* for a missing id or the default "API Generations" bucket (no useful landing —
|
|
654
|
+
* it is the catch-all, not a real workspace the user navigates to).
|
|
655
|
+
*/
|
|
656
|
+
function buildProjectUrl(projectId, opts = {}) {
|
|
657
|
+
if (!projectId || opts.is_default) return undefined;
|
|
658
|
+
return `${APP_BASE_URL}/media?project=${encodeURIComponent(projectId)}`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ─── MCP Apps generation widget helpers ──────────────────────────────────────
|
|
662
|
+
// When the host renders MCP Apps (claude.ai via the remote connector, Claude
|
|
663
|
+
// Desktop over stdio), generation tools return IMMEDIATELY after submit and the
|
|
664
|
+
// ui://kolbo/generation.html widget takes over: live progress, inline result,
|
|
665
|
+
// action buttons. Text-only hosts never enter this path — their blocking
|
|
666
|
+
// behavior and response bytes are UNCHANGED.
|
|
667
|
+
const { UI, uiResult, appsEnabled, modelInfo } = require('../apps');
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Chip identity for a model: the CLEAN display name + its icon, resolved from
|
|
671
|
+
* the same /v1/models catalog `list_models` renders. Callers pass whatever the
|
|
672
|
+
* user/LLM supplied (an identifier like `google_tts`, `fal-ai/…/omnihuman/v1.5`,
|
|
673
|
+
* or a display name) — the card must never show the raw id.
|
|
674
|
+
*/
|
|
675
|
+
async function modelChipFields(client, model) {
|
|
676
|
+
const info = await modelInfo(client, model).catch(() => null);
|
|
677
|
+
return {
|
|
678
|
+
model: model || 'Smart Select',
|
|
679
|
+
model_name: (info && info.name) || model || 'Smart Select',
|
|
680
|
+
model_icon: (info && info.icon) || null,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Resolve `visual_dna_ids` to {id, name, thumbnail} so the card can show WHICH
|
|
686
|
+
* characters are locked in, not just how many. An id tells the user nothing;
|
|
687
|
+
* the face does.
|
|
688
|
+
*
|
|
689
|
+
* One list fetch per process, cached — the DNA catalog barely moves within a
|
|
690
|
+
* session, and a generation card must never add a round-trip per chip. A miss
|
|
691
|
+
* (id not in the caller's own DNAs) degrades to the bare id, which is exactly
|
|
692
|
+
* what the card showed before.
|
|
693
|
+
*/
|
|
694
|
+
const _dnaChipCache = new Map();
|
|
695
|
+
let _dnaChipLoaded = 0;
|
|
696
|
+
const DNA_CHIP_TTL = 5 * 60 * 1000;
|
|
697
|
+
|
|
698
|
+
function dnaThumb(row) {
|
|
699
|
+
if (!row || typeof row !== 'object') return null;
|
|
700
|
+
const first = Array.isArray(row.images) ? row.images[0] : null;
|
|
701
|
+
return row.sheet_url
|
|
702
|
+
|| row.thumbnail_url
|
|
703
|
+
|| row.characterSheet
|
|
704
|
+
|| (typeof first === 'string' ? first : first && first.url)
|
|
705
|
+
|| null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function rememberDna(row, fallbackId) {
|
|
709
|
+
if (!row || typeof row !== 'object') return null;
|
|
710
|
+
const id = String(row.id || row._id || fallbackId || '');
|
|
711
|
+
if (!id) return null;
|
|
712
|
+
const rec = { id, name: row.name || id, thumbnail: dnaThumb(row) };
|
|
713
|
+
_dnaChipCache.set(id, rec);
|
|
714
|
+
return rec;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function resolveVisualDnas(client, ids) {
|
|
718
|
+
const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id) : [];
|
|
719
|
+
if (!list.length) return [];
|
|
720
|
+
|
|
721
|
+
const stale = Date.now() - _dnaChipLoaded > DNA_CHIP_TTL;
|
|
722
|
+
if (stale || list.some((id) => !_dnaChipCache.has(id))) {
|
|
723
|
+
try {
|
|
724
|
+
const res = await client.get('/v1/visual-dna?scope=mine');
|
|
725
|
+
const rows = res?.visual_dnas || res?.data || [];
|
|
726
|
+
for (const row of rows) rememberDna(row);
|
|
727
|
+
_dnaChipLoaded = Date.now();
|
|
728
|
+
} catch {
|
|
729
|
+
// Offline / rate-limited — fall through to per-id fetch.
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// scope=mine misses global / shared / teammate DNAs. The generating card
|
|
734
|
+
// then printed "1 Visual DNA" with no face. Fetch the missing ids directly.
|
|
735
|
+
const missing = list.filter((id) => !_dnaChipCache.has(id));
|
|
736
|
+
if (missing.length) {
|
|
737
|
+
await Promise.all(missing.map(async (id) => {
|
|
738
|
+
try {
|
|
739
|
+
const res = await client.get(`/v1/visual-dna/${encodeURIComponent(id)}`);
|
|
740
|
+
rememberDna(res && res.visual_dna ? res.visual_dna : res, id);
|
|
741
|
+
} catch {
|
|
742
|
+
// Leave the bare id — the chip still names it.
|
|
743
|
+
}
|
|
744
|
+
}));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return list.map((id) => _dnaChipCache.get(id) || { id, name: id, thumbnail: null });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const _presetCache = new Map();
|
|
751
|
+
let _presetLoaded = 0;
|
|
752
|
+
const PRESET_TTL = 10 * 60 * 1000;
|
|
753
|
+
|
|
754
|
+
async function resolvePreset(client, presetId) {
|
|
755
|
+
if (!presetId) return null;
|
|
756
|
+
const key = String(presetId);
|
|
757
|
+
const stale = Date.now() - _presetLoaded > PRESET_TTL;
|
|
758
|
+
if (!stale && _presetCache.has(key)) return _presetCache.get(key);
|
|
759
|
+
if (stale || _presetCache.size === 0) {
|
|
760
|
+
try {
|
|
761
|
+
const res = await client.get('/v1/presets');
|
|
762
|
+
for (const row of res?.presets || res?.data || []) {
|
|
763
|
+
const id = row?.id || row?._id || row?.identifier;
|
|
764
|
+
if (!id) continue;
|
|
765
|
+
_presetCache.set(String(id), {
|
|
766
|
+
id: String(id),
|
|
767
|
+
name: row.name || String(id),
|
|
768
|
+
thumbnail: row.thumbnail_url || row.thumbnail || null,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
_presetLoaded = Date.now();
|
|
772
|
+
} catch {
|
|
773
|
+
// Offline — the chip keeps the word "preset".
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return _presetCache.get(key) || null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function decorateSettings(client, settings) {
|
|
780
|
+
const s = { ...(settings || {}) };
|
|
781
|
+
if (!s.preset_id) return s;
|
|
782
|
+
const preset = await resolvePreset(client, s.preset_id);
|
|
783
|
+
if (!preset) return s;
|
|
784
|
+
return {
|
|
785
|
+
...s,
|
|
786
|
+
preset_name: preset.name,
|
|
787
|
+
...(preset.thumbnail ? { preset_thumbnail: preset.thumbnail } : {}),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const _mbChipCache = new Map();
|
|
792
|
+
let _mbChipLoaded = 0;
|
|
793
|
+
|
|
794
|
+
function rememberMoodboard(row, fallbackId) {
|
|
795
|
+
if (!row || typeof row !== 'object') return null;
|
|
796
|
+
const id = String(row.id || row._id || fallbackId || '');
|
|
797
|
+
if (!id) return null;
|
|
798
|
+
const rec = {
|
|
799
|
+
id,
|
|
800
|
+
name: row.name || id,
|
|
801
|
+
thumbnail: row.thumbnail_url || row.thumbnail || row.cover_url || (Array.isArray(row.images) ? row.images[0] : null) || null,
|
|
802
|
+
};
|
|
803
|
+
_mbChipCache.set(id, rec);
|
|
804
|
+
return rec;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function resolveMoodboards(client, ids) {
|
|
808
|
+
const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id) : [];
|
|
809
|
+
if (!list.length) return [];
|
|
810
|
+
const stale = Date.now() - _mbChipLoaded > DNA_CHIP_TTL;
|
|
811
|
+
if (stale || list.some((id) => !_mbChipCache.has(id))) {
|
|
812
|
+
try {
|
|
813
|
+
const res = await client.get('/v1/moodboards');
|
|
814
|
+
for (const row of res?.moodboards || res?.data || []) rememberMoodboard(row);
|
|
815
|
+
_mbChipLoaded = Date.now();
|
|
816
|
+
} catch { /* fall through to per-id */ }
|
|
817
|
+
}
|
|
818
|
+
const missing = list.filter((id) => !_mbChipCache.has(id));
|
|
819
|
+
if (missing.length) {
|
|
820
|
+
await Promise.all(missing.map(async (id) => {
|
|
821
|
+
try {
|
|
822
|
+
const res = await client.get(`/v1/moodboards/${encodeURIComponent(id)}`);
|
|
823
|
+
rememberMoodboard(res && res.moodboard ? res.moodboard : res, id);
|
|
824
|
+
} catch { /* bare id */ }
|
|
825
|
+
}));
|
|
826
|
+
}
|
|
827
|
+
return list.map((id) => _mbChipCache.get(id) || { id, name: id, thumbnail: null });
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function mediaRefs(p) {
|
|
831
|
+
const images = Array.isArray(p.reference_images)
|
|
832
|
+
? p.reference_images.filter(Boolean)
|
|
833
|
+
: (p.reference_image ? [p.reference_image] : []);
|
|
834
|
+
return {
|
|
835
|
+
// Omitted when empty: a live card MERGES status payloads over its own
|
|
836
|
+
// state, so a present-but-empty reference_images (the single-id status
|
|
837
|
+
// path has no input refs of its own) wiped the submit-time thumbnails
|
|
838
|
+
// off the finished card.
|
|
839
|
+
...(images.length ? { reference_images: images, reference_image: p.reference_image || images[0] } : {}),
|
|
840
|
+
...(Array.isArray(p.reference_videos) && p.reference_videos.length
|
|
841
|
+
? { reference_videos: p.reference_videos.filter(Boolean) } : {}),
|
|
842
|
+
...(Array.isArray(p.reference_audio) && p.reference_audio.length
|
|
843
|
+
? { reference_audio: p.reference_audio.filter(Boolean) } : {}),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function moodboardIds(settings) {
|
|
848
|
+
const s = settings || {};
|
|
849
|
+
if (Array.isArray(s.moodboard_ids) && s.moodboard_ids.length) return s.moodboard_ids;
|
|
850
|
+
return s.moodboard_id ? [s.moodboard_id] : [];
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Build the "submitted — widget is live" tool result for a UI host.
|
|
855
|
+
* @param {object} p
|
|
856
|
+
* tool MCP tool name (e.g. 'generate_image')
|
|
857
|
+
* kind 'image' | 'video' | 'audio' | '3d' | 'scenes'
|
|
858
|
+
* gen the submit response ({ generation_id, poll_interval_hint })
|
|
859
|
+
* client KolboClient (for model icon lookup)
|
|
860
|
+
* model, prompt, count, settings, reference_images, estimated_seconds
|
|
861
|
+
* voice resolved voice record { name, thumbnail } (speech only)
|
|
862
|
+
* poll_tool widget-side status tool (default 'get_generation_status')
|
|
863
|
+
* status_args args for poll_tool (default { generation_id, wait: true })
|
|
864
|
+
*/
|
|
865
|
+
async function uiGenerating(p) {
|
|
866
|
+
// No ETAs anywhere — just a spinner until the poll flips to completed.
|
|
867
|
+
const chip = await modelChipFields(p.client, p.model);
|
|
868
|
+
const settings = await decorateSettings(p.client, p.settings || {});
|
|
869
|
+
const structured = {
|
|
870
|
+
phase: 'generating',
|
|
871
|
+
widget: 'generation',
|
|
872
|
+
kind: p.kind,
|
|
873
|
+
tool: p.tool,
|
|
874
|
+
generation_id: p.gen.generation_id,
|
|
875
|
+
poll_tool: p.poll_tool || 'get_generation_status',
|
|
876
|
+
// Keep at most one long-wait status call in flight per widget. Without
|
|
877
|
+
// wait=true, every open card calls tools/call every few seconds, flooding
|
|
878
|
+
// the host's global progress/context stream and API rate limits.
|
|
879
|
+
status_args: p.status_args || { generation_id: p.gen.generation_id, wait: true },
|
|
880
|
+
...chip,
|
|
881
|
+
...(p.voice ? { voice_name: p.voice.name, voice_thumbnail: p.voice.thumbnail } : {}),
|
|
882
|
+
prompt: p.prompt,
|
|
883
|
+
count: p.count || 1,
|
|
884
|
+
settings,
|
|
885
|
+
visual_dnas: await resolveVisualDnas(p.client, settings.visual_dna_ids),
|
|
886
|
+
moodboards: await resolveMoodboards(p.client, moodboardIds(settings)),
|
|
887
|
+
// `reference_image` is retained for older widget builds. New widgets render
|
|
888
|
+
// every browser-loadable image supplied to the generation.
|
|
889
|
+
...mediaRefs(p),
|
|
890
|
+
...linkFields(p.tool, p.gen),
|
|
891
|
+
};
|
|
892
|
+
// Batch mode (prompts[] fan-out): ONE widget tracks every id in the set.
|
|
893
|
+
if (Array.isArray(p.generation_ids) && p.generation_ids.length > 1) {
|
|
894
|
+
structured.generation_ids = p.generation_ids;
|
|
895
|
+
structured.prompts = p.prompts;
|
|
896
|
+
}
|
|
897
|
+
const text = JSON.stringify({
|
|
898
|
+
status: 'submitted',
|
|
899
|
+
generation_id: p.gen.generation_id,
|
|
900
|
+
// The session this landed in. Pass it back as `session_id` on the next call
|
|
901
|
+
// of the same set to keep the whole set in one session (see sessionIdField).
|
|
902
|
+
session_id: p.gen.session_id,
|
|
903
|
+
...(Array.isArray(p.generation_ids) && p.generation_ids.length > 1
|
|
904
|
+
? { batch: true, generation_ids: p.generation_ids } : {}),
|
|
905
|
+
...(p.failed_submissions && p.failed_submissions.length
|
|
906
|
+
? { failed_submissions: p.failed_submissions } : {}),
|
|
907
|
+
...(p.warning ? { _warning: p.warning } : {}),
|
|
908
|
+
_widget_note: 'A live Kolbo widget is rendering this generation for the user (progress + final result + action buttons). Tell the user it is generating and the card above will update. CREDIT GUARD: END YOUR TURN now — do not keep Thinking, writing skills/files, or planning while the card spins (that burns coding credits). Do NOT poll in a loop. If you need the output URLs for a follow-up step, call get_generation_status ONCE with wait=true as the only next tool — it blocks until done. Tracking several generations? Pass ALL their ids in generation_ids in that one call.',
|
|
909
|
+
_paid_note: 'This generation is RUNNING and the user is paying for it. If you now realize the tool, model, or parameters were wrong, call cancel_generation with this generation_id FIRST, then start the replacement — never leave a wrong generation running alongside its retry (the user gets two cards and two charges).',
|
|
910
|
+
}, null, 2);
|
|
911
|
+
return uiResult(UI.generation, text, structured);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Return a paid generation immediately for hosts that cannot render MCP Apps
|
|
916
|
+
* and enforce short tool-call timeouts (for example Manus custom MCP). This is
|
|
917
|
+
* deliberately plain MCP content: no ui:// resource and no promise of a card.
|
|
918
|
+
* The existing get_generation_status tool is the durable status endpoint.
|
|
919
|
+
*/
|
|
920
|
+
function asyncGenerating(p) {
|
|
921
|
+
const ids = Array.isArray(p.generation_ids) && p.generation_ids.length
|
|
922
|
+
? p.generation_ids
|
|
923
|
+
: [p.gen.generation_id].filter(Boolean);
|
|
924
|
+
const defaultStatusArgs = ids.length > 1
|
|
925
|
+
? { generation_ids: ids, wait: false }
|
|
926
|
+
: { generation_id: ids[0], wait: false };
|
|
927
|
+
const pollTool = p.poll_tool || 'get_generation_status';
|
|
928
|
+
const statusArgs = { ...(p.status_args || defaultStatusArgs), wait: false };
|
|
929
|
+
const structured = {
|
|
930
|
+
status: 'submitted',
|
|
931
|
+
generation_id: p.gen.generation_id,
|
|
932
|
+
session_id: p.gen.session_id,
|
|
933
|
+
...(ids.length > 1 ? { batch: true, generation_ids: ids } : {}),
|
|
934
|
+
...(p.failed_submissions && p.failed_submissions.length
|
|
935
|
+
? { failed_submissions: p.failed_submissions } : {}),
|
|
936
|
+
...(p.warning ? { warning: p.warning } : {}),
|
|
937
|
+
poll_tool: pollTool,
|
|
938
|
+
status_args: statusArgs,
|
|
939
|
+
next_action: `The job is running. Do not submit it again. When the result is needed, call ${pollTool} with the supplied status_args. If it is still processing, tell the user and check again later; do not poll in a tight loop.`,
|
|
940
|
+
paid_job_notice: ids.length > 1
|
|
941
|
+
? `This is a paid batch. Only if the user asks to stop or approves a replacement, cancel every running generation first: ${ids.join(', ')}.`
|
|
942
|
+
: 'This is a paid generation. Only if the user asks to stop or approves a replacement, cancel this generation before starting the replacement.',
|
|
943
|
+
};
|
|
944
|
+
return {
|
|
945
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
946
|
+
structuredContent: structured,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Wrap an already-completed generation result with the widget.
|
|
952
|
+
*
|
|
953
|
+
* Used by tools that stay blocking even on UI hosts (creative director), AND —
|
|
954
|
+
* since structuredContent costs a text host nothing — by every generation tool
|
|
955
|
+
* on its normal blocking return. That second case is why model names, model
|
|
956
|
+
* avatars and Visual DNA chips were missing in Kolbo Code: it does not advertise
|
|
957
|
+
* MCP Apps, so `ui()` is false, `uiGenerating` never runs, and the host had to
|
|
958
|
+
* rebuild the card from raw text that carries only a model IDENTIFIER. Shipping
|
|
959
|
+
* the resolved payload here fixes every non-Apps host at once, exactly the way
|
|
960
|
+
* the list tools already do it (see listResult).
|
|
961
|
+
*
|
|
962
|
+
* The TEXT is unchanged, so text-only hosts see precisely what they saw before.
|
|
963
|
+
*/
|
|
964
|
+
function preferOwnedUrls(urls) {
|
|
965
|
+
const list = Array.isArray(urls) ? urls.filter((item) => typeof item === 'string' && item) : [];
|
|
966
|
+
const ours = list.filter((item) => {
|
|
967
|
+
try {
|
|
968
|
+
const host = new URL(item).hostname;
|
|
969
|
+
return /(?:^|\.)kolbo\.ai$/.test(host) || /digitaloceanspaces\.com$/.test(host);
|
|
970
|
+
} catch {
|
|
971
|
+
return false;
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
return ours.length ? ours : (list.length ? list : urls);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function uiCompleted(p, textPayload, extraContent) {
|
|
978
|
+
const chip = await modelChipFields(p.client, p.model);
|
|
979
|
+
const settings = p.settings ? await decorateSettings(p.client, p.settings) : undefined;
|
|
980
|
+
const structured = {
|
|
981
|
+
phase: 'completed',
|
|
982
|
+
widget: 'generation',
|
|
983
|
+
kind: p.kind,
|
|
984
|
+
tool: p.tool,
|
|
985
|
+
...chip,
|
|
986
|
+
prompt: p.prompt,
|
|
987
|
+
count: p.count || 1,
|
|
988
|
+
// Omitted entirely when the caller has none. A live generation card merges
|
|
989
|
+
// an incoming status payload over its own state, so an empty-but-present
|
|
990
|
+
// `settings` wiped the resolution / aspect / DNA chips off the finished
|
|
991
|
+
// card. Every reader already does `sc.settings || {}`.
|
|
992
|
+
...(settings ? { settings } : {}),
|
|
993
|
+
visual_dnas: await resolveVisualDnas(p.client, (settings || {}).visual_dna_ids),
|
|
994
|
+
moodboards: await resolveMoodboards(p.client, moodboardIds(settings)),
|
|
995
|
+
...mediaRefs(p),
|
|
996
|
+
urls: preferOwnedUrls(p.urls),
|
|
997
|
+
thumbnail_url: p.thumbnail_url,
|
|
998
|
+
title: p.title,
|
|
999
|
+
duration: p.duration,
|
|
1000
|
+
scenes: p.scenes,
|
|
1001
|
+
// Independent per-item results (get_generation_status checking several ids
|
|
1002
|
+
// at once) — each one carries its OWN state/media, unlike `scenes`/`urls`
|
|
1003
|
+
// above which assume everything finished together. Only set when the
|
|
1004
|
+
// caller actually has this shape; every existing caller is unaffected.
|
|
1005
|
+
...(Array.isArray(p.items) ? { items: p.items } : {}),
|
|
1006
|
+
// Transcription payload. get_generation_status is the ONLY way the live
|
|
1007
|
+
// transcript widget learns its result, and it reads text/srt_url/txt_url off
|
|
1008
|
+
// this object — but uiCompleted is shaped for the generation card and
|
|
1009
|
+
// dropped every one, so a finished transcription rendered "(empty
|
|
1010
|
+
// transcript)" with no SRT/TXT buttons while the text sat in the status
|
|
1011
|
+
// response. structuredContent SHADOWS the text block on widget hosts, so
|
|
1012
|
+
// omitting a field here is the same as deleting it.
|
|
1013
|
+
...(typeof p.text === 'string' ? { text: p.text } : {}),
|
|
1014
|
+
...(p.srt_url ? { srt_url: p.srt_url } : {}),
|
|
1015
|
+
...(p.word_by_word_srt_url ? { word_by_word_srt_url: p.word_by_word_srt_url } : {}),
|
|
1016
|
+
...(p.txt_url ? { txt_url: p.txt_url } : {}),
|
|
1017
|
+
...(p.audio_url ? { audio_url: p.audio_url } : {}),
|
|
1018
|
+
// The voice, by name and portrait. uiGenerating has carried this since the
|
|
1019
|
+
// chips were introduced; uiCompleted never did, so it silently dropped a
|
|
1020
|
+
// resolved voice its caller had already looked up — every FINISHED speech
|
|
1021
|
+
// card fell back to `settings.voice`, printing a raw ElevenLabs id where the
|
|
1022
|
+
// name belongs and rendering the generic note placeholder instead of the
|
|
1023
|
+
// voice's face. Exactly the "never show a raw id on a card" rule, broken on
|
|
1024
|
+
// the one path the user actually ends up looking at.
|
|
1025
|
+
...(p.voice ? { voice_name: p.voice.name, voice_thumbnail: p.voice.thumbnail } : {}),
|
|
1026
|
+
// The RAW generation state, when the caller has one. `phase` above is
|
|
1027
|
+
// hardcoded 'completed' — it means "this tool call finished", not "the
|
|
1028
|
+
// generation finished" — so a status check on a still-running job looked
|
|
1029
|
+
// done to every reader. A live generation card polls get_generation_status
|
|
1030
|
+
// from its own iframe and reads `state` FIRST; without it, a processing job
|
|
1031
|
+
// resolved as completed-with-no-media and painted a red failure card.
|
|
1032
|
+
...(p.state ? { state: p.state } : {}),
|
|
1033
|
+
credits_used: p.credits_used,
|
|
1034
|
+
...linkFields(p.tool, p.gen),
|
|
1035
|
+
};
|
|
1036
|
+
const out = uiResult(UI.generation, textPayload, structured);
|
|
1037
|
+
if (Array.isArray(extraContent) && extraContent.length) {
|
|
1038
|
+
out.content = [...out.content, ...extraContent];
|
|
1039
|
+
}
|
|
1040
|
+
return out;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ─── Text-payload budget ─────────────────────────────────────────────────────
|
|
1044
|
+
//
|
|
1045
|
+
// Whatever a tool returns as TEXT is what the model actually reads, and hosts
|
|
1046
|
+
// reject or spill-to-disk anything much past this. A list tool that dumps every
|
|
1047
|
+
// field of every row blows it instantly: list_presets measured 632,919 chars,
|
|
1048
|
+
// get_stock_categories 257,817, list_voices 190,286 — all pretty-printed with
|
|
1049
|
+
// `JSON.stringify(x, null, 2)` and no cap. The widget path already slims rows to
|
|
1050
|
+
// id/title/thumbnail; the text path has to do the same or the tool is unusable
|
|
1051
|
+
// on exactly the text hosts (Claude Code, Cursor, Codex) that depend on it.
|
|
1052
|
+
const MAX_TEXT_CHARS = 20000;
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Build a compact text payload for a list-shaped result.
|
|
1056
|
+
*
|
|
1057
|
+
* @param {object[]} items rows from the API
|
|
1058
|
+
* @param {object} opts
|
|
1059
|
+
* @param {string[]} opts.fields keys to keep per row, in order (others dropped)
|
|
1060
|
+
* @param {number} [opts.cap] max rows to include (default 50)
|
|
1061
|
+
* @param {number} [opts.total] true total, so the model knows more exist
|
|
1062
|
+
* @param {object} [opts.extra] extra top-level keys to merge in
|
|
1063
|
+
* @param {string} [opts.note] guidance on how to fetch the rest
|
|
1064
|
+
*/
|
|
1065
|
+
function compactList(items, { fields, cap = 50, total, extra, note } = {}) {
|
|
1066
|
+
const rows = Array.isArray(items) ? items : [];
|
|
1067
|
+
const kept = rows.slice(0, cap).map((row) => {
|
|
1068
|
+
if (!row || typeof row !== 'object') return row;
|
|
1069
|
+
const out = {};
|
|
1070
|
+
for (const f of fields || Object.keys(row)) {
|
|
1071
|
+
// Drop empties — a row of nulls costs tokens and tells the model nothing.
|
|
1072
|
+
if (row[f] !== undefined && row[f] !== null && row[f] !== '') out[f] = row[f];
|
|
1073
|
+
}
|
|
1074
|
+
return out;
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
const payload = { ...(extra || {}), items: kept, count: kept.length };
|
|
1078
|
+
if (total != null) payload.total = total;
|
|
1079
|
+
const omitted = Math.max(rows.length - kept.length, 0);
|
|
1080
|
+
if (omitted > 0 || (total != null && total > kept.length)) {
|
|
1081
|
+
payload._truncated = {
|
|
1082
|
+
omitted_from_this_page: omitted,
|
|
1083
|
+
hint: note || 'Narrow with the tool\'s filter args, or page for more.',
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
let text = JSON.stringify(payload);
|
|
1088
|
+
if (text.length > MAX_TEXT_CHARS) {
|
|
1089
|
+
// Still too big even trimmed (very long descriptions). Halve until it fits
|
|
1090
|
+
// rather than returning something the host will truncate at a random byte.
|
|
1091
|
+
let n = kept.length;
|
|
1092
|
+
while (n > 1 && text.length > MAX_TEXT_CHARS) {
|
|
1093
|
+
n = Math.floor(n / 2);
|
|
1094
|
+
payload.items = kept.slice(0, n);
|
|
1095
|
+
payload.count = n;
|
|
1096
|
+
payload._truncated = {
|
|
1097
|
+
omitted_from_this_page: rows.length - n,
|
|
1098
|
+
hint: note || 'Result was too large for one response; narrow with filter args.',
|
|
1099
|
+
};
|
|
1100
|
+
text = JSON.stringify(payload);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return text;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Turn a 403 INSUFFICIENT_CREDITS into the plans/upgrade card.
|
|
1108
|
+
*
|
|
1109
|
+
* Without this the user just sees the raw API sentence and has to go find the
|
|
1110
|
+
* pricing page themselves. The card states the shortfall, shows live
|
|
1111
|
+
* promo-adjusted plans, and links to app.kolbo.ai/pricing.
|
|
1112
|
+
*
|
|
1113
|
+
* Returns null for any other error so callers can rethrow untouched. The plan
|
|
1114
|
+
* fetch is best-effort: if it fails we still return a card carrying the
|
|
1115
|
+
* balance/shortfall and the pricing link, because the ONE thing this path must
|
|
1116
|
+
* never do is swallow the reason the generation did not run.
|
|
1117
|
+
*/
|
|
1118
|
+
async function insufficientCreditsResult(client, err, options = {}) {
|
|
1119
|
+
if (!err || err.code !== 'INSUFFICIENT_CREDITS') return null;
|
|
1120
|
+
if (options.commerce === false) {
|
|
1121
|
+
// ChatGPT profile: no plans, no prices, no pricing link — OpenAI's
|
|
1122
|
+
// directory treats those as disallowed digital-goods commerce. The card
|
|
1123
|
+
// states the refusal; where to add credits is the user's own business.
|
|
1124
|
+
const balance = err.data?.balance;
|
|
1125
|
+
const structured = {
|
|
1126
|
+
widget: 'generation',
|
|
1127
|
+
phase: 'failed',
|
|
1128
|
+
error: 'This needs more Kolbo credits than the account has left. Nothing was generated or charged.',
|
|
1129
|
+
balance,
|
|
1130
|
+
required: err.data?.required,
|
|
1131
|
+
_hint: 'The generation did NOT run and nothing was charged. Tell the user their Kolbo account is out of credits for this request. Do not quote prices, do not link to pricing or checkout, and do not offer to purchase anything.',
|
|
1132
|
+
};
|
|
1133
|
+
const text = JSON.stringify({
|
|
1134
|
+
error: structured.error,
|
|
1135
|
+
code: 'INSUFFICIENT_CREDITS',
|
|
1136
|
+
balance,
|
|
1137
|
+
required: structured.required,
|
|
1138
|
+
_hint: structured._hint,
|
|
1139
|
+
}, null, 2);
|
|
1140
|
+
return uiResult(UI.generation, text, structured);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
let data = null;
|
|
1144
|
+
try {
|
|
1145
|
+
data = await client.get('/v1/account/plans');
|
|
1146
|
+
} catch {
|
|
1147
|
+
// Offline / rate-limited — fall through to the minimal card.
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const balance = err.data?.balance ?? data?.credits?.total;
|
|
1151
|
+
const required = err.data?.required;
|
|
1152
|
+
const structured = {
|
|
1153
|
+
widget: 'plans',
|
|
1154
|
+
reason: 'insufficient_credits',
|
|
1155
|
+
balance,
|
|
1156
|
+
required,
|
|
1157
|
+
shortfall: (Number.isFinite(balance) && Number.isFinite(required))
|
|
1158
|
+
? Math.max(0, required - balance) : undefined,
|
|
1159
|
+
current_plan: data?.current_plan || null,
|
|
1160
|
+
plans: data?.plans || [],
|
|
1161
|
+
credit_packs: data?.credit_packs || [],
|
|
1162
|
+
pricing_url: err.data?.pricing_url || data?.pricing_url || 'https://app.kolbo.ai/pricing',
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
// structuredContent shadows the text on widget hosts, so the refusal reason
|
|
1166
|
+
// has to be inside the object too — an agent that only reads structured
|
|
1167
|
+
// content must still understand that nothing was generated.
|
|
1168
|
+
structured.error = err.message;
|
|
1169
|
+
structured._hint = 'The generation did NOT run and nothing was charged. An upgrade card is shown to the user. Tell them they are out of credits and point at the card; do not retry the generation, and do not attempt to purchase anything for them.';
|
|
1170
|
+
|
|
1171
|
+
const text = JSON.stringify({
|
|
1172
|
+
error: err.message,
|
|
1173
|
+
code: 'INSUFFICIENT_CREDITS',
|
|
1174
|
+
balance,
|
|
1175
|
+
required,
|
|
1176
|
+
pricing_url: structured.pricing_url,
|
|
1177
|
+
_hint: structured._hint,
|
|
1178
|
+
}, null, 2);
|
|
1179
|
+
|
|
1180
|
+
return uiResult(UI.plans, text, structured);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
module.exports = {
|
|
1184
|
+
MAX_FILE_BYTES,
|
|
1185
|
+
MAX_TEXT_CHARS,
|
|
1186
|
+
compactList,
|
|
1187
|
+
VISUAL_DNA_MAX_BYTES,
|
|
1188
|
+
LOCAL_FILE_ROUTING,
|
|
1189
|
+
FILE_INPUT_TOOLS,
|
|
1190
|
+
attachFileInputHints,
|
|
1191
|
+
DEFAULT_MAX_FILE_MB,
|
|
1192
|
+
isHttpUrl,
|
|
1193
|
+
assertSafeUrl,
|
|
1194
|
+
safeFetch,
|
|
1195
|
+
readResponseBuffer,
|
|
1196
|
+
guessFilename,
|
|
1197
|
+
guessContentType,
|
|
1198
|
+
resolveToBuffer,
|
|
1199
|
+
pollOrTimedOut,
|
|
1200
|
+
creditFields,
|
|
1201
|
+
projectIdField,
|
|
1202
|
+
sessionIdField,
|
|
1203
|
+
projectScopeReadField,
|
|
1204
|
+
inlineImageBlocks,
|
|
1205
|
+
buildOpenUrl,
|
|
1206
|
+
linkFields,
|
|
1207
|
+
buildProjectUrl,
|
|
1208
|
+
uiGenerating,
|
|
1209
|
+
asyncGenerating,
|
|
1210
|
+
uiCompleted,
|
|
1211
|
+
insufficientCreditsResult,
|
|
1212
|
+
appsEnabled,
|
|
1213
|
+
};
|