hermoso 0.1.330 → 0.1.335

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.
Files changed (2) hide show
  1. package/mcp/tools.mjs +23 -9
  2. package/package.json +1 -1
package/mcp/tools.mjs CHANGED
@@ -12944,7 +12944,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12944
12944
  name: z.string(),
12945
12945
  objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS \u2014 which is what Reddit calls website traffic'),
12946
12946
  spendCapCents: z.number().optional().describe('lifetime spend ceiling for the whole campaign, in minor units of the ad account\u2019s currency'),
12947
- useCatalog: z.boolean().optional().describe('SALES only: true makes it a product-catalog campaign (the new spelling of CATALOG_SALES) \u2014 every ad group under it must then use a catalog'),
12947
+ useCatalog: z.boolean().optional().describe('SALES only: true makes it a product-catalog campaign (the new spelling of CATALOG_SALES) \u2014 every ad group under it must then use a catalog (create_reddit_ads_ad_group with productSetId + shoppingType). Frozen once created'),
12948
12948
  },
12949
12949
  outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), useCatalog: z.boolean().optional(), adAccountId: z.string().optional() },
12950
12950
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -12969,6 +12969,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
12969
12969
  conversionPixelId: z.string().optional().describe('only needed when the ad account has more than one pixel'),
12970
12970
  optimizationGoal: z.string().optional().describe('the event this ad group optimizes toward, e.g. PURCHASE, SIGN_UP, LEAD, ADD_TO_CART, PAGE_VISIT, CLICKS, VIDEO_VIEW_6S; leave it unset on a CONVERSIONS or SALES campaign to take the campaign\u2019s. CUSTOM_EVENT (new) optimizes toward a custom pixel event: Reddit switches it on per ad account and answers "not available for this ad account" where it has not. Cannot be changed later'),
12971
12971
  savedAudienceId: z.string().optional().describe('reuse a saved audience instead of spelling targeting out — from list_reddit_ads_saved_audiences'),
12972
+ viewThroughConversionType: z.enum(['SEVEN_DAY_CLICKS', 'SEVEN_DAY_CLICKS_ONE_DAY_VIEW']).optional().describe('the conversion window counted. SEVEN_DAY_CLICKS_ONE_DAY_VIEW is only accepted with a conversion goal (PURCHASE, SIGN_UP, LEAD, ADD_TO_CART, PAGE_VISIT), and Reddit requires it for a catalog SALES ad group optimizing for PURCHASE. Cannot be changed later'),
12973
+ productSetId: z.string().optional().describe('CATALOG SALES — the Reddit product set this ad group advertises (a UUID from Reddit Ads Manager > Catalogs). Required on every ad group under a SALES campaign created with useCatalog:true; Reddit silently drops it on any other campaign, and the note says so'),
12974
+ shoppingType: z.enum(['DYNAMIC', 'STATIC']).optional().describe('CATALOG SALES — DYNAMIC builds each ad from the product set, STATIC links a normal ad to the catalog. Required on every ad group under a useCatalog:true SALES campaign'),
12972
12975
  targeting: z.object({
12973
12976
  communities: z.array(z.string()).optional().describe('bare subreddit NAMES, e.g. ["running"] — not t5_ ids, not "r/running"'),
12974
12977
  excludedCommunities: z.array(z.string()).optional(),
@@ -12997,7 +13000,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12997
13000
  endHour: z.number(),
12998
13001
  })).optional().describe('weekly dayparting windows — omit to run all week'),
12999
13002
  },
