@contenthero/mcp 0.3.2 → 0.3.3
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/dist/client.d.ts +18 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +27 -7
- package/dist/client.js.map +1 -1
- package/dist/format.d.ts +74 -5
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +456 -13
- package/dist/format.js.map +1 -1
- package/dist/models.d.ts +5 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +17 -0
- package/dist/models.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +954 -87
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/format.js
CHANGED
|
@@ -4,15 +4,27 @@
|
|
|
4
4
|
* a readable message instead of a transport failure.
|
|
5
5
|
*/
|
|
6
6
|
import { ContentHeroError, InsufficientCreditsError, RateLimitError } from '@contenthero/sdk';
|
|
7
|
-
function text(body, isError = false) {
|
|
7
|
+
export function text(body, isError = false) {
|
|
8
8
|
return { content: [{ type: 'text', text: body }], isError };
|
|
9
9
|
}
|
|
10
|
-
/** A finished image/video generation: list the asset URLs. */
|
|
10
|
+
/** A finished image/video generation: list the asset URLs, plus the placement outcome when placed on a project. */
|
|
11
11
|
export function completedResult(gen) {
|
|
12
12
|
const urls = gen.outputUrls ?? [];
|
|
13
13
|
const noun = urls.length === 1 ? gen.contentType : `${gen.contentType}s`;
|
|
14
14
|
const header = `Done. ${urls.length} ${noun} from ${gen.modelId} (outputId ${gen.outputId}):`;
|
|
15
|
-
|
|
15
|
+
const lines = [header, ...urls.map((u, i) => `${i + 1}. ${u}`)];
|
|
16
|
+
const p = gen.placement;
|
|
17
|
+
if (p) {
|
|
18
|
+
if (p.surface === 'canvas') {
|
|
19
|
+
lines.push(`Placed as a canvas layer (id ${p.layerId ?? p.itemId ?? 'resolved'}) on slide ${p.slideId ?? 'resolved'}. Use that layer id to chain further ops (animate, reposition, reorder, set as background).`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
lines.push(`Placed on the timeline (clip id ${p.itemId ?? 'resolved'}). Use that clip id to chain further ops.`);
|
|
23
|
+
}
|
|
24
|
+
if (p.warnings?.length)
|
|
25
|
+
lines.push(`Placement notes: ${p.warnings.join('; ')}`);
|
|
26
|
+
}
|
|
27
|
+
return text(lines.join('\n'));
|
|
16
28
|
}
|
|
17
29
|
/** Suggested seconds to wait before re-polling a job, by content type. */
|
|
18
30
|
export function pollAfterSecondsFor(contentType) {
|
|
@@ -28,6 +40,31 @@ export function audioResult(result) {
|
|
|
28
40
|
const header = `Done. Audio generated (outputId ${result.outputId}):`;
|
|
29
41
|
return text([header, ...urls.map((u, i) => `${i + 1}. ${u}`)].join('\n'));
|
|
30
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* In-place clip enhancement: ONE JOB PER SOURCE, so the agent gets every outputId.
|
|
45
|
+
*
|
|
46
|
+
* Reporting only the first would let an agent see one recording finish and call the whole edit done, while the
|
|
47
|
+
* other recordings were still running. The applied-automatically note matters too: unlike every other async
|
|
48
|
+
* tool here, the caller does NOT place the result, so without saying so an agent would reasonably try to.
|
|
49
|
+
*/
|
|
50
|
+
export function enhanceClipsResult(result) {
|
|
51
|
+
const jobs = result.outputs ?? [];
|
|
52
|
+
if (jobs.length === 0) {
|
|
53
|
+
return text(result.note ?? 'Nothing to enhance: no audible clips in that selection.');
|
|
54
|
+
}
|
|
55
|
+
const lines = jobs.map((j, i) => `${i + 1}. outputId ${j.outputId} covers ${j.clipIds.length} clip${j.clipIds.length === 1 ? '' : 's'}` +
|
|
56
|
+
` from one source (${j.windows} window${j.windows === 1 ? '' : 's'})`);
|
|
57
|
+
const header = jobs.length === 1
|
|
58
|
+
? 'Enhancing 1 source. Poll its outputId with get_generation_status (or wait_for_generation):'
|
|
59
|
+
: `Enhancing ${jobs.length} sources as separate jobs, because a noise profile is estimated per recording. Poll EVERY outputId:`;
|
|
60
|
+
const footer = [
|
|
61
|
+
'The enhanced audio is applied to the clips automatically when each job lands, so no placement call is needed.',
|
|
62
|
+
result.silencedClipsExcluded
|
|
63
|
+
? `${result.silencedClipsExcluded} silenced clip${result.silencedClipsExcluded === 1 ? ' was' : 's were'} skipped.`
|
|
64
|
+
: null,
|
|
65
|
+
].filter(Boolean);
|
|
66
|
+
return text([header, ...lines, ...footer].join('\n'));
|
|
67
|
+
}
|
|
31
68
|
/** Result of a get_cost preflight: the estimate, with nothing generated or charged. */
|
|
32
69
|
export function costResult(est) {
|
|
33
70
|
const what = est.modelId ?? est.contentType ?? 'this generation';
|
|
@@ -64,7 +101,8 @@ export function generationBatchResult(gens) {
|
|
|
64
101
|
/** A finished transcription: header line plus the transcript body. */
|
|
65
102
|
export function transcriptResult(t) {
|
|
66
103
|
const lang = t.language ? ` (${t.language})` : '';
|
|
67
|
-
const
|
|
104
|
+
const cost = t.creditsUsed > 0 ? `, ${t.creditsUsed} credits` : '';
|
|
105
|
+
const header = `Transcript${lang}, ${t.wordCount} words${cost} (outputId ${t.outputId}):`;
|
|
68
106
|
return text([header, '', t.transcript].join('\n'));
|
|
69
107
|
}
|
|
70
108
|
/** Join the non-empty lines (drops null/empty entries). */
|
|
@@ -90,7 +128,7 @@ export function avatarResult(a) {
|
|
|
90
128
|
traits ? `traits: ${traits}` : null,
|
|
91
129
|
a.niche.length ? `niche: ${a.niche.join(', ')}` : null,
|
|
92
130
|
a.looks.length ? `looks (${a.looks.length}):` : 'looks: none',
|
|
93
|
-
...a.looks.map((l) => ` - ${l.name ?? l.lookType ?? 'look'} (id ${l.id})${l.isDefault ? ' [default]' : ''}: ${l.imageUrl ?? 'none'}`),
|
|
131
|
+
...a.looks.map((l) => ` - ${l.name ?? l.lookType ?? 'look'} (id ${l.id})${l.isDefault ? ' [default]' : ''}${l.isFavorited ? ' [favorite]' : ''}${l.isArchived ? ' [archived]' : ''}: ${l.imageUrl ?? 'none'}`),
|
|
94
132
|
]));
|
|
95
133
|
}
|
|
96
134
|
/** List of saved voices. */
|
|
@@ -166,25 +204,85 @@ export function brandKnowledgeSearchResult(matches) {
|
|
|
166
204
|
export function brandKnowledgeItemResult(item, verb = 'Added') {
|
|
167
205
|
return text(`${verb} knowledge item: "${item.title ?? '(untitled)'}" [${item.sourceType ?? 'unknown'}] (id ${item.id}).`);
|
|
168
206
|
}
|
|
169
|
-
|
|
170
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Confirmation of a universal favorite / unfavorite / archive / unarchive action.
|
|
209
|
+
* `target` describes what was acted on: a studio variation slot when
|
|
210
|
+
* variationIndex is set, otherwise a top-level asset by type + id.
|
|
211
|
+
*/
|
|
212
|
+
export function statusActionResult(action, target) {
|
|
213
|
+
const what = target.variationIndex != null
|
|
214
|
+
? `variation ${target.variationIndex} of output ${target.id}`
|
|
215
|
+
: `${target.assetType ?? 'asset'} ${target.id}`;
|
|
216
|
+
return text(`${action} ${what}.`);
|
|
171
217
|
}
|
|
172
|
-
/** List of
|
|
218
|
+
/** List of library media, one row per VARIATION (the atomic grain). */
|
|
173
219
|
export function mediaListResult(items) {
|
|
174
220
|
if (!items.length)
|
|
175
221
|
return text('No media found.');
|
|
176
222
|
const rows = items.map((m) => {
|
|
177
|
-
|
|
223
|
+
// A studio generation lists as one row per variation; show which slot when it has siblings. The
|
|
224
|
+
// addressable token for this variation is `<id>-<variant+1>`.
|
|
225
|
+
const varTag = m.generationSize > 1 ? ` | v${m.variant + 1}/${m.generationSize}` : '';
|
|
226
|
+
const favTag = m.isFavorited ? ' [favorite]' : '';
|
|
178
227
|
const promptStr = m.prompt ? ` | ${m.prompt.slice(0, 80)}${m.prompt.length > 80 ? '...' : ''}` : '';
|
|
179
228
|
const kindTag = m.kind === 'board'
|
|
180
229
|
? ` | board${m.boardType ? `:${m.boardType}` : ''}`
|
|
181
230
|
: m.kind && m.kind !== 'creation'
|
|
182
231
|
? ` | ${m.kind}`
|
|
183
232
|
: '';
|
|
184
|
-
|
|
233
|
+
const nameStr = m.fileName ? ` | ${m.fileName}` : '';
|
|
234
|
+
const durStr = m.durationSeconds != null ? ` | ${Math.round(m.durationSeconds)}s` : '';
|
|
235
|
+
// Every item is a single variation carrying its resolved url; surface it inline so the agent can
|
|
236
|
+
// reference the media directly (e.g. add it to a timeline) without a get call.
|
|
237
|
+
const urlStr = m.url ? ` | ${m.url}` : '';
|
|
238
|
+
return `- [${m.type}] ${m.model ?? ''} (id ${m.id})${varTag}${favTag}${kindTag}${nameStr}${durStr} | ${m.status}${promptStr}${urlStr}`;
|
|
185
239
|
});
|
|
186
240
|
return text([`${items.length} item(s) (newest first):`, ...rows].join('\n'));
|
|
187
241
|
}
|
|
242
|
+
/** Semantic library-search matches: assets ranked by relevance, with matched scene timestamps for video. */
|
|
243
|
+
export function mediaSearchResult(results) {
|
|
244
|
+
if (!results.length)
|
|
245
|
+
return text('No matching media found.');
|
|
246
|
+
const rows = results.map((r) => {
|
|
247
|
+
const rel = ` | ${Math.round(r.relevance * 100)}%`;
|
|
248
|
+
const kindTag = r.kind ? `[${r.kind}]` : '[media]';
|
|
249
|
+
const summaryStr = r.summary ? ` | ${r.summary.slice(0, 90)}${r.summary.length > 90 ? '...' : ''}` : '';
|
|
250
|
+
const scenesStr = r.scenes.length
|
|
251
|
+
? ` | scenes: ${r.scenes.map((s) => `${(s.startMs / 1000).toFixed(1)}-${(s.endMs / 1000).toFixed(1)}s`).join(', ')}`
|
|
252
|
+
: '';
|
|
253
|
+
const urlStr = r.url ? ` | ${r.url}` : '';
|
|
254
|
+
return `- ${kindTag} (id ${r.id})${rel}${summaryStr}${scenesStr}${urlStr}`;
|
|
255
|
+
});
|
|
256
|
+
return text([`${results.length} match(es) (most relevant first):`, ...rows].join('\n'));
|
|
257
|
+
}
|
|
258
|
+
/** The user's folders (their own + the built-in derived folders). */
|
|
259
|
+
export function folderListResult(data) {
|
|
260
|
+
const own = data.folders.map((f) => `- ${f.name} [${f.type}] (id ${f.id})${f.parentId ? ` | in ${f.parentId}` : ''}`);
|
|
261
|
+
const derived = data.derived.map((d) => `- ${d.name} (key ${d.key})`);
|
|
262
|
+
return text([
|
|
263
|
+
own.length ? `Your folders (${own.length}):` : 'You have no folders yet.',
|
|
264
|
+
...own,
|
|
265
|
+
'',
|
|
266
|
+
'Built-in folders:',
|
|
267
|
+
...derived,
|
|
268
|
+
].join('\n'));
|
|
269
|
+
}
|
|
270
|
+
/** One folder's contents (media items + entities). */
|
|
271
|
+
export function folderContentsResult(folder, items) {
|
|
272
|
+
const header = folder ? `"${folder.name}" - ${items.length} item(s):` : `${items.length} item(s):`;
|
|
273
|
+
if (!items.length)
|
|
274
|
+
return text(`${header}\n(empty)`);
|
|
275
|
+
const rows = items.map((i) => {
|
|
276
|
+
if (i.type === 'media') {
|
|
277
|
+
const rel = i.relevance != null ? ` | ${Math.round(i.relevance * 100)}%` : '';
|
|
278
|
+
const fav = i.isFavorited ? ' [favorite]' : '';
|
|
279
|
+
const summ = i.summary ? ` | ${i.summary.slice(0, 80)}${i.summary.length > 80 ? '...' : ''}` : '';
|
|
280
|
+
return `- [${i.kind ?? 'media'}] (${i.sourceTable} ${i.sourceRecordId} v${i.variant})${rel}${fav}${summ}${i.url ? ` | ${i.url}` : ''}`;
|
|
281
|
+
}
|
|
282
|
+
return `- [${i.type}] ${i.name} (id ${i.id})${i.subtype ? ` | ${i.subtype}` : ''}`;
|
|
283
|
+
});
|
|
284
|
+
return text([header, ...rows].join('\n'));
|
|
285
|
+
}
|
|
188
286
|
/** One studio output's detail, with its variations. */
|
|
189
287
|
export function mediaResult(m) {
|
|
190
288
|
const specs = [
|
|
@@ -201,16 +299,103 @@ export function mediaResult(m) {
|
|
|
201
299
|
m.script ? `script: ${m.script}` : null,
|
|
202
300
|
specs || null,
|
|
203
301
|
`status: ${m.status}${m.creditsUsed != null ? ` | ${m.creditsUsed} credits` : ''}`,
|
|
204
|
-
`variations (${m.
|
|
205
|
-
...m.variations.map((v) => ` ${v.variation}. ${v.url ?? `(no url, ${v.status})`}${v.isFavorited ? ' [favorite]' : ''}`),
|
|
302
|
+
`variations (${m.generationSize}):`,
|
|
303
|
+
...m.variations.map((v) => ` ${v.variation}. ${v.url ?? `(no url, ${v.status})`}${v.isFavorited ? ' [favorite]' : ''}${v.isArchived ? ' [archived]' : ''}`),
|
|
206
304
|
]));
|
|
207
305
|
}
|
|
306
|
+
/** One resolved batch item's metadata line (no image; that is added separately). */
|
|
307
|
+
function batchItemLine(it, index, hasImage) {
|
|
308
|
+
const label = `[${index + 1}]`;
|
|
309
|
+
if (!it.ok) {
|
|
310
|
+
const ref = it.mediaId ?? ('url' in it.input ? it.input.url : JSON.stringify(it.input));
|
|
311
|
+
return `${label} ERROR (${ref}): ${it.error ?? 'could not resolve'}`;
|
|
312
|
+
}
|
|
313
|
+
const idPart = it.mediaId
|
|
314
|
+
? `${it.type ?? 'media'} ${it.mediaId}${it.variation != null ? ` v${it.variation}` : ''}`
|
|
315
|
+
: `${it.type ?? 'media'} (url)`;
|
|
316
|
+
const others = it.otherVariations.length > 0 ? ` | other variations: ${it.otherVariations.join(', ')}` : '';
|
|
317
|
+
const model = it.model ? ` from ${it.model}` : '';
|
|
318
|
+
const prompt = it.prompt ? `\n prompt: ${it.prompt}` : '';
|
|
319
|
+
// Explain the absence of an image so the model does not assume it failed.
|
|
320
|
+
let note = '';
|
|
321
|
+
if (!hasImage) {
|
|
322
|
+
if (it.type === 'audio')
|
|
323
|
+
note = '\n (audio: no visual; use the url)';
|
|
324
|
+
else if (it.type === 'video')
|
|
325
|
+
note = '\n (video: no still available for this view; use the url)';
|
|
326
|
+
else if (it.type === 'transcript')
|
|
327
|
+
note = '\n (transcript: text only)';
|
|
328
|
+
}
|
|
329
|
+
// MEASURED GEOMETRY, when the spine has it. Without these numbers a caller cannot compute an asset's true
|
|
330
|
+
// aspect (so it stretches it on placement) and cannot align to the VISIBLE artwork of a padded logo at all.
|
|
331
|
+
// `content` is the artwork's bounds inside the file; when it is smaller than the file, say so explicitly,
|
|
332
|
+
// because that difference is the whole reason to read it.
|
|
333
|
+
let geom = '';
|
|
334
|
+
if (it.geometry) {
|
|
335
|
+
const { width, height, content } = it.geometry;
|
|
336
|
+
geom = `\n dimensions: ${width}x${height}`;
|
|
337
|
+
if (content) {
|
|
338
|
+
const trimmed = content.width < width || content.height < height;
|
|
339
|
+
geom += trimmed
|
|
340
|
+
? ` | artwork: ${content.width}x${content.height} at (${content.x}, ${content.y}) -- the rest is transparent margin, so place and align by THIS box, not the file`
|
|
341
|
+
: ' | artwork fills the frame';
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// MEASURED DURATION, for anything time-based. Reported for AUDIO too, which is the point: audio has no
|
|
345
|
+
// dimensions, so it carried no measured facts at all, and `edit_audio` requires a durationSeconds to price
|
|
346
|
+
// the job. The only way to call it correctly was to download the file and probe it.
|
|
347
|
+
const dur = it.durationSeconds != null ? `\n duration: ${it.durationSeconds.toFixed(2)}s` : '';
|
|
348
|
+
return `${label} ${idPart}${model}${others}\n ${it.url}${geom}${dur}${prompt}${note}`;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* A vision-enabled media batch (get_media). Returns a text summary + one metadata
|
|
352
|
+
* line per item, and, for each image item whose bytes were fetched, an IMAGE
|
|
353
|
+
* content block so the calling model can SEE it. Images arrive as a parallel
|
|
354
|
+
* array (fetched + base64-encoded by the caller, image items only; null for
|
|
355
|
+
* video/audio/errors). This just assembles the result. See get-context §9.5.
|
|
356
|
+
*/
|
|
357
|
+
export function mediaBatchResult(result, images) {
|
|
358
|
+
const { items } = result;
|
|
359
|
+
const okCount = items.filter((i) => i.ok).length;
|
|
360
|
+
const keyframeCount = items.reduce((n, it) => n + (it.keyframes?.length ?? 0), 0);
|
|
361
|
+
const shownImages = images.filter(Boolean).length + keyframeCount;
|
|
362
|
+
const summary = `Resolved ${okCount}/${items.length} media item(s); ${shownImages} image(s) attached below` +
|
|
363
|
+
(keyframeCount > 0 ? ` (incl. ${keyframeCount} video keyframe(s))` : '') +
|
|
364
|
+
`.\n\n` +
|
|
365
|
+
items.map((it, i) => batchItemLine(it, i, Boolean(images[i]) || (it.keyframes?.length ?? 0) > 0)).join('\n');
|
|
366
|
+
const content = [{ type: 'text', text: summary }];
|
|
367
|
+
items.forEach((it, i) => {
|
|
368
|
+
const img = images[i];
|
|
369
|
+
if (img) {
|
|
370
|
+
content.push({ type: 'text', text: `Image for item [${i + 1}]:` });
|
|
371
|
+
content.push({ type: 'image', data: img.data, mimeType: img.mimeType });
|
|
372
|
+
}
|
|
373
|
+
const keyframes = it.keyframes ?? [];
|
|
374
|
+
if (keyframes.length > 0) {
|
|
375
|
+
content.push({ type: 'text', text: `${keyframes.length} keyframe(s) for item [${i + 1}] (raw footage, in order):` });
|
|
376
|
+
for (const kf of keyframes) {
|
|
377
|
+
const parsed = parseDataUrl(kf.dataUrl);
|
|
378
|
+
if (parsed)
|
|
379
|
+
content.push({ type: 'image', data: parsed.data, mimeType: parsed.mimeType });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
return { content };
|
|
384
|
+
}
|
|
208
385
|
/** Phase 1 of an upload: the signed URL + the PUT-then-complete instructions. */
|
|
209
386
|
export function mediaUploadResult(r) {
|
|
387
|
+
// The headers are listed EXPLICITLY rather than described, because this instruction is executed by an agent
|
|
388
|
+
// and "with the file's Content-Type" was about to become wrong. Object storage is moving to R2, where the
|
|
389
|
+
// presigned URL signs the owner in as `x-amz-meta-user_id`; a PUT missing it is refused with
|
|
390
|
+
// SignatureDoesNotMatch (verified: 403 with Content-Type alone, 200 with both). Telling the caller which
|
|
391
|
+
// headers to send, from the server's own answer, means the migration needs no change here at all.
|
|
392
|
+
const headers = r.uploadHeaders ?? { 'Content-Type': 'the file MIME type' };
|
|
210
393
|
return text(lines([
|
|
211
394
|
`Upload created (id ${r.outputId}). Two steps remain:`,
|
|
212
|
-
`1. PUT the file bytes to this URL
|
|
395
|
+
`1. PUT the file bytes to this URL (expires ${r.expiresAt}):`,
|
|
213
396
|
` ${r.uploadUrl}`,
|
|
397
|
+
' Send EXACTLY these headers, unchanged:',
|
|
398
|
+
...Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`),
|
|
214
399
|
`2. Call complete_media_upload(outputId: "${r.outputId}") to finalize.`,
|
|
215
400
|
'Once complete, reference the media by its outputId in generations or post assets.',
|
|
216
401
|
]));
|
|
@@ -644,4 +829,262 @@ export function errorResult(err) {
|
|
|
644
829
|
}
|
|
645
830
|
return text('Unknown error', true);
|
|
646
831
|
}
|
|
832
|
+
// -- editor / canvas ops ------------------------------------------------------
|
|
833
|
+
/** The outcome of an applyEditorOps batch: the new revision + a per-op summary. */
|
|
834
|
+
export function editorOpsResult(r) {
|
|
835
|
+
const okCount = r.results.filter((x) => x.ok).length;
|
|
836
|
+
const failures = r.results.filter((x) => !x.ok);
|
|
837
|
+
const created = r.results.flatMap((x) => x.createdIds ?? []);
|
|
838
|
+
const lines = [
|
|
839
|
+
// The surface no longer names the ops: it says `editor` or `canvas`, and "editor op(s)" reads worse than
|
|
840
|
+
// saying nothing, since the caller already knows which tool they invoked.
|
|
841
|
+
`Applied ${okCount}/${r.results.length} op(s). New revision: ${r.revision}.`,
|
|
842
|
+
];
|
|
843
|
+
if (created.length)
|
|
844
|
+
lines.push(`Created: ${created.join(', ')}.`);
|
|
845
|
+
// Async effect ops (remove_background) dispatch a job and return its outputId; surface it so the agent can poll.
|
|
846
|
+
const generating = r.results.map((x) => x.generatingOutputId).filter((id) => !!id);
|
|
847
|
+
if (generating.length) {
|
|
848
|
+
lines.push(`Dispatched ${generating.length} async job(s); wait_for_generation on: ${generating.join(', ')}.`);
|
|
849
|
+
}
|
|
850
|
+
if (r.renderUrl)
|
|
851
|
+
lines.push(`Preview: ${r.renderUrl}`);
|
|
852
|
+
if (failures.length) {
|
|
853
|
+
lines.push('Failed ops:');
|
|
854
|
+
for (const f of failures)
|
|
855
|
+
lines.push(` - ${f.op}: ${f.error ?? 'unknown error'}`);
|
|
856
|
+
}
|
|
857
|
+
const warnings = r.results.flatMap((x) => x.warnings ?? []);
|
|
858
|
+
if (warnings.length)
|
|
859
|
+
lines.push(`Warnings: ${warnings.join('; ')}.`);
|
|
860
|
+
// A partial failure is surfaced as an error result so the caller (agent) can self-correct.
|
|
861
|
+
return text(lines.join('\n'), failures.length > 0);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* EXPOSURE GUARD for ProjectDetail.
|
|
865
|
+
*
|
|
866
|
+
* The MCP answers in TEXT, so a field this formatter does not print is INVISIBLE to the calling agent even
|
|
867
|
+
* though the SDK fetched it. That makes silent drift the default: the app can add a field, the SDK type can
|
|
868
|
+
* carry it, every build and test stays green, and no agent can ever see it. `compositionSpace` sat in
|
|
869
|
+
* exactly that state, and the cost was an agent sizing every layer 2.26x wrong with no error.
|
|
870
|
+
*
|
|
871
|
+
* `satisfies Record<keyof ProjectDetail, ...>` makes the omission a DECISION rather than an accident: add a
|
|
872
|
+
* field to ProjectDetail and this stops compiling until someone classifies it. Omitting is fine; omitting
|
|
873
|
+
* silently is not.
|
|
874
|
+
*/
|
|
875
|
+
const PROJECT_DETAIL_EXPOSURE = {
|
|
876
|
+
// Rendered in the summary line or the JSON body below.
|
|
877
|
+
id: 'rendered',
|
|
878
|
+
title: 'rendered',
|
|
879
|
+
kind: 'rendered',
|
|
880
|
+
// Added when `surface` joined ProjectDetail (8aecfd0). It went unnoticed because `dist/` is gitignored
|
|
881
|
+
// and this file typechecks against the BUILT SDK, so a stale dist hid the missing key until the next
|
|
882
|
+
// rebuild. Same value as `kind`, which is the name it used to have.
|
|
883
|
+
surface: 'rendered',
|
|
884
|
+
orientation: 'rendered',
|
|
885
|
+
width: 'rendered',
|
|
886
|
+
height: 'rendered',
|
|
887
|
+
revision: 'rendered',
|
|
888
|
+
compositionSpace: 'rendered',
|
|
889
|
+
groups: 'rendered',
|
|
890
|
+
state: 'rendered',
|
|
891
|
+
renderUrl: 'rendered (opt-in)',
|
|
892
|
+
brandKitId: 'rendered',
|
|
893
|
+
// Deliberately omitted, with the reason. Each of these is reachable through a dedicated tool, or is
|
|
894
|
+
// list-view metadata that tells a single-project reader nothing it did not already know by fetching it.
|
|
895
|
+
assetReferences: 'omitted: large payload; the composition state already names what is in use',
|
|
896
|
+
thumbnailUrl: 'omitted: presentation metadata, not an editing input',
|
|
897
|
+
isArchived: 'omitted: lifecycle state, surfaced by list_projects',
|
|
898
|
+
isFavorited: 'omitted: lifecycle state, surfaced by list_projects',
|
|
899
|
+
archivedAt: 'omitted: lifecycle state, surfaced by list_projects',
|
|
900
|
+
favoritedAt: 'omitted: lifecycle state, surfaced by list_projects',
|
|
901
|
+
createdAt: 'omitted: list metadata',
|
|
902
|
+
updatedAt: 'omitted: superseded by revision, which is the token that actually matters here',
|
|
903
|
+
exportedPostId: 'omitted: publishing workflow, owned by the post tools',
|
|
904
|
+
exportedUrl: 'omitted: publishing workflow, owned by the post tools',
|
|
905
|
+
shareId: 'omitted: sharing workflow, no editing effect',
|
|
906
|
+
};
|
|
907
|
+
void PROJECT_DETAIL_EXPOSURE;
|
|
908
|
+
/** A single project's full detail (read-before-write): metadata, surface, revision, and the state JSON. */
|
|
909
|
+
export function projectDetailResult(p) {
|
|
910
|
+
return text(`Project ${p.id}: "${p.title}" (${p.kind}, ${p.orientation} ${p.width}x${p.height}), revision ${p.revision}.\n` +
|
|
911
|
+
`Pass this revision back as expectedRevision when you edit.\n` +
|
|
912
|
+
// The output resolution above is NOT the coordinate space layer geometry uses. Stating both, adjacent
|
|
913
|
+
// and labelled, is the point: an agent that read only "2168x1152" sized every layer 2.26x too large
|
|
914
|
+
// and got no error for it, because an oversized box is valid input.
|
|
915
|
+
(p.compositionSpace
|
|
916
|
+
? `Layer geometry is in composition space ${p.compositionSpace.width}x${p.compositionSpace.height} ` +
|
|
917
|
+
`(center-relative px), NOT the ${p.width}x${p.height} output resolution. ` +
|
|
918
|
+
`Use ${p.compositionSpace.width}x${p.compositionSpace.height} as layerWidth/layerHeight for a full-frame layer.\n`
|
|
919
|
+
: '') +
|
|
920
|
+
(p.renderUrl ? `Preview: ${p.renderUrl}\n` : '') +
|
|
921
|
+
// An agent asked to keep a design on-brand otherwise has no way to know WHICH kit this project is
|
|
922
|
+
// linked to: it can list kits, but not resolve the association.
|
|
923
|
+
(p.brandKitId ? `Brand kit: ${p.brandKitId} (read it with get_brand_kit).\n` : '') +
|
|
924
|
+
(p.groups?.length
|
|
925
|
+
? `Groups: ${p.groups
|
|
926
|
+
.map((g) => `${g.name || `Group ${g.ordinal ?? '?'}`} [${g.id}] (${g.memberClipIds.length} clips)`)
|
|
927
|
+
.join('; ')}\n` +
|
|
928
|
+
` Rename with update_group; bulk-edit a whole group with update_clips { groupId }.\n`
|
|
929
|
+
: '') +
|
|
930
|
+
`\n` +
|
|
931
|
+
JSON.stringify(p.state, null, 2));
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Live context (get_context). Returns a text summary + the discriminated context JSON, plus IMAGE content
|
|
935
|
+
* block(s) so the calling model can actually SEE: the viewport `snapshot` (capture) when the user's screen was
|
|
936
|
+
* requested, and/or the inline composed-output render (`context.rendered.dataUrl`) when `render` was requested.
|
|
937
|
+
* The heavy render data URL is stripped from the JSON text (it rides only as the image block).
|
|
938
|
+
*/
|
|
939
|
+
export function liveContextResult(result, snapshot) {
|
|
940
|
+
const { context, participant, participants } = result;
|
|
941
|
+
if (!context || !participant) {
|
|
942
|
+
return text('No live context: no one is currently viewing this in the open app (no session within the presence window). ' +
|
|
943
|
+
'The user may not have the editor/studio/content open right now.');
|
|
944
|
+
}
|
|
945
|
+
// Pull the inline render out as image block(s). A still carries `rendered.dataUrl` (one image); a filmstrip /
|
|
946
|
+
// clip carries `rendered.frames[].dataUrl` (many). Keep the light `rendered` metadata in the JSON but drop the
|
|
947
|
+
// bulky dataUrl(s) so the text summary stays readable.
|
|
948
|
+
const rendered = (context.rendered ?? null);
|
|
949
|
+
const renderImages = [];
|
|
950
|
+
let contextForJson = context;
|
|
951
|
+
if (rendered) {
|
|
952
|
+
const still = parseDataUrl(rendered.dataUrl);
|
|
953
|
+
const frames = Array.isArray(rendered.frames) ? rendered.frames : null;
|
|
954
|
+
if (still)
|
|
955
|
+
renderImages.push(still);
|
|
956
|
+
if (frames) {
|
|
957
|
+
for (const f of frames) {
|
|
958
|
+
const img = parseDataUrl(f.dataUrl);
|
|
959
|
+
if (img)
|
|
960
|
+
renderImages.push(img);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (renderImages.length > 0) {
|
|
964
|
+
// Strip the base64 payloads from the JSON but keep the frame timing (frame / atSec).
|
|
965
|
+
const strippedFrames = frames
|
|
966
|
+
? frames.map((f) => {
|
|
967
|
+
const { dataUrl: _drop, ...rest } = f;
|
|
968
|
+
return rest;
|
|
969
|
+
})
|
|
970
|
+
: undefined;
|
|
971
|
+
contextForJson = {
|
|
972
|
+
...context,
|
|
973
|
+
rendered: {
|
|
974
|
+
...rendered,
|
|
975
|
+
...(still ? { dataUrl: '[attached as an image below]' } : {}),
|
|
976
|
+
...(strippedFrames ? { frames: strippedFrames } : {}),
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const others = participants.length > 1 ? ` (${participants.length} live participants; showing the most recent)` : '';
|
|
982
|
+
const renderNote = renderImages.length === 1
|
|
983
|
+
? 'A render of your work is attached below.\n'
|
|
984
|
+
: renderImages.length > 1
|
|
985
|
+
? `${renderImages.length} rendered frames are attached below, in order.\n`
|
|
986
|
+
: '';
|
|
987
|
+
const summary = `Live context on the ${String(context.surface)} surface${others}, updated ${participant.updatedAt}.\n` +
|
|
988
|
+
(snapshot ? 'An image of what the user is looking at (their screen) is attached below.\n' : '') +
|
|
989
|
+
renderNote +
|
|
990
|
+
`\n` +
|
|
991
|
+
JSON.stringify(contextForJson, null, 2);
|
|
992
|
+
const content = [{ type: 'text', text: summary }];
|
|
993
|
+
if (snapshot)
|
|
994
|
+
content.push({ type: 'image', data: snapshot.data, mimeType: snapshot.mimeType });
|
|
995
|
+
for (const img of renderImages)
|
|
996
|
+
content.push({ type: 'image', data: img.data, mimeType: img.mimeType });
|
|
997
|
+
return { content };
|
|
998
|
+
}
|
|
999
|
+
/** Parse a `data:<mime>;base64,<data>` URL into an image block's parts. Returns null on any non-data-URL. */
|
|
1000
|
+
function parseDataUrl(dataUrl) {
|
|
1001
|
+
if (typeof dataUrl !== 'string')
|
|
1002
|
+
return null;
|
|
1003
|
+
const m = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl);
|
|
1004
|
+
const mimeType = m?.[1];
|
|
1005
|
+
const data = m?.[2];
|
|
1006
|
+
if (!mimeType || !data)
|
|
1007
|
+
return null;
|
|
1008
|
+
return { mimeType, data };
|
|
1009
|
+
}
|
|
1010
|
+
/** The project list: one line per project (id, kind, title, state flags). */
|
|
1011
|
+
export function projectListResult(projects) {
|
|
1012
|
+
if (projects.length === 0)
|
|
1013
|
+
return text('No projects found.');
|
|
1014
|
+
const lines = projects.map((p) => {
|
|
1015
|
+
const flags = [p.isArchived ? 'archived' : null, p.isFavorited ? 'favorited' : null].filter(Boolean).join(', ');
|
|
1016
|
+
return `- ${p.id} [${p.kind}] "${p.title}" ${p.orientation}${flags ? ` (${flags})` : ''}`;
|
|
1017
|
+
});
|
|
1018
|
+
return text(`${projects.length} project(s):\n${lines.join('\n')}`);
|
|
1019
|
+
}
|
|
1020
|
+
/** A freshly created project: the id + kind to start editing against. */
|
|
1021
|
+
export function projectCreatedResult(p) {
|
|
1022
|
+
return text(`Created ${p.kind} project ${p.id}: "${p.title}" (${p.orientation} ${p.width}x${p.height}), revision ${p.revision}.\n` +
|
|
1023
|
+
// The TOOL is still called update_timeline; `kind` is what says which one applies.
|
|
1024
|
+
`Use this id with update_${p.kind === 'canvas' ? 'canvas' : 'timeline'} to add content.`);
|
|
1025
|
+
}
|
|
1026
|
+
/** Confirmation of a permanent delete. */
|
|
1027
|
+
export function projectDeletedResult(projectId) {
|
|
1028
|
+
return text(`Permanently deleted project ${projectId}. This cannot be undone.`);
|
|
1029
|
+
}
|
|
1030
|
+
/** The canvas layer-type catalog (types + editable props) as readable text + the JSON. */
|
|
1031
|
+
export function layerTypesResult(cat) {
|
|
1032
|
+
const lines = cat.layerTypes.map((t) => `- ${t.type}: ${t.description} (props: ${t.props.map((p) => p.name).join(', ')}; supports: ${t.supports.join(', ')})`);
|
|
1033
|
+
const ops = cat.ops ? cat.ops.ops.map((o) => `- ${o.shape} ${o.description}`) : [];
|
|
1034
|
+
return text(`Canvas layer types (edit via update_canvas ops):\n${lines.join('\n')}\n\n` +
|
|
1035
|
+
(ops.length ? `update_canvas ops (${cat.ops.description}):\n${ops.join('\n')}\n\n` : '') +
|
|
1036
|
+
`Shared prop groups: ${Object.keys(cat.sharedProps).join(', ')}.\n\n` +
|
|
1037
|
+
JSON.stringify(cat, null, 2));
|
|
1038
|
+
}
|
|
1039
|
+
/** A completed export -> the download URL; an in-flight one -> the exportId to poll. */
|
|
1040
|
+
export function exportJobResult(job) {
|
|
1041
|
+
if (job.status === 'completed') {
|
|
1042
|
+
return text(`Export ${job.exportId} completed.\nDownload: ${job.outputUrl}`);
|
|
1043
|
+
}
|
|
1044
|
+
if (job.status === 'failed') {
|
|
1045
|
+
return text(`Export ${job.exportId} failed: ${job.errorMessage ?? 'unknown error'}.`, true);
|
|
1046
|
+
}
|
|
1047
|
+
const pct = typeof job.progress === 'number' ? ` (${Math.round(job.progress * 100)}%)` : '';
|
|
1048
|
+
return text(`Export ${job.exportId} is ${job.status}${pct}. Still rendering. Poll get_export with this exportId for the download URL.`);
|
|
1049
|
+
}
|
|
1050
|
+
/** The export-format catalog as readable text + JSON. */
|
|
1051
|
+
export function exportFormatsResult(cat) {
|
|
1052
|
+
const lines = cat.formats.map((f) => `- ${f.format} (${f.surfaces.join('/')}${f.async ? ', async' : ''}): ${f.description}`);
|
|
1053
|
+
return text(`Export formats:\n${lines.join('\n')}\n\nResolutions (mp4): ${cat.resolutions.join(', ')}. Qualities: ${cat.qualities.join(', ')}.\n\n` +
|
|
1054
|
+
JSON.stringify(cat, null, 2));
|
|
1055
|
+
}
|
|
1056
|
+
/** The editor timeline clip + track-type catalog as readable text + the JSON. */
|
|
1057
|
+
export function editorTranscriptResult(r) {
|
|
1058
|
+
if (!r.mediaTranscribed) {
|
|
1059
|
+
return text(r.note ?? 'No transcript available for this project yet.');
|
|
1060
|
+
}
|
|
1061
|
+
// A readable, clip-by-clip transcript in timeline order: each line is one clip, marked [disabled] when it is
|
|
1062
|
+
// cut (excluded from the render) so the agent sees what is already removed. In word mode each line also
|
|
1063
|
+
// summarizes its word / silence / event counts. The full structured data (clipIds, timeline frames, word
|
|
1064
|
+
// timing, silences, audio events) follows as JSON for exact targeting via update_timeline.
|
|
1065
|
+
const lines = r.segments.map((s) => {
|
|
1066
|
+
const tag = s.disabled ? `[disabled${s.disabledReason ? `:${s.disabledReason}` : ''}]` : '[enabled]';
|
|
1067
|
+
const body = s.text ? s.text : '(no speech)';
|
|
1068
|
+
const extra = s.words || s.silences || s.audioEvents
|
|
1069
|
+
? ` {${s.words?.length ?? 0} words, ${s.silences?.length ?? 0} silences, ${s.audioEvents?.length ?? 0} events}`
|
|
1070
|
+
: '';
|
|
1071
|
+
return `${tag} ${s.clipId} ${s.sourceStartMs}-${s.sourceEndMs}ms (frames ${s.fromFrame}-${s.fromFrame + s.durationFrames}):${extra} ${body}`;
|
|
1072
|
+
});
|
|
1073
|
+
const speakerLine = r.speakers && r.speakers.length > 0 ? `Speakers: ${r.speakers.join(', ')}.\n` : '';
|
|
1074
|
+
return text(`Transcript for ${r.projectId} (${r.segmentCount} clip segment(s), ${r.fps}fps, revision ${r.revision}). [disabled] = cut/excluded from the render, [enabled] = kept.\n` +
|
|
1075
|
+
`Cut non-destructively with update_timeline disable_ranges (source-media ranges) or set_disabled (whole clip); restore with set_disabled disabled:false. Word timing + timeline frames + silences + audio events are in the JSON when granularity 'word' was requested.\n` +
|
|
1076
|
+
speakerLine +
|
|
1077
|
+
`To edit safely against a concurrent change, pass revision ${r.revision} as expectedRevision; or omit expectedRevision to just apply to the current state.\n\n` +
|
|
1078
|
+
`${lines.join('\n')}\n\n` +
|
|
1079
|
+
JSON.stringify(r, null, 2));
|
|
1080
|
+
}
|
|
1081
|
+
export function timelineTypesResult(cat) {
|
|
1082
|
+
const clips = cat.clipTypes.map((t) => `- ${t.type}: ${t.description} (props: ${t.props.map((p) => p.name).join(', ')})`);
|
|
1083
|
+
const tracks = cat.trackTypes.map((t) => `- ${t.trackType}: holds ${t.holds.join(', ')}`);
|
|
1084
|
+
const editOps = cat.editOps ? cat.editOps.ops.map((o) => `- ${o.shape} ${o.description}`) : [];
|
|
1085
|
+
return text(`Editor timeline clip types (edit via update_timeline ops):\n${clips.join('\n')}\n\nTrack types:\n${tracks.join('\n')}\n\n` +
|
|
1086
|
+
(editOps.length ? `update_timeline edit ops (${cat.editOps.description}):\n${editOps.join('\n')}\n\n` : '') +
|
|
1087
|
+
`Shared prop groups: ${Object.keys(cat.sharedProps).join(', ')}.\n\n` +
|
|
1088
|
+
JSON.stringify(cat, null, 2));
|
|
1089
|
+
}
|
|
647
1090
|
//# sourceMappingURL=format.js.map
|