hermoso 0.1.483 → 0.1.485

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.
@@ -1256,7 +1256,7 @@ export const neutralNote = (t) => {
1256
1256
  // sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
1257
1257
  // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
1258
1258
  // fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
1259
- export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['fallate', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'emailfeedback']);
1259
+ export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['fallate', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'emailfeedback']);
1260
1260
  const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
1261
1261
  const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
1262
1262
  const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
package/mcp/tools.mjs CHANGED
@@ -6072,8 +6072,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
6072
6072
  paidPartnership: z.boolean().optional().describe('INSTAGRAM — the PAID PARTNERSHIP label. A COMPLIANCE DECLARATION: set it whenever the post is sponsored, gifted or otherwise paid for. Opt-in and never inferred — it is the poster’s own statement about their commercial relationship.'),
6073
6073
  brandedContentSponsorIds: z.array(z.string()).optional().describe('INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.'),
6074
6074
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel.'),
6075
- story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
6076
- aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
6075
+ story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. An Instagram Story keeps its caption: people see the start under the Story in the app and tap for the rest (instagram.com does not show it). It takes NO collaborators, product tags or trialReel \u2014 any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
6076
+ aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL / THREADS \u2014 Meta\u2019s is_ai_generated self-disclosure (Threads: is_made_with_ai, the AI info label; a text-only Threads post is declared only when you pass true). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
6077
6077
  altText: z.union([z.string(), z.array(z.string())]).optional().describe('ACCESSIBILITY \u2014 the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order \u2014 use that on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta\u2019s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY \u2014 Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (Meta\u2019s AI disclosure defaults from PROVENANCE: a Hermoso render is declared is_ai_generated; media that came through upload_file or an external URL, i.e. the user\u2019s own photos or footage, is NOT. Pass `aiGenerated` to force it either way.)'),
6078
6078
  pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
6079
6079
  // ── THREADS PUBLISH KNOBS (2026-08-17). Meta documents ~24 container parameters and we sent five; these are
@@ -6235,8 +6235,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
6235
6235
  instagramLocationId: z.string().optional().describe('INSTAGRAM — tag a place (called locationId on post_to_meta; locationId here is the Google Business listing). It is the pageId from search_meta_places, the NUMERIC ID OF A FACEBOOK PAGE associated with that location, not a place name and not coordinates; a non-numeric value is refused rather than sent.'),
6236
6236
  brandedContentSponsorIds: z.array(z.string()).optional().describe('INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.'),
6237
6237
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.'),
6238
- story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
6239
- aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
6238
+ story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. An Instagram Story keeps its caption: people see the start under the Story in the app and tap for the rest (instagram.com does not show it). It takes NO collaborators, product tags or trialReel \u2014 any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
6239
+ aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
6240
6240
  // ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
6241
6241
  // to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
6242
6242
  boardId: z.string().optional().describe('PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately.'),
@@ -6422,7 +6422,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6422
6422
  linkPicture: z.string().optional().describe('FACEBOOK — replaces the link preview image url; an empty string removes the override.'),
6423
6423
  audience: z.object({ countries: z.array(z.string()).optional().describe('Facebook Page post audience: two-letter country codes, e.g. CA, US'), regions: z.array(z.string()).optional().describe('Facebook Page post audience: Meta numeric region keys (the ads location search returns them)'), cities: z.array(z.string()).optional().describe('Facebook Page post audience: Meta numeric city keys (the ads location search returns them)'), minAge: z.number().optional().describe('Facebook Page post audience: minimum age, one of 13, 15, 18, 21 or 25') }).optional().describe('FACEBOOK — replaces who can see the Page post {countries, regions, cities, minAge}; {} removes the limit.'),
