hermoso 0.1.382 → 0.1.385

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/README.md CHANGED
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **897 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **901 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
@@ -231,7 +231,7 @@ block entirely if you signed in above; it is there for CI, where the process can
231
231
 
232
232
  Then ask your agent: *“Generate an image ad with Hermoso.”*
233
233
 
234
- ### What the 897 tools cover
234
+ ### What the 901 tools cover
235
235
 
236
236
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
237
237
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/bin/hermoso.mjs CHANGED
@@ -43,15 +43,26 @@ async function saveConfig(c) {
43
43
  }
44
44
 
45
45
  // ---- minimal arg parser: positionals + --flags (--flag value | --flag=value | boolean --flag) ----
46
+ // `--json` MEANS TWO THINGS, AND GIVEN BOTH WAYS THE PAYLOAD MUST SURVIVE (2026-10-06). On `call`, `--json '{…}'` is the
47
+ // arguments; everywhere, a bare `--json` asks for machine output. `call make_static_ads --json '{"dryRun":true}' --json`
48
+ // used to keep only the LAST one — `true` — so the arguments were dropped in silence and the tool ran with none: a
49
+ // paid tool queued four renders nobody asked for, dryRun and all. A repeated --json now keeps the object as the
50
+ // arguments and records the bare one as the output request (`__jsonOut`), whichever order they came in.
46
51
  function parse(argv) {
47
52
  const pos = [], flags = {};
53
+ const set = (k, v) => {
54
+ if (k === 'json' && Object.prototype.hasOwnProperty.call(flags, 'json') && (flags.json === true) !== (v === true)) {
55
+ flags.json = v === true ? flags.json : v; flags.__jsonOut = true; return;
56
+ }
57
+ flags[k] = v;
58
+ };
48
59
  for (let i = 0; i < argv.length; i++) {
49
60
  const a = argv[i];
50
61
  if (a.startsWith('--')) {
51
62
  const eq = a.indexOf('=');
52
- if (eq >= 0) { flags[a.slice(2, eq)] = a.slice(eq + 1); }
53
- else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { flags[a.slice(2)] = argv[++i]; }
54
- else { flags[a.slice(2)] = true; }
63
+ if (eq >= 0) { set(a.slice(2, eq), a.slice(eq + 1)); }
64
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { set(a.slice(2), argv[++i]); }
65
+ else { set(a.slice(2), true); }
55
66
  } else pos.push(a);
56
67
  }
57
68
  return { pos, flags };
@@ -296,7 +307,15 @@ add --json to any command for machine output.`);
296
307
  // `--raw` is a real property on generate_image / generate_text / generate_video, and reserving it would make three
297
308
  // tools uncallable from the shorthand. A tool that ever needs a `--json` or `--args` argument can still pass it
298
309
  // inside --args '{"json":…}'.
299
- const CALL_FLAGS = new Set(['json', 'args', 'args-file', 'structured']);
310
+ const CALL_FLAGS = new Set(['json', 'args', 'args-file', 'structured', '__jsonOut']); // __jsonOut: a second, bare --json beside a --json payload (see parse)
311
+
312
+ // How long `call` waits for a tool, in ms: 20 minutes unless HERMOSO_CALL_TIMEOUT_MS names a positive number. Pure, so
313
+ // tools/cli-call-timeout-check.mjs lifts and runs it; a garbled value falls back rather than becoming "no wait".
314
+ const CLI_CALL_TIMEOUT_MS = 20 * 60 * 1000;
315
+ function cliCallTimeoutMs(env) {
316
+ const n = Number(env);
317
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : CLI_CALL_TIMEOUT_MS;
318
+ }
300
319
 
301
320
  async function readArgsPayload(flags) {
302
321
  let src = null;
@@ -376,9 +395,16 @@ async function runTool(reg, name, flags, extraPos = [], preset = null) {
376
395
  if (extraPos.length) bad.push(`unexpected value${extraPos.length > 1 ? 's' : ''} ${extraPos.map((v) => JSON.stringify(v)).join(', ')} . Every argument is a --flag or goes inside --json`);
377
396
  if (bad.length) return die(`${bad.join('\n ')}\n Run: hermoso tools ${name}`);
378
397
 
379
- const res = await client.callTool({ name, arguments: args });
398
+ // THE SDK'S 60-SECOND DEFAULT IS NOT OUR TIMEOUT (2026-10-06). client.callTool() with no options gives up after
399
+ // DEFAULT_REQUEST_TIMEOUT_MSEC (60s) and throws "MCP error -32001: Request timed out" — while the tool, running in
400
+ // this same process, carries on, finishes and BILLS. Measured: `hermoso call generate_image` on the default model
401
+ // took ~100s, printed the timeout and exited, and both images were then sitting in the Library, paid for. The
402
+ // registry is in-process, so the only clock that should bound a call is the tool's own (render tools wait up to
403
+ // their job timeout and then hand back a job id). HERMOSO_CALL_TIMEOUT_MS overrides; it is not a --flag because
404
+ // every --flag here is a tool argument.
405
+ const res = await client.callTool({ name, arguments: args }, undefined, { timeout: cliCallTimeoutMs(process.env.HERMOSO_CALL_TIMEOUT_MS) });
380
406
  const text = (res.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
381
- if (flags.json === true) console.log(JSON.stringify(res, null, 2));
407
+ if (flags.json === true || flags.__jsonOut === true) console.log(JSON.stringify(res, null, 2));
382
408
  else if (flags.structured) console.log(JSON.stringify(res.structuredContent ?? null, null, 2));
383
409
  else if (text) console.log(text);
384
410
  else console.log(JSON.stringify(res.structuredContent ?? res, null, 2));
package/mcp/client.mjs CHANGED
@@ -454,6 +454,8 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
454
454
  export async function apiSSE(p, body = {}) {
455
455
  const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
456
456
  if (!res.ok) { let e; try { e = (await res.json()).error; } catch {} throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
457
+ // A server that answered with plain JSON (an older revision) is read as JSON, never as an empty stream.
458
+ if (!/text\/event-stream/i.test(res.headers.get('content-type') || '')) { const j = await res.json(); return j && Object.prototype.hasOwnProperty.call(j, 'data') ? j.data : j; }
457
459
  const reader = res.body.getReader(); const dec = new TextDecoder();
458
460
  let buf = '', done = null, error = null; const progress = [];
459
461
  for (;;) {
@@ -0,0 +1,190 @@
1
+ // STATIC ADS AT SCALE (2026-10-06) — the pure half of `make_static_ads`.
2
+ //
3
+ // A competing ads studio shipped on 2026-09-30: a brand kit from a website, the products imported from the site, pick
4
+ // one or several products and a count (up to 20 per product), and get back a batch of FINISHED static ads, each a
5
+ // different selling angle with its headline and CTA on the image, in 1:1, 4:5 or 9:16. We had every part of that
6
+ // (draft_brand, the product library, the batch planner the web Studio fans into up to 30 ranked statics) but an agent
7
+ // had to stitch it together itself: plan_variations (capped at 8, plans only), then one generate_image per variant —
8
+ // and a CLI generate_image held past 60s died client-side while the render still billed. This is the one-call shape.
9
+ //
10
+ // Everything here is PURE so tools/static-ads-batch-check.mjs runs the real functions. Byte-twin: cli/mcp/static-batch.mjs.
11
+
12
+ export const STATIC_BATCH = Object.freeze({
13
+ COUNT_MAX: 20, // ads per product, the ceiling the market offers
14
+ COUNT_DEFAULT: 4,
15
+ PRODUCTS_MAX: 10, // products in one call
16
+ RATIOS: Object.freeze(['1:1', '4:5', '9:16']),
17
+ RATIO_DEFAULT: '4:5',
18
+ // The server bounds queued+running renders per account (JOB_QUEUE_MAX, 16). One call never queues more than this;
19
+ // the rest of the plan comes back as `remaining`, to be sent again as `plan` once some have finished.
20
+ QUEUE_PER_CALL: 12,
21
+ });
22
+
23
+ const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
24
+ const STOP = /^(the|and|for|with|of|by|a|an|in|on)$/;
25
+ const tokens = (s) => norm(s).split(' ').filter((w) => w.length > 1 && !STOP.test(w));
26
+
27
+ // The brand's catalog as rows {title, image}. A saved brand holds products in several historical shapes, and none of
28
+ // them is authoritative alone: productTitles [{image,title}] (a drafted site's own product names), products (strings
29
+ // or {title,image}), productNames (the hero names the drafter picked), productImages + product (photos, the hero
30
+ // first). A row with no image is still a row: the planner can write for it; the render then grounds on the hero.
31
+ export function catalogOf(brand) {
32
+ const b = brand && typeof brand === 'object' ? brand : {};
33
+ const rows = [];
34
+ const seen = new Set();
35
+ const add = (title, image) => {
36
+ const t = String(title || '').trim();
37
+ const img = typeof image === 'string' && /^https?:\/\//i.test(image) ? image : '';
38
+ if (!t && !img) return;
39
+ const k = norm(t) || img;
40
+ if (seen.has(k)) { const r = rows.find((x) => (norm(x.title) || x.image) === k); if (r && !r.image && img) r.image = img; return; }
41
+ seen.add(k);
42
+ rows.push({ title: t, image: img });
43
+ };
44
+ for (const p of Array.isArray(b.productTitles) ? b.productTitles : []) if (p && typeof p === 'object') add(p.title, p.image);
45
+ for (const p of Array.isArray(b.products) ? b.products : []) {
46
+ if (typeof p === 'string') add(p, '');
47
+ else if (p && typeof p === 'object') add(p.title || p.name, p.image || (Array.isArray(p.images) ? p.images[0] : ''));
48
+ }
49
+ for (const n of Array.isArray(b.productNames) ? b.productNames : []) add(n, '');
50
+ return rows;
51
+ }
52
+
53
+ // The brand's default product photo (what every render grounds on when nothing names another product).
54
+ export function heroImageOf(brand) {
55
+ const b = brand && typeof brand === 'object' ? brand : {};
56
+ return (typeof b.product === 'string' && b.product) || (Array.isArray(b.productImages) ? b.productImages.find(Boolean) : '') || '';
57
+ }
58
+
59
+ // Match a product the caller NAMED against the catalog. Exact normalized title first; otherwise coverage of the asked
60
+ // words minus a penalty per extra word in the title (an extra word is usually a different item: "Wool Runner" must not
61
+ // land on "Wool Runner Mizzles" when a plain "Wool Runner" exists). Below two matched words (or all of a one-word ask)
62
+ // it is no match — a wrong product photo on a finished ad is worse than none.
63
+ export function matchCatalogProduct(catalog, name) {
64
+ const q = tokens(name);
65
+ if (!q.length || !Array.isArray(catalog) || !catalog.length) return null;
66
+ const qn = norm(name);
67
+ let best = null, bestScore = 0;
68
+ for (const row of catalog) {
69
+ const t = norm(row.title);
70
+ if (!t) continue;
71
+ let score;
72
+ if (t === qn) score = 1000;
73
+ else {
74
+ const tt = tokens(row.title);
75
+ const matched = q.filter((w) => tt.includes(w)).length;
76
+ if (matched < Math.min(2, q.length)) continue;
77
+ const extras = tt.filter((w) => !q.includes(w)).length;
78
+ score = matched * 10 - extras * 2 + (t.startsWith(qn) ? 3 : 0) + (row.image ? 1 : 0);
79
+ }
80
+ if (score > bestScore) { bestScore = score; best = row; }
81
+ }
82
+ return best;
83
+ }
84
+
85
+ // Which products a call is for. Named products resolve against the catalog (an unmatched name is still advertised,
86
+ // by its own words, with no product photo of its own); `all` takes the catalog rows that have a photo; nothing named
87
+ // means the brand's hero.
88
+ export function resolveProducts(brand, products) {
89
+ const catalog = catalogOf(brand);
90
+ const asked = Array.isArray(products) ? products.map((p) => String(p || '').trim()).filter(Boolean) : [];
91
+ if (asked.length === 1 && /^(all|every|everything|all products)$/i.test(asked[0])) {
92
+ const withPhoto = catalog.filter((r) => r.image);
93
+ return (withPhoto.length ? withPhoto : catalog).slice(0, STATIC_BATCH.PRODUCTS_MAX).map((r) => ({ name: r.title || 'the product', image: r.image, matched: true }));
94
+ }
95
+ if (!asked.length) {
96
+ const b = brand && typeof brand === 'object' ? brand : {};
97
+ const name = (Array.isArray(b.productNames) && b.productNames[0]) || b.sells || b.name || '';
98
+ return name ? [{ name: String(name), image: '', matched: false, hero: true }] : [];
99
+ }
100
+ return asked.slice(0, STATIC_BATCH.PRODUCTS_MAX).map((n) => {
101
+ const hit = matchCatalogProduct(catalog, n);
102
+ return hit ? { name: hit.title || n, asked: n, image: hit.image, matched: true } : { name: n, image: '', matched: false };
103
+ });
104
+ }
105
+
106
+ export function normalizeRatios(list) {
107
+ const want = (Array.isArray(list) ? list : [list]).map((r) => String(r || '').trim()).filter(Boolean);
108
+ const out = [...new Set(want.filter((r) => STATIC_BATCH.RATIOS.includes(r)))];
109
+ return out.length ? out : [STATIC_BATCH.RATIO_DEFAULT];
110
+ }
111
+
112
+ export function clampCount(n) {
113
+ const v = Math.round(Number(n));
114
+ if (!Number.isFinite(v)) return STATIC_BATCH.COUNT_DEFAULT;
115
+ return Math.max(1, Math.min(STATIC_BATCH.COUNT_MAX, v));
116
+ }
117
+
118
+ // The brief the planner sees for one product: the product, the caller's steer, and the angles they asked for.
119
+ export function plannerBrief(product, { brief = '', angles = [] } = {}) {
120
+ const a = (Array.isArray(angles) ? angles : []).map((x) => String(x || '').trim()).filter(Boolean).slice(0, 12);
121
+ return [
122
+ String(product || '').trim(),
123
+ brief ? `What to lean into: ${String(brief).trim().slice(0, 600)}` : '',
124
+ a.length ? `Spread the variants across these angles (one angle per variant, in this order, repeating if there are more variants than angles): ${a.join('; ')}.` : '',
125
+ ].filter(Boolean).join('\n');
126
+ }
127
+
128
+ // One render's prompt: the planner's visual brief, then the exact words, then (with no logo on file) the no-logo rule. The words are QUOTED so
129
+ // the model prints them as given; a static whose headline is misspelled is not finished.
130
+ export function staticAdPrompt(v, { logo = 'server', productPhoto = false } = {}) {
131
+ const x = v && typeof v === 'object' ? v : {};
132
+ const lines = [String(x.prompt || x.visual || '').trim()];
133
+ const words = [
134
+ x.headline ? `headline "${String(x.headline).trim()}"` : '',
135
+ x.supporting ? `supporting line "${String(x.supporting).trim()}"` : '',
136
+ x.cta ? `call to action "${String(x.cta).trim()}"` : '',
137
+ ].filter(Boolean);
138
+ if (words.length) lines.push(`On-image text, spelled exactly as given and nothing else: ${words.join(', ')}. Bold ad typography with a clear hierarchy, crisp and correctly spelled. A finished, ready-to-run static ad.`);
139
+ if (productPhoto) lines.push('The first reference image is the REAL product: keep its shape, colours and label exactly.');
140
+ // THE LOGO IS LAID ON, NEVER PAINTED (2026-10-06): 'overlay' (and the old 'attached' / 'server') add nothing here. The
141
+ // render goes with brandLogo:true, and the image route tells the model to keep a corner clear and lays the brand's real
142
+ // logo file over the finished ad (lib/logo-intent.mjs). The model is never handed the logo to re-draw.
143
+ if (!['overlay', 'attached', 'server'].includes(logo)) lines.push('Do not draw or invent any logo or wordmark.');
144
+ return lines.filter(Boolean).join('\n\n');
145
+ }
146
+
147
+ // The render list for a plan: every variant once per ratio, in plan order, each carrying what it renders.
148
+ export function renderList(plan, ratios) {
149
+ const out = [];
150
+ for (const p of Array.isArray(plan) ? plan : []) {
151
+ for (const v of Array.isArray(p?.variants) ? p.variants : []) {
152
+ for (const r of normalizeRatios(ratios)) out.push({ product: p.product, image: p.image || '', variant: v, aspectRatio: r });
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // Split a render list at what one call may queue; the rest goes back to the caller as a plan of its own.
159
+ export function splitForQueue(list, cap = STATIC_BATCH.QUEUE_PER_CALL) {
160
+ const n = Math.max(0, Math.floor(Number(cap) || 0));
161
+ return { now: list.slice(0, n), later: list.slice(n) };
162
+ }
163
+
164
+ // Rebuild a plan (products → variants) from leftover render items, so `remaining` can be passed straight back as `plan`
165
+ // with the same `aspectRatios`. A variant appears once even when several of its ratios were left over.
166
+ export function planFromItems(items) {
167
+ const byProduct = new Map();
168
+ for (const it of Array.isArray(items) ? items : []) {
169
+ const key = `${it.product}\u0000${it.image || ''}`;
170
+ if (!byProduct.has(key)) byProduct.set(key, { product: it.product, image: it.image || '', variants: [] });
171
+ const p = byProduct.get(key);
172
+ if (!p.variants.includes(it.variant)) p.variants.push(it.variant);
173
+ }
174
+ return [...byProduct.values()];
175
+ }
176
+
177
+ // Credits for one image on the model that will run, read off GET /api/generate/status. null = could not tell, which is
178
+ // said as such and never rendered as a number. NOT the `best` row: every ad here carries the product photo and/or the
179
+ // logo, and a referenced render with no model named composites on the catalog's FIRST row (Nano Banana Pro). Measured
180
+ // 2026-10-06: `best` was GPT Image 2.5 at 7 credits, the quote said 7, and each job settled at 20 (17 for the render +
181
+ // the label check) on Nano Banana Pro.
182
+ export function perAdCredits(status, model = '') {
183
+ const rows = status?.options?.image?.models;
184
+ if (!Array.isArray(rows) || !rows.length) return null;
185
+ const row = (model && rows.find((m) => m && m.id === model)) || rows[0];
186
+ if (!row) return null;
187
+ const bySize = row.creditsBySize && (row.creditsBySize['2K'] ?? row.creditsBySize['1K']);
188
+ const c = Number(bySize ?? row.credits);
189
+ return Number.isFinite(c) && c > 0 ? c : null;
190
+ }
package/mcp/tools.mjs CHANGED
@@ -26,6 +26,8 @@ import { withHints, videoChoiceText, videoChoiceHints, neutralCreditsText, credi
26
26
  // behind HERMOSO_CHATGPT_EXT=1 + `?ext=1` on a widget host — see registerChatGPTExtensions() below and
27
27
  // ./openai-extensions.mjs. Same twin-safe specifier rule.
28
28
  import { registerLibraryApp, installEntrypointIcons, chatgptExtOn, EXT_PREFS_KEY } from './openai-extensions.mjs';
29
+ // STATIC ADS AT SCALE (2026-10-06): the pure catalog/plan/prompt helpers behind make_static_ads. Same twin-safe specifier rule.
30
+ import { STATIC_BATCH, resolveProducts, heroImageOf, normalizeRatios, clampCount, plannerBrief, staticAdPrompt, renderList, splitForQueue, planFromItems, perAdCredits } from './static-batch.mjs';
29
31
 
30
32
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
31
33
  // /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
@@ -2094,7 +2096,9 @@ export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
2094
2096
  // create RE-MEASURED 2026-09-24 (26000 → 42000): edit_timeline (the open edit primitive, 11,026 by this method, about
2095
2097
  // 2,500 on the wire) and video_frames joined it. Both are on-demand (ON_DEMAND_TOOLS), so they only cost a session that
2096
2098
  // names `create` (or all); this figure is what that session pays.
2097
- export const TOOL_GROUP_TOKENS = { core: 4300, research: 6600, create: 42000, channels: 37100, channel_admin: 66300, analytics: 39100, files: 10600, workspace: 9200, ads: 246700 };
2099
+ // create RE-MEASURED 2026-10-06 (42000 → 55000, measured 54,974 by tools/tool-group-truth-check.mjs): make_static_ads
2100
+ // joined it (on-demand), on top of the create tools added since 09-24 that had used up the 1.3× tolerance.
2101
+ export const TOOL_GROUP_TOKENS = { core: 4300, research: 6600, create: 55000, channels: 37100, channel_admin: 66300, analytics: 39100, files: 10600, workspace: 9200, ads: 246700 };
2098
2102
 
2099
2103
  // THE DEFAULT ROSTER IS EVERYTHING EXCEPT `ads` AND `analytics`. Paid-campaign management across every ad platform
2100
2104
  // is ~236K tokens on its own — more than everything else put together — because each platform carries a full
@@ -2265,7 +2269,7 @@ export const widgetHostHides = (name, widgetHost) => (widgetHost
2265
2269
  // self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
2266
2270
  // direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
2267
2271
  // edit_timeline, and edit_timeline's names video_frames, so a caller that starts from post_edit reaches both.
2268
- export const ON_DEMAND_TOOLS = new Set(['edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft']); // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine)
2272
+ export const ON_DEMAND_TOOLS = new Set(['edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads']); // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine)
2269
2273
 
2270
2274
  // ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
2271
2275
  // ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
@@ -4996,7 +5000,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
4996
5000
  // status, and /api/errors/report will not read a marker off an untrusted reporter — see toRef in client.mjs,
4997
5001
  // which this sentence is the model for.
4998
5002
  if (isRemote()) throw Object.assign(new Error('`path` only works when Hermoso runs on your own machine (stdio/CLI). On the hosted connector I can\'t read your files — put the file at a public https URL and pass `url`, or pass `dataUri` for something small.'), { status: 400, _userInput: true });
4999
- buf = await readFile(a.path);
5003
+ try { buf = await readFile(a.path); }
5004
+ catch (e) {
5005
+ // A PATH THAT IS NOT THERE IS THE CALLER'S TO FIX, AND THE MESSAGE HAS TO SAY HOW (2026-10-06, fp 59547e05-39a:
5006
+ // a bare "ENOENT: no such file or directory, open '/tmp/p04boss.mp4'" 22 times from one agent in five hours,
5007
+ // filed as `unknown`). The usual cause is an agent in a SANDBOX: its /tmp is not the /tmp this process reads.
5008
+ // Authored 400 + _userInput, so the ledger files it as the caller's and the agent is told exactly what to change.
5009
+ const why = { ENOENT: 'there is no file at that path', EACCES: 'this process is not allowed to read it', EPERM: 'this process is not allowed to read it', EISDIR: 'that path is a folder, not a file' }[e?.code];
5010
+ if (!why) throw e;
5011
+ throw Object.assign(new Error(`Can't read \`${String(a.path).slice(0, 300)}\`: ${why} on the machine Hermoso runs on (working folder ${process.cwd()}). If you wrote the file from a sandboxed tool, its temp folder is probably not this one: save it somewhere both can read and pass that absolute path, or pass \`url\` (a public link) or \`dataUri\` instead. Retrying the same path will fail the same way.`), { status: 400, _userInput: true });
5012
+ }
5000
5013
  fileName = fileName || String(a.path).split(/[\\/]/).pop();