13000
- outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), effectiveStatus: z.string().optional(), budget: z.number().nullable().optional(), note: z.string().optional() },
13003
+ outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), effectiveStatus: z.string().optional(), budget: z.number().nullable().optional(), optimizationGoal: z.string().nullable().optional(), viewThroughConversionType: z.string().nullable().optional(), productSetId: z.string().nullable().optional(), shoppingType: z.string().nullable().optional(), note: z.string().optional() },
13001
13004
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
13002
13005
  }, wrap(async (a) => {
13003
13006
  const d = await apiPost('/api/reddit-ads/ad-groups', a);
@@ -13018,10 +13021,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
13018
13021
  startTime: z.string().optional(),
13019
13022
  endTime: z.string().optional(),
13020
13023
  savedAudienceId: z.string().optional().describe('point this ad group at a saved audience instead'),
13024
+ productSetId: z.string().optional().describe('CATALOG SALES — rebind the ad group to another product set (a UUID from Reddit Ads Manager > Catalogs). Reddit stores an edit without checking the set exists or that the campaign uses a catalog, so pass the right id; the note says if it was not kept'),
13025
+ shoppingType: z.enum(['DYNAMIC', 'STATIC']).optional().describe('CATALOG SALES — DYNAMIC or STATIC product ads. Reddit has been measured keeping the value an ad group was created with, and the note says when it did not change'),
13021
13026
  targeting: z.record(z.any()).optional().describe('same shape as create_reddit_ads_ad_group — REPLACES the existing targeting'),
13022
13027
  schedule: z.array(z.any()).optional(),
13023
13028
  },
13024
- outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), budget: z.number().nullable().optional(), note: z.string().optional() },
13029
+ outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), budget: z.number().nullable().optional(), productSetId: z.string().nullable().optional(), shoppingType: z.string().nullable().optional(), note: z.string().optional() },
13025
13030
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
13026
13031
  }, wrap(async (a) => {
13027
13032
  const d = await apiPost('/api/reddit-ads/ad-groups/update', a);
@@ -13133,7 +13138,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13133
13138
  }));
