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 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.
@@ -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 an AI ad studio you drive over MCP. FIVE INDEPENDENT AREAS — none is a step in a pipeline and no tool needs you to have used another one first:',
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
- 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
+ // 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 d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '', ...(_len ? { durationSeconds: _len } : {}), hook: hook || '', setting: setting || '', userAsk: String(product || '') });
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 the plan has nobody on camera, or if they are a REAL person with no likeness consent on file.'),
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
- : 'No saved brand for this workspace yet — onboard one with draft_brand (it saves automatically), or the user can onboard in the web Studio.';
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.270",
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",