5001
5014
  contentType = EXT_MIME[(fileName.split('.').pop() || '').toLowerCase()] || 'application/octet-stream';
5002
5015
  }
@@ -5775,7 +5788,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5775
5788
  }, wrap(async (a) => {
5776
5789
  if (a.redo && a.redo.id) {
5777
5790
  const r = a.redo;
5778
- const d = await apiPost('/api/schedule/refill/redo', { id: r.id, feedback: r.feedback || '', part: r.change || 'both', remember: r.remember !== false, confirm: r.confirm === true });
5791
+ const d = await (r.confirm === true ? apiSSE : apiPost)('/api/schedule/refill/redo', { id: r.id, feedback: r.feedback || '', part: r.change || 'both', remember: r.remember !== false, confirm: r.confirm === true }); // streamed when it renders: past the edge's 100s limit (2026-10-06)
5779
5792
  if (d.needsConfirm) return ok(`${d.summary}\nAsk the user before spending; call run_post_refill again with redo.confirm:true only on a yes.`, d);
5780
5793
  return ok([d.summary || '', d.draft ? `The new version (${d.draft.id}, ${d.draft.kind}, ${d.draft.at}):\n ${String(d.draft.message || '').replace(/\n+/g, ' / ').slice(0, 300)}\n ${d.draft.media || ''}\nIt is still a draft: approve it with run_post_refill approve.` : ''].filter(Boolean).join('\n'), d);
5781
5794
  }
@@ -5789,19 +5802,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
5789
5802
  d.discarded ? `Discarded ${d.discarded} draft(s).` : '',
5790
5803
  d.edited ? `Edited ${d.edited} draft(s).` : '',
5791
5804
  (d.editErrors || []).length ? `Edits refused: ${d.editErrors.map(x => `${x.draftId} — ${x.error}`).join(' · ')}` : '',
5805
+ (d.editNotes || []).length ? d.editNotes.map(x => `${x.draftId}: ${x.note}`).join('\n') : '',
5792
5806
  (d.unknown || []).length ? `No such draft: ${d.unknown.join(', ')}` : '',
5793
5807
  (d.learned || []).length ? `Saved for future batches: ${d.learned.map((x) => `"${x}"`).join(', ')}.` : '',
5794
5808
  `${d.remaining} draft(s) still waiting for approval.`,
5795
5809
  ].filter(Boolean).join('\n'), d);
