hermoso 0.1.221 → 0.1.224

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
- **822 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **823 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
  **What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
@@ -171,7 +171,7 @@ block entirely if you signed in above; it is there for CI, where the process can
171
171
 
172
172
  Then ask your agent: *“Generate an image ad with Hermoso.”*
173
173
 
174
- ### What the 822 tools cover
174
+ ### What the 823 tools cover
175
175
 
176
176
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
177
177
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/tools.mjs CHANGED
@@ -500,6 +500,14 @@ const AR_UNIVERSAL = ['9:16', '16:9'];
500
500
  const RAW_TOOL_NOTE = 'raw:true dispatches your prompt to the model BYTE-IDENTICAL — no rewriting, no appended guidance, no negative prompt, no brand references attached on your behalf. Credits, the durable delivery of the finished asset and the per-model validation are unchanged.';
501
501
  const AD_LENGTH_MAX = 180, AD_LENGTH_MIN = 4;
502
502
  const clampAdSeconds = (n) => Math.max(AD_LENGTH_MIN, Math.min(AD_LENGTH_MAX, Math.round(n)));
503
+ // What a video reference ACTUALLY gave the planner (/api/create's `reference_watched`), in one line. A remix of footage
504
+ // nobody watched must say so, so the ⚠ note is printed whenever the server wrote one.
505
+ const refWatchedLine = (w) => {
506
+ if (!w) return '';
507
+ const src = { tiktok: 'TikTok', instagram: 'Instagram', facebook: 'Facebook', x: 'X', youtube: 'YouTube' }[w.platform] || 'video';
508
+ const got = [w.durationSeconds ? `${w.durationSeconds}s` : '', w.frames ? `${w.frames} ${w.footage ? 'frames' : 'thumbnail'}` : '', w.transcript ? 'transcript' : ''].filter(Boolean).join(' · ');
509
+ return `\nWatched: ${src}${got ? ` — ${got}` : ''}${w.note ? `\n⚠ ${w.note}` : ''}`;
510
+ };
503
511
 
504
512
  // ── THE ATTRIBUTION PAIR EVERY PUBLISH TOOL CARRIES (2026-08-05) ─────────────────────────────────────────────────
505
513
  // The hook and the subject are the INTENT behind a post, and publish time is the ONLY moment they exist: a caption
@@ -3285,7 +3293,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3285
3293
  // will; if it does not name the brand we just pinned, the switch did not take and saying otherwise is the bug.
3286
3294
  const w = await apiGet('/api/workspace').catch(() => null);
3287
3295
  const took = w && (hit.id === 'default' ? (!w.profileId || w.profileId === 'default' || w.storeSuffix === '') : String(w.profileId) === String(hit.id)) && !w.shared;
3288
- if (!took) return { content: [{ type: 'text', text: `The switch to ${hit.name} (${hit.id}) did NOT take: after pinning, the server still resolves this connection to ${w ? `profile ${w.profileId || 'default'}${w.shared ? ' (a shared workspace)' : ''}` : 'an unreadable workspace'}. Nothing was changed on that brand. If this is a CLI, an exported HERMOSO_PROFILE / HERMOSO_OWNER in your shell is overriding the key's pin — unset it and try again.` }], isError: true };
3296
+ if (!took) return { content: [{ type: 'text', text: `The switch to ${hit.name} (${hit.id}) did NOT take: after pinning, the server still resolves this connection to ${w ? `profile ${w.profileId || 'default'}${w.shared ? ' (a shared workspace)' : ''}` : 'an unreadable workspace'}. Nothing was changed on that brand. If this is a CLI, an exported ${ENV_PREFIX}_PROFILE / ${ENV_PREFIX}_OWNER in your shell is overriding the key's pin — unset it and try again.` }], isError: true };
3289
3297
  // The roster was gated to the workspace this SESSION started in; the workspace just moved, so re-gate it.
3290
3298
  const _rg = await regateForWorkspace(ctx);
3291
3299
  const _rgNote = _rg && _rg.providers
@@ -4252,7 +4260,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4252
4260
  locationId: z.string().optional().describe("GOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user."),
4253
4261
  visibility: z.enum(['public', 'unlisted', 'private', 'draft']).optional().describe("how it should be published — DEFAULT 'public' (live). Only pass something else if the user explicitly asked to stage/hide it. Not every channel supports every value; an impossible combination is refused when you schedule it, with the reason."),
4254
4262
  visibilityByChannel: z.record(z.string()).optional().describe('override visibility for one channel, e.g. { "tiktok": "draft" } to go live everywhere but stage TikTok for review'),
