@contenthero/mcp 0.4.12 → 0.4.14
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.d.ts +75 -4
- package/dist/format.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4552 -43
- package/dist/model-presentation.d.ts +35 -0
- package/dist/model-presentation.d.ts.map +1 -0
- package/dist/models.d.ts +2 -2
- package/dist/models.d.ts.map +1 -1
- package/dist/server.d.ts +76 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/widget/generation.d.ts +2 -2
- package/dist/widget/generation.d.ts.map +1 -1
- package/dist/widget-uri.d.ts +20 -1
- package/dist/widget-uri.d.ts.map +1 -1
- package/package.json +6 -5
- package/dist/client.js +0 -54
- package/dist/client.js.map +0 -1
- package/dist/format.js +0 -1760
- package/dist/format.js.map +0 -1
- package/dist/groups.js +0 -142
- package/dist/groups.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/models.js +0 -166
- package/dist/models.js.map +0 -1
- package/dist/server.js +0 -3315
- package/dist/server.js.map +0 -1
- package/dist/widget/generation.js +0 -4
- package/dist/widget/generation.js.map +0 -1
- package/dist/widget-uri.js +0 -12
- package/dist/widget-uri.js.map +0 -1
package/dist/format.js
DELETED
|
@@ -1,1760 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Helpers that turn SDK results and errors into MCP CallToolResult content.
|
|
3
|
-
* Tool errors are returned as `isError` results (not thrown) so the agent sees
|
|
4
|
-
* a readable message instead of a transport failure.
|
|
5
|
-
*/
|
|
6
|
-
// The spec's own spelling of the key that binds a result to its widget. See `server.ts` for why only the
|
|
7
|
-
// constants come from this package and not its server helpers.
|
|
8
|
-
import { aspectLabel } from '@contenthero-ai/brand-ui';
|
|
9
|
-
import { RESOURCE_URI_META_KEY } from '@modelcontextprotocol/ext-apps';
|
|
10
|
-
import { GENERATION_WIDGET_URI } from './widget-uri.js';
|
|
11
|
-
import { ContentHeroError, InsufficientCreditsError, RateLimitError } from '@contenthero/sdk';
|
|
12
|
-
export function text(body, isError = false) {
|
|
13
|
-
return { content: [{ type: 'text', text: body }], isError };
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* A finished generation: the asset URLs and placement outcome, plus the asset ITSELF.
|
|
17
|
-
*
|
|
18
|
-
* ## Why this used to be text only
|
|
19
|
-
*
|
|
20
|
-
* 🚨 **AN AGENT THAT GENERATES AN IMAGE COULD NOT SEE IT.** This returned a header, an outputId and a
|
|
21
|
-
* numbered list of urls, so the model had metadata and nothing else. Measured 2026-09-19 in a real ChatGPT
|
|
22
|
-
* and Claude session: Claude said "I can't see the image myself, only the metadata", and the link it
|
|
23
|
-
* surfaced was dead on arrival because ChatGPT had appended `utm_source=chatgpt.com` to a presigned URL and
|
|
24
|
-
* broken its signature.
|
|
25
|
-
*
|
|
26
|
-
* ⭐ The machinery already existed and was wired to the wrong tools: `mediaBatchResult` and
|
|
27
|
-
* `liveContextResult` have pushed image blocks for a while. Generation, the surface where a user most wants
|
|
28
|
-
* to SEE the result, was the one that did not.
|
|
29
|
-
*
|
|
30
|
-
* ## ⛔ THE AGENT CANNOT SEE WHAT IT MADE FROM THIS RESULT, AND THAT IS DELIBERATE
|
|
31
|
-
*
|
|
32
|
-
* An `image` block feeds the MODEL's vision; a `resource_link` gives the HOST something to render for the
|
|
33
|
-
* human. So the user sees every variation inline, and the model has a name and a url.
|
|
34
|
-
*
|
|
35
|
-
* ⭐ **`get_media` IS HOW A MODEL ACTUALLY LOOKS AT SOMETHING.** It embeds bytes as image blocks for exactly
|
|
36
|
-
* that purpose, so an agent that needs to judge a result (is the hand wrong, is the text legible, which of
|
|
37
|
-
* these four is best) calls it with the outputId. Embedding bytes HERE instead would spend the user's
|
|
38
|
-
* context on every generation to answer a question they may never ask, and a four-image batch would pay
|
|
39
|
-
* that cost four times over, repeated in every later turn.
|
|
40
|
-
*
|
|
41
|
-
* ⚠️ ATTACHMENTS ARE BUILT BY THE CALLER, not here. This module stays pure, the same split
|
|
42
|
-
* `mediaBatchResult` already uses: the handler decides, the formatter assembles.
|
|
43
|
-
*/
|
|
44
|
-
/**
|
|
45
|
-
* What the generation WIDGET reads.
|
|
46
|
-
*
|
|
47
|
-
* ⭐⭐ `structuredContent` IS THE WIDGET'S ONLY INPUT. It is a separate channel from `content`: the blocks
|
|
48
|
-
* feed the model and any host without app support, this feeds the UI. Both are emitted, so nothing regresses
|
|
49
|
-
* where widgets are unsupported and nothing is duplicated where they are.
|
|
50
|
-
*
|
|
51
|
-
* ⚠️ URLS ONLY, NEVER BYTES. The widget runs in the host's frame and fetches media itself, and our
|
|
52
|
-
* capability urls carry their token in the QUERY STRING, so `<video src>` loads one directly with no header
|
|
53
|
-
* to set. Embedding base64 here would pay the context cost twice over.
|
|
54
|
-
*/
|
|
55
|
-
/**
|
|
56
|
-
* Where one output lives in the product.
|
|
57
|
-
*
|
|
58
|
-
* ## ⭐⭐⭐ COMPUTED HERE, NOT IN THE WIDGET, BECAUSE HERE THE INDEX IS ALREADY A NUMBER
|
|
59
|
-
*
|
|
60
|
-
* The widget's first version parsed the slot back out of the output's NAME (`<id>-3` means slot 2), which
|
|
61
|
-
* is a derivation of something this function has in its hand. Parsing a number out of a string we formatted
|
|
62
|
-
* two lines earlier is how an off-by-one gets in, and the symptom would be "Open shows the wrong picture",
|
|
63
|
-
* which reads as a broken link rather than an index bug.
|
|
64
|
-
*
|
|
65
|
-
* ⚠️ **ONE-BASED IN THE NAME, ZERO-BASED IN THE URL.** `<id>-3` is what a person reads as "variation 3",
|
|
66
|
-
* and `variation=2` is the studio's `imageIndex`, which is slot space. Both conventions are correct in
|
|
67
|
-
* their own place and the conversion belongs at exactly one boundary, which is this one.
|
|
68
|
-
*
|
|
69
|
-
* ⚠️ A single-output generation gets NO `variation`. There is no variation to name, and passing 0 would
|
|
70
|
-
* imply there was a choice.
|
|
71
|
-
*/
|
|
72
|
-
/** Where a deep link points when no client base url was threaded through. */
|
|
73
|
-
export const DEFAULT_APP_URL = 'https://app.contenthero.ai';
|
|
74
|
-
export function studioUrlFor(baseUrl, outputId, index, total) {
|
|
75
|
-
const root = baseUrl.replace(/\/+$/, '');
|
|
76
|
-
/**
|
|
77
|
-
* ⭐⭐⭐ **ONE-BASED, BECAUSE EVERY OTHER THING A PERSON SEES IS.**
|
|
78
|
-
*
|
|
79
|
-
* The reference they copy is `<id>-1`. The detail view says "Variation 1 of 4". A url saying
|
|
80
|
-
* `variation=0` for that same picture made three surfaces disagree, and the one that disagreed was the
|
|
81
|
-
* only one anybody would ever paste into a message or a bug report.
|
|
82
|
-
*
|
|
83
|
-
* ⛔ The studio's `imageIndex` is a ZERO-BASED slot and stays that way. Slot space is an internal fact
|
|
84
|
-
* about `task_outcomes` and `item_statuses`, not a number to show anyone. The app subtracts one when it
|
|
85
|
-
* reads this parameter, so the conversion sits at the boundary where the two vocabularies meet rather
|
|
86
|
-
* than leaking slot space into a shareable link.
|
|
87
|
-
*/
|
|
88
|
-
const variation = total > 1 ? `&variation=${index + 1}` : '';
|
|
89
|
-
return `${root}/studio?output=${encodeURIComponent(outputId)}${variation}`;
|
|
90
|
-
}
|
|
91
|
-
export function mediaWidgetData(input) {
|
|
92
|
-
return {
|
|
93
|
-
outputId: input.outputId ?? null,
|
|
94
|
-
contentType: input.contentType ?? null,
|
|
95
|
-
modelId: input.modelId ?? '',
|
|
96
|
-
/**
|
|
97
|
-
* ⛔⛔ **NULL WHEN THERE IS NOTHING TO NAME, AND THE WIDGET MUST RENDER NO CHIP.**
|
|
98
|
-
*
|
|
99
|
-
* This used to be `displayName ?? modelId`, fed by a catalog fetch with a `catch` that returned the id.
|
|
100
|
-
* The id reads like a label, so a failed fetch showed as a chip flickering between kebab case and title
|
|
101
|
-
* case rather than as a failure. The name now arrives on the row.
|
|
102
|
-
*/
|
|
103
|
-
modelName: input.modelDisplayName ?? null,
|
|
104
|
-
modelBrandColor: input.modelBrandColor ?? null,
|
|
105
|
-
modelIconKey: input.modelIconKey ?? null,
|
|
106
|
-
displayAspect: input.displayAspect ?? null,
|
|
107
|
-
/** Verbatim. Some prompts are JSON-shaped because the person authored one; that object IS the prompt. */
|
|
108
|
-
prompt: input.prompt ?? null,
|
|
109
|
-
items: input.items.map((it) => ({
|
|
110
|
-
url: it.url,
|
|
111
|
-
posterUrl: it.posterUrl ?? null,
|
|
112
|
-
name: it.name,
|
|
113
|
-
contentType: it.contentType,
|
|
114
|
-
displayAspect: it.displayAspect ?? null,
|
|
115
|
-
openUrl: it.openUrl ?? null,
|
|
116
|
-
reference: it.reference ?? null,
|
|
117
|
-
modelName: it.modelName ?? null,
|
|
118
|
-
modelBrandColor: it.modelBrandColor ?? null,
|
|
119
|
-
modelIconKey: it.modelIconKey ?? null,
|
|
120
|
-
})),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
/** A generation's widget payload. A thin adapter over {@link mediaWidgetData}, not a second builder. */
|
|
124
|
-
export function generationWidgetData(gen, posterUrls = [], baseUrl = DEFAULT_APP_URL) {
|
|
125
|
-
const urls = gen.outputUrls ?? [];
|
|
126
|
-
return mediaWidgetData({
|
|
127
|
-
outputId: gen.outputId,
|
|
128
|
-
contentType: gen.contentType,
|
|
129
|
-
modelId: gen.modelId,
|
|
130
|
-
modelDisplayName: gen.modelDisplayName,
|
|
131
|
-
modelBrandColor: gen.modelBrandColor,
|
|
132
|
-
modelIconKey: gen.modelIconKey,
|
|
133
|
-
/**
|
|
134
|
-
* ⭐ SHARED, which is what makes a generation render as a ROW. Every variation of one generation has
|
|
135
|
-
* the same shape by construction, so the widget lays them out side by side for comparison rather than
|
|
136
|
-
* as a mixed grid.
|
|
137
|
-
*/
|
|
138
|
-
displayAspect: gen.displayAspect,
|
|
139
|
-
prompt: gen.prompt,
|
|
140
|
-
items: urls.map((url, i) => ({
|
|
141
|
-
url,
|
|
142
|
-
posterUrl: posterUrls[i] ?? null,
|
|
143
|
-
name: `${gen.outputId}${urls.length > 1 ? `-${i + 1}` : ''}`,
|
|
144
|
-
contentType: gen.contentType,
|
|
145
|
-
displayAspect: gen.displayAspect ?? null,
|
|
146
|
-
// A generation lives in the studio. Another producer supplies its own destination.
|
|
147
|
-
openUrl: studioUrlFor(baseUrl, gen.outputId, i, urls.length),
|
|
148
|
-
// Every generation output is referenceable by id, which is what makes Animate and Edit meaningful.
|
|
149
|
-
reference: `${gen.outputId}${urls.length > 1 ? `-${i + 1}` : ''}`,
|
|
150
|
-
})),
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
export function completedResult(gen, attachments = [], posterUrls = [],
|
|
154
|
-
/** The server this client talks to, so Open deep-links to it rather than always to production. */
|
|
155
|
-
baseUrl) {
|
|
156
|
-
const urls = gen.outputUrls ?? [];
|
|
157
|
-
const noun = urls.length === 1 ? gen.contentType : `${gen.contentType}s`;
|
|
158
|
-
/**
|
|
159
|
-
* ⭐⭐ **ONE TEXT LIST, AND NO `resource_link` BLOCKS. THREE REPRESENTATIONS OF ONE URL WAS TWO TOO MANY.**
|
|
160
|
-
*
|
|
161
|
-
* This went through both extremes before landing here. Listing the urls in prose AND attaching a link per
|
|
162
|
-
* output printed every url twice. Suppressing the prose left the links alone, and a host renders those as
|
|
163
|
-
* `name: uri` with NO SEPARATOR BETWEEN THEM, so url 1 ended flush against filename 2 and anything
|
|
164
|
-
* splitting on whitespace read a corrupted token. Measured at all three boundaries of a four-image batch.
|
|
165
|
-
*
|
|
166
|
-
* ⛔ THE SEPARATOR WAS NEVER OURS TO ADD. Our text block ends with a newline; the run-together is the host
|
|
167
|
-
* concatenating sibling blocks, and no content we emit can put a break between two of them.
|
|
168
|
-
*
|
|
169
|
-
* ⭐ So the fallback is the thing we fully control: one newline-delimited list. The widget is the surface
|
|
170
|
-
* that renders, and this is what a host without app support (or a model reading the transcript) gets. It
|
|
171
|
-
* costs less than the links did and it cannot be run together by anybody.
|
|
172
|
-
*/
|
|
173
|
-
/**
|
|
174
|
-
* ⭐ **THE ID IS AN ACCEPTABLE FALLBACK HERE AND NOWHERE ELSE.** This block's reader is the model, for
|
|
175
|
-
* whom `gpt-image-2` is a true and directly useful token. The widget's chip has a human reader, for whom
|
|
176
|
-
* the same string is an unexplained failure wearing a label's clothes, so there it renders as nothing.
|
|
177
|
-
*/
|
|
178
|
-
const header = `Done. ${urls.length} ${noun} from ${gen.modelDisplayName ?? gen.modelId} (outputId ${gen.outputId}):`;
|
|
179
|
-
const lines = [header, ...urls.map((u, i) => `${i + 1}. ${u}`)];
|
|
180
|
-
const p = gen.placement;
|
|
181
|
-
if (p) {
|
|
182
|
-
if (p.surface === 'canvas') {
|
|
183
|
-
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).`);
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
186
|
-
lines.push(`Placed on the timeline (clip id ${p.itemId ?? 'resolved'}). Use that clip id to chain further ops.`);
|
|
187
|
-
}
|
|
188
|
-
if (p.warnings?.length)
|
|
189
|
-
lines.push(`Placement notes: ${p.warnings.join('; ')}`);
|
|
190
|
-
}
|
|
191
|
-
// ⚠️ A TRAILING NEWLINE, because a host concatenates blocks without inserting one. Without it the last
|
|
192
|
-
// url ran straight into the next block's rendering, producing `...MBCo_Kkcfe3bafb-...-1.png: https://...`
|
|
193
|
-
// and a token that anything splitting on whitespace would read as part of the filename.
|
|
194
|
-
const content = [{ type: 'text', text: lines.join('\n') + '\n' }];
|
|
195
|
-
for (const a of attachments) {
|
|
196
|
-
if (a.kind === 'bytes') {
|
|
197
|
-
content.push({ type: a.type, data: a.data, mimeType: a.mimeType });
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
// A `resource_link` names the bytes without carrying them. The uri is a capability url, so it does not
|
|
201
|
-
// expire and appended query parameters cannot invalidate it.
|
|
202
|
-
content.push({ type: 'resource_link', uri: a.uri, name: a.name, mimeType: a.mimeType });
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
/**
|
|
206
|
-
* ⭐⭐⭐ **THE `_meta` KEY IS WHAT MAKES A WIDGET APPEAR.** Without it the host has an HTML resource it was
|
|
207
|
-
* never told to mount, and the result renders as blocks alone. With it, a host that supports MCP Apps
|
|
208
|
-
* shows the widget and a host that does not ignores the key entirely.
|
|
209
|
-
*
|
|
210
|
-
* ⚠️ Emitted ALONGSIDE the blocks, never instead of them. Two mechanisms, one of which is better where it
|
|
211
|
-
* exists: ChatGPT and Claude get the widget, anything else still gets an image it can draw.
|
|
212
|
-
*/
|
|
213
|
-
return {
|
|
214
|
-
content,
|
|
215
|
-
isError: false,
|
|
216
|
-
structuredContent: generationWidgetData(gen, posterUrls, baseUrl),
|
|
217
|
-
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
/** Suggested seconds to wait before re-polling a job, by content type. */
|
|
221
|
-
export function pollAfterSecondsFor(contentType) {
|
|
222
|
-
return contentType === 'image' ? 5 : 15;
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* How to call `get_generation_status`, written as the call itself.
|
|
226
|
-
*
|
|
227
|
-
* ⚠️ THE ARGUMENT IS `outputIds` AND IT IS AN ARRAY, ALWAYS, even for one job. Every handoff here used to
|
|
228
|
-
* say "call get_generation_status with this outputId", which names a parameter that does not exist: an agent
|
|
229
|
-
* following the sentence literally sends `{ outputId }` and the schema rejects it. Naming the shape in prose
|
|
230
|
-
* is what drifted, so these messages now print the call instead, and every site shares this one function.
|
|
231
|
-
*/
|
|
232
|
-
export function getStatusCall(outputIds) {
|
|
233
|
-
return `get_generation_status { outputIds: [${outputIds.map((id) => `"${id}"`).join(', ')}] }`;
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* A slow job that did not finish within the smart-wait window.
|
|
237
|
-
*
|
|
238
|
-
* ## ⭐⭐⭐ THIS IS WHERE THE SKELETONS COME FROM, AND WHY IT IS THE ONLY PLACE THEY COULD
|
|
239
|
-
*
|
|
240
|
-
* A generation returns one of two ways: it finished inside the smart wait, in which case there is nothing
|
|
241
|
-
* to show a spinner for, or it did not, and until now that produced ONE SENTENCE of prose asking the agent
|
|
242
|
-
* to poll. Every video takes that path. So the person who waited longest got the least: a paragraph, while
|
|
243
|
-
* the same job in the studio shows placeholder cards filling in.
|
|
244
|
-
*
|
|
245
|
-
* ⛔ **THE FIX IS NOT TO MAKE `generate_*` RETURN EARLY.** That would hand every host the pending path,
|
|
246
|
-
* including hosts with no MCP Apps support, which would lose the inline image they get today. The pending
|
|
247
|
-
* path already exists and already reaches exactly the people who are waiting.
|
|
248
|
-
*
|
|
249
|
-
* ⚠️ **THE TEXT STAYS, WORD FOR WORD.** It is what a host without app support renders, and it is what the
|
|
250
|
-
* AGENT reads to know it must poll. The widget is added ALONGSIDE it, not instead of it: an agent that
|
|
251
|
-
* stopped polling because the prose was replaced by a payload it cannot see would leave the generation
|
|
252
|
-
* unclaimed.
|
|
253
|
-
*
|
|
254
|
-
* ⚠️ No model NAME here, and that is deliberate. Resolving one would mean a second network call from
|
|
255
|
-
* inside a `catch`, which is the exact shape that produced a chip flickering between kebab case and title
|
|
256
|
-
* case. The widget polls `get_generation_status`, and the name arrives with the first response.
|
|
257
|
-
*/
|
|
258
|
-
export function pendingResult(outputId, pollAfterSeconds = 15, shape) {
|
|
259
|
-
/**
|
|
260
|
-
* ⚠️ THE REASSURANCE MUST MATCH THE MEDIUM. This said "This is normal for video" on every pending result,
|
|
261
|
-
* including image jobs, where it reads as the server describing something other than what was asked for.
|
|
262
|
-
* `shape` is present precisely when we know which medium it is.
|
|
263
|
-
*/
|
|
264
|
-
const normal = shape?.contentType === 'image' ? '' : ' This is normal for video.';
|
|
265
|
-
const prose = `Still rendering (outputId ${outputId}).${normal} Call ${getStatusCall([outputId])} in ~${pollAfterSeconds}s [poll_after_seconds: ${pollAfterSeconds}] to get the final URLs.`;
|
|
266
|
-
if (!shape)
|
|
267
|
-
return text(prose);
|
|
268
|
-
return {
|
|
269
|
-
content: [{ type: 'text', text: prose }],
|
|
270
|
-
structuredContent: {
|
|
271
|
-
outputId,
|
|
272
|
-
status: 'processing',
|
|
273
|
-
contentType: shape.contentType,
|
|
274
|
-
modelId: shape.modelId,
|
|
275
|
-
modelName: null,
|
|
276
|
-
modelBrandColor: null,
|
|
277
|
-
modelIconKey: null,
|
|
278
|
-
displayAspect: shape.displayAspect ?? null,
|
|
279
|
-
prompt: null,
|
|
280
|
-
/** At least one, or the widget renders a grid with nothing in it and looks broken rather than busy. */
|
|
281
|
-
expected: Math.max(1, shape.expected ?? 1),
|
|
282
|
-
pollAfterSeconds,
|
|
283
|
-
items: [],
|
|
284
|
-
},
|
|
285
|
-
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
/** Synchronous audio result (already complete on submit). */
|
|
289
|
-
/**
|
|
290
|
-
* Synchronous audio: already complete when the call returns.
|
|
291
|
-
*
|
|
292
|
-
* ## ⛔⛔⛔ THIS RENDERED NOTHING FOR THE ENTIRE LIFE OF THE WIDGET
|
|
293
|
-
*
|
|
294
|
-
* Audio has a first-class MCP block AND the widget plays it, and neither reached anyone, because this
|
|
295
|
-
* builder returned plain text and `generate_audio` was never added to the hand-maintained list of tools
|
|
296
|
-
* that declare a widget. Nobody decided audio should be invisible; it is what an allowlist does to
|
|
297
|
-
* anything nobody remembered to add.
|
|
298
|
-
*
|
|
299
|
-
* ⚠️ No model name, brand or aspect here: a synchronous result carries none of them, and inventing them
|
|
300
|
-
* would be worse than a chip that renders nothing. Audio has no shape, so `displayAspect` is genuinely
|
|
301
|
-
* null rather than unknown.
|
|
302
|
-
*/
|
|
303
|
-
export function audioResult(result, baseUrl = DEFAULT_APP_URL) {
|
|
304
|
-
const urls = result.outputUrls ?? [];
|
|
305
|
-
const header = `Done. Audio generated (outputId ${result.outputId}):`;
|
|
306
|
-
const prose = [header, ...urls.map((u, i) => `${i + 1}. ${u}`)].join('\n');
|
|
307
|
-
if (!urls.length)
|
|
308
|
-
return text(prose);
|
|
309
|
-
return {
|
|
310
|
-
content: [{ type: 'text', text: prose }],
|
|
311
|
-
structuredContent: mediaWidgetData({
|
|
312
|
-
outputId: result.outputId,
|
|
313
|
-
contentType: 'audio',
|
|
314
|
-
items: urls.map((url, i) => ({
|
|
315
|
-
url,
|
|
316
|
-
name: `${result.outputId}${urls.length > 1 ? `-${i + 1}` : ''}`,
|
|
317
|
-
contentType: 'audio',
|
|
318
|
-
// Audio has no shape, so there is nothing for a tile to take.
|
|
319
|
-
displayAspect: null,
|
|
320
|
-
openUrl: studioUrlFor(baseUrl, result.outputId, i, urls.length),
|
|
321
|
-
})),
|
|
322
|
-
}),
|
|
323
|
-
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* In-place clip enhancement: ONE JOB PER SOURCE, so the agent gets every outputId.
|
|
328
|
-
*
|
|
329
|
-
* Reporting only the first would let an agent see one recording finish and call the whole edit done, while the
|
|
330
|
-
* other recordings were still running. The applied-automatically note matters too: unlike every other async
|
|
331
|
-
* tool here, the caller does NOT place the result, so without saying so an agent would reasonably try to.
|
|
332
|
-
*/
|
|
333
|
-
export function enhanceClipsResult(result) {
|
|
334
|
-
const jobs = result.outputs ?? [];
|
|
335
|
-
if (jobs.length === 0) {
|
|
336
|
-
return text(result.note ?? 'Nothing to enhance: no audible clips in that selection.');
|
|
337
|
-
}
|
|
338
|
-
const lines = jobs.map((j, i) => `${i + 1}. outputId ${j.outputId} covers ${j.clipIds.length} clip${j.clipIds.length === 1 ? '' : 's'}` +
|
|
339
|
-
` from one source (${j.windows} window${j.windows === 1 ? '' : 's'})`);
|
|
340
|
-
const poll = `Poll with ${getStatusCall(jobs.map((j) => j.outputId))}`;
|
|
341
|
-
const header = jobs.length === 1
|
|
342
|
-
? `Enhancing 1 source. ${poll}:`
|
|
343
|
-
: `Enhancing ${jobs.length} sources as separate jobs, because a noise profile is estimated per recording. ${poll} (EVERY id, in one call):`;
|
|
344
|
-
const footer = [
|
|
345
|
-
'The enhanced audio is applied to the clips automatically when each job lands, so no placement call is needed.',
|
|
346
|
-
result.silencedClipsExcluded
|
|
347
|
-
? `${result.silencedClipsExcluded} silenced clip${result.silencedClipsExcluded === 1 ? ' was' : 's were'} skipped.`
|
|
348
|
-
: null,
|
|
349
|
-
].filter(Boolean);
|
|
350
|
-
return text([header, ...lines, ...footer].join('\n'));
|
|
351
|
-
}
|
|
352
|
-
/** Result of a get_cost preflight: the estimate, with nothing generated or charged. */
|
|
353
|
-
export function costResult(est) {
|
|
354
|
-
const what = est.modelId ?? est.contentType ?? 'this generation';
|
|
355
|
-
const credits = `${est.creditsEstimate} credit${est.creditsEstimate === 1 ? '' : 's'}`;
|
|
356
|
-
return text(`Estimated cost: ${credits} for ${what}. No generation ran and nothing was charged.`);
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* One generation's status. Used directly for a single id, and per-row by the batch form below.
|
|
360
|
-
*
|
|
361
|
-
* ⭐ A STILL-RUNNING GENERATION REPORTS THE URLS IT ALREADY HAS. `outputUrls` fills in slot by slot,
|
|
362
|
-
* so a 4-image batch can have three finished assets while `status` is still 'processing'. Reporting
|
|
363
|
-
* only "still processing" threw those away and made every caller block on the SLOWEST slot, even
|
|
364
|
-
* though the finished ones are already visible in the app's own grid. The caller can start reviewing
|
|
365
|
-
* immediately and re-poll only for the remainder.
|
|
366
|
-
*
|
|
367
|
-
* ⚠️ THE PARTIAL LIST IS NOT A FINAL ONE, so it never uses `completedResult`'s "Done." header. A
|
|
368
|
-
* caller that stopped at a partial result believing it was complete would silently lose images,
|
|
369
|
-
* which is the failure this is meant to prevent, not cause.
|
|
370
|
-
*/
|
|
371
|
-
export function generationStatusResult(gen, attachments = [], baseUrl) {
|
|
372
|
-
/**
|
|
373
|
-
* ⛔⛔ **THIS DROPPED THE ATTACHMENTS AND THEREFORE RENDERED NOTHING.** It called `completedResult(gen)`
|
|
374
|
-
* with no second argument, so POLLING returned text alone even after the generate handlers started
|
|
375
|
-
* attaching blocks. That is the path EVERY async generation takes, which is every video, so the common
|
|
376
|
-
* case stayed blank while the synchronous one worked.
|
|
377
|
-
*
|
|
378
|
-
* ⭐ Found by the `verify:inline` harness in its first run, minutes after it existed. The unit tests could
|
|
379
|
-
* not see it: they call `completedResult` directly and never go through here.
|
|
380
|
-
*/
|
|
381
|
-
if (gen.status === 'completed') {
|
|
382
|
-
const res = completedResult(gen, attachments, [], baseUrl);
|
|
383
|
-
/**
|
|
384
|
-
* ⛔⛔ **A REPORT CARRIES NO DISPLAY PAYLOAD, AND STRIPPING IT IS NOT COSMETIC.**
|
|
385
|
-
*
|
|
386
|
-
* This tool does not declare the widget, because `generate_*` already returns one that polls itself to
|
|
387
|
-
* completion and a second card for the same generation is the duplicate we removed. A result that still
|
|
388
|
-
* carried `structuredContent` and `_meta` would be ignored by the host, but it would also make
|
|
389
|
-
* "emits widget data" and "declares the widget" disagree, and that equivalence is exactly what the
|
|
390
|
-
* completeness guard checks in both directions. An invariant with an exemption is a list again.
|
|
391
|
-
*/
|
|
392
|
-
delete res.structuredContent;
|
|
393
|
-
delete res._meta;
|
|
394
|
-
return res;
|
|
395
|
-
}
|
|
396
|
-
if (gen.status === 'failed') {
|
|
397
|
-
return text(`Generation ${gen.outputId} failed: ${gen.error ?? 'unknown error'}`, true);
|
|
398
|
-
}
|
|
399
|
-
const secs = pollAfterSecondsFor(gen.contentType);
|
|
400
|
-
const ready = gen.outputUrls ?? [];
|
|
401
|
-
const poll = `Call get_generation_status again in ~${secs}s [poll_after_seconds: ${secs}]`;
|
|
402
|
-
if (ready.length === 0) {
|
|
403
|
-
return text(`Generation ${gen.outputId} is still ${gen.status}. ${poll}.`);
|
|
404
|
-
}
|
|
405
|
-
const noun = ready.length === 1 ? gen.contentType : `${gen.contentType}s`;
|
|
406
|
-
return text([
|
|
407
|
-
`Partial. ${ready.length} ${noun} ready from ${gen.modelId} (outputId ${gen.outputId}), more still ${gen.status}:`,
|
|
408
|
-
...ready.map((u, i) => `${i + 1}. ${u}`),
|
|
409
|
-
`NOT the full set. ${poll} for the rest.`,
|
|
410
|
-
].join('\n'));
|
|
411
|
-
}
|
|
412
|
-
/** One or more generations (snapshot or post-wait). Falls through to the single form for one id. */
|
|
413
|
-
export function generationBatchResult(gens, attachmentsByOutputId = {}, baseUrl) {
|
|
414
|
-
// ⚠️ ONLY THE SINGLE FORM ATTACHES. A batch status covering ten generations would embed ten sets of
|
|
415
|
-
// bytes into one result, which is the context blow-up the link design was originally protecting against.
|
|
416
|
-
// The single form is what a caller polling one generation hits, and that is the case worth rendering.
|
|
417
|
-
if (gens.length === 1)
|
|
418
|
-
return generationStatusResult(gens[0], attachmentsByOutputId[gens[0].outputId] ?? [], baseUrl);
|
|
419
|
-
const rows = gens.map((gen) => {
|
|
420
|
-
if (gen.status === 'completed') {
|
|
421
|
-
const urls = gen.outputUrls ?? [];
|
|
422
|
-
return `- ${gen.outputId}: completed | ${urls.join(', ') || '(no urls)'}`;
|
|
423
|
-
}
|
|
424
|
-
if (gen.status === 'failed') {
|
|
425
|
-
return `- ${gen.outputId}: failed | ${gen.error ?? 'unknown error'}`;
|
|
426
|
-
}
|
|
427
|
-
const secs = pollAfterSecondsFor(gen.contentType);
|
|
428
|
-
// Same rule as the single form: surface the slots that already landed rather than making the
|
|
429
|
-
// caller block on the slowest one. The count says the set is incomplete, so a row can never be
|
|
430
|
-
// mistaken for a finished generation.
|
|
431
|
-
const ready = gen.outputUrls ?? [];
|
|
432
|
-
if (ready.length > 0) {
|
|
433
|
-
return `- ${gen.outputId}: ${gen.status}, ${ready.length} ready so far | ${ready.join(', ')} [poll_after_seconds: ${secs}]`;
|
|
434
|
-
}
|
|
435
|
-
return `- ${gen.outputId}: ${gen.status} [poll_after_seconds: ${secs}]`;
|
|
436
|
-
});
|
|
437
|
-
return text([`${gens.length} generation(s):`, ...rows].join('\n'));
|
|
438
|
-
}
|
|
439
|
-
/** A finished transcription: header line plus the transcript body. */
|
|
440
|
-
export function transcriptResult(t) {
|
|
441
|
-
const lang = t.language ? ` (${t.language})` : '';
|
|
442
|
-
const cost = t.creditsUsed > 0 ? `, ${t.creditsUsed} credits` : '';
|
|
443
|
-
const header = `Transcript${lang}, ${t.wordCount} words${cost} (outputId ${t.outputId}):`;
|
|
444
|
-
return text([header, '', t.transcript].join('\n'));
|
|
445
|
-
}
|
|
446
|
-
/** Join the non-empty lines (drops null/empty entries). */
|
|
447
|
-
function lines(parts) {
|
|
448
|
-
return parts.filter((p) => !!p).join('\n');
|
|
449
|
-
}
|
|
450
|
-
/** List of avatars, each with the fields an agent needs to drive lip-sync. */
|
|
451
|
-
export function avatarListResult(avatars) {
|
|
452
|
-
if (!avatars.length) {
|
|
453
|
-
return text('No avatars found. Create one in the ContentHero app first.');
|
|
454
|
-
}
|
|
455
|
-
const rows = avatars.map((a) => `- ${a.name} (id ${a.id})${a.isDefault ? ' [default]' : ''} | image: ${a.imageUrl ?? 'none'} | voice: ${a.defaultVoiceId ?? 'none'}`);
|
|
456
|
-
return text([`${avatars.length} avatar(s):`, ...rows].join('\n'));
|
|
457
|
-
}
|
|
458
|
-
/** One avatar's full detail, including its looks. */
|
|
459
|
-
export function avatarResult(a) {
|
|
460
|
-
const traits = [a.gender, a.age, a.ethnicity].filter(Boolean).join(', ');
|
|
461
|
-
return text(lines([
|
|
462
|
-
`${a.name} (id ${a.id})${a.isDefault ? ' [default]' : ''}`,
|
|
463
|
-
`image (base look, use as imageUrl for generate_lip_sync): ${a.imageUrl ?? 'none'}`,
|
|
464
|
-
`default voice (use as voiceId): ${a.defaultVoiceId ?? 'none'}`,
|
|
465
|
-
a.description ? `description: ${a.description}` : null,
|
|
466
|
-
traits ? `traits: ${traits}` : null,
|
|
467
|
-
a.niche.length ? `niche: ${a.niche.join(', ')}` : null,
|
|
468
|
-
a.looks.length ? `looks (${a.looks.length}):` : 'looks: none',
|
|
469
|
-
...a.looks.map((l) => ` - ${l.name ?? l.lookType ?? 'look'} (id ${l.id})${l.isDefault ? ' [default]' : ''}${l.isFavorited ? ' [favorite]' : ''}${l.isArchived ? ' [archived]' : ''}: ${l.imageUrl ?? 'none'}`),
|
|
470
|
-
]));
|
|
471
|
-
}
|
|
472
|
-
/**
|
|
473
|
-
* A just-created avatar, which is NOT READY.
|
|
474
|
-
*
|
|
475
|
-
* ⚠️ THE POINT OF A SEPARATE FORMATTER IS THE WAIT. `avatarResult` describes a finished avatar, and
|
|
476
|
-
* using it here would show an avatar with `image: none` and no looks, which reads as "created, and
|
|
477
|
-
* empty" rather than "created, and still generating". A model that reads it that way goes on to
|
|
478
|
-
* generate a look into an avatar whose own first look is still in flight, or reports success to the
|
|
479
|
-
* user for something they cannot yet see.
|
|
480
|
-
*
|
|
481
|
-
* Says the poll call explicitly, the same way `pendingResult` does for a generation.
|
|
482
|
-
*/
|
|
483
|
-
export function avatarPendingResult(created) {
|
|
484
|
-
const a = created.avatar;
|
|
485
|
-
return text(lines([
|
|
486
|
-
`Created "${a.name}" (id ${a.id}).`,
|
|
487
|
-
'',
|
|
488
|
-
`⚠️ NOT READY YET: status is ${created.status}. The avatar has no image until its first look`,
|
|
489
|
-
'finishes generating, which is also when its default look and profile photo are set.',
|
|
490
|
-
`Poll with: get_avatar { "avatarId": "${a.id}" } until status is "completed" (usually 1-4 minutes).`,
|
|
491
|
-
'',
|
|
492
|
-
'Credits are charged when that look completes, not now, so a failed generation is not charged.',
|
|
493
|
-
]));
|
|
494
|
-
}
|
|
495
|
-
/** List of saved voices. */
|
|
496
|
-
export function voiceListResult(voices) {
|
|
497
|
-
if (!voices.length)
|
|
498
|
-
return text('No saved voices found.');
|
|
499
|
-
const rows = voices.map((v) => `- ${v.name ?? '(unnamed)'} (voiceId ${v.voiceId})${v.isFavorited ? ' [favorite]' : ''}${v.previewUrl ? ` | preview: ${v.previewUrl}` : ''}`);
|
|
500
|
-
return text([`${voices.length} voice(s):`, ...rows].join('\n'));
|
|
501
|
-
}
|
|
502
|
-
/** One voice's full detail. */
|
|
503
|
-
export function voiceResult(v) {
|
|
504
|
-
const traits = [v.gender, v.age, v.accent, v.language].filter(Boolean).join(', ');
|
|
505
|
-
return text(lines([
|
|
506
|
-
`${v.name ?? '(unnamed)'} (voiceId ${v.voiceId})${v.isFavorited ? ' [favorite]' : ''}`,
|
|
507
|
-
v.provider ? `provider: ${v.provider}` : null,
|
|
508
|
-
traits ? `traits: ${traits}` : null,
|
|
509
|
-
v.description ? `description: ${v.description}` : null,
|
|
510
|
-
v.useCase ? `use case: ${v.useCase}` : null,
|
|
511
|
-
v.previewUrl ? `preview: ${v.previewUrl}` : null,
|
|
512
|
-
]));
|
|
513
|
-
}
|
|
514
|
-
/** List of brand kits. */
|
|
515
|
-
export function brandKitListResult(kits) {
|
|
516
|
-
if (!kits.length)
|
|
517
|
-
return text('No brand kits found. Create one in the ContentHero app first.');
|
|
518
|
-
const rows = kits.map((k) => `- ${k.name}${k.businessName && k.businessName !== k.name ? ` (${k.businessName})` : ''} (id ${k.id})${k.isDefault ? ' [default]' : ''}${k.nicheDefinition ? ` | niche: ${k.nicheDefinition}` : ''}`);
|
|
519
|
-
return text([`${kits.length} brand kit(s):`, ...rows].join('\n'));
|
|
520
|
-
}
|
|
521
|
-
/**
|
|
522
|
-
* One brand kit in full. The kit is deeply structured (visual identity, voice,
|
|
523
|
-
* curated sections, linked accounts, knowledge), so return a short header plus
|
|
524
|
-
* the whole object as JSON: faithful and complete, and an agent reads it cleanly.
|
|
525
|
-
*/
|
|
526
|
-
export function brandKitResult(kit, extraction) {
|
|
527
|
-
const header = `Brand kit "${kit.name}"${kit.isDefault ? ' [default]' : ''} (id ${kit.id}):`;
|
|
528
|
-
// Stated in words, not just left in the JSON, because the caller has to know the kit it just got back is
|
|
529
|
-
// still FILLING IN. Without this line an agent reads an almost-empty kit and concludes extraction failed.
|
|
530
|
-
const note = extraction && extraction.status !== 'unconfigured'
|
|
531
|
-
? extraction.status === 'deduped'
|
|
532
|
-
? 'Extraction was ALREADY RUNNING for this kit, so nothing new was queued. Poll extractionStatus with get_brand_kit.'
|
|
533
|
-
: 'Extraction STARTED and is still running. The fields below will fill in. Poll extractionStatus with get_brand_kit.'
|
|
534
|
-
: extraction?.status === 'unconfigured'
|
|
535
|
-
? 'Extraction is NOT CONFIGURED on this deployment, so nothing was queued.'
|
|
536
|
-
: null;
|
|
537
|
-
return text([header, ...(note ? ['', note] : []), '', JSON.stringify(kit, null, 2)].join('\n'));
|
|
538
|
-
}
|
|
539
|
-
/** A created/updated/archived brand-kit section. */
|
|
540
|
-
export function brandKitSectionResult(s, verb = 'Section') {
|
|
541
|
-
const fieldCount = Array.isArray(s.fields) ? s.fields.length : 0;
|
|
542
|
-
return text(`${verb}: "${s.sectionName}" in tab "${s.tab}" (id ${s.id}) | ${fieldCount} field(s).`);
|
|
543
|
-
}
|
|
544
|
-
/** A brand kit that was just archived. */
|
|
545
|
-
export function brandKnowledgeListResult(result) {
|
|
546
|
-
if (!result.items.length) {
|
|
547
|
-
return text('No knowledge items in this brand kit yet. Add one with add_brand_knowledge.');
|
|
548
|
-
}
|
|
549
|
-
const more = result.hasMore ? ` (showing ${result.items.length} of ${result.total})` : '';
|
|
550
|
-
const lines = result.items.map((k) => `- ${k.title ?? '(untitled)'} [${k.sourceType ?? 'unknown'}] (id ${k.id})`);
|
|
551
|
-
return text([`${result.total} knowledge item(s)${more}:`, ...lines].join('\n'));
|
|
552
|
-
}
|
|
553
|
-
export function brandKnowledgeDetailResult(item) {
|
|
554
|
-
return text([
|
|
555
|
-
`${item.title ?? '(untitled)'} [${item.sourceType ?? 'unknown'}] (id ${item.id})`,
|
|
556
|
-
item.sourceUrl ? `Source: ${item.sourceUrl}` : null,
|
|
557
|
-
'',
|
|
558
|
-
item.content ?? '(no stored body; use search_brand_knowledge for the full depth)',
|
|
559
|
-
]
|
|
560
|
-
.filter((l) => l !== null)
|
|
561
|
-
.join('\n'));
|
|
562
|
-
}
|
|
563
|
-
export function brandKnowledgeSearchResult(matches) {
|
|
564
|
-
if (!matches.length) {
|
|
565
|
-
return text('No matching knowledge found. Try a broader query or a lower threshold.');
|
|
566
|
-
}
|
|
567
|
-
const blocks = matches.map((m, i) => {
|
|
568
|
-
const score = m.similarity.toFixed(3);
|
|
569
|
-
const header = `[${i + 1}] ${m.title ?? '(untitled)'} (item ${m.knowledgeId ?? '?'}, score ${score})`;
|
|
570
|
-
return `${header}\n${m.content}`;
|
|
571
|
-
});
|
|
572
|
-
return text([`${matches.length} match(es):`, ...blocks].join('\n\n'));
|
|
573
|
-
}
|
|
574
|
-
export function brandKnowledgeItemResult(item, verb = 'Added') {
|
|
575
|
-
return text(`${verb} knowledge item: "${item.title ?? '(untitled)'}" [${item.sourceType ?? 'unknown'}] (id ${item.id}).`);
|
|
576
|
-
}
|
|
577
|
-
/**
|
|
578
|
-
* Confirmation of a universal favorite / unfavorite / archive / unarchive action.
|
|
579
|
-
* `target` describes what was acted on: a studio variation slot when
|
|
580
|
-
* variationIndex is set, otherwise a top-level asset by type + id.
|
|
581
|
-
*/
|
|
582
|
-
export function statusActionResult(action, target) {
|
|
583
|
-
const what = target.variationIndex != null
|
|
584
|
-
? `variation ${target.variationIndex} of output ${target.id}`
|
|
585
|
-
: `${target.assetType ?? 'asset'} ${target.id}`;
|
|
586
|
-
return text(`${action} ${what}.`);
|
|
587
|
-
}
|
|
588
|
-
/** List of library media, one row per VARIATION (the atomic grain). */
|
|
589
|
-
export function mediaListResult(items) {
|
|
590
|
-
if (!items.length)
|
|
591
|
-
return text('No media found.');
|
|
592
|
-
const rows = items.map((m) => {
|
|
593
|
-
// A studio generation lists as one row per variation; show which slot when it has siblings. The
|
|
594
|
-
// addressable token for this variation is `<id>-<variant+1>`.
|
|
595
|
-
const varTag = m.generationSize > 1 ? ` | v${m.variant + 1}/${m.generationSize}` : '';
|
|
596
|
-
const favTag = m.isFavorited ? ' [favorite]' : '';
|
|
597
|
-
const promptStr = m.prompt ? ` | ${m.prompt.slice(0, 80)}${m.prompt.length > 80 ? '...' : ''}` : '';
|
|
598
|
-
const kindTag = m.kind === 'board'
|
|
599
|
-
? ` | board${m.boardType ? `:${m.boardType}` : ''}`
|
|
600
|
-
: m.kind && m.kind !== 'creation'
|
|
601
|
-
? ` | ${m.kind}`
|
|
602
|
-
: '';
|
|
603
|
-
const nameStr = m.fileName ? ` | ${m.fileName}` : '';
|
|
604
|
-
const durStr = m.durationSeconds != null ? ` | ${Math.round(m.durationSeconds)}s` : '';
|
|
605
|
-
// Every item is a single variation carrying its resolved url; surface it inline so the agent can
|
|
606
|
-
// reference the media directly (e.g. add it to a timeline) without a get call.
|
|
607
|
-
const urlStr = m.url ? ` | ${m.url}` : '';
|
|
608
|
-
return `- [${m.type}] ${m.model ?? ''} (id ${m.id})${varTag}${favTag}${kindTag}${nameStr}${durStr} | ${m.status}${promptStr}${urlStr}`;
|
|
609
|
-
});
|
|
610
|
-
return text([`${items.length} item(s) (newest first):`, ...rows].join('\n'));
|
|
611
|
-
}
|
|
612
|
-
/** Semantic library-search matches: assets ranked by relevance, with matched scene timestamps for video. */
|
|
613
|
-
export function mediaSearchResult(results) {
|
|
614
|
-
if (!results.length)
|
|
615
|
-
return text('No matching media found.');
|
|
616
|
-
const rows = results.map((r) => {
|
|
617
|
-
const rel = ` | ${Math.round(r.relevance * 100)}%`;
|
|
618
|
-
const kindTag = r.kind ? `[${r.kind}]` : '[media]';
|
|
619
|
-
const summaryStr = r.summary ? ` | ${r.summary.slice(0, 90)}${r.summary.length > 90 ? '...' : ''}` : '';
|
|
620
|
-
const scenesStr = r.scenes.length
|
|
621
|
-
? ` | scenes: ${r.scenes.map((s) => `${(s.startMs / 1000).toFixed(1)}-${(s.endMs / 1000).toFixed(1)}s`).join(', ')}`
|
|
622
|
-
: '';
|
|
623
|
-
const urlStr = r.url ? ` | ${r.url}` : '';
|
|
624
|
-
return `- ${kindTag} (id ${r.id})${rel}${summaryStr}${scenesStr}${urlStr}`;
|
|
625
|
-
});
|
|
626
|
-
return text([`${results.length} match(es) (most relevant first):`, ...rows].join('\n'));
|
|
627
|
-
}
|
|
628
|
-
/** The user's folders (their own + the built-in derived folders). */
|
|
629
|
-
export function folderListResult(data) {
|
|
630
|
-
const own = data.folders.map((f) => `- ${f.name} [${f.type}] (id ${f.id})${f.parentId ? ` | in ${f.parentId}` : ''}`);
|
|
631
|
-
const derived = data.derived.map((d) => `- ${d.name} (key ${d.key})`);
|
|
632
|
-
return text([
|
|
633
|
-
own.length ? `Your folders (${own.length}):` : 'You have no folders yet.',
|
|
634
|
-
...own,
|
|
635
|
-
'',
|
|
636
|
-
'Built-in folders:',
|
|
637
|
-
...derived,
|
|
638
|
-
].join('\n'));
|
|
639
|
-
}
|
|
640
|
-
/** One folder's contents (media items + entities). */
|
|
641
|
-
export function folderContentsResult(folder, items) {
|
|
642
|
-
const header = folder ? `"${folder.name}" - ${items.length} item(s):` : `${items.length} item(s):`;
|
|
643
|
-
if (!items.length)
|
|
644
|
-
return text(`${header}\n(empty)`);
|
|
645
|
-
const rows = items.map((i) => {
|
|
646
|
-
if (i.type === 'media') {
|
|
647
|
-
const rel = i.relevance != null ? ` | ${Math.round(i.relevance * 100)}%` : '';
|
|
648
|
-
const fav = i.isFavorited ? ' [favorite]' : '';
|
|
649
|
-
const summ = i.summary ? ` | ${i.summary.slice(0, 80)}${i.summary.length > 80 ? '...' : ''}` : '';
|
|
650
|
-
return `- [${i.kind ?? 'media'}] (${i.sourceTable} ${i.sourceRecordId} v${i.variant})${rel}${fav}${summ}${i.url ? ` | ${i.url}` : ''}`;
|
|
651
|
-
}
|
|
652
|
-
return `- [${i.type}] ${i.name} (id ${i.id})${i.subtype ? ` | ${i.subtype}` : ''}`;
|
|
653
|
-
});
|
|
654
|
-
return text([header, ...rows].join('\n'));
|
|
655
|
-
}
|
|
656
|
-
/** One studio output's detail, with its variations. */
|
|
657
|
-
export function mediaResult(m) {
|
|
658
|
-
const specs = [
|
|
659
|
-
m.aspectRatio ? `aspect ${m.aspectRatio}` : null,
|
|
660
|
-
m.resolution ? `res ${m.resolution}` : null,
|
|
661
|
-
m.duration ? `${m.duration}s` : null,
|
|
662
|
-
]
|
|
663
|
-
.filter(Boolean)
|
|
664
|
-
.join(', ');
|
|
665
|
-
return text(lines([
|
|
666
|
-
`${m.type} from ${m.model ?? 'unknown'} (id ${m.id})${m.selectedVariation ? `, variation ${m.selectedVariation}` : ''}`,
|
|
667
|
-
m.kind && m.kind !== 'creation' ? `kind: ${m.kind}${m.boardType ? ` (${m.boardType})` : ''}` : null,
|
|
668
|
-
m.prompt ? `prompt: ${m.prompt}` : null,
|
|
669
|
-
m.script ? `script: ${m.script}` : null,
|
|
670
|
-
specs || null,
|
|
671
|
-
`status: ${m.status}${m.creditsUsed != null ? ` | ${m.creditsUsed} credits` : ''}`,
|
|
672
|
-
`variations (${m.generationSize}):`,
|
|
673
|
-
...m.variations.map((v) => ` ${v.variation}. ${v.url ?? `(no url, ${v.status})`}${v.isFavorited ? ' [favorite]' : ''}${v.isArchived ? ' [archived]' : ''}`),
|
|
674
|
-
]));
|
|
675
|
-
}
|
|
676
|
-
/** One resolved batch item's metadata line (no image; that is added separately). */
|
|
677
|
-
function batchItemLine(it, index, hasImage) {
|
|
678
|
-
const label = `[${index + 1}]`;
|
|
679
|
-
if (!it.ok) {
|
|
680
|
-
const ref = it.mediaId ?? ('url' in it.input ? it.input.url : JSON.stringify(it.input));
|
|
681
|
-
return `${label} ERROR (${ref}): ${it.error ?? 'could not resolve'}`;
|
|
682
|
-
}
|
|
683
|
-
const idPart = it.mediaId
|
|
684
|
-
? `${it.type ?? 'media'} ${it.mediaId}${it.variation != null ? ` v${it.variation}` : ''}`
|
|
685
|
-
: `${it.type ?? 'media'} (url)`;
|
|
686
|
-
const others = it.otherVariations.length > 0 ? ` | other variations: ${it.otherVariations.join(', ')}` : '';
|
|
687
|
-
const model = it.model ? ` from ${it.model}` : '';
|
|
688
|
-
const prompt = it.prompt ? `\n prompt: ${it.prompt}` : '';
|
|
689
|
-
// Explain the absence of an image so the model does not assume it failed.
|
|
690
|
-
let note = '';
|
|
691
|
-
if (!hasImage) {
|
|
692
|
-
if (it.type === 'audio')
|
|
693
|
-
note = '\n (audio: no visual; use the url)';
|
|
694
|
-
else if (it.type === 'video')
|
|
695
|
-
note = '\n (video: no still available for this view; use the url)';
|
|
696
|
-
else if (it.type === 'transcript')
|
|
697
|
-
note = '\n (transcript: text only)';
|
|
698
|
-
}
|
|
699
|
-
// MEASURED GEOMETRY, when the spine has it. Without these numbers a caller cannot compute an asset's true
|
|
700
|
-
// aspect (so it stretches it on placement) and cannot align to the VISIBLE artwork of a padded logo at all.
|
|
701
|
-
// `content` is the artwork's bounds inside the file; when it is smaller than the file, say so explicitly,
|
|
702
|
-
// because that difference is the whole reason to read it.
|
|
703
|
-
let geom = '';
|
|
704
|
-
if (it.geometry) {
|
|
705
|
-
const { width, height, content } = it.geometry;
|
|
706
|
-
geom = `\n dimensions: ${width}x${height}`;
|
|
707
|
-
if (content) {
|
|
708
|
-
const trimmed = content.width < width || content.height < height;
|
|
709
|
-
geom += trimmed
|
|
710
|
-
? ` | 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`
|
|
711
|
-
: ' | artwork fills the frame';
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
// MEASURED DURATION, for anything time-based. Reported for AUDIO too, which is the point: audio has no
|
|
715
|
-
// dimensions, so it carried no measured facts at all, and `edit_audio` requires a durationSeconds to price
|
|
716
|
-
// the job. The only way to call it correctly was to download the file and probe it.
|
|
717
|
-
const dur = it.durationSeconds != null ? `\n duration: ${it.durationSeconds.toFixed(2)}s` : '';
|
|
718
|
-
return `${label} ${idPart}${model}${others}\n ${it.url}${geom}${dur}${prompt}${note}`;
|
|
719
|
-
}
|
|
720
|
-
/**
|
|
721
|
-
* A vision-enabled media batch (get_media). Returns a text summary + one metadata
|
|
722
|
-
* line per item, and, for each image item whose bytes were fetched, an IMAGE
|
|
723
|
-
* content block so the calling model can SEE it. Images arrive as a parallel
|
|
724
|
-
* array (fetched + base64-encoded by the caller, image items only; null for
|
|
725
|
-
* video/audio/errors). This just assembles the result. See get-context §9.5.
|
|
726
|
-
*/
|
|
727
|
-
/**
|
|
728
|
-
* Turn resolved media into tiles.
|
|
729
|
-
*
|
|
730
|
-
* ## ⭐⭐⭐ THE BYTES ARE FOR THE AGENT, THE URLS ARE FOR THE PERSON, AND ONE CALL DOES BOTH
|
|
731
|
-
*
|
|
732
|
-
* The image blocks above are the agent's vision and they cost context, which is why they run through one
|
|
733
|
-
* shared budget. The widget renders from URLS, which cost nothing. So a call can attach as many pixels as
|
|
734
|
-
* the budget allows AND display every item, and the two limits do not fight: more items means fewer
|
|
735
|
-
* inlined images, never a card that shows less than was asked for.
|
|
736
|
-
*
|
|
737
|
-
* ⛔ **NO CHIP, BECAUSE `model` HERE IS A RAW ID.** `gpt-image-2` reads like a label, and substituting one
|
|
738
|
-
* is the exact defect that made the generation chip flicker between kebab case and title case. Resolving
|
|
739
|
-
* it needs the registry, the same way the generation path got its name, so until this payload carries a
|
|
740
|
-
* display name the tiles render their media and no label.
|
|
741
|
-
*
|
|
742
|
-
* ⚠️ Transcripts are skipped: the widget has no element for text, and a tile that renders nothing is worse
|
|
743
|
-
* than an item the summary already describes in words.
|
|
744
|
-
*/
|
|
745
|
-
function mediaBatchItems(result, baseUrl) {
|
|
746
|
-
const items = [];
|
|
747
|
-
for (const it of result.items) {
|
|
748
|
-
if (!it.ok || !it.url)
|
|
749
|
-
continue;
|
|
750
|
-
if (it.type !== 'image' && it.type !== 'video' && it.type !== 'audio')
|
|
751
|
-
continue;
|
|
752
|
-
const g = it.geometry;
|
|
753
|
-
items.push({
|
|
754
|
-
url: it.url,
|
|
755
|
-
// A video's still, so a tile shows something before anyone presses play.
|
|
756
|
-
posterUrl: it.type === 'video' ? it.imageUrl : null,
|
|
757
|
-
/** The reference the API takes: `<id>` or `<id>-<n>`, one-based, matching what a person reads. */
|
|
758
|
-
name: it.mediaId ? `${it.mediaId}${it.variation && it.variation > 1 ? `-${it.variation}` : ''}` : it.url,
|
|
759
|
-
// ⚠️ Only a mediaId is referenceable. A raw url resolved here is not a library item the API can name.
|
|
760
|
-
reference: it.mediaId
|
|
761
|
-
? `${it.mediaId}${it.variation && it.variation > 1 ? `-${it.variation}` : ''}`
|
|
762
|
-
: undefined,
|
|
763
|
-
contentType: it.type,
|
|
764
|
-
// MEASURED, from the storage spine. Null when nothing measured it, which the tile handles.
|
|
765
|
-
displayAspect: g ? aspectLabel(g.width, g.height) : null,
|
|
766
|
-
openUrl: it.mediaId
|
|
767
|
-
? `${baseUrl.replace(/\/+$/, '')}/studio?output=${encodeURIComponent(it.mediaId)}` +
|
|
768
|
-
(it.variation && it.variation > 1 ? `&variation=${it.variation}` : '')
|
|
769
|
-
: undefined,
|
|
770
|
-
});
|
|
771
|
-
}
|
|
772
|
-
return items;
|
|
773
|
-
}
|
|
774
|
-
export function mediaBatchResult(result, images, baseUrl = DEFAULT_APP_URL) {
|
|
775
|
-
const { items } = result;
|
|
776
|
-
const okCount = items.filter((i) => i.ok).length;
|
|
777
|
-
const keyframeCount = items.reduce((n, it) => n + (it.keyframes?.length ?? 0), 0);
|
|
778
|
-
const shownImages = images.filter(Boolean).length + keyframeCount;
|
|
779
|
-
const summary = `Resolved ${okCount}/${items.length} media item(s); ${shownImages} image(s) attached below` +
|
|
780
|
-
(keyframeCount > 0 ? ` (incl. ${keyframeCount} video keyframe(s))` : '') +
|
|
781
|
-
`.\n\n` +
|
|
782
|
-
items.map((it, i) => batchItemLine(it, i, Boolean(images[i]) || (it.keyframes?.length ?? 0) > 0)).join('\n');
|
|
783
|
-
const content = [{ type: 'text', text: summary }];
|
|
784
|
-
items.forEach((it, i) => {
|
|
785
|
-
const img = images[i];
|
|
786
|
-
if (img) {
|
|
787
|
-
content.push({ type: 'text', text: `Image for item [${i + 1}]:` });
|
|
788
|
-
content.push({ type: 'image', data: img.data, mimeType: img.mimeType });
|
|
789
|
-
}
|
|
790
|
-
const keyframes = it.keyframes ?? [];
|
|
791
|
-
if (keyframes.length > 0) {
|
|
792
|
-
content.push({ type: 'text', text: `${keyframes.length} keyframe(s) for item [${i + 1}] (raw footage, in order):` });
|
|
793
|
-
for (const kf of keyframes) {
|
|
794
|
-
const parsed = parseDataUrl(kf.dataUrl);
|
|
795
|
-
if (parsed)
|
|
796
|
-
content.push({ type: 'image', data: parsed.data, mimeType: parsed.mimeType });
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
});
|
|
800
|
-
/**
|
|
801
|
-
* ⭐ DISPLAY COSTS NOTHING EXTRA. The blocks above are the agent's vision and are budget-bounded; this is
|
|
802
|
-
* urls, so every resolved item renders whether or not its pixels fit that budget.
|
|
803
|
-
*/
|
|
804
|
-
const tiles = mediaBatchItems(result, baseUrl);
|
|
805
|
-
if (tiles.length === 0)
|
|
806
|
-
return { content };
|
|
807
|
-
return {
|
|
808
|
-
content,
|
|
809
|
-
structuredContent: mediaWidgetData({ items: tiles }),
|
|
810
|
-
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
811
|
-
};
|
|
812
|
-
}
|
|
813
|
-
/** Phase 1 of an upload: the signed URL + the PUT-then-complete instructions. */
|
|
814
|
-
export function mediaUploadResult(r) {
|
|
815
|
-
// The headers are listed EXPLICITLY rather than described, because this instruction is executed by an agent
|
|
816
|
-
// and "with the file's Content-Type" was about to become wrong. Object storage is moving to R2, where the
|
|
817
|
-
// presigned URL signs the owner in as `x-amz-meta-user_id`; a PUT missing it is refused with
|
|
818
|
-
// SignatureDoesNotMatch (verified: 403 with Content-Type alone, 200 with both). Telling the caller which
|
|
819
|
-
// headers to send, from the server's own answer, means the migration needs no change here at all.
|
|
820
|
-
const headers = r.uploadHeaders ?? { 'Content-Type': 'the file MIME type' };
|
|
821
|
-
return text(lines([
|
|
822
|
-
`Upload created (id ${r.outputId}). Two steps remain:`,
|
|
823
|
-
`1. PUT the file bytes to this URL (expires ${r.expiresAt}):`,
|
|
824
|
-
` ${r.uploadUrl}`,
|
|
825
|
-
' Send EXACTLY these headers, unchanged:',
|
|
826
|
-
...Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`),
|
|
827
|
-
`2. Call complete_media_upload(outputId: "${r.outputId}") to finalize.`,
|
|
828
|
-
'Once complete, reference the media by its outputId in generations or post assets.',
|
|
829
|
-
]));
|
|
830
|
-
}
|
|
831
|
-
/** A finalized upload or import: a first-class media output. */
|
|
832
|
-
/**
|
|
833
|
-
* Media the person's own bytes just became.
|
|
834
|
-
*
|
|
835
|
-
* ## ⭐ AN UPLOAD IS NEW MEDIA IN THEIR LIBRARY, SO IT DISPLAYS
|
|
836
|
-
*
|
|
837
|
-
* The rule is that a tool returning newly created or newly acquired media shows it, and an upload is the
|
|
838
|
-
* second. Confirmation is the value: a thumbnail says the right file landed, where a line of text says
|
|
839
|
-
* only that something did.
|
|
840
|
-
*
|
|
841
|
-
* ⛔ This could not render at all until the API started returning `contentType`. Guessing image from a
|
|
842
|
-
* url's extension is what renders a video as a broken image, so text was the honest answer while the type
|
|
843
|
-
* was unknown, and it remains the answer for a `document`, which has no element.
|
|
844
|
-
*
|
|
845
|
-
* ⚠️ Referenceable, unlike an export: `outputId` is exactly what `generate_*` accepts, which is what the
|
|
846
|
-
* prose has always told the caller.
|
|
847
|
-
*/
|
|
848
|
-
export function uploadedMediaResult(r, baseUrl = DEFAULT_APP_URL) {
|
|
849
|
-
const prose = `Media ready (id ${r.outputId}): ${r.url}. Reference it by outputId in generate_* or add_post_asset, or find it via list_media / get_media.`;
|
|
850
|
-
return renderableMedia(prose, r.outputId, r.url, r.contentType, baseUrl);
|
|
851
|
-
}
|
|
852
|
-
/**
|
|
853
|
-
* The shared tail of every "here is one new library item" result.
|
|
854
|
-
*
|
|
855
|
-
* ⚠️ ONE PLACE, because an upload and an import differ in their prose and in nothing else that matters
|
|
856
|
-
* here. Written twice they would drift the first time one of them learned something the other did not.
|
|
857
|
-
*/
|
|
858
|
-
function renderableMedia(prose, outputId, url, contentType, baseUrl) {
|
|
859
|
-
const medium = contentType === 'image' || contentType === 'video' || contentType === 'audio' ? contentType : undefined;
|
|
860
|
-
if (!outputId || !medium)
|
|
861
|
-
return text(prose);
|
|
862
|
-
return {
|
|
863
|
-
content: [{ type: 'text', text: prose }],
|
|
864
|
-
structuredContent: mediaWidgetData({
|
|
865
|
-
outputId,
|
|
866
|
-
contentType: medium,
|
|
867
|
-
items: [
|
|
868
|
-
{
|
|
869
|
-
url,
|
|
870
|
-
name: outputId,
|
|
871
|
-
contentType: medium,
|
|
872
|
-
reference: outputId,
|
|
873
|
-
openUrl: studioUrlFor(baseUrl, outputId, 0, 1),
|
|
874
|
-
},
|
|
875
|
-
],
|
|
876
|
-
}),
|
|
877
|
-
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
/**
|
|
881
|
-
* The result of an import, which may have created nothing.
|
|
882
|
-
*
|
|
883
|
-
* ## Why a duplicate gets its own sentence rather than the same one
|
|
884
|
-
*
|
|
885
|
-
* An import of bytes the account already holds is a successful no-op. Reporting it as "Media ready" would
|
|
886
|
-
* be a lie an agent then acts on: it would try to reference an `outputId` that is null, or import again on
|
|
887
|
-
* the next run because nothing said it had already happened.
|
|
888
|
-
*
|
|
889
|
-
* The two duplicate cases differ in what the caller can DO next, so they read differently:
|
|
890
|
-
*
|
|
891
|
-
* an existing library item -> there is an id to use, so give it
|
|
892
|
-
* no library item -> the bytes are an export or a look; there is no id, so say what it IS
|
|
893
|
-
*
|
|
894
|
-
* Saying "already imported" without naming what it is would send someone hunting for a library item that
|
|
895
|
-
* does not exist. That is the exact confusion this whole fix came from.
|
|
896
|
-
*/
|
|
897
|
-
export function importedMediaResult(r, baseUrl = DEFAULT_APP_URL) {
|
|
898
|
-
if (!r.alreadyExisted) {
|
|
899
|
-
return renderableMedia(`Media ready (id ${r.outputId}): ${r.url}. Reference it by outputId in generate_* or add_post_asset, or find it via list_media / get_media.`, r.outputId, r.url, r.contentType, baseUrl);
|
|
900
|
-
}
|
|
901
|
-
if (r.outputId) {
|
|
902
|
-
return text(`Already in your library (id ${r.outputId}): ${r.url}. Nothing was imported: these exact bytes are already there. Reference it by outputId as usual.`);
|
|
903
|
-
}
|
|
904
|
-
const what = r.existing?.role ? `a ${r.existing.role}` : 'an existing file';
|
|
905
|
-
return text(`You already have this file. Nothing was imported: these exact bytes are already in your account as ${what}` +
|
|
906
|
-
`${r.existing?.objectName ? ` (${r.existing.objectName})` : ''}. ` +
|
|
907
|
-
`It is not a library item, so there is no outputId to reference. Use its URL directly: ${r.url}`);
|
|
908
|
-
}
|
|
909
|
-
export function balanceResult(b) {
|
|
910
|
-
return text(`Balance: ${b.balance} credits (tier: ${b.tier}, auto top-up: ${b.autoTopupEnabled ? 'on' : 'off'}).`);
|
|
911
|
-
}
|
|
912
|
-
// -- reference elements (named reference library) -----------------------------
|
|
913
|
-
/** List of the account's saved reference elements. */
|
|
914
|
-
export function elementListResult(items) {
|
|
915
|
-
if (!items.length) {
|
|
916
|
-
return text('No reference elements. Create one with create_element, then reference it in a Kling generation by elementId.');
|
|
917
|
-
}
|
|
918
|
-
const rows = items.map((e) => {
|
|
919
|
-
const media = e.input_video_url ? '1 video' : `${e.input_urls.length} image(s)`;
|
|
920
|
-
return `- ${e.name} (id ${e.id}) | ${e.category} | ${media}${e.description ? ` | ${e.description.slice(0, 60)}` : ''}`;
|
|
921
|
-
});
|
|
922
|
-
return text([`${items.length} element(s):`, ...rows].join('\n'));
|
|
923
|
-
}
|
|
924
|
-
/** Confirmation that an element was deleted. */
|
|
925
|
-
export function elementDeletedResult(id) {
|
|
926
|
-
return text(`Deleted element ${id}.`);
|
|
927
|
-
}
|
|
928
|
-
/** One saved reference element. */
|
|
929
|
-
export function elementResult(e, verb) {
|
|
930
|
-
if (verb) {
|
|
931
|
-
return text(`${verb} element "${e.name}" (id ${e.id}, ${e.category}). Reference it in a Kling generation via references.elements [{ elementId: "${e.id}" }] and @${e.name} in the prompt.`);
|
|
932
|
-
}
|
|
933
|
-
return text(lines([
|
|
934
|
-
`${e.name} (id ${e.id}) | ${e.category}`,
|
|
935
|
-
e.description ? `description: ${e.description}` : null,
|
|
936
|
-
e.input_video_url ? `video: ${e.input_video_url}` : `images (${e.input_urls.length}): ${e.input_urls.join(', ')}`,
|
|
937
|
-
`Reference in a Kling prompt as @${e.name}; pass references.elements [{ elementId: "${e.id}" }].`,
|
|
938
|
-
]));
|
|
939
|
-
}
|
|
940
|
-
// -- models (discovery catalog) -----------------------------------------------
|
|
941
|
-
/** Read a possibly-absent capability field from the loosely-typed bag. */
|
|
942
|
-
function cap(m, key) {
|
|
943
|
-
return m.capabilities[key];
|
|
944
|
-
}
|
|
945
|
-
/** Human duration spec, e.g. "5s|10s", "4-12s", "8s", or null when not applicable. */
|
|
946
|
-
function durationSummary(d) {
|
|
947
|
-
if (!d || d.mode === 'none')
|
|
948
|
-
return null;
|
|
949
|
-
if (d.mode === 'locked')
|
|
950
|
-
return `${d.value}s`;
|
|
951
|
-
if (d.mode === 'discrete')
|
|
952
|
-
return Array.isArray(d.options) ? `${d.options.join('s|')}s` : null;
|
|
953
|
-
if (d.mode === 'range')
|
|
954
|
-
return `${d.min}-${d.max}s`;
|
|
955
|
-
return null;
|
|
956
|
-
}
|
|
957
|
-
/** Compact, decision-relevant capability summary for the list view. */
|
|
958
|
-
function capabilitySummary(m) {
|
|
959
|
-
const parts = [];
|
|
960
|
-
const inputs = cap(m, 'inputTypes');
|
|
961
|
-
if (Array.isArray(inputs) && inputs.length)
|
|
962
|
-
parts.push(`inputs:${inputs.join('+')}`);
|
|
963
|
-
const dur = durationSummary(cap(m, 'duration'));
|
|
964
|
-
if (dur)
|
|
965
|
-
parts.push(`dur:${dur}`);
|
|
966
|
-
const res = cap(m, 'resolution')?.supported;
|
|
967
|
-
if (Array.isArray(res) && res.length)
|
|
968
|
-
parts.push(`res:${res.join('/')}`);
|
|
969
|
-
const ar = cap(m, 'aspectRatio')?.supported;
|
|
970
|
-
if (Array.isArray(ar) && ar.length)
|
|
971
|
-
parts.push(`ar:${ar.join('/')}`);
|
|
972
|
-
if (cap(m, 'audio')?.supported)
|
|
973
|
-
parts.push('audio');
|
|
974
|
-
const refMax = Math.max(cap(m, 'maxImageRefs') ?? 0, cap(m, 'maxVideoRefs') ?? 0, cap(m, 'maxAudioRefs') ?? 0);
|
|
975
|
-
if (refMax > 0)
|
|
976
|
-
parts.push(`refs:≤${refMax}`);
|
|
977
|
-
return parts.join(' | ');
|
|
978
|
-
}
|
|
979
|
-
/** List of models in the discovery catalog. */
|
|
980
|
-
export function modelListResult(models) {
|
|
981
|
-
if (!models.length)
|
|
982
|
-
return text('No models found.');
|
|
983
|
-
const rows = models.map((m) => {
|
|
984
|
-
const summary = capabilitySummary(m);
|
|
985
|
-
const def = m.isDefault ? ' [default]' : '';
|
|
986
|
-
return `- [${m.contentType}] ${m.modelId} (${m.displayName})${def} | ${m.kind}${summary ? ` | ${summary}` : ''}`;
|
|
987
|
-
});
|
|
988
|
-
return text([
|
|
989
|
-
`${models.length} model(s). Call get_model(modelId) for the full request shape before generating:`,
|
|
990
|
-
...rows,
|
|
991
|
-
].join('\n'));
|
|
992
|
-
}
|
|
993
|
-
/** One model's full request shape (the grounding view). */
|
|
994
|
-
export function modelResult(m) {
|
|
995
|
-
const res = cap(m, 'resolution');
|
|
996
|
-
const ar = cap(m, 'aspectRatio');
|
|
997
|
-
const dur = cap(m, 'duration');
|
|
998
|
-
const gen = cap(m, 'generations');
|
|
999
|
-
const audio = cap(m, 'audio');
|
|
1000
|
-
const features = cap(m, 'features');
|
|
1001
|
-
const enabledFeatures = features ? Object.keys(features).filter((k) => features[k]) : [];
|
|
1002
|
-
const refLines = [
|
|
1003
|
-
cap(m, 'maxImageRefs') ? `image refs: up to ${cap(m, 'maxImageRefs')}` : null,
|
|
1004
|
-
cap(m, 'maxVideoRefs') ? `video refs: up to ${cap(m, 'maxVideoRefs')}` : null,
|
|
1005
|
-
cap(m, 'maxAudioRefs') ? `audio refs: up to ${cap(m, 'maxAudioRefs')}` : null,
|
|
1006
|
-
].filter(Boolean);
|
|
1007
|
-
return text(lines([
|
|
1008
|
-
`${m.modelId} (${m.displayName})${m.isDefault ? ' [default]' : ''}`,
|
|
1009
|
-
`type: ${m.contentType} | operation: ${m.kind}`,
|
|
1010
|
-
m.description ? `description: ${m.description}` : null,
|
|
1011
|
-
m.tags.length ? `tags: ${m.tags.join(', ')}` : null,
|
|
1012
|
-
'',
|
|
1013
|
-
'Request shape:',
|
|
1014
|
-
` prompt: ${cap(m, 'promptMode') ?? 'optional'}${cap(m, 'promptMaxChars') ? ` (max ${cap(m, 'promptMaxChars')} chars)` : ''}`,
|
|
1015
|
-
Array.isArray(cap(m, 'inputTypes')) && cap(m, 'inputTypes').length
|
|
1016
|
-
? ` input types: ${cap(m, 'inputTypes').join(', ')}`
|
|
1017
|
-
: null,
|
|
1018
|
-
res?.supported?.length
|
|
1019
|
-
? ` resolution: ${res.supported.join(', ')}${res.default ? ` (default ${res.default})` : ''}`
|
|
1020
|
-
: null,
|
|
1021
|
-
ar?.supported?.length
|
|
1022
|
-
? ` aspect ratio: ${ar.supported.join(', ')}${ar.default ? ` (default ${ar.default})` : ''}`
|
|
1023
|
-
: null,
|
|
1024
|
-
durationSummary(dur) ? ` duration: ${durationSummary(dur)}${dur?.default ? ` (default ${dur.default}s)` : ''}` : null,
|
|
1025
|
-
audio?.supported ? ` audio: supported${audio.alwaysOn ? ' (always on)' : ''}` : null,
|
|
1026
|
-
cap(m, 'negativePrompt') ? ' negativePrompt: supported' : null,
|
|
1027
|
-
gen ? ` generations: ${gen.min}-${gen.max} (default ${gen.default})` : null,
|
|
1028
|
-
...refLines.map((l) => ` ${l}`),
|
|
1029
|
-
enabledFeatures.length ? ` features: ${enabledFeatures.join(', ')}` : null,
|
|
1030
|
-
...promptReferenceLines(m.promptReferences),
|
|
1031
|
-
'',
|
|
1032
|
-
"Build the request within this shape, preview cost with the matching generate tool's getCost option, then run it.",
|
|
1033
|
-
]));
|
|
1034
|
-
}
|
|
1035
|
-
export function platformListResult(platforms) {
|
|
1036
|
-
if (!platforms.length)
|
|
1037
|
-
return text('No publish platforms found.');
|
|
1038
|
-
const rows = platforms.map((p) => {
|
|
1039
|
-
const fmts = p.formats.map((f) => f.value).join(', ');
|
|
1040
|
-
return `- ${p.platform} (${p.name})${p.connected ? ' [connected]' : ''} | formats: ${fmts || 'post'}`;
|
|
1041
|
-
});
|
|
1042
|
-
return text([
|
|
1043
|
-
`${platforms.length} publish platform(s). Call get_platform(platform[, format]) for the exact fields, options, and limits a post requires:`,
|
|
1044
|
-
...rows,
|
|
1045
|
-
].join('\n'));
|
|
1046
|
-
}
|
|
1047
|
-
export function platformResult(p) {
|
|
1048
|
-
const fmtBlocks = p.formats.map((fmt) => {
|
|
1049
|
-
const fields = Object.keys(p.fieldTemplatesByFormat[fmt] ?? {});
|
|
1050
|
-
return ` ${fmt}: ${fields.length ? fields.join(', ') : '(no fields)'}`;
|
|
1051
|
-
});
|
|
1052
|
-
const enumLines = Object.entries(p.enums).map(([k, vals]) => {
|
|
1053
|
-
const rendered = vals
|
|
1054
|
-
.map((v) => (v && typeof v === 'object' && 'id' in v ? String(v.id) : String(v)))
|
|
1055
|
-
.join(', ');
|
|
1056
|
-
return ` ${k}: ${rendered}`;
|
|
1057
|
-
});
|
|
1058
|
-
const limitLines = p.characterLimits
|
|
1059
|
-
? Object.entries(p.characterLimits).map(([k, n]) => ` ${k}: ${n}`)
|
|
1060
|
-
: [];
|
|
1061
|
-
return text(lines([
|
|
1062
|
-
`${p.platform} (${p.name})`,
|
|
1063
|
-
`formats: ${p.formats.join(', ')}`,
|
|
1064
|
-
`posting modes: ${p.postingModes.join(', ')}`,
|
|
1065
|
-
'',
|
|
1066
|
-
'Fields by format (set these as platformSettings on a post in update_card):',
|
|
1067
|
-
...fmtBlocks,
|
|
1068
|
-
enumLines.length ? '' : null,
|
|
1069
|
-
enumLines.length ? 'Allowed option values:' : null,
|
|
1070
|
-
...enumLines,
|
|
1071
|
-
limitLines.length ? '' : null,
|
|
1072
|
-
limitLines.length ? 'Character limits:' : null,
|
|
1073
|
-
...limitLines,
|
|
1074
|
-
'',
|
|
1075
|
-
'Fill platformSettings to this shape, then attach it as a post with update_card.',
|
|
1076
|
-
]));
|
|
1077
|
-
}
|
|
1078
|
-
/** Render the reference-addressing guidance (how to tag references in the prompt). */
|
|
1079
|
-
function promptReferenceLines(pr) {
|
|
1080
|
-
if (!pr || pr.scheme === 'none')
|
|
1081
|
-
return [];
|
|
1082
|
-
const tokens = pr.inputs
|
|
1083
|
-
.filter((i) => i.token)
|
|
1084
|
-
.map((i) => `${i.token}${i.max > 1 ? ` (up to ${i.max})` : ''}`)
|
|
1085
|
-
.join(', ');
|
|
1086
|
-
return [
|
|
1087
|
-
'',
|
|
1088
|
-
`Referencing (${pr.scheme}${pr.honored ? ', bound' : ', positional'}):`,
|
|
1089
|
-
` ${pr.instruction}`,
|
|
1090
|
-
tokens ? ` tokens: ${tokens}` : null,
|
|
1091
|
-
];
|
|
1092
|
-
}
|
|
1093
|
-
// -- posts (content pipeline) -------------------------------------------------
|
|
1094
|
-
/** One line summarizing a post. */
|
|
1095
|
-
function cardLine(p) {
|
|
1096
|
-
const where = p.platforms.length ? p.platforms.join('+') : (p.platform ?? 'general');
|
|
1097
|
-
const when = p.publishedAt
|
|
1098
|
-
? ` | published ${p.publishedAt}`
|
|
1099
|
-
: p.scheduledAt
|
|
1100
|
-
? ` | scheduled ${p.scheduledAt}`
|
|
1101
|
-
: '';
|
|
1102
|
-
// `[archived]` rather than a status. The list excludes archived cards unless asked for, so when one
|
|
1103
|
-
// appears here the agent asked for it and the flag confirms the filter did what it said.
|
|
1104
|
-
return `- ${p.title || '(untitled)'} (id ${p.id})${p.isArchived ? ' [archived]' : ''} | ${where}${when}`;
|
|
1105
|
-
}
|
|
1106
|
-
/** List of posts with pagination context. */
|
|
1107
|
-
export function cardListResult(result) {
|
|
1108
|
-
/**
|
|
1109
|
-
* ⭐⭐⭐ NAME THE SCOPE, INCLUDING WHEN THE LIST IS EMPTY.
|
|
1110
|
-
*
|
|
1111
|
-
* This read covers ONE space and falls back to the default when none is named, so "No cards found" and
|
|
1112
|
-
* "9 card(s)" are both answers about a board the caller may not have meant. Measured 2026-09-14: a call
|
|
1113
|
-
* passing `space_id`, where the tool declares `spaceId`, had the key dropped, listed the default space,
|
|
1114
|
-
* and read as proof that the tool ignored its filters.
|
|
1115
|
-
*
|
|
1116
|
-
* ⚠️ THE EMPTY CASE IS THE ONE THAT MATTERS MOST. A wrong-scope list of cards at least looks unfamiliar;
|
|
1117
|
-
* a wrong-scope EMPTY list looks like the thing you asked for does not exist.
|
|
1118
|
-
*/
|
|
1119
|
-
const where = result.space ? ` in ${result.space.name}` : '';
|
|
1120
|
-
if (!result.cards.length)
|
|
1121
|
-
return text(`No cards found${where}.`);
|
|
1122
|
-
const more = result.hasMore ? ` (showing ${result.cards.length} of ${result.total}; raise limit/offset for more)` : '';
|
|
1123
|
-
return text([`${result.total} card(s)${where}${more}:`, ...result.cards.map(cardLine)].join('\n'));
|
|
1124
|
-
}
|
|
1125
|
-
/**
|
|
1126
|
-
* A single post summary line (create / update / schedule / archive results).
|
|
1127
|
-
*
|
|
1128
|
-
* ⚠️ THIS USED TO PRINT `p.status`, AND A CARD NO LONGER HAS ONE. The field was almost always `draft`
|
|
1129
|
-
* regardless of the card's real state, so it told the agent nothing while looking like it did. What it
|
|
1130
|
-
* prints now is what is true: where the card sits, when it publishes, and whether it is archived.
|
|
1131
|
-
*
|
|
1132
|
-
* ⭐ ARCHIVE IS SHOWN ONLY WHEN TRUE. A live card saying "not archived" is noise on every line, and
|
|
1133
|
-
* `spaceListResult` below already made this call for spaces.
|
|
1134
|
-
*/
|
|
1135
|
-
export function postSummaryResult(p, prefix = 'Post') {
|
|
1136
|
-
const stage = p.stageId ? ` | stage ${p.stageId}` : '';
|
|
1137
|
-
// The schedule is surfaced here because scheduling is now part of update_card rather than its own tool.
|
|
1138
|
-
// Without it a caller who just set a publish time gets no confirmation of what time was actually stored.
|
|
1139
|
-
const scheduled = p.scheduledAt ? ` | Scheduled: ${p.scheduledAt}` : '';
|
|
1140
|
-
const archived = p.isArchived ? ' | ARCHIVED' : '';
|
|
1141
|
-
return text(`${prefix}: ${p.title || '(untitled)'} (id ${p.id})${stage}${scheduled}${archived}`);
|
|
1142
|
-
}
|
|
1143
|
-
/** One card in full, with its posts and assets. */
|
|
1144
|
-
export function cardResult(p) {
|
|
1145
|
-
return text(lines([
|
|
1146
|
-
`${p.title || '(untitled)'} (id ${p.id}) | platform: ${p.platform ?? 'general'}`,
|
|
1147
|
-
p.stageId ? `stage: ${p.stageId}` : null,
|
|
1148
|
-
// Stated only when archived, and it says WHEN, because "archived" with no date is half a fact.
|
|
1149
|
-
p.archivedAt ? `archived: ${p.archivedAt}` : null,
|
|
1150
|
-
p.scheduledAt ? `scheduled: ${p.scheduledAt}` : null,
|
|
1151
|
-
p.publishedAt ? `published: ${p.publishedAt}` : null,
|
|
1152
|
-
p.publishUrl ? `publish url: ${p.publishUrl}` : null,
|
|
1153
|
-
p.notes ? `notes: ${p.notes}` : null,
|
|
1154
|
-
/**
|
|
1155
|
-
* ⭐⭐⭐ **PRINTED UNCONDITIONALLY, INCLUDING AT 0, BECAUSE IT IS AN INPUT TO THE NEXT CALL.**
|
|
1156
|
-
*
|
|
1157
|
-
* Every other line here is omitted when empty, which is right for a fact the agent merely reads.
|
|
1158
|
-
* This one is a TOKEN the agent has to hand back: `update_card` refuses a `notes` write that does
|
|
1159
|
-
* not carry the revision it read. Hiding it at 0 would make a never-edited card the single case
|
|
1160
|
-
* where the agent has nothing to send, and 0 is a real revision, not an absence.
|
|
1161
|
-
*/
|
|
1162
|
-
`revision: ${p.revision} (pass as expectedRevision to write notes)`,
|
|
1163
|
-
p.tags?.length ? `tags: ${p.tags.join(', ')}` : null,
|
|
1164
|
-
`posts (${p.posts.length}):`,
|
|
1165
|
-
...p.posts.map((d) => {
|
|
1166
|
-
const set = settingsKeys(d.platformSettings);
|
|
1167
|
-
return ` - ${d.platform} (id ${d.id})${d.format ? ` ${d.format}` : ''} | ${d.status ?? 'draft'}${d.connectedAccountId ? ` | account ${d.connectedAccountId}` : ' | no connected account'}${set ? ` | settings: ${set}` : ''}`;
|
|
1168
|
-
}),
|
|
1169
|
-
`assets (${p.assets.length}):`,
|
|
1170
|
-
...p.assets.map((a) => ` - [${a.assetType ?? '?'}] ${a.assetUrl ?? '(no url)'} (id ${a.id})`),
|
|
1171
|
-
]));
|
|
1172
|
-
}
|
|
1173
|
-
/** List of stages (the agent resolves a stage from here before placing a post). */
|
|
1174
|
-
/**
|
|
1175
|
-
* The account's spaces.
|
|
1176
|
-
*
|
|
1177
|
-
* The card count is stated on every row because "which board has work on it" is the question an agent
|
|
1178
|
-
* asks next, and making it call get_space per row to find out is the N+1 the API already avoids.
|
|
1179
|
-
*/
|
|
1180
|
-
export function spaceListResult(spaces) {
|
|
1181
|
-
if (!spaces.length)
|
|
1182
|
-
return text('No spaces found.');
|
|
1183
|
-
const rows = spaces.map((s) => {
|
|
1184
|
-
const bits = [`id ${s.id}`, `${s.postCount ?? 0} card(s)`];
|
|
1185
|
-
if (s.isFavorite)
|
|
1186
|
-
bits.push('favorite');
|
|
1187
|
-
if (s.archivedAt)
|
|
1188
|
-
bits.push('ARCHIVED');
|
|
1189
|
-
return `- ${s.name} (${bits.join(', ')})`;
|
|
1190
|
-
});
|
|
1191
|
-
return text([`${spaces.length} space(s):`, ...rows].join('\n'));
|
|
1192
|
-
}
|
|
1193
|
-
/** A deleted space. */
|
|
1194
|
-
export function spaceDeletedResult(id) {
|
|
1195
|
-
return text(`Deleted space ${id}. Its stages went with it; the space had to be empty of cards.`);
|
|
1196
|
-
}
|
|
1197
|
-
/** One space. */
|
|
1198
|
-
export function spaceResult(s) {
|
|
1199
|
-
const lines = [
|
|
1200
|
-
`${s.name} (id ${s.id})`,
|
|
1201
|
-
`Cards: ${s.postCount ?? 0}`,
|
|
1202
|
-
`Favorite: ${s.isFavorite ? 'yes' : 'no'}`,
|
|
1203
|
-
s.archivedAt ? `Archived: ${s.archivedAt}` : 'Archived: no',
|
|
1204
|
-
s.coverUrl ? `Cover: ${s.coverUrl}` : 'Cover: none',
|
|
1205
|
-
`Updated: ${s.updatedAt}`,
|
|
1206
|
-
];
|
|
1207
|
-
return text(lines.join('\n'));
|
|
1208
|
-
}
|
|
1209
|
-
export function stageListResult(result) {
|
|
1210
|
-
/**
|
|
1211
|
-
* ⭐⭐⭐ NAME THE SCOPE, EMPTY CASE INCLUDED, matching `cardListResult` word for word.
|
|
1212
|
-
*
|
|
1213
|
-
* Stages are per-space and this read falls back to the default space when none is named, so a list of
|
|
1214
|
-
* unfamiliar column names and "No stages found." are both answers about a board the caller may not have
|
|
1215
|
-
* meant. The old signature took a bare `Stage[]` and had nothing to say it with.
|
|
1216
|
-
*/
|
|
1217
|
-
const where = result.space ? ` in ${result.space.name}` : '';
|
|
1218
|
-
if (!result.stages.length)
|
|
1219
|
-
return text(`No stages found${where}.`);
|
|
1220
|
-
const rows = result.stages.map((s) => `- ${s.name} (id ${s.id}${s.slug ? `, slug ${s.slug}` : ''})`);
|
|
1221
|
-
return text([`${result.stages.length} stage(s)${where} (in order):`, ...rows].join('\n'));
|
|
1222
|
-
}
|
|
1223
|
-
/** One created or updated stage. */
|
|
1224
|
-
export function stageResult(s, respaced = false) {
|
|
1225
|
-
const lines = [
|
|
1226
|
-
`Stage ${s.name} (id ${s.id})`,
|
|
1227
|
-
s.slug ? `Slug: ${s.slug}` : null,
|
|
1228
|
-
s.color ? `Color: ${s.color}` : null,
|
|
1229
|
-
`Position: ${s.sortOrder}`,
|
|
1230
|
-
].filter(Boolean);
|
|
1231
|
-
/*
|
|
1232
|
-
⚠️ SAID OUT LOUD, because it is the one answer the caller cannot derive. A move with no room between
|
|
1233
|
-
two columns renumbers the WHOLE board, so every other stage the agent is holding is stale and a
|
|
1234
|
-
renumbered key looks like any other number.
|
|
1235
|
-
*/
|
|
1236
|
-
if (respaced) {
|
|
1237
|
-
lines.push('The whole board was renumbered to make room. Call list_stages again: every other stage position you are holding is now stale.');
|
|
1238
|
-
}
|
|
1239
|
-
return text(lines.join('\n'));
|
|
1240
|
-
}
|
|
1241
|
-
/** A deleted stage, and the board that is left. */
|
|
1242
|
-
export function stageDeletedResult(id, movedCards, stages) {
|
|
1243
|
-
const moved = movedCards > 0
|
|
1244
|
-
? `${movedCards} ${movedCards === 1 ? 'card' : 'cards'} moved to the target stage.`
|
|
1245
|
-
: 'It held no cards.';
|
|
1246
|
-
const rows = stages.map((s) => `- ${s.name} (id ${s.id})`);
|
|
1247
|
-
return text([`Deleted stage ${id}. ${moved}`, '', `${stages.length} stage(s) remaining:`, ...rows].join('\n'));
|
|
1248
|
-
}
|
|
1249
|
-
/** The non-empty keys of a post's platformSettings, for a compact summary. */
|
|
1250
|
-
function settingsKeys(settings) {
|
|
1251
|
-
if (!settings)
|
|
1252
|
-
return null;
|
|
1253
|
-
const keys = Object.keys(settings).filter((k) => {
|
|
1254
|
-
const v = settings[k];
|
|
1255
|
-
if (v == null)
|
|
1256
|
-
return false;
|
|
1257
|
-
if (Array.isArray(v))
|
|
1258
|
-
return v.length > 0;
|
|
1259
|
-
if (typeof v === 'string')
|
|
1260
|
-
return v.length > 0;
|
|
1261
|
-
return true;
|
|
1262
|
-
});
|
|
1263
|
-
return keys.length ? keys.join(', ') : null;
|
|
1264
|
-
}
|
|
1265
|
-
/** A created or updated post. */
|
|
1266
|
-
export function postResult(d) {
|
|
1267
|
-
const set = settingsKeys(d.platformSettings);
|
|
1268
|
-
return text(`Post: ${d.platform} (id ${d.id})${d.format ? ` ${d.format}` : ''} | ${d.status ?? 'draft'}${d.connectedAccountId ? ` | account ${d.connectedAccountId}` : ' | no connected account (set one before publishing)'}${set ? ` | settings: ${set}` : ' | no settings (set platformSettings to make it publishable)'}.`);
|
|
1269
|
-
}
|
|
1270
|
-
/** An attached asset. */
|
|
1271
|
-
export function assetResult(a) {
|
|
1272
|
-
return text(`Asset attached: [${a.assetType ?? '?'}] ${a.assetUrl ?? '(no url)'} (id ${a.id}).`);
|
|
1273
|
-
}
|
|
1274
|
-
/** A post's assets in their (new) order. */
|
|
1275
|
-
export function assetOrderResult(assets) {
|
|
1276
|
-
if (!assets.length)
|
|
1277
|
-
return text('No assets on this post.');
|
|
1278
|
-
return text([
|
|
1279
|
-
`Assets reordered (${assets.length}):`,
|
|
1280
|
-
...assets.map((a, i) => ` ${i + 1}. [${a.assetType ?? '?'}] ${a.assetUrl ?? '(no url)'} (id ${a.id})`),
|
|
1281
|
-
].join('\n'));
|
|
1282
|
-
}
|
|
1283
|
-
/** Confirmation of a detached asset. */
|
|
1284
|
-
export function assetRemovedResult(r) {
|
|
1285
|
-
return text(`Asset removed (id ${r.id}).`);
|
|
1286
|
-
}
|
|
1287
|
-
/** Confirmation of a detached post. */
|
|
1288
|
-
export function postRemovedResult(r) {
|
|
1289
|
-
return text(`Post removed (id ${r.id}).`);
|
|
1290
|
-
}
|
|
1291
|
-
/** The account's tags. */
|
|
1292
|
-
export function tagListResult(tags) {
|
|
1293
|
-
if (!tags.length)
|
|
1294
|
-
return text('No tags yet. Create one with create_tag.');
|
|
1295
|
-
return text([`${tags.length} tag(s):`, ...tags.map((t) => `- ${t.name} (id ${t.id})`)].join('\n'));
|
|
1296
|
-
}
|
|
1297
|
-
/** A created or renamed tag. */
|
|
1298
|
-
export function tagResult(t, verb = 'Tag') {
|
|
1299
|
-
return text(`${verb}: ${t.name} (id ${t.id}).`);
|
|
1300
|
-
}
|
|
1301
|
-
/** Confirmation of a deleted tag. */
|
|
1302
|
-
export function tagDeletedResult(r) {
|
|
1303
|
-
return text(`Tag deleted (id ${r.id}). It was removed from all posts.`);
|
|
1304
|
-
}
|
|
1305
|
-
/** The result of publishing a card's posts (one outcome per post). */
|
|
1306
|
-
export function publishResult(r) {
|
|
1307
|
-
if (!r.results.length) {
|
|
1308
|
-
return text('Nothing to publish: this card has no posts. Add one with update_card first.', true);
|
|
1309
|
-
}
|
|
1310
|
-
const rows = r.results.map((d) => d.success
|
|
1311
|
-
? `- ${d.platform}: published${d.url ? ` | ${d.url}` : ''}`
|
|
1312
|
-
: `- ${d.platform}: FAILED | ${d.error ?? 'unknown error'}`);
|
|
1313
|
-
const header = `Published ${r.publishedCount}/${r.results.length} post(s)${r.failedCount ? `, ${r.failedCount} failed` : ''}:`;
|
|
1314
|
-
return text([header, ...rows].join('\n'), r.publishedCount === 0);
|
|
1315
|
-
}
|
|
1316
|
-
// -- inspiration / research ---------------------------------------------------
|
|
1317
|
-
/** Compact integer formatting (1.2M, 45.3K) for engagement counts. */
|
|
1318
|
-
function compactNum(n) {
|
|
1319
|
-
if (n == null)
|
|
1320
|
-
return '?';
|
|
1321
|
-
if (n >= 1_000_000)
|
|
1322
|
-
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
1323
|
-
if (n >= 1_000)
|
|
1324
|
-
return `${(n / 1_000).toFixed(1)}K`;
|
|
1325
|
-
return String(n);
|
|
1326
|
-
}
|
|
1327
|
-
/** One line summarizing a tracked account. */
|
|
1328
|
-
function accountLine(a) {
|
|
1329
|
-
const handle = a.handle ? `@${a.handle}` : (a.name ?? '(unnamed)');
|
|
1330
|
-
// The kind is on every line because the list is MIXED by default: without it a reader cannot tell the
|
|
1331
|
-
// owner's own profile from a competitor they watch, and those mean opposite things.
|
|
1332
|
-
const kind = a.accountType === 'brand' ? ' [yours]' : a.accountType === 'inspiration' ? ' [watching]' : '';
|
|
1333
|
-
return `- ${handle} (id ${a.id})${kind} | ${a.platform ?? '?'} | ${compactNum(a.followerCount)} followers`;
|
|
1334
|
-
}
|
|
1335
|
-
/** List of tracked accounts, either kind. */
|
|
1336
|
-
export function trackedAccountListResult(accounts, noun = 'tracked account(s)') {
|
|
1337
|
-
if (!accounts.length)
|
|
1338
|
-
return text(`No ${noun} found. Add one in the ContentHero app first.`);
|
|
1339
|
-
return text([`${accounts.length} ${noun}:`, ...accounts.map(accountLine)].join('\n'));
|
|
1340
|
-
}
|
|
1341
|
-
/** One line summarizing an outlier / content item. */
|
|
1342
|
-
function outlierLine(o) {
|
|
1343
|
-
const score = o.outlierScore != null ? `${o.outlierScore.toFixed(1)}x` : 'n/a';
|
|
1344
|
-
const creator = o.sourceCreator || (o.accountHandle ? `@${o.accountHandle}` : '');
|
|
1345
|
-
// One list spans the owner's posts and the creators they watch, so each line has to say which it is.
|
|
1346
|
-
const own = o.isOwn ? ' [yours]' : '';
|
|
1347
|
-
return `- [${score}] ${o.title ?? '(untitled)'}${own}${creator ? ` | ${creator}` : ''} | ${compactNum(o.viewCount)} views (id ${o.id})`;
|
|
1348
|
-
}
|
|
1349
|
-
/** A page of outliers. */
|
|
1350
|
-
export function outlierListResult(result) {
|
|
1351
|
-
if (!result.outliers.length) {
|
|
1352
|
-
return text('No content found. Track some creators in the ContentHero app, or widen the filters.');
|
|
1353
|
-
}
|
|
1354
|
-
const more = result.hasMore ? ` (showing ${result.outliers.length} of ${result.total})` : '';
|
|
1355
|
-
return text([`${result.total} outlier(s) by score${more}:`, ...result.outliers.map(outlierLine)].join('\n'));
|
|
1356
|
-
}
|
|
1357
|
-
/**
|
|
1358
|
-
* One tracked account with its performance.
|
|
1359
|
-
*
|
|
1360
|
-
* Merged from two formatters that printed the same account from the same table and differed only in how
|
|
1361
|
-
* much they showed: the inspiration one omitted totals, averages and recent content for no reason other
|
|
1362
|
-
* than which function you happened to call.
|
|
1363
|
-
*/
|
|
1364
|
-
export function accountDetailResult(d) {
|
|
1365
|
-
const a = d.account;
|
|
1366
|
-
const handle = a.handle ? `@${a.handle}` : (a.name ?? '(unnamed)');
|
|
1367
|
-
const kind = a.accountType === 'brand' ? ' [yours]' : a.accountType === 'inspiration' ? ' [watching]' : '';
|
|
1368
|
-
const avgEng = d.averages.engagementRate != null ? `${(d.averages.engagementRate * 100).toFixed(1)}%` : 'n/a';
|
|
1369
|
-
const avgScore = d.averages.outlierScore != null ? `${d.averages.outlierScore.toFixed(2)}x` : 'n/a';
|
|
1370
|
-
return text(lines([
|
|
1371
|
-
`${handle} (id ${a.id})${kind} | ${a.platform ?? '?'} | ${compactNum(a.followerCount)} followers`,
|
|
1372
|
-
`content tracked: ${d.contentCount}`,
|
|
1373
|
-
`totals: ${compactNum(d.totals.views)} views, ${compactNum(d.totals.likes)} likes, ${compactNum(d.totals.comments)} comments`,
|
|
1374
|
-
`averages: ${compactNum(d.averages.views)} views/post, ${avgEng} engagement, ${avgScore} outlier score`,
|
|
1375
|
-
d.topContent.length ? 'top content by outlier score:' : 'top content: none yet',
|
|
1376
|
-
...d.topContent.map(outlierLine),
|
|
1377
|
-
d.recentContent.length ? 'most recent:' : null,
|
|
1378
|
-
...d.recentContent.map(outlierLine),
|
|
1379
|
-
]));
|
|
1380
|
-
}
|
|
1381
|
-
/** The transcript block, at whatever grain was asked for. */
|
|
1382
|
-
function transcriptLines(t) {
|
|
1383
|
-
// The status is printed even when there is text, because 'failed' and 'absent' are the difference between
|
|
1384
|
-
// "there is nothing to read" and "ask again later", and a reader cannot infer that from an empty body.
|
|
1385
|
-
const head = `transcript [${t.status}]${t.language ? ` (${t.language})` : ''}${t.windowed ? ' (windowed)' : ''}:`;
|
|
1386
|
-
if (t.segments) {
|
|
1387
|
-
if (!t.segments.length) {
|
|
1388
|
-
return [head, t.windowed ? ' (no segments in that window)' : ' (none stored)'];
|
|
1389
|
-
}
|
|
1390
|
-
return [head, ...t.segments.map((sg) => ` [${(sg.startMs / 1000).toFixed(1)}s] ${sg.text}`)];
|
|
1391
|
-
}
|
|
1392
|
-
return [head, t.text ? t.text : ' (none stored)'];
|
|
1393
|
-
}
|
|
1394
|
-
/** One tracked post in full, with its transcript when it was asked for. */
|
|
1395
|
-
export function inspirationContentResult(c) {
|
|
1396
|
-
const stats = `${compactNum(c.viewCount)} views, ${compactNum(c.likeCount)} likes, ${compactNum(c.commentCount)} comments`;
|
|
1397
|
-
const score = c.outlierScore != null ? `${c.outlierScore.toFixed(1)}x outlier` : null;
|
|
1398
|
-
return text(lines([
|
|
1399
|
-
`${c.title ?? '(untitled)'} (id ${c.id})${c.isOwn ? ' [yours]' : ''}`,
|
|
1400
|
-
`${c.platform ?? '?'} ${c.contentType ?? ''} | ${c.sourceCreator ?? c.accountHandle ?? ''}`.trim(),
|
|
1401
|
-
`${stats}${score ? ` | ${score}` : ''}`,
|
|
1402
|
-
c.url ? `url: ${c.url}` : null,
|
|
1403
|
-
c.publishedAt ? `published: ${c.publishedAt}` : null,
|
|
1404
|
-
c.hashtags.length ? `hashtags: ${c.hashtags.join(' ')}` : null,
|
|
1405
|
-
c.description ? `description: ${c.description}` : null,
|
|
1406
|
-
...(c.transcript ? transcriptLines(c.transcript) : []),
|
|
1407
|
-
]));
|
|
1408
|
-
}
|
|
1409
|
-
/** One line summarizing a connected account (a publish target). */
|
|
1410
|
-
function connectedAccountLine(a) {
|
|
1411
|
-
const handle = a.accountHandle ? `@${a.accountHandle}` : (a.accountName ?? '(unnamed)');
|
|
1412
|
-
const status = a.connectionStatus ? ` | ${a.connectionStatus}` : '';
|
|
1413
|
-
return `- ${handle} (id ${a.id}) | ${a.platform ?? '?'}${a.isDefault ? ' [default]' : ''}${status}`;
|
|
1414
|
-
}
|
|
1415
|
-
/** List of connected accounts (publish targets). */
|
|
1416
|
-
export function connectedAccountListResult(accounts) {
|
|
1417
|
-
if (!accounts.length) {
|
|
1418
|
-
return text('No connected accounts. Connect a social account in the ContentHero app to publish.');
|
|
1419
|
-
}
|
|
1420
|
-
return text([`${accounts.length} connected account(s):`, ...accounts.map(connectedAccountLine)].join('\n'));
|
|
1421
|
-
}
|
|
1422
|
-
/** One connected account in detail, including its capabilities. */
|
|
1423
|
-
export function connectedAccountResult(a) {
|
|
1424
|
-
const handle = a.accountHandle ? `@${a.accountHandle}` : (a.accountName ?? '(unnamed)');
|
|
1425
|
-
const caps = a.capabilities ? Object.keys(a.capabilities).filter((k) => a.capabilities[k]) : [];
|
|
1426
|
-
return text(lines([
|
|
1427
|
-
`${handle} (id ${a.id}) | ${a.platform ?? '?'}${a.isDefault ? ' [default]' : ''}`,
|
|
1428
|
-
`status: ${a.connectionStatus ?? 'unknown'}${a.connectionType ? ` (${a.connectionType})` : ''}`,
|
|
1429
|
-
a.accountUrl ? `url: ${a.accountUrl}` : null,
|
|
1430
|
-
caps.length ? `capabilities: ${caps.join(', ')}` : null,
|
|
1431
|
-
a.lastValidatedAt ? `last validated: ${a.lastValidatedAt}` : null,
|
|
1432
|
-
`Use this id as connectedAccountId on a post in update_card to publish here.`,
|
|
1433
|
-
]));
|
|
1434
|
-
}
|
|
1435
|
-
/** Map any thrown error onto a readable isError result. */
|
|
1436
|
-
export function errorResult(err) {
|
|
1437
|
-
if (err instanceof InsufficientCreditsError) {
|
|
1438
|
-
const parts = [];
|
|
1439
|
-
if (err.required != null)
|
|
1440
|
-
parts.push(`need ${err.required}`);
|
|
1441
|
-
if (err.balance != null)
|
|
1442
|
-
parts.push(`have ${err.balance}`);
|
|
1443
|
-
const detail = parts.length ? ` (${parts.join(', ')})` : '';
|
|
1444
|
-
return text(`Insufficient credits${detail}. Top up to continue.`, true);
|
|
1445
|
-
}
|
|
1446
|
-
if (err instanceof RateLimitError) {
|
|
1447
|
-
const wait = err.retryAfter != null ? ` Retry in ${err.retryAfter}s.` : '';
|
|
1448
|
-
return text(`Rate limit exceeded.${wait || ' Wait a moment before retrying.'}`, true);
|
|
1449
|
-
}
|
|
1450
|
-
if (err instanceof ContentHeroError || err instanceof Error) {
|
|
1451
|
-
return text(err.message, true);
|
|
1452
|
-
}
|
|
1453
|
-
return text('Unknown error', true);
|
|
1454
|
-
}
|
|
1455
|
-
// -- editor / canvas ops ------------------------------------------------------
|
|
1456
|
-
/** The outcome of an applyEditorOps batch: the new revision + a per-op summary. */
|
|
1457
|
-
export function editorOpsResult(r) {
|
|
1458
|
-
const okCount = r.results.filter((x) => x.ok).length;
|
|
1459
|
-
const failures = r.results.filter((x) => !x.ok);
|
|
1460
|
-
const created = r.results.flatMap((x) => x.createdIds ?? []);
|
|
1461
|
-
const lines = [
|
|
1462
|
-
// The surface no longer names the ops: it says `editor` or `canvas`, and "editor op(s)" reads worse than
|
|
1463
|
-
// saying nothing, since the caller already knows which tool they invoked.
|
|
1464
|
-
`Applied ${okCount}/${r.results.length} op(s). New revision: ${r.revision}.`,
|
|
1465
|
-
];
|
|
1466
|
-
if (created.length)
|
|
1467
|
-
lines.push(`Created: ${created.join(', ')}.`);
|
|
1468
|
-
// Async effect ops (remove_background) dispatch a job and return its outputId; surface it so the agent can poll.
|
|
1469
|
-
const generating = r.results.map((x) => x.generatingOutputId).filter((id) => !!id);
|
|
1470
|
-
if (generating.length) {
|
|
1471
|
-
lines.push(`Dispatched ${generating.length} async job(s); get_generation_status on: ${generating.join(', ')}.`);
|
|
1472
|
-
}
|
|
1473
|
-
if (r.renderUrl)
|
|
1474
|
-
lines.push(`Preview: ${r.renderUrl}`);
|
|
1475
|
-
if (failures.length) {
|
|
1476
|
-
lines.push('Failed ops:');
|
|
1477
|
-
for (const f of failures)
|
|
1478
|
-
lines.push(` - ${f.op}: ${f.error ?? 'unknown error'}`);
|
|
1479
|
-
}
|
|
1480
|
-
const warnings = r.results.flatMap((x) => x.warnings ?? []);
|
|
1481
|
-
if (warnings.length)
|
|
1482
|
-
lines.push(`Warnings: ${warnings.join('; ')}.`);
|
|
1483
|
-
// A partial failure is surfaced as an error result so the caller (agent) can self-correct.
|
|
1484
|
-
return text(lines.join('\n'), failures.length > 0);
|
|
1485
|
-
}
|
|
1486
|
-
/**
|
|
1487
|
-
* EXPOSURE GUARD for ProjectDetail.
|
|
1488
|
-
*
|
|
1489
|
-
* The MCP answers in TEXT, so a field this formatter does not print is INVISIBLE to the calling agent even
|
|
1490
|
-
* though the SDK fetched it. That makes silent drift the default: the app can add a field, the SDK type can
|
|
1491
|
-
* carry it, every build and test stays green, and no agent can ever see it. `compositionSpace` sat in
|
|
1492
|
-
* exactly that state, and the cost was an agent sizing every layer 2.26x wrong with no error.
|
|
1493
|
-
*
|
|
1494
|
-
* `satisfies Record<keyof ProjectDetail, ...>` makes the omission a DECISION rather than an accident: add a
|
|
1495
|
-
* field to ProjectDetail and this stops compiling until someone classifies it. Omitting is fine; omitting
|
|
1496
|
-
* silently is not.
|
|
1497
|
-
*/
|
|
1498
|
-
const PROJECT_DETAIL_EXPOSURE = {
|
|
1499
|
-
// Rendered in the summary line or the JSON body below.
|
|
1500
|
-
id: 'rendered',
|
|
1501
|
-
title: 'rendered',
|
|
1502
|
-
kind: 'rendered',
|
|
1503
|
-
// Added when `surface` joined ProjectDetail (8aecfd0). It went unnoticed because `dist/` is gitignored
|
|
1504
|
-
// and this file typechecks against the BUILT SDK, so a stale dist hid the missing key until the next
|
|
1505
|
-
// rebuild. Same value as `kind`, which is the name it used to have.
|
|
1506
|
-
surface: 'rendered',
|
|
1507
|
-
orientation: 'rendered',
|
|
1508
|
-
width: 'rendered',
|
|
1509
|
-
height: 'rendered',
|
|
1510
|
-
revision: 'rendered',
|
|
1511
|
-
compositionSpace: 'rendered',
|
|
1512
|
-
groups: 'rendered',
|
|
1513
|
-
state: 'rendered',
|
|
1514
|
-
renderUrl: 'rendered (opt-in)',
|
|
1515
|
-
brandKitId: 'rendered',
|
|
1516
|
-
// Deliberately omitted, with the reason. Each of these is reachable through a dedicated tool, or is
|
|
1517
|
-
// list-view metadata that tells a single-project reader nothing it did not already know by fetching it.
|
|
1518
|
-
assetReferences: 'omitted: large payload; the composition state already names what is in use',
|
|
1519
|
-
thumbnailUrl: 'omitted: presentation metadata, not an editing input',
|
|
1520
|
-
isArchived: 'omitted: lifecycle state, surfaced by list_projects',
|
|
1521
|
-
isFavorited: 'omitted: lifecycle state, surfaced by list_projects',
|
|
1522
|
-
archivedAt: 'omitted: lifecycle state, surfaced by list_projects',
|
|
1523
|
-
favoritedAt: 'omitted: lifecycle state, surfaced by list_projects',
|
|
1524
|
-
createdAt: 'omitted: list metadata',
|
|
1525
|
-
updatedAt: 'omitted: superseded by revision, which is the token that actually matters here',
|
|
1526
|
-
exportedCardId: 'omitted: publishing workflow, owned by the card tools',
|
|
1527
|
-
exportedUrl: 'omitted: publishing workflow, owned by the post tools',
|
|
1528
|
-
shareId: 'omitted: sharing workflow, no editing effect',
|
|
1529
|
-
};
|
|
1530
|
-
void PROJECT_DETAIL_EXPOSURE;
|
|
1531
|
-
/** A single project's full detail (read-before-write): metadata, surface, revision, and the state JSON. */
|
|
1532
|
-
export function projectDetailResult(p) {
|
|
1533
|
-
return text(`Project ${p.id}: "${p.title}" (${p.kind}, ${p.orientation} ${p.width}x${p.height}), revision ${p.revision}.\n` +
|
|
1534
|
-
`Pass this revision back as expectedRevision when you edit.\n` +
|
|
1535
|
-
// The output resolution above is NOT the coordinate space layer geometry uses. Stating both, adjacent
|
|
1536
|
-
// and labeled, is the point: an agent that read only "2168x1152" sized every layer 2.26x too large
|
|
1537
|
-
// and got no error for it, because an oversized box is valid input.
|
|
1538
|
-
(p.compositionSpace
|
|
1539
|
-
? `Layer geometry is in composition space ${p.compositionSpace.width}x${p.compositionSpace.height} ` +
|
|
1540
|
-
`(center-relative px), NOT the ${p.width}x${p.height} output resolution. ` +
|
|
1541
|
-
`Use ${p.compositionSpace.width}x${p.compositionSpace.height} as layerWidth/layerHeight for a full-frame layer.\n`
|
|
1542
|
-
: '') +
|
|
1543
|
-
(p.renderUrl ? `Preview: ${p.renderUrl}\n` : '') +
|
|
1544
|
-
// An agent asked to keep a design on-brand otherwise has no way to know WHICH kit this project is
|
|
1545
|
-
// linked to: it can list kits, but not resolve the association.
|
|
1546
|
-
(p.brandKitId ? `Brand kit: ${p.brandKitId} (read it with get_brand_kit).\n` : '') +
|
|
1547
|
-
(p.groups?.length
|
|
1548
|
-
? `Groups: ${p.groups
|
|
1549
|
-
.map((g) => `${g.name || `Group ${g.ordinal ?? '?'}`} [${g.id}] (${g.memberClipIds.length} clips)`)
|
|
1550
|
-
.join('; ')}\n` +
|
|
1551
|
-
` Rename with update_group; bulk-edit a whole group with update_clips { groupId }.\n`
|
|
1552
|
-
: '') +
|
|
1553
|
-
`\n` +
|
|
1554
|
-
JSON.stringify(p.state, null, 2));
|
|
1555
|
-
}
|
|
1556
|
-
/**
|
|
1557
|
-
* Live context (get_context). Returns a text summary + the discriminated context JSON, plus IMAGE content
|
|
1558
|
-
* block(s) so the calling model can actually SEE: the viewport `snapshot` (capture) when the user's screen was
|
|
1559
|
-
* requested, and/or the inline composed-output render (`context.rendered.dataUrl`) when `render` was requested.
|
|
1560
|
-
* The heavy render data URL is stripped from the JSON text (it rides only as the image block).
|
|
1561
|
-
*/
|
|
1562
|
-
export function liveContextResult(result, snapshot) {
|
|
1563
|
-
const { context, participant, participants } = result;
|
|
1564
|
-
if (!context || !participant) {
|
|
1565
|
-
return text('No live context: no one is currently viewing this in the open app (no session within the presence window). ' +
|
|
1566
|
-
'The user may not have the editor/studio/content open right now.');
|
|
1567
|
-
}
|
|
1568
|
-
// Pull the inline render out as image block(s). A still carries `rendered.dataUrl` (one image); a filmstrip /
|
|
1569
|
-
// clip carries `rendered.frames[].dataUrl` (many). Keep the light `rendered` metadata in the JSON but drop the
|
|
1570
|
-
// bulky dataUrl(s) so the text summary stays readable.
|
|
1571
|
-
const rendered = (context.rendered ?? null);
|
|
1572
|
-
const renderImages = [];
|
|
1573
|
-
let contextForJson = context;
|
|
1574
|
-
if (rendered) {
|
|
1575
|
-
const still = parseDataUrl(rendered.dataUrl);
|
|
1576
|
-
const frames = Array.isArray(rendered.frames) ? rendered.frames : null;
|
|
1577
|
-
if (still)
|
|
1578
|
-
renderImages.push(still);
|
|
1579
|
-
if (frames) {
|
|
1580
|
-
for (const f of frames) {
|
|
1581
|
-
const img = parseDataUrl(f.dataUrl);
|
|
1582
|
-
if (img)
|
|
1583
|
-
renderImages.push(img);
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
if (renderImages.length > 0) {
|
|
1587
|
-
// Strip the base64 payloads from the JSON but keep the frame timing (frame / atSec).
|
|
1588
|
-
const strippedFrames = frames
|
|
1589
|
-
? frames.map((f) => {
|
|
1590
|
-
const { dataUrl: _drop, ...rest } = f;
|
|
1591
|
-
return rest;
|
|
1592
|
-
})
|
|
1593
|
-
: undefined;
|
|
1594
|
-
contextForJson = {
|
|
1595
|
-
...context,
|
|
1596
|
-
rendered: {
|
|
1597
|
-
...rendered,
|
|
1598
|
-
...(still ? { dataUrl: '[attached as an image below]' } : {}),
|
|
1599
|
-
...(strippedFrames ? { frames: strippedFrames } : {}),
|
|
1600
|
-
},
|
|
1601
|
-
};
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
const others = participants.length > 1 ? ` (${participants.length} live participants; showing the most recent)` : '';
|
|
1605
|
-
const renderNote = renderImages.length === 1
|
|
1606
|
-
? 'A render of your work is attached below.\n'
|
|
1607
|
-
: renderImages.length > 1
|
|
1608
|
-
? `${renderImages.length} rendered frames are attached below, in order.\n`
|
|
1609
|
-
: '';
|
|
1610
|
-
const summary = `Live context on the ${String(context.surface)} surface${others}, updated ${participant.updatedAt}.\n` +
|
|
1611
|
-
(snapshot ? 'An image of what the user is looking at (their screen) is attached below.\n' : '') +
|
|
1612
|
-
renderNote +
|
|
1613
|
-
`\n` +
|
|
1614
|
-
JSON.stringify(contextForJson, null, 2);
|
|
1615
|
-
const content = [{ type: 'text', text: summary }];
|
|
1616
|
-
if (snapshot)
|
|
1617
|
-
content.push({ type: 'image', data: snapshot.data, mimeType: snapshot.mimeType });
|
|
1618
|
-
for (const img of renderImages)
|
|
1619
|
-
content.push({ type: 'image', data: img.data, mimeType: img.mimeType });
|
|
1620
|
-
return { content };
|
|
1621
|
-
}
|
|
1622
|
-
/** Parse a `data:<mime>;base64,<data>` URL into an image block's parts. Returns null on any non-data-URL. */
|
|
1623
|
-
function parseDataUrl(dataUrl) {
|
|
1624
|
-
if (typeof dataUrl !== 'string')
|
|
1625
|
-
return null;
|
|
1626
|
-
const m = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl);
|
|
1627
|
-
const mimeType = m?.[1];
|
|
1628
|
-
const data = m?.[2];
|
|
1629
|
-
if (!mimeType || !data)
|
|
1630
|
-
return null;
|
|
1631
|
-
return { mimeType, data };
|
|
1632
|
-
}
|
|
1633
|
-
/** The project list: one line per project (id, kind, title, state flags). */
|
|
1634
|
-
export function projectListResult(projects) {
|
|
1635
|
-
if (projects.length === 0)
|
|
1636
|
-
return text('No projects found.');
|
|
1637
|
-
const lines = projects.map((p) => {
|
|
1638
|
-
const flags = [p.isArchived ? 'archived' : null, p.isFavorited ? 'favorited' : null].filter(Boolean).join(', ');
|
|
1639
|
-
return `- ${p.id} [${p.kind}] "${p.title}" ${p.orientation}${flags ? ` (${flags})` : ''}`;
|
|
1640
|
-
});
|
|
1641
|
-
return text(`${projects.length} project(s):\n${lines.join('\n')}`);
|
|
1642
|
-
}
|
|
1643
|
-
/** A freshly created project: the id + kind to start editing against. */
|
|
1644
|
-
export function projectCreatedResult(p) {
|
|
1645
|
-
return text(`Created ${p.kind} project ${p.id}: "${p.title}" (${p.orientation} ${p.width}x${p.height}), revision ${p.revision}.\n` +
|
|
1646
|
-
// The TOOL is still called update_timeline; `kind` is what says which one applies.
|
|
1647
|
-
`Use this id with update_${p.kind === 'canvas' ? 'canvas' : 'timeline'} to add content.`);
|
|
1648
|
-
}
|
|
1649
|
-
/** Confirmation of a permanent delete. */
|
|
1650
|
-
export function projectDeletedResult(projectId) {
|
|
1651
|
-
return text(`Permanently deleted project ${projectId}. This cannot be undone.`);
|
|
1652
|
-
}
|
|
1653
|
-
/** The canvas layer-type catalog (types + editable props) as readable text + the JSON. */
|
|
1654
|
-
export function layerTypesResult(cat) {
|
|
1655
|
-
const lines = cat.layerTypes.map((t) => `- ${t.type}: ${t.description} (props: ${t.props.map((p) => p.name).join(', ')}; supports: ${t.supports.join(', ')})`);
|
|
1656
|
-
const ops = cat.ops ? cat.ops.ops.map((o) => `- ${o.shape} ${o.description}`) : [];
|
|
1657
|
-
return text(`Canvas layer types (edit via update_canvas ops):\n${lines.join('\n')}\n\n` +
|
|
1658
|
-
(ops.length ? `update_canvas ops (${cat.ops.description}):\n${ops.join('\n')}\n\n` : '') +
|
|
1659
|
-
`Shared prop groups: ${Object.keys(cat.sharedProps).join(', ')}.\n\n` +
|
|
1660
|
-
JSON.stringify(cat, null, 2));
|
|
1661
|
-
}
|
|
1662
|
-
/** A completed export -> the download URL; an in-flight one -> the exportId to poll. */
|
|
1663
|
-
/**
|
|
1664
|
-
* Which formats the widget can actually draw.
|
|
1665
|
-
*
|
|
1666
|
-
* ⚠️ `pdf` and `pptx` have no element, and a multi-slide `png`/`jpg` export comes back as a ZIP rather than
|
|
1667
|
-
* an image. Rendering a tile for any of them would show a broken picture where the text already gives a
|
|
1668
|
-
* working download link, so they stay text.
|
|
1669
|
-
*/
|
|
1670
|
-
const EXPORT_MEDIUM = { mp4: 'video', png: 'image', jpg: 'image' };
|
|
1671
|
-
/**
|
|
1672
|
-
* An export, DISPLAYED. Separate from {@link exportJobResult} by name, not by a flag.
|
|
1673
|
-
*
|
|
1674
|
-
* ## ⛔⛔ ONLY THE TOOL THAT STARTED THE EXPORT KNOWS ITS FORMAT
|
|
1675
|
-
*
|
|
1676
|
-
* `get_export` polls by exportId alone, so it cannot know whether the file is an mp4 or a pptx and can
|
|
1677
|
-
* never render one. Leaving both paths inside one builder made the completeness guard read `get_export` as
|
|
1678
|
-
* a tool that emits a widget, which it does not, and an invariant that has to be argued with is not one.
|
|
1679
|
-
* Two names, each true on its own.
|
|
1680
|
-
*/
|
|
1681
|
-
export function completedExportResult(job, format, baseUrl = DEFAULT_APP_URL) {
|
|
1682
|
-
const prose = `Export ${job.exportId} completed.\nDownload: ${job.outputUrl}`;
|
|
1683
|
-
const medium = EXPORT_MEDIUM[format];
|
|
1684
|
-
if (job.status !== 'completed' || !job.outputUrl || !medium)
|
|
1685
|
-
return exportJobResult(job);
|
|
1686
|
-
{
|
|
1687
|
-
/**
|
|
1688
|
-
* ## ⛔⛔ AN EXPORT RENDERS, AND IT IS NOT REFERENCEABLE
|
|
1689
|
-
*
|
|
1690
|
-
* Someone waited for a render, so they should see it. But an `exportId` is not an `outputId`: no
|
|
1691
|
-
* generate tool resolves one, so Animate, Edit and Recreate would emit messages the agent cannot act
|
|
1692
|
-
* on. Omitting `reference` is what hides them, and Download, the verb that actually applies to a
|
|
1693
|
-
* rendered file, stays.
|
|
1694
|
-
*
|
|
1695
|
-
* ⚠️ NO `openUrl` EITHER, and not because it is hard to compute. An export's home is a download; the
|
|
1696
|
-
* project it came from is a different destination with a different meaning, and `/editor/{id}` versus
|
|
1697
|
-
* `/canvas/{id}` is not knowable from here anyway.
|
|
1698
|
-
*/
|
|
1699
|
-
return {
|
|
1700
|
-
content: [{ type: 'text', text: prose }],
|
|
1701
|
-
structuredContent: mediaWidgetData({
|
|
1702
|
-
contentType: medium,
|
|
1703
|
-
items: [{ url: job.outputUrl, name: job.exportId, contentType: medium }],
|
|
1704
|
-
}),
|
|
1705
|
-
_meta: { [RESOURCE_URI_META_KEY]: GENERATION_WIDGET_URI, ui: { resourceUri: GENERATION_WIDGET_URI } },
|
|
1706
|
-
};
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
/** An export, REPORTED. No widget: a poll does not know the format, so it cannot draw the file. */
|
|
1710
|
-
export function exportJobResult(job) {
|
|
1711
|
-
if (job.status === 'completed') {
|
|
1712
|
-
return text(`Export ${job.exportId} completed.\nDownload: ${job.outputUrl}`);
|
|
1713
|
-
}
|
|
1714
|
-
if (job.status === 'failed') {
|
|
1715
|
-
return text(`Export ${job.exportId} failed: ${job.errorMessage ?? 'unknown error'}.`, true);
|
|
1716
|
-
}
|
|
1717
|
-
const pct = typeof job.progress === 'number' ? ` (${Math.round(job.progress * 100)}%)` : '';
|
|
1718
|
-
return text(`Export ${job.exportId} is ${job.status}${pct}. Still rendering. Poll get_export with this exportId for the download URL.`);
|
|
1719
|
-
}
|
|
1720
|
-
/** The export-format catalog as readable text + JSON. */
|
|
1721
|
-
export function exportFormatsResult(cat) {
|
|
1722
|
-
const lines = cat.formats.map((f) => `- ${f.format} (${f.surfaces.join('/')}${f.async ? ', async' : ''}): ${f.description}`);
|
|
1723
|
-
return text(`Export formats:\n${lines.join('\n')}\n\nResolutions (mp4): ${cat.resolutions.join(', ')}. Qualities: ${cat.qualities.join(', ')}.\n\n` +
|
|
1724
|
-
JSON.stringify(cat, null, 2));
|
|
1725
|
-
}
|
|
1726
|
-
/** The editor timeline clip + track-type catalog as readable text + the JSON. */
|
|
1727
|
-
export function editorTranscriptResult(r) {
|
|
1728
|
-
if (!r.mediaTranscribed) {
|
|
1729
|
-
return text(r.note ?? 'No transcript available for this project yet.');
|
|
1730
|
-
}
|
|
1731
|
-
// A readable, clip-by-clip transcript in timeline order: each line is one clip, marked [disabled] when it is
|
|
1732
|
-
// cut (excluded from the render) so the agent sees what is already removed. In word mode each line also
|
|
1733
|
-
// summarizes its word / silence / event counts. The full structured data (clipIds, timeline frames, word
|
|
1734
|
-
// timing, silences, audio events) follows as JSON for exact targeting via update_timeline.
|
|
1735
|
-
const lines = r.segments.map((s) => {
|
|
1736
|
-
const tag = s.disabled ? `[disabled${s.disabledReason ? `:${s.disabledReason}` : ''}]` : '[enabled]';
|
|
1737
|
-
const body = s.text ? s.text : '(no speech)';
|
|
1738
|
-
const extra = s.words || s.silences || s.audioEvents
|
|
1739
|
-
? ` {${s.words?.length ?? 0} words, ${s.silences?.length ?? 0} silences, ${s.audioEvents?.length ?? 0} events}`
|
|
1740
|
-
: '';
|
|
1741
|
-
return `${tag} ${s.clipId} ${s.sourceStartMs}-${s.sourceEndMs}ms (frames ${s.fromFrame}-${s.fromFrame + s.durationFrames}):${extra} ${body}`;
|
|
1742
|
-
});
|
|
1743
|
-
const speakerLine = r.speakers && r.speakers.length > 0 ? `Speakers: ${r.speakers.join(', ')}.\n` : '';
|
|
1744
|
-
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` +
|
|
1745
|
-
`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` +
|
|
1746
|
-
speakerLine +
|
|
1747
|
-
`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` +
|
|
1748
|
-
`${lines.join('\n')}\n\n` +
|
|
1749
|
-
JSON.stringify(r, null, 2));
|
|
1750
|
-
}
|
|
1751
|
-
export function timelineTypesResult(cat) {
|
|
1752
|
-
const clips = cat.clipTypes.map((t) => `- ${t.type}: ${t.description} (props: ${t.props.map((p) => p.name).join(', ')})`);
|
|
1753
|
-
const tracks = cat.trackTypes.map((t) => `- ${t.trackType}: holds ${t.holds.join(', ')}`);
|
|
1754
|
-
const editOps = cat.editOps ? cat.editOps.ops.map((o) => `- ${o.shape} ${o.description}`) : [];
|
|
1755
|
-
return text(`Editor timeline clip types (edit via update_timeline ops):\n${clips.join('\n')}\n\nTrack types:\n${tracks.join('\n')}\n\n` +
|
|
1756
|
-
(editOps.length ? `update_timeline edit ops (${cat.editOps.description}):\n${editOps.join('\n')}\n\n` : '') +
|
|
1757
|
-
`Shared prop groups: ${Object.keys(cat.sharedProps).join(', ')}.\n\n` +
|
|
1758
|
-
JSON.stringify(cat, null, 2));
|
|
1759
|
-
}
|
|
1760
|
-
//# sourceMappingURL=format.js.map
|