5796
5810
  }
5797
- const d = await apiPost('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force });
5811
+ const d = await (a.dryRun === false ? apiSSE : apiPost)('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force }); // a live run renders for minutes: streamed past the edge's 100s limit (2026-10-06)
5798
5812
  const lines = (d.posts || []).map((p, i) => ` ${i + 1}. ${p.at} → ${(p.channels || []).map(c => `${c}[${(p.visibilityByChannel || {})[c] || 'public'}]`).join(', ')}\n ${String(p.message || '').replace(/\n+/g, ' / ').slice(0, 220)}\n new ${p.kind}: ${p.media || `(made when it runs live) ${String(p.asset?.scene || '').slice(0, 160)}`} · ${p.asset?.credits || 0} credits`);
5799
5813
  const q = (d.queued || []).filter(x => x && x.id).length, qFail = (d.queued || []).filter(x => x && x.error);
5800
5814
  return ok([
5801
5815
  d.summary || '',
5802
5816
  lines.length ? `\nThe posts:\n${lines.join('\n')}` : '',
5803
5817
  (d.noChannels || d.zeroBudget || (d.enabled === false && !(d.posts || []).length)) ? '' : d.dryRun ? `\nNOTHING WAS RENDERED OR QUEUED — this was a preview${d.copyCredits ? ` (writing its copy cost ${d.copyCredits} credit(s))` : ''}. Show it to the user; call again with dryRun:false only if they say yes.`
5804
- : (d.mode === 'auto' ? `\n${q} post(s) scheduled. Pull any of them before it goes out with cancel_scheduled.` : `\n${(d.drafts || []).length} draft(s) waiting for approval — approve, edit or discard them with run_post_refill.`),
5818
+ : (d.mode === 'auto' ? `\n${q} post(s) scheduled. Pull any of them before it goes out with cancel_scheduled.` : ((d.drafts || []).length ? `\n${d.drafts.length} new draft(s) waiting for approval — approve, edit or discard them with run_post_refill.` : '\nNo new drafts were made by this run (get_post_refill lists any drafts already waiting).')),
5805
5819
  qFail.length ? `\n${qFail.length} could not be queued: ${qFail.map(x => `${x.at} — ${x.error}`).join(' · ')}` : '',
5806
5820
  (d.notes || []).length ? `\nNotes: ${d.notes.join(' · ')}` : '',
5807
5821
  ].filter(Boolean).join('\n'), d);
@@ -11549,7 +11563,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11549
11563
  const alOut = { ok: z.boolean().optional(), id: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() };
11550
11564
  server.registerTool('list_applovin_ads_campaigns', {
11551
11565
  title: 'List AppLovin Ads campaigns',
11552
- description: 'The brand’s AppLovin Ads campaigns: id, name, LIVE/PAUSED, daily budget, goal, countries, landing page. Read-only, free.' + AL_CM_NOTE,
11566
+ description: 'The brand’s AppLovin Ads campaigns: id, name, LIVE/PAUSED, daily budget, goal, countries, landing page. Rename or edit one with update_applovin_ads_campaign; pause or go live with set_applovin_ads_status. Read-only, free.' + AL_CM_NOTE,
11553
11567
  inputSchema: { campaignIds: alIds.describe('only these ids (up to 100)'), page: z.number().optional().describe('from 1'), size: z.number().optional().describe('1–100, default 100') },
11554
11568
  outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), campaigns: z.array(z.any()).optional(), page: z.number().optional(), note: z.string().optional() },
11555
11569
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -11581,7 +11595,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11581
11595
  }, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/campaign', a); return ok(d.note, d); }));
11582
11596
  server.registerTool('update_applovin_ads_campaign', {
11583
11597
  title: 'Update an AppLovin Ads campaign',
11584
- description: 'Change an AppLovin campaign’s name, budget, goal value, countries, end date or landing page, read back. On a LIVE campaign a budget, goal, country or end-date change needs confirm:true. Status is set_applovin_ads_status; goal type, bidding and start date cannot change.' + AL_CM_NOTE,
11598
+ description: 'Rename an AppLovin campaign or change its budget, goal value, countries, US states or metros, end date or landing page, read back. On a LIVE campaign a budget, goal, country or end-date change needs confirm:true. Status is set_applovin_ads_status; goal type, bidding and start date cannot change.' + AL_CM_NOTE,
11585
11599
  inputSchema: {
11586
11600
  campaignId: z.string(), name: z.string().optional(), dailyBudget: z.number().optional(), countryBudgets: z.record(z.number()).optional(),
11587
11601
  goalValue: z.number().optional(), countryGoals: z.record(z.number()).optional(), countries: alIds, usRegions: alIds, usMetros: alIds,
@@ -11606,7 +11620,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11606
11620
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
11607
11621
  }, wrap(async (a) => {
11608
11622
  const d = await apiGet('/api/applovin-ads/creative-sets', { ...a, ...(a.creativeSetIds ? { creativeSetIds: a.creativeSetIds.join(',') } : {}) });
11609
- return ok([d.note, ...(d.creativeSets || []).map(c => `• ${c.name} (${c.id}) — ${c.status || '?'}: ${(c.assets || []).map(x => `${x.type || x.resourceType} ${x.id} ${x.status}`).join('; ')}`)].join('\n'), d);
11623
+ return ok([d.note, ...(d.creativeSets || []).map(c => `• ${c.name} (${c.id}) — ${c.status || '?'}, in campaign(s) ${[...new Set([...(c.campaignIds || []), ...(c.campaignId ? [c.campaignId] : [])])].join(', ') || 'none'}: ${(c.assets || []).map(x => `${x.type || x.resourceType} ${x.id} ${x.status}`).join('; ')}`)].join('\n'), d);
11610
11624
  }));
11611
11625
  server.registerTool('create_applovin_ads_creative_set', {
11612
11626
  title: 'Create an AppLovin creative set (paused)',
@@ -11622,6 +11636,27 @@ function buildTools(rawServer, opts = {}, sink = null) {
11622
11636
  outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), removedFrom: z.array(z.string()).optional(), stillIn: z.array(z.string()).optional(), verdict: z.string().optional(), note: z.string().optional() },
11623
11637
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
11624
11638
  }, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/remove', a); return ok(d.note, d); }));
11639
+ server.registerTool('update_applovin_ads_creative_set', {
11640
+ title: 'Edit an AppLovin creative set',
11641
+ description: 'Edit an AppLovin creative set, read back: rename it, change its countries or languages (an empty list means all), its landing page (url; "" falls back to the campaign’s), replace its assets (assetIds, checked against AppLovin’s own asset types first), or add it to more campaigns (addToCampaignIds, up to 20). Adding a LIVE set to a LIVE campaign needs confirm:true. Status is set_applovin_ads_status.' + AL_CM_NOTE,
11642
+ inputSchema: { creativeSetId: z.string(), name: z.string().optional(), countries: alIds.describe('ISO codes; [] = all countries'), languages: alIds.describe('e.g. ENGLISH; [] = all'), url: z.string().optional().describe('landing page for this set; "" = the campaign’s'), assetIds: alIds.describe('REPLACES the set’s assets'), addToCampaignIds: alIds.describe('also run it in these campaigns (up to 20)'), confirm: z.boolean().optional().describe('required to add a LIVE set to a LIVE campaign') },
11643
+ outputSchema: { ...alOut, changed: z.array(z.string()).optional(), creativeSet: z.any().optional() },
11644
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
11645
+ }, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/update', a); return ok(d.note, d); }));
11646
+ server.registerTool('clone_applovin_ads_creative_set', {
11647
+ title: 'Copy an AppLovin creative set into another campaign (paused)',
11648
+ description: 'Copy an AppLovin creative set into another campaign, born PAUSED and read back. AppLovin only clones a set that already belongs to a campaign.' + AL_CM_NOTE,
11649
+ inputSchema: { creativeSetId: z.string(), campaignId: z.string().describe('the campaign to copy it into') },
11650
+ outputSchema: { ...alOut, sourceId: z.string().optional(), campaignId: z.string().optional(), version: z.string().optional(), creativeSet: z.any().optional(), pausedAfterCreate: z.boolean().optional() },
11651
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
11652
+ }, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/clone', a); return ok(d.note, d); }));
11653
+ server.registerTool('list_applovin_ads_catalogs', {
11654
+ title: 'List AppLovin catalogs',
11655
+ description: 'The AppLovin account’s product catalogs and their variant sets: the catalogId and variantSetId a dynamic-ads campaign (create_applovin_ads_campaign) needs. Website accounts. Read-only, free.' + AL_CM_NOTE,
11656
+ inputSchema: {},
11657
+ outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), catalogs: z.array(z.any()).optional(), note: z.string().optional() },
11658
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
11659
+ }, wrap(async () => { const d = await apiGet('/api/applovin-ads/catalogs', {}); return ok(d.note, d); }));
11625
11660
  server.registerTool('list_applovin_ads_assets', {
11626
11661
  title: 'List AppLovin assets',
11627
11662
  description: 'Assets on the AppLovin account: id, type (VID_LONG_P, VID_SHORT_P, IMG_INTER_P, IMG_BANNER, HOSTED_HTML), review status and rejection reasons. Read-only, free.' + AL_CM_NOTE,
@@ -11646,7 +11681,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11646
11681
  since: z.string().optional().describe('YYYY-MM-DD, default 7 days ago'), until: z.string().optional().describe('YYYY-MM-DD, default today'),
11647
11682
  columns: alIds.describe('e.g. day, campaign, campaign_id_external, creative_set, country, impressions, clicks, ctr, cost, sales, roas_7d, chka_usd_7d'),
11648
11683
  attribution: z.enum(['click', 'click_and_view']).optional().describe('web only, default click'), filters: z.record(z.array(z.string())).optional().describe('column -> values'),
11649
- cohort: z.boolean().optional().describe('attribute to impression day'), limit: z.number().optional().describe('1–10000, default 1000'), offset: z.number().optional(),
11684
+ cohort: z.boolean().optional().describe('attribute to impression day'), notZero: z.boolean().optional().describe('drop rows whose metrics are all zero'), limit: z.number().optional().describe('1–10000, default 1000'), offset: z.number().optional(),
11650
11685
  },
11651
11686
  outputSchema: { ok: z.boolean().optional(), type: z.string().optional(), since: z.string().optional(), until: z.string().optional(), columns: z.array(z.string()).optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), note: z.string().optional() },
11652
11687
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -17955,24 +17990,26 @@ function buildTools(rawServer, opts = {}, sink = null) {
17955
17990
  model: z.string().optional().describe('image model id from hermoso_capabilities. A model whose `refs.mode` is "edit" there (gpt-image-2.5) takes your refImages on ITS OWN editor, up to its `refs.max`, instead of the default compositor'),
17956
17991
  imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
17957
17992
  fixLabel: z.boolean().optional().describe('default true: when the saved brand\'s product photo rides in this render, the product\'s label on the finished image is READ and compared with the photo, and re-printed from the photo at close range ONLY if it came out wrong (a label that is already right costs only the check, a credit or two; a re-print adds about ten). The reply says whether the label was checked, fixed or left as rendered (`labelPass`). Pass false when the user wants the packaging left exactly as generated: nothing is checked or re-printed.'),
17993
+ logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: the real logo file laid flat (lockup, corner). in_scene: ON something in the scene (cup, shirt, sign, moving or angled), from the file, then checked. auto (default): from the prompt, none unless asked'),
17958
17994
  mask: z.string().optional().describe('MASKED EDIT — change ONE region of an image and keep the rest: a local path or URL of a mask image for refImages[0] (the image being edited). Either convention works and the reply says which it read: TRANSPARENT pixels = change, or, on a mask with no transparency, WHITE = change and black = keep. Any size; it is scaled to the image. The mask GUIDES the edit rather than stencilling it: the new content can blend a little past its edge. Runs on the model hermoso_capabilities marks `refs.mask` (gpt-image-2.5): leave `model` empty or name that one — any other named model is refused, free. Needs refImages; the result keeps the source image\'s own frame, so aspectRatio is not applied.'),
17959
17995
  },
17960
17996
  outputSchema: {
17961
17997
  image: z.string().optional().describe('the served absolute URL of the finished image'),
17962
17998
  model: z.string().optional().describe('the product-facing label of the model that rendered it'),
17963
17999
  productCheck: z.any().optional().describe('present when the brand\'s product photo was attached: {verdict: match|mismatch|unclear|absent, wordmark, issues[]} — the render compared against the real product photo. mismatch/absent means the product in the image is NOT the brand\'s product; say so, never present it as done'),
18000
+ logoCheck: z.any().optional().describe('after an in_scene logo: {verdict: match|drift|absent|unclear, where, issues[], note}'),
17964
18001
  labelPass: z.any().optional().describe('present when the product\'s label lines were known: {status: checked (read and already right, left as rendered) | fixed (read wrong and re-printed from the photo) | left (not re-printed: label faces away, product not found, or the pass could not run) | off (fixLabel:false), read, expected[], note}. Tell the user the note.'),
17965
18002
  },
17966
18003
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
17967
18004
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
17968
- }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel }) => {
18005
+ }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement }) => {
17969
18006
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
17970
18007
  const maskRef = mask ? await toRef(mask) : undefined; // a local mask file travels the same way a local reference does
17971
18008
  // `raw === true` only — a raw render is opt-in and must be stated properly, so a truthy stray value never
17972
18009
  // silently turns off the brand pipeline on an on-brand ad (the same rule lib/raw-passthrough.mjs's predicate uses).
17973
18010
  // A MASKED EDIT IS AN EDIT OF THE CALLER'S OWN IMAGE: the saved brand's product photos must not be hydrated in front
17974
18011
  // of it, so a mask implies useBrand:false (the server also refuses a mask with no refImages, free).
17975
- const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) }; // fixLabel:false only when stated: the label check is ON by default
18012
+ const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}) }; // fixLabel:false only when stated: the label check is ON by default
17976
18013
  // A CALLER WITH A WAIT BUDGET GETS A QUEUED JOB (2026-09-21, R321). Only `/v1/tools/generate_image?wait=` sets