13134
13139
  server.registerTool('update_reddit_ads_campaign', {
13135
13140
  title: 'Edit a Reddit campaign',
13136
- description: 'Change an existing Reddit campaign’s name, spend cap, budget, goal type or flight dates. Amounts are ordinary numbers in the ad account’s currency. This does NOT activate, pause or archive anything — use set_reddit_ads_status for that. The result is read back from Reddit before you are told it took.',
13141
+ description: 'Change an existing Reddit campaign’s name, spend cap, budget, goal type or flight dates. Amounts are ordinary numbers in the ad account’s currency. The OBJECTIVE and useCatalog cannot be changed: Reddit freezes both at creation and refuses a change even from an old objective name to its new one (IMPRESSIONS to BRAND_AWARENESS), so create a new campaign instead. This does NOT activate, pause or archive anything — use set_reddit_ads_status for that. The result is read back from Reddit before you are told it took.',
13137
13142
  inputSchema: {
13138
13143
  adAccountId: z.string().optional(),
13139
13144
  campaignId: z.string(),
@@ -17479,11 +17484,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
17479
17484
 
17480
17485
  server.registerTool('post_edit', {
17481
17486
  title: 'Post-production edit',
17482
- description: "MECHANICAL post-production on an EXISTING video (URL): ordered primitives run by ffmpeg in seconds, ~2 credits flat, NO AI model, as a NEW video. Ops: a branded end card (adds its seconds), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, watermark (brand logo), grain (anti-AI), text (timed words in a native look: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle; start/end), join (this video FOLLOWED BY clips[]: Library URLs, direct files or public post links, as one 1080x1920 video, loudness matched). Presets are shortcuts: text/watermark take any x/y, grain any amount, join any ffmpeg transition, a bridge any sound link. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}], bridge}], and it ALWAYS gets a bridge unless the user asks for a bare cut: {kind:'impact'} cuts the hook just before its payoff (found from the footage; cutAt overrides) and lands our clip on a punch-in and flash, with the payoff sound FROM THE HOOK ITSELF: its own audio carries across the cut, else one generated from its frames (up to 8 credits), else a neutral impact. {kind:'text', text, then?} only when the user asks for words over the cut. No voiceover bridge: have our clip's host say the connecting line. matchCut = where our clip starts. ANY OTHER EDIT, op or bridge (whip, zoom, freeze, wipe, split screen, generated shot) is edit_timeline: free keyframes, any size. NEVER generate_video/render_ad for these.",
17487
+ description: "MECHANICAL post-production on an EXISTING video (URL): ordered primitives run by ffmpeg in seconds, ~2 credits flat, NO AI model, as a NEW video. Ops: a branded end card (adds its seconds), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, music (a bed UNDER the clip, its own sound kept and ducked under: omit track = a free library track picked by mood, no attribution; or track = an audio link: an upload_file URL, a find_sound link, a video post's sound; track 'generate' composes it, paid, ONLY when the user asks; start/end, db, replace), watermark (brand logo), grain (anti-AI), text (timed words in a native look: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle; start/end), join (this video FOLLOWED BY clips[]: Library URLs, direct files or public post links, as one 1080x1920 video, loudness matched). Presets are shortcuts: text/watermark take any x/y, grain any amount, join any ffmpeg transition, a bridge any sound link. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}], bridge}], and it ALWAYS gets a bridge unless the user asks for a bare cut: {kind:'impact'} cuts the hook just before its payoff (found from the footage; cutAt overrides) and lands our clip on a punch-in and flash, with the payoff sound FROM THE HOOK ITSELF: its own audio carries across the cut, else one generated from its frames (up to 8 credits), else a neutral impact. {kind:'text', text, then?} only when the user asks for words over the cut. No voiceover bridge: have our clip's host say the connecting line. matchCut = where our clip starts. ANY OTHER EDIT, op or bridge (whip, zoom, freeze, wipe, split screen, generated shot) is edit_timeline: free keyframes, any size. NEVER generate_video/render_ad for these.",
17483
17488
  inputSchema: {
17484
17489
  videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link'),
17485
17490
  ops: z.array(z.object({
17486
- op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'append_card', 'watermark', 'grain', 'text', 'join']),
17491
+ op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'music', 'append_card', 'watermark', 'grain', 'text', 'join']),
17487
17492
  start: z.number().optional().describe('trim/mute/text window start (s)'),
17488
17493
  end: z.number().optional().describe('trim/mute/text window end (s)'),
17489
17494
  text: z.string().optional().describe('text: the words, verbatim'),
@@ -17498,7 +17503,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
17498
17503
  transition: z.string().optional().describe("join: 'cut' default, 'crossfade', or any ffmpeg xfade name (wipeleft…)"),
17499
17504
  bridge: z.object({ kind: z.enum(['impact', 'text']), cutAt: z.number().optional().describe('omit: found from the footage'), matchCut: z.number().optional(), sound: z.string().optional().describe("'auto' default, 'own', 'impact', 'whoosh', 'none', or an audio URL (find_sound)"), flash: z.boolean().optional(), shake: z.boolean().optional(), text: z.string().optional(), then: z.string().optional() }).optional().describe('join: connects the hook to the first clip'),
17500
17505
  factor: z.number().optional().describe('speed 0.5-2'),
17501
- db: z.number().optional().describe('audio_gain -20..+6 dB'),
17506
+ db: z.number().optional().describe('audio_gain -20..+6 dB / music: trim on its automatic level, -20..+12'),
17507
+ track: z.string().optional().describe("music: omit = a library track by mood; a library track name; an audio link; 'generate' (paid, only on the user's ask)"),
17508
+ mood: z.string().optional().describe('music: mood words for the library pick, or the brief for generated music'),
17509
+ from: z.number().optional().describe('music: the second of the track to start from'),
17510
+ duck: z.union([z.literal('auto'), z.boolean()]).optional().describe("music: 'auto' default = dips while the clip's own sound plays"),
17511
+ replace: z.boolean().optional().describe("music: true = replaces the clip's own sound"),
17512
+ fadeOut: z.number().optional().describe('music: fade-out seconds 0-5'),
17502
17513
  seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s / transition 0.2-1.5s'),
17503
17514
  headline: z.string().optional().describe('append_card: big line (default: brand name)'),
17504
17515
  tagline: z.string().optional().describe('append_card: smaller line under the headline'),
@@ -18111,12 +18122,14 @@ const MEMORY_PII_RE = /(\+\d[\d\s().-]{7,}\d)|(\(?\b\d{3}\)?[\s.-]\d{3}[\s.-]\d{
18111
18122
  // A conversation's language is not a brand fact; a market's is (journey QA 2026-09-25 — lib/memory-hygiene.mjs says why).
18112
18123
  const MEMORY_LANG_RE = /\b(?:(?:in|en)\s+(?:English|Spanish|French|German|Portuguese|Italian|Dutch|Polish|Turkish|Indonesian|Swedish|Romanian|Japanese|Korean|Chinese|Mandarin|Cantonese|Arabic|Hebrew|Russian|Ukrainian|Hindi|Bengali|Thai|Greek|Vietnamese|Czech|Danish|Finnish|Hungarian|Norwegian|Malay|Filipino|Tagalog|Urdu|Persian|Farsi|Swahili|español|espanol|castellano|français|francais|deutsch|português|portugues|italiano|nederlands)|(?:English|Spanish|French|German|Portuguese|Italian|Dutch|Polish|Turkish|Indonesian|Swedish|Romanian|Japanese|Korean|Chinese|Mandarin|Cantonese|Arabic|Hebrew|Russian|Ukrainian|Hindi|Bengali|Thai|Greek|Vietnamese|Czech|Danish|Finnish|Hungarian|Norwegian|Malay|Filipino|Tagalog|Urdu|Persian|Farsi|Swahili)(?:[- ](?:language|speaking)|\s+(?:copy|ads?|replies|responses|answers|messages|captions|text|version|translations?))|(?:speaks?|writes?|communicates?|chats?|talks?|converses?|types?|messages?|replies|responds?|prefers?)\s+(?:in\s+)?(?:English|Spanish|French|German|Portuguese|Italian|Dutch|Polish|Turkish|Indonesian|Swedish|Romanian|Japanese|Korean|Chinese|Mandarin|Cantonese|Arabic|Hebrew|Russian|Ukrainian|Hindi|Bengali|Thai|Greek|Vietnamese|Czech|Danish|Finnish|Hungarian|Norwegian|Malay|Filipino|Tagalog|Urdu|Persian|Farsi|Swahili))\b/i;
18113
18124
  const MEMORY_MARKET_RE = /\b(?:markets?|audiences?|customers?|buyers?|shoppers?|clients?|patients?|guests?|countr(?:y|ies)|regions?|locales?|bilingual|Mexico|Mexican|Spain|LATAM|Latin America|Latinx?|Hispanic|France|Germany|Brazil|Portugal|Italy|Quebec|Canada|Japan|Korea|Taiwan|Hong Kong|India|Indonesia|Philippines|Netherlands|Belgium|Switzerland|Austria|Poland|Sweden|Norway|Denmark|Finland|Israel|Ukraine|Argentina|Colombia|Peru|Vietnam|Thailand|Malaysia|Singapore|Puerto Rico|Miami|Europe|EU|DACH|MENA|GCC)\b/i;
18125
+ // A language the user asked for on EVERY ad ("Always write our ads in Spanish") is a standing rule, and is kept.
18126
+ const MEMORY_STANDING_RE = /(?<![\p{L}\p{N}])(?:always|from now on|from here on|going forward|by default|default language|every time|every (?:single )?(?:ad|post|video|image|creative)s?|all (?:of )?(?:my|our|the|future) (?:ads|posts|videos|images|creatives|copy)|all future (?:ads|posts|copy)|permanently|siempre|de ahora en adelante|a partir de ahora|por defecto|todos (?:mis|nuestros|los) anuncios|sempre|de agora em diante|a partir de agora|por padrão|todos os (?:meus |nossos )?anúncios|toujours|désormais|dorénavant|à partir de maintenant|par défaut|toutes (?:mes|nos|les) (?:annonces|pubs|publicités)|immer|ab jetzt|von nun an|künftig|zukünftig|standardmäßig|alle (?:meine|unsere) (?:Anzeigen|Werbungen)|d'ora in poi|da ora in poi|d'ora in avanti|di default|altijd|voortaan|zawsze|od teraz|domyślnie|her zaman|bundan sonra|varsayılan olarak|selalu|mulai sekarang|alltid|altid|fremover|framöver|från och med nu|mereu|întotdeauna|vždy|vždycky|mindig|πάντα|всегда|отныне|по умолчанию|завжди|відтепер|תמיד|מעכשיו|دائما|دائمًا|دائماً|من الآن فصاعدا|हमेशा|luôn luôn|từ giờ trở đi|palagi|lagi)(?![\p{L}\p{N}])|いつも|常に|今後|これから|总是|總是|一直|以后|以後|今后|始终|始終|항상|언제나|앞으로|เสมอ|ตลอดไป/iu;
18114
18127
  function memoryNoteVerdict(text) {
18115
18128
  const t = String(text || '').trim();
18116
18129
  if (!t) return { ok: false, reason: 'empty' };
18117
18130
  if (MEMORY_PII_RE.test(t)) return { ok: false, reason: 'pii', message: 'Not saved: Memory never holds a phone number or an email address.' };
18118
18131
  if (MEMORY_MECHANICS_RE.test(t)) return { ok: false, reason: 'mechanics', message: 'Not saved: that is about how Hermoso or a platform API behaves, not about the brand. Memory holds brand, audience, offer, taste and working preferences only — product behaviour lives in the tools themselves.' };
18119
- if (MEMORY_LANG_RE.test(t) && !MEMORY_MARKET_RE.test(t)) return { ok: false, reason: 'language', message: 'Not saved: the language someone chats in, or asked for once, is not a brand fact. Every request is answered, and its ads written, in the language it is written in. If the brand sells to a market that speaks another language, save that instead (for example: "Our customers are in Mexico: ads in Spanish").' };
18132
+ if (MEMORY_LANG_RE.test(t) && !MEMORY_MARKET_RE.test(t) && !MEMORY_STANDING_RE.test(t)) return { ok: false, reason: 'language', message: 'Not saved: the language someone chats in, or asked for once, is not a brand fact. Every request is answered, and its ads written, in the language it is written in. Save a language only when the user asked for it on EVERY ad, phrased as that rule (for example: "Always write our ads in Spanish"), or when the brand sells to a market that speaks it (for example: "Our customers are in Mexico: ads in Spanish").' };
18120
18133
  return { ok: true };
18121
18134
  }
18122
18135
  server.registerTool('remember', {
@@ -20313,7 +20326,8 @@ function memoryNoteVerdict(text) {
20313
20326
  const url = abs(d.image);
20314
20327
  const img = await imageBlock(url); // show the cloned creative inline, not just a link
20315
20328
  const resid = d.residual && d.residual.clean === false ? `\n⚠ Residual source branding may remain: ${d.residual.note}` : '';
20316
- return { content: [{ type: 'text', text: `Cloned ad ready: ${url}${d.model ? ` (${d.model})` : ''}${resid}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: url } };
20329
+ const prod = d.productNote ? `\n${d.productNote}` : ''; // a product-mode clone runs the label check + product comparison (server.js /api/remix/render): say what they found
20330
+ return { content: [{ type: 'text', text: `Cloned ad ready: ${url}${d.model ? ` (${d.model})` : ''}${resid}${prod}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: url } };
20317
20331
  }));
20318
20332
  server.registerTool('remix_static', {
20319
20333
  title: 'Clone a static ad (old name)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.330",
3
+ "version": "0.1.335",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 864 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",