4255
- optimizeCopy: z.boolean().optional().describe('RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules at publish time wherever no per-channel caption was written — YouTube gets a keyword title, a structured multi-paragraph description and search tags; Instagram/TikTok hashtags; LinkedIn longer; X/Bluesky short; Pinterest keyword-rich. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked.'),
4263
+ optimizeCopy: z.boolean().optional().describe('RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written — YouTube gets a keyword title, a structured multi-paragraph description and search tags; Instagram/TikTok hashtags; LinkedIn longer; X/Bluesky short; Pinterest keyword-rich. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked.'),
4256
4264
  },
4257
4265
  outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), label: z.string().optional() },
4258
4266
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -4292,7 +4300,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4292
4300
  at: z.string().optional().describe('the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.'),
4293
4301
  message: z.string().optional().describe('replace the caption used for every channel that has no override'),
4294
4302
  captions: z.record(z.string()).optional().describe('replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one'),
4295
- optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules at publish time wherever no per-channel caption was written (YouTube keyword title + structured description + tags, Instagram/TikTok hashtags, LinkedIn longer, X/Bluesky short, Pinterest keyword-rich); a channel with its own caption is left exactly as written. Send false to switch it off on this item.'),
4303
+ optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written (YouTube keyword title + structured description + tags, Instagram/TikTok hashtags, LinkedIn longer, X/Bluesky short, Pinterest keyword-rich); a channel with its own caption is left exactly as written. Send false to switch it off on this item.'),
4296
4304
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('replaces the channel list'),
4297
4305
  imageUrl: z.string().optional().describe('swap the image; "" removes it'),
4298
4306
  videoUrl: z.string().optional().describe('swap the video; "" removes it'),
@@ -15629,7 +15637,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15629
15637
  hook: z.string().optional().describe('force the VISUAL scroll-stop mechanic the opening beat is built on — a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "macro_asmr"). Omit to let the planner pick. A hook that cannot be delivered in this brief is DROPPED with the reason rather than rendered wrongly — an on-screen-text hook on an authentic/UGC ad is the one that bites, because that register carries zero on-screen text.'),
15630
15638
  setting: z.string().optional().describe('force the WHERE — a setting id from list_hooks (e.g. "kitchen", "gym", or a surreal one like "volcano_rim" / "airplane_wing", which are played 100% straight and never acknowledged). Omit for a neutral setting.'),
15631
15639
  recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
15632
- reference: z.string().optional().describe('a reference ad URL to remix the angle from — Facebook Ad Library, LinkedIn Ad Library or Google Ads Transparency links (the real ad’s copy/advertiser are fetched and fed into the concept)'),
15640
+ reference: z.string().optional().describe('a reference to remix: 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'),
15633
15641
  language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
15634
15642
  },