6424
6424
  targetAudience: z.object({ geoLocations: z.array(z.string()).optional().describe('LinkedIn geo facet: numeric id or urn:li:geo value from search_linkedin_ads_targeting'), industries: z.array(z.string()).optional().describe('LinkedIn industry facet: numeric id or urn:li:industry value from search_linkedin_ads_targeting'), seniorities: z.array(z.string()).optional().describe('LinkedIn seniority facet: numeric id or urn:li:seniority value from search_linkedin_ads_targeting'), jobFunctions: z.array(z.string()).optional().describe('LinkedIn job function facet: numeric id or urn:li:function value from search_linkedin_ads_targeting'), staffCountRanges: z.array(z.string()).optional().describe('LinkedIn company size bands, e.g. SIZE_11_TO_50'), degrees: z.array(z.string()).optional().describe('LinkedIn degree facet: numeric id or urn:li:degree value from search_linkedin_ads_targeting'), fieldsOfStudy: z.array(z.string()).optional().describe('LinkedIn field of study facet: numeric id or urn:li:fieldOfStudy value from search_linkedin_ads_targeting'), organizations: z.array(z.string()).optional().describe('LinkedIn school or organization facet: numeric id or urn:li:organization value from search_linkedin_ads_targeting') }).optional().describe('LINKEDIN COMPANY PAGE — replaces who sees the post; {} removes the limit. The matching audience must be over 300 followers.'),
6425
- aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures). OMIT IT and the value already on the post stays; a post that never had one is decided from provenance at publish: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
6425
+ aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and the value already on the post stays; a post that never had one is decided from provenance at publish: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
6426
6426
  boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
6427
6427
  chatId: z.string().optional().describe('TELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one.'),
6428
6428
  buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — replace the URL buttons ([] removes them).'),
@@ -8612,7 +8612,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8612
8612
  excludedCustomAudiences: metaIdList.describe('custom audiences to exclude: list of {id, name}'),
8613
8613
  locales: z.array(z.number()).optional().describe('Meta language ids from find_meta_audiences(type:"adlocale")'),
8614
8614
  publisherPlatforms: z.array(z.enum(['facebook', 'instagram', 'audience_network', 'messenger', 'threads'])).optional().describe('placement platforms: facebook, instagram, audience_network, messenger or threads'),
8615
- facebookPositions: z.array(z.string()).optional().describe('feed, story, facebook_reels, marketplace, video_feeds, search, instream_video, right_hand_column, …'),
8615
+ facebookPositions: z.array(z.string()).optional().describe('feed, story, facebook_reels, marketplace, search, instream_video, right_hand_column, …'),
8616
8616
  instagramPositions: z.array(z.string()).optional().describe('stream, story, reels, explore, profile_feed, …'),
8617
8617
  messengerPositions: z.array(z.string()).optional().describe('Messenger placements, e.g. sponsored_messages'), audienceNetworkPositions: z.array(z.string()).optional().describe('Audience Network placement names, as Meta defines them'),
8618
8618
  devicePlatforms: z.array(z.enum(['mobile', 'desktop'])).optional().describe('mobile or desktop'), userOs: z.array(z.enum(['iOS', 'Android'])).optional().describe('iOS or Android'),
@@ -8680,7 +8680,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8680
8680
  cards: z.array(z.object({ headline: z.string().optional().describe('headline for this carousel card'), description: z.string().optional().describe('description line for this carousel card'), link: z.string().optional().describe('destination URL for this carousel card') })).optional().describe('carousel cards in order — each may set its own headline/description/link'),
8681
8681
  carouselEndCard: z.boolean().optional().describe('append the Page end card to a carousel'),
8682
8682
  link: z.string().optional().describe('destination URL (defaults to the brand domain)'),
8683
- cta: z.string().optional().describe('call-to-action, e.g. SHOP_NOW / LEARN_MORE / SIGN_UP (default LEARN_MORE); WHATSAPP_MESSAGE for a click-to-WhatsApp ad (see destinationType).'),
8683
+ cta: z.string().optional().describe('call-to-action, e.g. SHOP_NOW / LEARN_MORE / SIGN_UP (default LEARN_MORE); WHATSAPP_MESSAGE for a click-to-WhatsApp ad (see destinationType). Any Meta call-to-action type works (SEE_DETAILS…); unknown ones are refused, not swapped.'),
8684
8684
  leadFormId: z.string().optional().describe('LEAD ADS — attach an instant form (from list_meta_lead_forms / create_meta_lead_form) so the button opens that form INSIDE Facebook/Instagram instead of sending the click to a website. Requires objective:"OUTCOME_LEADS"; the ad set is then optimized for LEAD_GENERATION and pointed at the form automatically. Read the submissions afterwards with read_meta_leads.'),