17977
18014
  // `waitMs`; every other caller renders inside the request exactly as before. The server does the same brand-photo
17978
18015
  // handling either way, then queues the ordinary image job; awaitRenderJob waits what the caller allowed and hands
@@ -18113,6 +18150,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18113
18150
  restrainedGrade: z.boolean().optional().describe('true for a calm / premium / muted look instead of the default punchy poster grade'),
18114
18151
  logo: z.string().optional().describe('a brand logo URL or path to place into the composition'),
18115
18152
  logo3d: z.boolean().optional().describe('first turn the flat logo into a volumetric 3D render (one extra billed image), then composite that'),
18153
+ logoPlacement: z.enum(['auto', 'overlay', 'in_scene']).optional().describe('overlay: the real logo file laid flat, exact. in_scene: placed into the composition from the file, then checked. auto (default): in_scene with logo3d, else overlay'),
18116
18154
  split: z.object({ mode: z.enum(['plain', 'before_after', 'versus', 'custom']), panels: z.array(z.string()).optional() }).passthrough().optional().describe('split/panel LAYOUT — only when the user asks for one ("split", "before/after", "versus screen"). "X vs Y" as a SCENE stays one unified frame'),
18117
18155
  reference: z.object({}).passthrough().optional().describe("fields YOU extracted by eye from a reference thumbnail. Extract ALL of: brief (one dense sentence on the concept), subject (pose/action generically, NEVER a specific identity), elements, location, composition, background, split (boolean), split_count, person_count (0-3), emotion (one of the 11 presets or 'other'), emotion_detail (one vivid sentence covering eyes, brows, mouth, head angle). emotion + emotion_detail carry the reference's actual facial performance, which is the single biggest CTR lever on a face; split/split_count reproduce its panel structure. The reference image itself is never sent to the model"),