15635
15643
  outputSchema: {
@@ -15690,7 +15698,56 @@ function buildTools(rawServer, opts = {}, sink = null) {
15690
15698
  + (_askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, which is outside the supported 4–180s range, so it was clamped to ${_len}s` : '')
15691
15699
  + (_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` : '');
15692
15700
  }
15693
- const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${_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'}.`;
15701
+ 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'}.`;
15702
+ return ok(text, c);
15703
+ }));
15704
+
15705
+ // ── CLONE A VIDEO FROM A LINK (2026-09-11) ────────────────────────────────────────────────────────────────────
15706
+ // Arcads' "paste a TikTok, clone it for my brand". The server does the watching (/api/create resolves a social post
15707
+ // to its real file, samples frames across it and transcribes it) and plans a board that keeps the ORIGINAL'S
15708
+ // skeleton — hook device, segment map, deliberate jump cuts, pacing — with this brand's product, cast and words.
15709
+ // It stops at the plan on purpose: render_ad is the spend, and the user sees the concept before paying for it.
15710
+ // Streamed (apiSSE), because watching + planning can outlast a 100s proxy limit on the non-streaming path.
15711
+ server.registerTool('clone_video', {
15712
+ title: 'Clone a video for your brand',
15713
+ description: 'Remake a video you like FOR THIS BRAND from its link — a TikTok, Instagram Reel, Facebook video or reel, X post, YouTube Short or video, or a direct video file URL. Hermoso WATCHES it first (frames across the whole clip plus a transcript of the voiceover, on-screen text and cut map), then plans a storyboard that keeps its hook device, structure, jump cuts and pacing while swapping in THIS brand\'s product, cast, setting and words — never the original\'s words, face or brand. The new ad MATCHES THE ORIGINAL\'S LENGTH (capped at 60s) unless durationSeconds is given. Renders nothing: pass the returned creative to render_ad to make the video. Costs the plan plus about 2 credits to read the link. The reply says exactly what was watched, and when a platform will not hand over the footage (YouTube sometimes refuses servers) it says the plan rests on the captions and thumbnail only. For a local file, upload_file it first and pass the URL.',
15714
+ inputSchema: {
15715
+ url: z.string().describe('the video to clone — a TikTok / Instagram Reel / Facebook / X / YouTube link, or a direct https video file URL'),
15716
+ product: z.string().optional().describe('what the new ad sells, plus any angle or offer; omit to use the saved brand\'s product'),
15717
+ changes: z.string().optional().describe('what to change or keep from the original, in the user\'s words (e.g. "same hook but in a gym", "keep the jump cut, older creator")'),
15718
+ brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace\'s saved brand (see get_brand)'),
15719
+ durationSeconds: z.number().optional().describe('override the length in seconds; omit to match the original'),
15720
+ language: z.string().optional().describe('language for the new ad\'s script and copy — default English'),
15721
+ },
15722
+ outputSchema: {
15723
+ format: z.string().optional().describe("always 'video'"),
15724
+ concept: z.string().optional().describe('the one-line creative concept'),
15725
+ copy: z.array(z.any()).optional().describe('copy variants ({headline, primary, cta})'),
15726
+ video_storyboard: z.any().optional().describe('the timed storyboard rebuilt from the original'),
15727
+ render_plan: z.any().optional().describe('the routing plan (structure/duration) render_ad honors'),
15728
+ vmodel: z.string().optional().describe('the video model id to render with'),
15729
+ reference_watched: z.any().optional().describe('what was actually read from the link: platform, durationSeconds, frames, transcript, footage, note'),
15730
+ brand: z.any().optional().describe('the brand grounding embedded in the creative'),
15731
+ },
15732
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
15733
+ }, wrap(async ({ url, product, changes, brand, durationSeconds, language }) => {
15734
+ const link = String(url || '').trim();
15735
+ if (!/^https?:\/\//i.test(link)) return ok('clone_video needs an https:// link to the video (a TikTok, Reel, Facebook, X or YouTube post, or a direct file). For a file on this machine, call upload_file first and pass the URL it returns. Nothing was charged.');
15736
+ const _askedLen = +durationSeconds > 0 ? Math.round(+durationSeconds) : 0;
15737
+ const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
15738
+ let brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
15739
+ if (typeof brand === 'string' && brand.trim()) {
15740
+ const _n = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
15741
+ try { const cur = await apiGet('/api/brand/current'); if (cur?.hasBrand && cur.brand && _n(cur.brand.name) === _n(brand)) brandObj = cur.brand; } catch {}
15742
+ }
15743
+ const brief = [`Recreate the reference video for this brand${product ? ` — advertising ${product}` : ''}: keep its hook device, structure, cuts and pacing, but make every word, face, setting and product this brand's own`, changes ? `What the user wants changed or kept: ${changes}` : ''].filter(Boolean).join('. ');
15744
+ const d = await apiSSE('/api/create', { stream: true, brand: brandObj, product: brief, format: 'video', reference: { url: link }, matchReferenceLength: !_len, ...(_len ? { durationSeconds: _len } : {}), language: language || '', userAsk: brief });
15745
+ const c = d?.data?.creative || d?.creative || d;
15746
+ if (brandObj && !c.brand) c.brand = { name: brandObj.name || '', domain: brandObj.domain || '', logo: brandObj.logo || '', sells: brandObj.sells || '', palette: (brandObj.palette || []).slice(0, 4), productImages: (brandObj.productImages || []).slice(0, 4) };
15747
+ const planned = Math.round(+c.render_plan?.duration_seconds || (c.video_storyboard?.scenes || []).reduce((s, x) => s + (+x.seconds || 0), 0) || 0);
15748
+ const w = c.reference_watched || null;
15749
+ const lenNote = _askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, outside the supported 4–180s, so it was clamped to ${_len}s` : (!_len && w?.lengthMatched ? (w.durationSeconds > w.lengthMatched + 1 ? ` — the original is ${w.durationSeconds}s; a clone matches its length up to 60s, so pass durationSeconds for a different length` : ' — matches the original') : '') + (!_len && w?.lengthMatched && planned && Math.abs(planned - w.lengthMatched) > 1 ? ` — ⚠ the plan came out at ${planned}s, not the ${w.lengthMatched}s asked for; say so before rendering` : '');
15750
+ const text = `Clone plan: "${c.concept || ''}"${refWatchedLine(w)}${w ? '' : '\n⚠ The link was not watched — the plan is not grounded in the original video.'}\nLength: ${planned || '—'}s${lenNote}\nHeadline: ${c.copy?.[0]?.headline || ''}\nScenes: ${(c.video_storyboard?.scenes || []).length}. Render model: ${c.vmodel || '—'}.\nNext: call render_ad with THIS ENTIRE creative object to make the video (it spends credits; show the user the concept first).`;
15694
15751
  return ok(text, c);
15695
15752
  }));
15696
15753
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.221",
3
+ "version": "0.1.224",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio and marketing MCP server with 822 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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.",
5
+ "description": "AI ad studio and marketing MCP server with 823 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"