hermoso 0.1.270 → 0.1.272
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/mcp/client.mjs +37 -2
- package/mcp/tool-hints.mjs +43 -0
- package/mcp/tools.mjs +56 -16
- package/package.json +1 -1
package/mcp/client.mjs
CHANGED
|
@@ -107,7 +107,7 @@ async function unwrap(res) {
|
|
|
107
107
|
// ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
|
|
108
108
|
// throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
|
|
109
109
|
// `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
|
|
110
|
-
throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}) });
|
|
110
|
+
throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}) }); // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
|
|
111
111
|
}
|
|
112
112
|
return body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body;
|
|
113
113
|
}
|
|
@@ -316,12 +316,47 @@ export async function connectedProviders() {
|
|
|
316
316
|
}
|
|
317
317
|
// Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
|
|
318
318
|
// the server reads the raw body. Lets an agent post ARBITRARY user files (not just Hermoso renders).
|
|
319
|
+
// A FILE BIGGER THAN ONE REQUEST GOES UP IN PARTS (2026-09-22). The hosted server sits behind Google's front door,
|
|
320
|
+
// which refuses any request body over 32 MiB before Hermoso sees it (measured: 30MB reached the app, 40MB was a
|
|
321
|
+
// 413). So above UPLOAD_SINGLE_MAX the bytes go through an upload ticket in parts, each a PUT with ?offset=&total=,
|
|
322
|
+
// and the last part answers the same {url, kind, bytes} a single POST does. The ticket carries the credential, so
|
|
323
|
+
// the part PUTs need no auth header. A 409 answers with `received`, which is where the next part starts.
|
|
324
|
+
const UPLOAD_SINGLE_MAX = 24 * 1024 * 1024, UPLOAD_PART = 8 * 1024 * 1024;
|
|
319
325
|
export async function apiUpload(p, buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
|
|
326
|
+
if (buf && buf.length > UPLOAD_SINGLE_MAX && p === '/api/upload') return apiUploadInParts(buf, { contentType, fileName });
|
|
320
327
|
const h = headers({ 'Content-Type': contentType });
|
|
321
328
|
if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
|
|
322
329
|
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: h, body: buf });
|
|
323
330
|
return unwrap(res);
|
|
324
331
|
}
|
|
332
|
+
export async function apiUploadInParts(buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
|
|
333
|
+
const t = await apiPost('/api/upload/ticket', {});
|
|
334
|
+
if (!t || !t.uploadUrl) throw new Error('Hermoso did not hand back an upload link — try again.');
|
|
335
|
+
let path0; try { path0 = new URL(t.uploadUrl).pathname; } catch { path0 = String(t.uploadUrl); }
|
|
336
|
+
const part = Math.min(UPLOAD_PART, Number(t.partMaxBytes) || UPLOAD_PART);
|
|
337
|
+
const total = buf.length; let offset = 0, last = null;
|
|
338
|
+
while (offset < total) {
|
|
339
|
+
const end = Math.min(total, offset + part);
|
|
340
|
+
const h = { 'Content-Type': contentType }; if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
|
|
341
|
+
let res, body, tries = 0;
|
|
342
|
+
for (;;) {
|
|
343
|
+
// A part IS safe to resend, unlike the writes fetchWrite refuses to retry: the server acknowledges a part it
|
|
344
|
+
// already holds (`duplicate`) and never appends it twice, so a transport drop here is retried.
|
|
345
|
+
try { res = await fetchWrite(`${API_BASE}${path0}?offset=${offset}&total=${total}`, { method: 'PUT', headers: h, body: buf.subarray(offset, end) }); body = await res.json().catch(() => ({})); if (res.status < 500) break; }
|
|
346
|
+
catch (e) { if (!e?._transport || ++tries >= 4) throw e; await new Promise((r) => setTimeout(r, 800 * tries)); continue; }
|
|
347
|
+
if (++tries >= 4) break;
|
|
348
|
+
await new Promise((r) => setTimeout(r, 800 * tries));
|
|
349
|
+
}
|
|
350
|
+
if (res.status === 409 && Number.isFinite(body.received)) { offset = body.received; continue; }
|
|
351
|
+
if (!res.ok) throw Object.assign(new Error((body && body.error) || `HTTP ${res.status}`), { status: res.status, _viaApi: true });
|
|
352
|
+
// A server that predates parts ingests the first part AS the file and answers a url with no `received` — never
|
|
353
|
+
// report that truncated file as the upload.
|
|
354
|
+
if (!Number.isFinite(body.received)) throw new Error('This Hermoso server does not take uploads in parts yet, so a file this large cannot be sent to it — pass a public `url` instead.');
|
|
355
|
+
last = body; offset = body.received;
|
|
356
|
+
}
|
|
357
|
+
if (!last || !last.url) throw new Error('The upload finished without a file URL — try again.');
|
|
358
|
+
return last;
|
|
359
|
+
}
|
|
325
360
|
// Ingest by URL: the SERVER fetches the bytes (SSRF-guarded on every redirect hop) so nothing has to cross this
|
|
326
361
|
// transport. Deliberately no body — /api/upload treats "a body AND a url" as an error rather than picking one.
|
|
327
362
|
export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
|
|
@@ -387,7 +422,7 @@ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 100
|
|
|
387
422
|
// make_template_ad refusals (a 400 the server had classed as the caller's config) sat on the admin board as
|
|
388
423
|
// "could not tell". `_viaApi` is the marker that says the server has seen it. It deliberately carries NO `jobId`:
|
|
389
424
|
// the tool layer reads a jobId on an error as "timed out, still rendering", which a failed job is not.
|
|
390
|
-
if (job.status === 'error') throw Object.assign(new Error(job.error || 'Render failed'), { _viaApi: true });
|
|
425
|
+
if (job.status === 'error') throw Object.assign(new Error(job.error || 'Render failed'), { _viaApi: true, ...(job.errorMeta?.videoChoice && typeof job.errorMeta.videoChoice === 'object' ? { videoChoice: job.errorMeta.videoChoice, status: 402 } : {}) }); // a queued video the balance could not cover refuses at the reserve with its options (errorMeta.videoChoice) — carry them so wrap() spells the choice
|
|
391
426
|
if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `hermoso jobs get ' + id + '`'), { jobId: id });
|
|
392
427
|
// NEVER SLEEP PAST THE DEADLINE. A 3s interval made every wait 3s-granular: a caller who asked for 1s was held 3s,
|
|
393
428
|
// and one who asked for 29s was held 30s, which is the whole 30-second step budget the ask exists to stay inside.
|
package/mcp/tool-hints.mjs
CHANGED
|
@@ -55,3 +55,46 @@ export function withHints(result, hints) {
|
|
|
55
55
|
|
|
56
56
|
/** Read them back — the shape a check and a client both use, so neither has to know the key. */
|
|
57
57
|
export const hintsOf = (result) => (result && result._meta && Array.isArray(result._meta[HINTS_KEY])) ? result._meta[HINTS_KEY] : [];
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
// ── A VIDEO THE CALLER EXPECTS AND CANNOT AFFORD IS A CHOICE, NOT A SWAP (2026-09-22, Dave) ─────────────────────
|
|
61
|
+
// The server refuses BEFORE planning or reserving — nothing billed — and the refusal carries `videoChoice`
|
|
62
|
+
// (server.js videoChoiceFor): the video's price against the balance, the image alternative priced, a top-up, and,
|
|
63
|
+
// only when one fits the balance together with the plan, a light draft. The text spells the same three options so
|
|
64
|
+
// a model can act on them; the hints are those options keyed as {do, why}, so an agent can branch instead of parsing.
|
|
65
|
+
// Which call makes the image depends on the tool that refused: plan_ad plans again with format image, render_ad goes
|
|
66
|
+
// back to plan_ad for an image plan, generate_video becomes generate_image. Pure, no imports — the twin rule above.
|
|
67
|
+
export function videoChoiceImageCall(tool) {
|
|
68
|
+
const t = String(tool || '');
|
|
69
|
+
if (t === 'generate_video') return 'generate_image({prompt: the same prompt})';
|
|
70
|
+
if (t === 'render_ad') return "plan_ad({…the same brief, format: 'image'}) then generate_image with its image_concept.prompt";
|
|
71
|
+
if (t === 'clone_video') return "plan_ad({reference: the same link, format: 'image'}) then generate_image";
|
|
72
|
+
return `${t || 'the same call'}({…the same arguments, format: 'image'})`;
|
|
73
|
+
}
|
|
74
|
+
export function videoChoiceDraftCall(tool, d) {
|
|
75
|
+
const t = String(tool || '');
|
|
76
|
+
const m = String(d?.model || ''), s = Math.round(Number(d?.durationSeconds) || 0);
|
|
77
|
+
if (t === 'plan_ad' || t === 'clone_video') return `${t}({…the same arguments, format: 'video', draft: {model: '${m}', durationSeconds: ${s}}})`;
|
|
78
|
+
return `${t || 'the same call'}({…the same arguments, model: '${m}', durationSeconds: ${s}})`;
|
|
79
|
+
}
|
|
80
|
+
export function videoChoiceHints(tool, choice) {
|
|
81
|
+
const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
|
|
82
|
+
const out = [
|
|
83
|
+
{ do: videoChoiceImageCall(tool), why: `the image version is ~${o.image?.credits ?? '?'} credits against a balance of ${c.balance ?? '?'}; the video needs ~${c.videoCredits ?? '?'}` },
|
|
84
|
+
{ do: 'buy_credits({})', why: `${c.short ?? '?'} credits short of the video; a pack or a plan covers it, then the same call plans the video` },
|
|
85
|
+
];
|
|
86
|
+
if (o.draft && o.draft.model) out.push({ do: videoChoiceDraftCall(tool, o.draft), why: `a light draft on ${o.draft.label || o.draft.model} (${o.draft.durationSeconds}s) is ~${(Number(o.draft.credits) || 0) + (Number(o.draft.planCredits) || 0)} credits and fits the balance; render the premium version after topping up` });
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
export function videoChoiceText(tool, choice) {
|
|
90
|
+
const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
|
|
91
|
+
const d = o.draft && o.draft.model ? o.draft : null;
|
|
92
|
+
const lines = [
|
|
93
|
+
`${c.seconds ? `A ${c.seconds}s video` : 'This video'} would cost about ${c.videoCredits ?? '?'} credits and the account has ${c.balance ?? '?'} (${c.short ?? '?'} short). Nothing was planned, rendered or charged. Tell the user and let them choose — never switch the format for them:`,
|
|
94
|
+
` 1. Make it as an image instead (~${o.image?.credits ?? '?'} credits): ${videoChoiceImageCall(tool)}.`,
|
|
95
|
+
` 2. Add credits: buy_credits({}) quotes a pack on a saved card or returns a checkout link${o.topup?.url ? ` (or ${o.topup.url})` : ''}; then repeat the same call and the video goes ahead as asked.`,
|
|
96
|
+
];
|
|
97
|
+
if (d) lines.push(` 3. Render the video anyway as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits): ${videoChoiceDraftCall(tool, d)}. Premium models once they top up.`);
|
|
98
|
+
else lines.push(` (No light-model draft fits this balance, so there is no "render anyway" option here.)`);
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { toolHeldBackByConnectors, toolProvider, toolUnoffered, metaAlternativeN
|
|
|
21
21
|
import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
|
|
22
22
|
import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
|
|
23
23
|
// THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
|
|
24
|
-
import { withHints } from './tool-hints.mjs';
|
|
24
|
+
import { withHints, videoChoiceText, videoChoiceHints } from './tool-hints.mjs';
|
|
25
25
|
|
|
26
26
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
27
27
|
// /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
|
|
@@ -65,6 +65,7 @@ const creatorLine = (c) => {
|
|
|
65
65
|
const bits = [c.source === 'generated' ? 'AI creator' : c.source === 'social' ? 'from a social profile' : c.source === 'upload' ? 'uploaded photo' : c.source];
|
|
66
66
|
if (c.source !== 'generated') bits.push(c.consented ? 'likeness consented' : 'NO likeness consent on file');
|
|
67
67
|
if (c.poses) bits.push(`${c.poses} pose plate${c.poses === 1 ? '' : 's'}`);
|
|
68
|
+
if (c.lowQualityRef && !c.refAccepted) bits.push('PHOTO TOO UNCLEAR TO CAST — not cast by default; replace it with save_creator (same name, a better photo url) or accept it with save_creator(name, useAnyway: true)');
|
|
68
69
|
if (c.voiceClone) bits.push('cloned voice');
|
|
69
70
|
else if (c.voice) bits.push(`voice ${c.voice}`);
|
|
70
71
|
return ` • ${c.name} — ${bits.join(', ')}${c.id ? ` [${c.id}]` : ''}\n ${c.image || '(portrait stored in-app as an uploaded photo — cast them by NAME; there is no url to hand a render tool)'}`;
|
|
@@ -233,7 +234,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
233
234
|
// ACT-DO-NOT-SURVEY last and the 2,048-byte cut landed inside it — 'DO NOT SURVE' — so the rule that exists to
|
|
234
235
|
// stop an agent answering a render request with a model catalog was itself the thing truncated. Whatever gets
|
|
235
236
|
// clipped from the tail must cost tool NAMES, which the roster still carries, never a rule, which it does not.
|
|
236
|
-
'Hermoso is
|
|
237
|
+
'Hermoso is marketing on autopilot, driven over MCP. FIVE INDEPENDENT AREAS — none is a step in a pipeline and no tool needs you to have used another one first:',
|
|
237
238
|
'ACT ON THE REQUEST, DO NOT SURVEY IT: asked to make something, make it. render_ad, generate_image and generate_video all run with `model` omitted and go to a sound default. hermoso_capabilities (free) is for a specific model id, an exact credit cost or a live duration — never the answer to a request to create something.',
|
|
238
239
|
'• RESEARCH the ads already winning: find_competitors, competitor_teardown, pull_competitor_ads, research_ads, search_meta_ads, search_google_ads, search_linkedin_ads, search_tiktok, search_instagram, search_youtube, search_reddit, search_threads, mine_angles, analyze_video, check_ad_policy.',
|
|
239
240
|
'• CREATE finished on-brand ads: render_ad, generate_image, generate_video, generate_avatar, make_template_ad, make_thumbnail, make_explainer, plan_ad, plan_variations; get_brand / draft_brand / update_brand; list_creators / save_creator; edit_video, dub_video, clip_video, reframe_video, upscale_video, stitch_video.',
|
|
@@ -437,12 +438,19 @@ const wrap = (fn) => {
|
|
|
437
438
|
// BOTH phrasings. The gates say "You're out of credits" while the reserve path says "Not enough credits";
|
|
438
439
|
// matching only the latter meant research, X posting and the competitor watch hit a 402 and told the agent
|
|
439
440
|
// nothing about how to fix it, so the top-up path this whole flow depends on was unreachable from those tools.
|
|
440
|
-
|
|
441
|
+
// A VIDEO THE CALLER EXPECTED AND CANNOT AFFORD IS A CHOICE (2026-09-22): the server refused before planning or
|
|
442
|
+
// reserving and sent the options (image priced, top-up, a light draft that fits) — spell them out, never a silent
|
|
443
|
+
// format swap and never just "top up". Read from the STRUCTURED field, exactly like the connector marker below.
|
|
444
|
+
if (e?.videoChoice && typeof e.videoChoice === 'object') { msg = 'Error: ' + videoChoiceText(_tool, e.videoChoice); _hints.push(...videoChoiceHints(_tool, e.videoChoice)); }
|
|
445
|
+
else if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
|
|
441
446
|
// connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
|
|
442
447
|
// in-agent) — Dave 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
|
|
443
448
|
else {
|
|
444
449
|
msg += notConnectedHint(e, msg);
|
|
445
450
|
// Read from the STRUCTURED signal, exactly as the sentence above is — never from the prose.
|
|
451
|
+
// META'S SECURITY HOLD (code 31/3858385): the server's sentence already carries the steps; this names the move
|
|
452
|
+
// so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
|
|
453
|
+
if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security hold: as the Facebook profile that connected Hermoso, turn on two-factor authentication, then in Ads Manager open Billing and payments and click Start authentication (or facebook.com/accountquality if there is no button), then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it' });
|
|
446
454
|
if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
|
|
447
455
|
}
|
|
448
456
|
// ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
|
|
@@ -454,7 +462,7 @@ const wrap = (fn) => {
|
|
|
454
462
|
// `_meta` is MCP's own sanctioned extension point (spec: any result MAY carry it), so every MCP client
|
|
455
463
|
// sees an ordinary error result and ignores a key it does not recognise. `publishWrap` spreads the result,
|
|
456
464
|
// so its ambiguous-publish advice keeps the marker rather than dropping it.
|
|
457
|
-
return withHints({ content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '' } } }, _hints);
|
|
465
|
+
return withHints({ content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '', ...(e?.videoChoice?.options && typeof e.videoChoice.options === 'object' ? { options: e.videoChoice.options } : {}) } } }, _hints); // `options` = the video choice's three ways forward, structured, so /v1 answers its 402 with them
|
|
458
466
|
}
|
|
459
467
|
};
|
|
460
468
|
return outer;
|
|
@@ -4433,7 +4441,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4433
4441
|
dataUri: z.string().optional().describe('base64 data: URI of the file bytes (data:<mime>;base64,<…>) — bytes travel over this connection, so keep it small'),
|
|
4434
4442
|
name: z.string().optional().describe('original file name — helps pick the right extension'),
|
|
4435
4443
|
},
|
|
4436
|
-
outputSchema: { url: z.string().optional(), kind: z.string().optional(), bytes: z.number().optional(), uploadUrl: z.string().optional().describe('the one-time PUT url, when getUploadUrl was asked for'), expiresAt: z.string().optional(), maxBytes: z.number().optional() },
|
|
4444
|
+
outputSchema: { url: z.string().optional(), kind: z.string().optional(), bytes: z.number().optional(), uploadUrl: z.string().optional().describe('the one-time PUT url, when getUploadUrl was asked for'), expiresAt: z.string().optional(), maxBytes: z.number().optional(), partMaxBytes: z.number().optional().describe('the largest single PUT the link takes; a bigger file goes in parts with ?offset=&total= (see the howto)') },
|
|
4437
4445
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
4438
4446
|
}, wrap(async (a) => {
|
|
4439
4447
|
// THE TICKET BRANCH RUNS FIRST AND ALONE: it is a request for a url, not an upload, so a source passed beside
|
|
@@ -4442,7 +4450,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4442
4450
|
const conflict = ['url', 'path', 'dataUri'].filter(k => String(a[k] || '').trim());
|
|
4443
4451
|
if (conflict.length) throw new Error(`\`getUploadUrl\` asks for a link to send bytes to; \`${conflict.join('` and `')}\` is a file to upload right now. Do one or the other.`);
|
|
4444
4452
|
const t = await apiPost('/api/upload/ticket', {});
|
|
4445
|
-
return ok(`PUT the file's raw bytes to this url and it answers with the durable Hermoso url:\n\n${t.uploadUrl}\n\n${t.howto}`, { uploadUrl: t.uploadUrl, expiresAt: t.expiresAt, maxBytes: t.maxBytes });
|
|
4453
|
+
return ok(`PUT the file's raw bytes to this url and it answers with the durable Hermoso url:\n\n${t.uploadUrl}\n\n${t.howto}`, { uploadUrl: t.uploadUrl, expiresAt: t.expiresAt, maxBytes: t.maxBytes, ...(t.partMaxBytes ? { partMaxBytes: t.partMaxBytes } : {}) });
|
|
4446
4454
|
}
|
|
4447
4455
|
// EXACTLY ONE SOURCE. Two is an ERROR: a caller who passes both has two different files in mind, and quietly
|
|
4448
4456
|
// preferring one of them ingests the wrong file and reports success.
|
|
@@ -16748,10 +16756,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16748
16756
|
recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
|
|
16749
16757
|
reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post, YouTube Short/video or a direct video file — which is WATCHED first (frames + voiceover/on-screen-text transcript) so the concept keeps its hook, structure and pacing. To remake one video for this brand at its own length, clone_video is the direct tool'),
|
|
16750
16758
|
language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
|
|
16759
|
+
draft: z.object({ model: z.string(), durationSeconds: z.number() }).optional().describe("ONLY after a video refusal that offered a light draft: the {model, durationSeconds} it named. The plan is then authored to that length and priced on that model. Never invent one — a video the account cannot cover is refused BEFORE planning with the three options (image / add credits / this draft when one fits), and the user chooses."),
|
|
16751
16760
|
},
|
|
16752
16761
|
outputSchema: {
|
|
16753
16762
|
format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
|
|
16754
16763
|
concept: z.string().optional().describe('the one-line creative concept'),
|
|
16764
|
+
format_note: z.string().optional().describe("the plan's own read-back when it chose an IMAGE on an open format because the balance could not cover the default video — states the video's real credit price"),
|
|
16765
|
+
budget_pick: z.any().optional().describe('{format, reason, videoCredits, balance, seconds} when format_note is set'),
|
|
16766
|
+
concept_count: z.number().optional().describe('how many distinct concepts the brief asked for; the plan builds out the first and sets variants to it'),
|
|
16755
16767
|
recipe: z.string().optional().describe('the resolved recipe id'),
|
|
16756
16768
|
recipe_label: z.string().optional().describe('the resolved recipe display name'),
|
|
16757
16769
|
copy: z.array(z.any()).optional().describe('copy variants ({headline, primary, cta})'),
|
|
@@ -16763,7 +16775,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16763
16775
|
brand: z.any().optional().describe('the brand grounding embedded in the creative (name, logo, palette, productImages)'),
|
|
16764
16776
|
},
|
|
16765
16777
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
16766
|
-
}, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting }) => {
|
|
16778
|
+
}, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft }) => {
|
|
16767
16779
|
// LENGTH SOVEREIGNTY over MCP (found live 2026-07-31: a 40-second brief came back as render_plan.duration_seconds
|
|
16768
16780
|
// 15, structure single_clip, scenes summing to 15 — the 40 was silently dropped because this tool declared no
|
|
16769
16781
|
// duration at all). /api/create has honored `durationSeconds` all along (it becomes the planner's "Target video
|
|
@@ -16782,7 +16794,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16782
16794
|
const _n = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
16783
16795
|
try { const cur = await apiGet('/api/brand/current'); if (cur?.hasBrand && cur.brand && _n(cur.brand.name) && _n(cur.brand.name) === _n(brand)) brandObj = cur.brand; } catch {}
|
|
16784
16796
|
}
|
|
16785
|
-
const
|
|
16797
|
+
const _draft = (draft && typeof draft === 'object' && String(draft.model || '').trim()) ? { model: String(draft.model).trim(), durationSeconds: Math.round(+draft.durationSeconds || 0) } : null; // the accepted light draft rides as itself; its length is the plan's length
|
|
16798
|
+
const d = await apiPost('/api/create', { brand: brandObj, product, format: _draft ? 'video' : format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '', ...(_draft ? { draft: _draft, durationSeconds: _draft.durationSeconds || _len || undefined } : (_len ? { durationSeconds: _len } : {})), hook: hook || '', setting: setting || '', userAsk: String(product || '') });
|
|
16786
16799
|
const c = d.creative || d;
|
|
16787
16800
|
// EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
|
|
16788
16801
|
// grounded on the account's SAVED brand — the video shipped with the WRONG brand's packshots and end lockup).
|
|
@@ -16807,7 +16820,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16807
16820
|
+ (_askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, which is outside the supported 4–180s range, so it was clamped to ${_len}s` : '')
|
|
16808
16821
|
+ (_len && _planned && Math.abs(_planned - _len) > 1 ? ` — ⚠ this does NOT match the ${_len}s you asked for; tell the user before rendering, or re-plan` : '');
|
|
16809
16822
|
}
|
|
16810
|
-
const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${refWatchedLine(c.reference_watched)}${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
|
|
16823
|
+
const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${c.format_note ? '\n' + c.format_note : ''}${c.concept_count > 1 ? `\nYou asked for ${c.concept_count} concepts: the concept line names each; this plan builds out the first (variants = ${c.variants || c.concept_count}). Call plan_ad again with a different angle for the others, or render_ad with variants for takes of this one.` : ''}${refWatchedLine(c.reference_watched)}${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
|
|
16811
16824
|
return ok(text, c);
|
|
16812
16825
|
}));
|
|
16813
16826
|
|
|
@@ -17102,7 +17115,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17102
17115
|
description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), an optional brand end card (only when the user asks), licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
|
|
17103
17116
|
inputSchema: {
|
|
17104
17117
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
17105
|
-
creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if
|
|
17118
|
+
creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default on any plan with a person on camera, and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if they are a REAL person with no likeness consent on file.'),
|
|
17106
17119
|
model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
|
|
17107
17120
|
durationSeconds: z.number().optional().describe('total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. A length that fits ONE clip of the render model renders as one continuous pass; longer is stitched from acts filled to that model’s clip maximum with the remainder last — the maximum is 15s on most models and 30s on the longest-clip one, so use dryRun:true to see the exact act split for free before spending.'),
|
|
17108
17121
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
@@ -17146,12 +17159,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17146
17159
|
const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
|
|
17147
17160
|
if (_len) a = { ...a, durationSeconds: _len };
|
|
17148
17161
|
const _clampNote = (_askedLen && _askedLen !== _len) ? `\n(${_askedLen}s is outside the supported 4–180s range — rendered at ${_len}s.)` : '';
|
|
17149
|
-
const { input, jobType, notes, needsProductPhoto, creator } = await apiPost('/api/render/assemble', a); // a passes wholesale — creator/resolution/captions/endCard/music/lockup/ttsVoice ride the body
|
|
17162
|
+
const { input, jobType, notes, needsProductPhoto, creator, ownRefNotice } = await apiPost('/api/render/assemble', a); // a passes wholesale — creator/resolution/captions/endCard/music/lockup/ttsVoice ride the body
|
|
17150
17163
|
// THE CAST IS THE READ-BACK, NEVER THE ASK. `creator` is the row the SERVER resolved out of this workspace's own
|
|
17151
17164
|
// roster; a half-remembered name that matched nobody, matched two people, or belongs to an unconsented real
|
|
17152
17165
|
// person never reaches here at all (the assemble route refuses, free, before a job exists). So this line names
|
|
17153
17166
|
// who is actually in the ad, and it names them from the resolution — the same law the ads tree follows.
|
|
17154
|
-
const _castLine = creator ? `\nCast: ${creator.name} (${creator.id}) — ${creator.source === 'generated' ? 'AI creator' : creator.source === 'social' ? 'from a social profile' : 'uploaded photo'}${creator.source !== 'generated' ? (creator.consented ? ', likeness consent on file' : '') : ''}.` : '';
|
|
17167
|
+
const _castLine = (creator ? `\nCast: ${creator.name} (${creator.id}) — ${creator.source === 'generated' ? 'AI creator' : creator.source === 'social' ? 'from a social profile' : 'uploaded photo'}${creator.source !== 'generated' ? (creator.consented ? ', likeness consent on file' : '') : ''}.` : '') + (ownRefNotice ? `\n⚠ ${ownRefNotice.text} ${ownRefNotice.fix}` : ''); // a creator's own photo too poor to cast is never cast silently (lowQualityRef) — say so and name the fix
|
|
17155
17168
|
// LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
|
|
17156
17169
|
// (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
|
|
17157
17170
|
// acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
|
|
@@ -18001,17 +18014,37 @@ function memoryNoteVerdict(text) {
|
|
|
18001
18014
|
description: 'Add a portrait to this workspace’s reusable CAST so the SAME person can star in future ads — the headless twin of the app’s + > Pick a creator > save. Pass the portrait’s public url (a generate_image render of a person, a headshot, any public photo) plus a name to call them by; from then on list_creators returns them and their url can be re-passed to generate_avatar / generate_video / recast_motion. Saving is FREE and renders nothing. LIKENESS — `source` says what the portrait IS: leave it "generated" for an AI-made person, and use "upload"/"social" ONLY for a REAL person. Pass consented:true only when the user has told you that person agreed to their likeness being used; never assert that on their behalf.',
|
|
18002
18015
|
inputSchema: {
|
|
18003
18016
|
name: z.string().describe('what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it'),
|
|
18004
|
-
image: z.string().describe('public https url of the portrait (an existing render’s url, or any public photo). Not a local file path — upload it with upload_file first and save the url that returns'),
|
|
18017
|
+
image: z.string().optional().describe('REQUIRED except with useAnyway. public https url of the portrait (an existing render’s url, or any public photo). Not a local file path — upload it with upload_file first and save the url that returns'),
|
|
18005
18018
|
source: z.enum(['generated', 'upload', 'social']).optional().describe('"generated" (default) = an AI-made person; "upload" / "social" = a REAL person'),
|
|
18006
18019
|
consented: z.boolean().optional().describe('REAL people only: the user has confirmed that person consented to their likeness being used in ads'),
|
|
18007
18020
|
voice: z.string().optional().describe('a default voice name for this persona (engines + voices are in hermoso_capabilities)'),
|
|
18008
18021
|
poses: z.array(z.string()).optional().describe('up to 4 extra full-body / angle plates of the SAME person (public urls) — they make a wider shot hold the identity'),
|
|
18009
18022
|
look: z.string().optional().describe('their canonical wardrobe/appearance in words — reused to hold the look steady across ads'),
|
|
18023
|
+
useAnyway: z.boolean().optional().describe('only for a creator whose saved photo was flagged too unclear to cast (render_ad says so): true casts the current photo as it is, no new image needed'),
|
|
18010
18024
|
},
|
|
18011
|
-
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), creator: z.any().optional() },
|
|
18025
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), creator: z.any().optional(), refQuality: z.any().optional() },
|
|
18012
18026
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18013
18027
|
}, wrap(async (a) => {
|
|
18014
18028
|
const name = String(a.name || '').trim(), image = String(a.image || '').trim();
|
|
18029
|
+
// A PHOTO FLAGGED TOO UNCLEAR TO CAST (lowQualityRef, 2026-09-22) is REPLACED in place by a new portrait under the
|
|
18030
|
+
// same name — scored by the same rule the onboarding used — or accepted as it is with useAnyway. A twin row would
|
|
18031
|
+
// make the creator's own cast ambiguous (two rows named as the brand → nobody is cast).
|
|
18032
|
+
if (name) {
|
|
18033
|
+
let _l = await readStore('heist.avatars.v1'); if (!Array.isArray(_l)) _l = [];
|
|
18034
|
+
const low = _l.find(x => x && x.lowQualityRef && !x.refAccepted && String(x.name || '').trim().toLowerCase() === name.toLowerCase());
|
|
18035
|
+
if (low && a.useAnyway === true && !image) {
|
|
18036
|
+
low.refAccepted = true; await writeStore('heist.avatars.v1', _l);
|
|
18037
|
+
return ok(`“${low.name}” will be cast with the current photo as it is.`, { ok: true, id: low.id, creator: { id: low.id, name: low.name, image: abs(low.image), source: low.source } });
|
|
18038
|
+
}
|
|
18039
|
+
if (low && image) {
|
|
18040
|
+
if (!/^https?:\/\//i.test(image) && !image.startsWith('/generated/')) return { content: [{ type: 'text', text: 'The new photo must be a public https url — upload the file with upload_file first, then pass the url it returns.' }], isError: true };
|
|
18041
|
+
const r = await apiPost('/api/creator/ref', { image, name: low.name });
|
|
18042
|
+
low.image = r?.image || image; low.refQuality = r?.refQuality || null; low.lowQualityRef = !!r?.lowQualityRef; delete low.refAccepted; low.poses = [];
|
|
18043
|
+
await writeStore('heist.avatars.v1', _l);
|
|
18044
|
+
const q = r?.refQuality?.score != null ? ` (photo quality ${r.refQuality.score}/100)` : '';
|
|
18045
|
+
return ok(r?.lowQualityRef ? `Replaced “${low.name}”’s photo${q}, but this one is still too unclear to cast well — try a sharper, front-facing, well-lit photo, or save_creator(name: "${low.name}", useAnyway: true).` : `Replaced “${low.name}”’s photo${q} — renders now cast them from it.`, { ok: true, id: low.id, creator: { id: low.id, name: low.name, image: abs(low.image), source: low.source }, refQuality: r?.refQuality || null });
|
|
18046
|
+
}
|
|
18047
|
+
}
|
|
18015
18048
|
if (!name || !image) return { content: [{ type: 'text', text: 'A creator needs both a name and a portrait url.' }], isError: true };
|
|
18016
18049
|
// The portrait must be FETCHABLE by every render lane that will consume it. A data: blob or a local path is a
|
|
18017
18050
|
// reference nothing downstream can resolve, so refuse here with the fix rather than saving a dead entry that
|
|
@@ -19281,13 +19314,20 @@ function memoryNoteVerdict(text) {
|
|
|
19281
19314
|
hasBrand: z.boolean().optional().describe('whether a brand is saved for this workspace'),
|
|
19282
19315
|
brand: z.any().optional().describe('the saved brand profile (name, domain, category, products, palette, …) or null'),
|
|
19283
19316
|
memoryCount: z.number().optional().describe('how many learned memory notes the workspace holds'),
|
|
19317
|
+
persona: z.string().nullable().optional().describe("who the user said they are at onboarding: creator | business | agency | marketer | explorer, or null when they never said. The same resolver the web Studio steers by."),
|
|
19318
|
+
personaNote: z.string().optional().describe('how to work for that kind of user (a creator wants organic content for their own feed, not ads by default; an explorer came to play with the models and needs no brand). Empty when persona is null.'),
|
|
19284
19319
|
},
|
|
19285
19320
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
19286
19321
|
}, wrap(async () => {
|
|
19287
19322
|
const d = await apiGet('/api/brand/current');
|
|
19323
|
+
// WHO IS ASKING (2026-09-22): a creator's saved "brand" is their personal brand and an explorer has none on purpose,
|
|
19324
|
+
// so the text says so before it says what the create tools do with the profile — the same note the web Studio reads.
|
|
19325
|
+
const who = d?.personaNote ? `\n${d.personaNote}` : '';
|
|
19288
19326
|
const text = d?.hasBrand
|
|
19289
|
-
? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand
|
|
19290
|
-
:
|
|
19327
|
+
? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.${who}`
|
|
19328
|
+
: (d?.persona === 'explorer'
|
|
19329
|
+
? `No saved brand for this workspace, and that is the user's choice: they picked "Just exploring". Make what they ask with no brand at all; a brand is optional (draft_brand, or the web Studio) and only worth mentioning if THEY ask for something about their own business.${who}`
|
|
19330
|
+
: `No saved brand for this workspace yet — onboard one with draft_brand (it saves automatically), or the user can onboard in the web Studio. Not a precondition: every tool works from what the user tells you.${who}`);
|
|
19291
19331
|
return ok(text, d);
|
|
19292
19332
|
}));
|
|
19293
19333
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.272",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 856 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|