18118
18156
  tweak: z.object({ kind: z.string(), value: z.string() }).describe('surgical pixel-faithful edit of a FINISHED thumbnail (needs sourceImage): kind emotion / background / background_color / rim_light, or any other kind with the edit in words as value').optional(),
@@ -18686,7 +18724,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18686
18724
  description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead — the omni engine transforms that clip per your prompt, inheriting its canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: by default a few small guards are appended (packaging/label safety with no reference image, a negative prompt where the model takes one, reference-binding lines) and hex colour codes become colour names; ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
18687
18725
  inputSchema: {
18688
18726
  prompt: z.string().optional().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction); optional with `shots`'),
18689
- raw: z.boolean().optional().describe('RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence. Billing, durable delivery and per-model validation are unchanged.'),
18727
+ raw: z.boolean().optional().describe('RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence (on Veo it is refused with the length and the cap instead). Billing, durable delivery and per-model validation are unchanged.'),
18690
18728
  refImage: z.string().optional().describe('local path or URL to anchor the first frame. ' + LIKENESS_TERMS),
18691
18729
  speak: z.string().optional().describe('A PERSON SAYING THESE EXACT WORDS TO CAMERA — the default for any "make my photo talk" / spokesperson / talk-to-camera ask. Pass with `creator` or a portrait as `refImage`: the video model films them saying it in their own voice, matched to who they are, and the length follows the words (leave durationSeconds out). `prompt` is then the staging (e.g. "natural", "walking in a park"). Real footage, not an animated photo; generate_avatar is the animated-photo look, only when asked for by name'),
