@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/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
- return text([header, ...urls.map((u, i) => `${i + 1}. ${u}`)].join('\n'));
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 header = `Transcript${lang}, ${t.wordCount} words (outputId ${t.outputId}):`;
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
- export function brandKitArchivedResult(kit) {
170
- return text(`Archived brand kit "${kit.name}" (id ${kit.id}).`);
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 studio outputs (media). */
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
- const vars = m.variationCount > 1 ? ` | ${m.variationCount} variations` : '';
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
- return `- [${m.type}] ${m.model ?? ''} (id ${m.id})${kindTag}${vars} | ${m.status}${promptStr}`;
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.variationCount}):`,
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 with the file's Content-Type (expires ${r.expiresAt}):`,
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