@contenthero/mcp 0.3.2 → 0.3.4

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