18692
18730
  creator: z.string().optional().describe('STAR A SAVED CREATOR in this clip — their id from list_creators, or the name you know them by, or a PRESET AI creator from list_creators presets (exact name or id; free, no generation). Their saved portrait rides first among the references as the on-camera person, with their saved consent, exactly as render_ad casts them; a real person saved from a photo keeps their real face on camera. An unknown name is refused by name, nothing charged.'),
@@ -21202,7 +21240,7 @@ function memoryNoteVerdict(text) {
21202
21240
 
21203
21241
  server.registerTool('plan_variations', {
21204
21242
  title: 'Plan ad variations',
21205
- description: "Fan a brief into N DISTINCT ad angles (different hooks/mechanics/audiences), each with its own headline + visual brief — then render each with generate_image and rank with score_ad. LLM planning only; renders nothing itself.",
21243
+ description: "Fan a brief into N DISTINCT ad angles (different hooks/mechanics/audiences), each with its own headline + visual brief — then render each with generate_image and rank with score_ad. LLM planning only; renders nothing itself. To plan AND render a batch of finished static ads for one or several products in one call (up to 20 per product), use make_static_ads.",
21206
21244
  inputSchema: {
21207
21245
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace’s saved brand'),
21208
21246
  product: z.string().describe('what to advertise'),
@@ -21222,6 +21260,102 @@ function memoryNoteVerdict(text) {
21222
21260
  return ok(text, d);
21223
21261
  }));
21224
21262
 
21263
+ // STATIC ADS AT SCALE (2026-10-06). The ads-studio shape in one call: products × count → finished statics,
21264
+ // each a different selling angle with its headline and CTA on the image. Planning runs on the same batch planner the
21265
+ // web Studio's make_variations fans out from (/api/batch/plan); every render is the ordinary QUEUED image job
21266
+ // (/api/generate/image queue:true), so brand photos, the product check and the label pass behave exactly as in
21267
+ // generate_image and nothing is held inside this request. Pure half: ./static-batch.mjs.
21268
+ server.registerTool('make_static_ads', {
21269
+ title: 'Make static ads at scale',
21270
+ description: "Make a BATCH of finished static image ads for one or several products in one call: each ad a genuinely different selling angle (offer, material or ingredient story, how it is used, a lifestyle moment, an objection, proof the brand really has) with its headline, supporting line and CTA printed on the image, the brand's real product photo and logo composited in. `products` names products from the saved brand's catalog (get_brand lists them; ['all'] takes every product with a photo; omit for the hero product; a name not in the catalog is still advertised, drawn from its words). `count` is ads PER PRODUCT (1-20). `aspectRatios` any of 1:1, 4:5, 9:16 (each ad is rendered natively once per ratio). `brief` steers (a sale, a season, an audience) and is never turned into a price, discount or claim the brand does not state; `angles` names the angles to spread across. ALWAYS PRICED FIRST: dryRun:true returns the plan and the quote for the planning call only; then pass that `plan` back (no dryRun) to render exactly it without planning again. Renders are QUEUED jobs, at most " + STATIC_BATCH.QUEUE_PER_CALL + " per call: anything past that comes back as `remaining`, to send again as `plan` once some finish. Read each job with get_job; never describe an ad before its URL arrives. For variants of ONE finished ad use headline_variants / resize_ad; to copy a competitor's static use clone_static.",
21271
+ inputSchema: {
21272
+ products: z.array(z.string()).optional().describe("product names from the saved brand's catalog (get_brand), ['all'] for every product with a photo, or omit for the hero product"),
21273
+ count: z.number().int().min(1).max(STATIC_BATCH.COUNT_MAX).optional().describe(`ads per product, 1-${STATIC_BATCH.COUNT_MAX} (default ${STATIC_BATCH.COUNT_DEFAULT})`),
21274
+ aspectRatios: z.array(z.enum(['1:1', '4:5', '9:16'])).optional().describe("canvases; every ad is rendered once per ratio (default ['4:5'])"),
21275
+ brief: z.string().optional().describe('what to lean into: a sale the brand is really running, a season, an audience, a tone'),
21276
+ angles: z.array(z.string()).optional().describe("the selling angles to spread across, e.g. ['price or offer', 'material story', 'how it is used', 'lifestyle moment']; omit and the planner picks a spread"),
21277
+ language: z.string().optional().describe('language of the on-image copy (default English)'),
21278
+ model: z.string().optional().describe('image model id from hermoso_capabilities; omit for the default'),
21279
+ brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile (passing it pins it like use_brand)'),
21280
+ plan: z.array(z.any()).optional().describe('the `plan` (or `remaining`) a previous call returned: render exactly those ads, no new planning'),
21281
+ dryRun: z.boolean().optional().describe('true = plan and quote only, nothing rendered'),
21282
+ logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay (default): the real logo file laid flat on each ad. in_scene: printed on something in the ad, then checked. none. auto: from each visual'),
21283
+ },
21284
+ outputSchema: {
21285
+ plan: z.array(z.any()).optional().describe('[{product, image, variants:[{angle, headline, supporting, cta, prompt}]}]'),
21286
+ jobs: z.array(z.any()).optional().describe('one queued image job per ad × ratio: {id, product, angle, headline, aspectRatio}'),
21287
+ remaining: z.array(z.any()).optional().describe('the part of the plan not queued this call — pass it back as `plan`'),
21288
+ perAdCredits: z.number().nullable().optional(),
21289
+ totalCredits: z.number().nullable().optional(),
21290
+ dryRun: z.boolean().optional(),
21291
+ notes: z.array(z.string()).optional(),
21292
+ },
21293
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
21294
+ }, wrap(async ({ products, count, aspectRatios, brief, angles, language, model, brandId, plan, dryRun, logoPlacement }) => {
21295
+ const brand = await activeBrand(brandId);
21296
+ const ratios = normalizeRatios(aspectRatios);
21297
+ const notes = [];
21298
+ let thePlan = Array.isArray(plan) && plan.length ? plan.filter((p) => p && Array.isArray(p.variants) && p.variants.length) : null;
21299
+ if (!thePlan) {
21300
+ const targets = resolveProducts(brand, products);
21301
+ if (!targets.length) throw Object.assign(new Error('Name the product to advertise in `products` (or save a brand first with draft_brand). Nothing was charged.'), { _userInput: true });
21302
+ const n = clampCount(count);
21303
+ thePlan = [];
21304
+ for (const t of targets) {
21305
+ if (!t.matched && !t.hero) notes.push(`"${t.name}" is not in the brand's catalog, so its ads are drawn from its name with no product photo of its own.`);
21306
+ const d = await apiPost('/api/batch/plan', { product: plannerBrief(t.name, { brief, angles }), count: n, language: language || '' });
21307
+ const vs = (d?.variants || []).slice(0, n).map((v) => ({ angle: v.angle || '', headline: v.headline || '', supporting: v.supporting || '', cta: v.cta || '', prompt: v.prompt || '' }));
21308
+ thePlan.push({ product: t.name, image: t.image || '', hero: !!t.hero, variants: vs });
21309
+ }
21310
+ }
21311
+ const list = renderList(thePlan, ratios);
21312
+ let status = null; try { status = await apiGet('/api/generate/status'); } catch {}
21313
+ const per = perAdCredits(status, model);
21314
+ const total = per == null ? null : per * list.length;
21315
+ const quote = per == null ? 'the per-ad price could not be read just now (each render reserves its own price when queued)' : `about ${per} credits per ad, plus about 3 where the product label is checked · about ${total}-${total + 3 * list.length} for ${list.length}`;
21316
+ const lines = thePlan.flatMap((p) => p.variants.map((v, i) => `${p.product} #${i + 1} [${v.angle}] "${v.headline}" · ${v.cta}`));
21317
+ if (dryRun) {
21318
+ return { content: [{ type: 'text', text: `PLAN (nothing rendered): ${list.length} ad${list.length === 1 ? '' : 's'} (${ratios.join(', ')}).\n${lines.join('\n')}\nQuote: ${quote}.${notes.length ? '\n' + notes.join('\n') : ''}\nTo render exactly these, call make_static_ads again with plan set to this plan and the same aspectRatios.` }], structuredContent: { plan: thePlan, perAdCredits: per, totalCredits: total, dryRun: true, ...(notes.length ? { notes } : {}) } };
21319
+ }
21320
+ if (total != null) {
21321
+ let bal = null; try { bal = (await apiGet('/api/credits'))?.balance; } catch {}
21322
+ if (bal != null && Number.isFinite(+bal) && +bal < total) throw Object.assign(new Error(`This batch needs about ${total} credits (${list.length} ads at about ${per}) and the balance is ${bal}. Nothing was charged. Ask for fewer ads, or top up (buy_credits).`), { status: 402, _userInput: true });
21323
+ }
21324
+ const { now, later } = splitForQueue(list);
21325
+ const heroImg = heroImageOf(brand);
21326
+ const logo = typeof brand?.logo === 'string' && /^https?:\/\//i.test(brand.logo) && !/\.svg(\?|#|$)/i.test(brand.logo) ? brand.logo : '';
21327
+ // THE LOGO, PER AD (2026-10-06): overlay by default (a flat lockup of the real file); in_scene puts it on something in
21328
+ // the ad from the real file and checks it; none leaves it off; auto reads each ad's visual. Never handed over as a
21329
+ // plain reference for the model to re-draw.
21330
+ const lmode = !logo || logoPlacement === 'none' ? 'none' : 'overlay';
21331
+ const lbody = lmode === 'none' ? (logo ? { logoPlacement: 'none' } : {}) : { brandLogo: true, logoPlacement: logoPlacement || 'overlay' };
21332
+ const jobs = [];
21333
+ let stoppedAt = null;
21334
+ for (let i = 0; i < now.length; i++) {
21335
+ const it = now[i];
21336
+ const isHero = it.image ? it.image === heroImg : true;
21337
+ let body;
21338
+ if (isHero && (it.image || brand)) {
21339
+ body = { prompt: staticAdPrompt(it.variant, { logo: lmode }), useBrand: true, ...lbody }; // the server attaches the hero photo and puts the real logo in the way lmode says
21340
+ } else if (it.image) {
21341
+ body = { prompt: staticAdPrompt(it.variant, { logo: lmode, productPhoto: true }), refImages: [it.image], useBrand: true, ...lbody };
21342
+ } else {
21343
+ body = logo ? { prompt: staticAdPrompt(it.variant, { logo: lmode }), useBrand: true, ...lbody } : { prompt: staticAdPrompt(it.variant, { logo: 'none' }), useBrand: false };
21344
+ }
21345
+ try {
21346
+ const q = await apiPost('/api/generate/image', { ...body, aspectRatio: it.aspectRatio, ...(model ? { model } : {}), queue: true });
21347
+ jobs.push({ id: q.jobId, product: it.product, angle: it.variant.angle, headline: it.variant.headline, aspectRatio: it.aspectRatio });
21348
+ } catch (e) {
21349
+ if (e && (e.status === 429 || e.status === 402) && jobs.length) { stoppedAt = i; notes.push(e.status === 429 ? 'The render queue is full, so the rest was not queued.' : String(e.message || '')); break; }
21350
+ throw e;
21351
+ }
21352
+ }
21353
+ const left = [...(stoppedAt == null ? [] : now.slice(stoppedAt)), ...later];
21354
+ const remaining = left.length ? planFromItems(left) : [];
21355
+ const text = `Queued ${jobs.length} static ad${jobs.length === 1 ? '' : 's'} (${quote}).\n${jobs.map((j, i) => `${i + 1}. ${j.product} [${j.angle}] "${j.headline}" ${j.aspectRatio} → job ${j.id}`).join('\n')}${remaining.length ? `\n${left.length} more not queued yet: call make_static_ads again with plan set to \`remaining\` and the same aspectRatios once some of these finish.` : ''}${notes.length ? '\n' + notes.join('\n') : ''}\nRead each with get_job until it reports done; there is no image until then.`;
21356
+ return { content: [{ type: 'text', text }], structuredContent: { plan: thePlan, jobs, ...(remaining.length ? { remaining } : {}), perAdCredits: per, totalCredits: total, ...(notes.length ? { notes } : {}) } };
21357
+ }));
21358
+
21225
21359
  // ---------- research analysis & creative remix (webapp Create-chat parity — the last four app-only chat tools, now headless) ----------
21226
21360
  server.group('research');
21227
21361
  // The web Studio versions of these read the CLIENT's chat/creative state; the MCP variants take explicit inputs and
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.382",
3
+ "version": "0.1.385",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "Marketing on autopilot, run from your own AI agent. 897 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, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. 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.",
5
+ "description": "Marketing on autopilot, run from your own AI agent. 901 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, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. 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",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"