8685
8685
  objective: z.enum(['OUTCOME_TRAFFIC', 'OUTCOME_AWARENESS', 'OUTCOME_ENGAGEMENT', 'OUTCOME_LEADS', 'OUTCOME_SALES']).optional().describe('default OUTCOME_TRAFFIC'),
8686
8686
  ...metaAdSetFields,
@@ -22263,6 +22263,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
22263
22263
  title: 'Generate video',
22264
22264
  description: 'Use this when the user asks for a video clip, b-roll or a shot from a description, or names a video model. For a finished video ad, Reel, TikTok ad or UGC video for their business, use render_ad. It renders one continuous clip on a real video model and returns its mp4 URL. A clip runs up to the model’s longest listed duration (the longest takes a multi-beat 30s spot in ONE take with native audio), so never assume an 8–10s cap or stitch what fits one clip; durationSeconds must be one of the model’s durations in hermoso_capabilities. NAME A MODEL in `model` to get it: an unnamed render uses a narrower auto-pool. 1–3 min. refImage anchors the first frame; ttsScript adds a voiceover. AUDIO IS NOT FREE BY DEFAULT: a clip with no audio of its own gets a music bed composed and CHARGED on top (see musicMood and audio), which can cost as much as a cheap draft clip. refVideo (a clip URL) EDITS that clip instead, keeping its canvas and length. RAW MODEL ACCESS: by default small guards are added (label safety with no reference image, a negative prompt where the model takes one, reference lines) and hex colours become names; ' + RAW_TOOL_NOTE + ' Spends credits (blocked on the Starter plan).',
22265
22265
  inputSchema: {
22266
+ dryRun: z.boolean().optional().describe('true = render NOTHING and return the credits this exact clip would hold (an estimate; the final charge is its actual cost). Free. Call this to price a clip before running it: without it the call renders and bills.'),
22266
22267
  prompt: z.string().optional().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction); optional with `shots`'),
22267
22268
  raw: z.boolean().optional().describe('Send the prompt to the model exactly as written, without the wording Hermoso usually adds (packaging and label guidance, reference lines, colour names for hex codes, quality hints). Use it when you want the model\'s own interpretation 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.'),
22268
22269
  refImage: z.string().optional().describe('local path or URL to anchor the first frame. ' + LIKENESS_TERMS),
@@ -22361,6 +22362,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
22361
22362
  if (a.endImage) a = { ...a, endImage: await toRef(a.endImage) };
22362
22363
  // an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
22363
22364
  // treatment (#310) instead of being treated as a system pick the fallback ladders may silently reroute
22365
+ // A PRICE CHECK RENDERS NOTHING (2026-10-31). generate_video had no dryRun at all: the key was dropped by the schema and the call
22366
+ // queued and billed a real clip. The same server-side quote the app's card and the other fix tools read, stopped at the hold.
22367
+ if (a.dryRun) { const { dryRun: _dr, ...rest } = a; const q = await quoteJob('video', { ...rest, refImage, modelExplicit: !!a.model }); return ok(quoteText(q, 'This video clip'), { raw: q }); }
22364
22368
  const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model, ...(a.cameraTrajectory ? { cameraTrajectory: a.cameraTrajectory } : a.cameraMove ? { cameraTrajectory: a.cameraMove } : {}) }, 'MCP video');
22365
22369
  const _iid = renderPayload(r)?.interactionId;
22366
22370
  return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_iid ? `\ninteractionId: ${_iid} (pass it with extend:true on the same model to continue this clip)` : ''}${switchNote(r)}${_srcLenNote}`, r);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.483",
3
+ "version": "0.1.485",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1091 tools. No markup on models. Publishing, scheduling, ad campaign management, comments, DMs and analytics are free on every plan. 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, Telegram and Google Business Profile. 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",