hermoso 0.1.330 → 0.1.336

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 +43 -24
  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(),
@@ -17151,75 +17156,80 @@ function buildTools(rawServer, opts = {}, sink = null) {
17151
17156
  // balance cannot cover it.
17152
17157
  const staticItemsText = (d, label) => {
17153
17158
  const items = Array.isArray(d.items) ? d.items : [];
17154
- const rows = items.map((x, i) => `${i + 1}. ${x[label] || ''}${x.image ? ` — ${abs(x.image)}` : ` — not delivered: ${x.error || 'failed'}`}${x.textCheck && x.textCheck.ok === false ? `\n ⚠ ${x.textCheck.note}` : ''}`);
17159
+ const rows = items.map((x, i) => `${i + 1}. ${x[label] || ''}${x.image ? ` — ${abs(x.image)}` : ` — not delivered: ${x.error || 'failed'}`}${x.textCheck && x.textCheck.ok === false ? `\n ⚠ ${x.textCheck.note}` : ''}${x.labelPass && x.labelPass.note ? `\n ${x.labelPass.note}` : ''}`);
17155
17160
  return `${rows.join('\n')}\n${d.note || ''}`;
17156
17161
  };
17157
17162
  const staticImages = async (d) => (await Promise.all((Array.isArray(d.items) ? d.items : []).filter(x => x.image).slice(0, 4).map(x => imageBlock(abs(x.image))))).filter(Boolean);
17158
17163
  const staticItemsOut = z.array(z.any()).optional().describe('one entry per output: the image URL (or an error), what changed, and a textCheck flag when the rendered text may not match');
17159
17164
  server.registerTool('edit_image', {
17160
17165
  title: 'Edit an image',
17161
- description: "EDIT an existing image in place with a plain-language instruction and keep everything else: 'make the headline bigger', 'add our logo bottom right', 'swap the background for a kitchen', 'remove the person on the left', 'erase all the text'. Pass `image` (URL, Library item, upload_file URL or local path) and `instruction`. The same edit the web Studio's Edit runs: composition, aspect ratio, people and every untouched line of text stay as they are; the saved brand's real name and website are pinned so an added line never invents one, and the brand's real logo is attached when the instruction asks for the logo. Set removal:true when the edit STRIPS text, branding or an object, so nothing branded is put back. For a precise region, pass `mask` (see generate_image). One image edit's credits; returns the new image URL. For a new image from a prompt use generate_image; to rebuild a competitor's ad for your brand use clone_static.",
17166
+ description: "EDIT an existing image in place with a plain-language instruction and keep everything else: 'make the headline bigger', 'add our logo bottom right', 'swap the background for a kitchen', 'remove the person on the left', 'erase all the text'. Pass `image` (URL, Library item, upload_file URL or local path) and `instruction`. The same edit the web Studio's Edit runs: composition, aspect ratio, people and every untouched line of text stay as they are; the saved brand's real name and website are pinned so an added line never invents one, and the brand's real logo is attached when the instruction asks for the logo. Set removal:true when the edit STRIPS text, branding or an object, so nothing branded is put back. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. For a precise region, pass `mask` (see generate_image). One image edit's credits; returns the new image URL. For a new image from a prompt use generate_image; to rebuild a competitor's ad for your brand use clone_static.",
17162
17167
  inputSchema: {
17163
17168
  image: z.string().describe('the image to edit: URL, Library item URL, upload_file URL or local path'),
17164
17169
  instruction: z.string().describe('the change to make, in plain words (pass the user’s own words for a removal or plain photo edit)'),
17165
17170
  removal: z.boolean().optional().describe('true when the edit REMOVES text, branding, a logo, a watermark, a person or an object, so the brand name and logo are not re-added'),
17166
17171
  mask: z.string().optional().describe('optional mask image (URL or local path) marking the region to change: transparent = change, or white = change on an opaque mask'),
17167
17172
  dryRun: z.boolean().optional().describe('true = return the exact credits this edit reserves and render nothing'),
17173
+ fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
17168
17174
  },
17169
17175
  outputSchema: {
17170
17176
  image: z.string().optional().describe('the served absolute URL of the edited image'),
17171
17177
  model: z.string().optional().describe('the model label that rendered it'),
17172
17178
  },
17173
17179
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17174
- }, wrap(async ({ image, instruction, removal, mask, dryRun }) => {
17180
+ }, wrap(async ({ image, instruction, removal, mask, dryRun, fixLabel }) => {
17175
17181
  const src = await toRef(image);
17176
17182
  const maskRef = mask ? await toRef(mask) : undefined;
17177
- if (dryRun) { const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}), dryRun: true }); return ok(quoteText(d && d.quote, 'This image edit (a change to the existing image, not a new render)'), {}); }
17178
- const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}) });
17183
+ const fl = fixLabel === false ? { fixLabel: false } : {};
17184
+ if (dryRun) { const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}), ...fl, dryRun: true }); return ok(quoteText(d && d.quote, 'This image edit (a change to the existing image, not a new render)'), {}); }
17185
+ const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}), ...fl });
17179
17186
  const img = await imageBlock(abs(d.image));
17180
- return { content: [{ type: 'text', text: `Edited image: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
17187
+ return { content: [{ type: 'text', text: `Edited image: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}${d.productNote ? `\n${d.productNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
17181
17188
  }));
17182
17189
  server.registerTool('headline_variants', {
17183
17190
  title: 'Headline variants of a static ad',
17184
- description: "Turn ONE finished static ad into several copies that differ ONLY in the headline, for an A/B test: same picture, product, layout, colours and every other line. Pass `image`, and either `headlines` (your own, up to 10) or `count` (default 5, max 10) to have distinct angles written for you in the saved brand's voice (never inventing numbers, prices, ratings or claims the ad or brand does not state); `brief` steers what to test. The ad's text is read first (3 credits), then one image edit per headline; each output is proofread and flagged (textCheck) if the rendered words do not match, never silently re-rendered. The whole batch is priced before anything runs. Returns each headline, its angle and its image URL.",
17191
+ description: "Turn ONE finished static ad into several copies that differ ONLY in the headline, for an A/B test: same picture, product, layout, colours and every other line. Pass `image`, and either `headlines` (your own, up to 10) or `count` (default 5, max 10) to have distinct angles written for you in the saved brand's voice (never inventing numbers, prices, ratings or claims the ad or brand does not state); `brief` steers what to test. The ad's text is read first (3 credits), then one image edit per headline; each output is proofread and flagged (textCheck) if the rendered words do not match, never silently re-rendered. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. The whole batch is priced before anything runs. Returns each headline, its angle and its image URL.",
17185
17192
  inputSchema: {
17186
17193
  image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
17187
17194
  headlines: z.array(z.string()).optional().describe('your own headlines to test (up to 10); omit to have them written'),
17188
17195
  count: z.number().int().min(1).max(10).optional().describe('how many headlines to write when `headlines` is omitted (default 5)'),
17189
17196
  brief: z.string().optional().describe('what to test, e.g. "price-led vs outcome-led" or "speak to busy parents"'),
17197
+ fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
17190
17198
  },
17191
17199
  outputSchema: { items: staticItemsOut, note: z.string().optional(), original: z.string().optional().describe('the headline read off the source ad') },
17192
17200
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17193
- }, wrap(async ({ image, headlines, count, brief }) => {
17194
- const d = await apiPost('/api/static/headlines', { image: await toRef(image), ...(headlines?.length ? { headlines } : {}), ...(count ? { count } : {}), ...(brief ? { brief } : {}) });
17201
+ }, wrap(async ({ image, headlines, count, brief, fixLabel }) => {
17202
+ const d = await apiPost('/api/static/headlines', { image: await toRef(image), ...(headlines?.length ? { headlines } : {}), ...(count ? { count } : {}), ...(brief ? { brief } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) });
17195
17203
  const imgs = await staticImages(d);
17196
17204
  return { content: [{ type: 'text', text: `Headline variants (original: “${d.original || ''}”):\n${staticItemsText(d, 'headline')}` }, ...imgs], structuredContent: { ...d, items: (d.items || []).map(x => ({ ...x, image: x.image ? abs(x.image) : null })) } };
17197
17205
  }));
17198
17206
  server.registerTool('resize_ad', {
17199
17207
  title: 'Resize a static ad for other placements',
17200
- description: "Re-lay out ONE finished static ad for other placements: the same ad, product, copy (word for word), logo and style, recomposed natively for each canvas rather than cropped. Pass `image` and optionally `aspectRatios` from 1:1, 4:5, 9:16, 16:9, 3:4, 4:3 (default 1:1, 4:5 and 9:16; the ad's own ratio is skipped). Reads the ad's text first (3 credits) so every line survives, then one image edit per canvas. Priced before it runs. For VIDEO use reframe_video.",
17208
+ description: "Re-lay out ONE finished static ad for other placements: the same ad, product, copy (word for word), logo and style, recomposed natively for each canvas rather than cropped. Pass `image` and optionally `aspectRatios` from 1:1, 4:5, 9:16, 16:9, 3:4, 4:3 (default 1:1, 4:5 and 9:16; the ad's own ratio is skipped). Reads the ad's text first (3 credits) so every line survives, then one image edit per canvas. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. Priced before it runs. For VIDEO use reframe_video.",
17201
17209
  inputSchema: {
17202
17210
  image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
17203
17211
  aspectRatios: z.array(z.enum(['1:1', '4:5', '9:16', '16:9', '3:4', '4:3'])).optional().describe('target canvases (default 1:1, 4:5, 9:16)'),
17212
+ fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
17204
17213
  },
17205
17214
  outputSchema: { items: staticItemsOut, note: z.string().optional(), sourceAspectRatio: z.string().optional() },
17206
17215
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17207
- }, wrap(async ({ image, aspectRatios }) => {
17208
- const d = await apiPost('/api/static/resize', { image: await toRef(image), ...(aspectRatios?.length ? { aspectRatios } : {}) });
17216
+ }, wrap(async ({ image, aspectRatios, fixLabel }) => {
17217
+ const d = await apiPost('/api/static/resize', { image: await toRef(image), ...(aspectRatios?.length ? { aspectRatios } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) });
17209
17218
  const imgs = await staticImages(d);
17210
17219
  return { content: [{ type: 'text', text: `Resized (source ${d.sourceAspectRatio || '?'}):\n${staticItemsText(d, 'aspectRatio')}` }, ...imgs], structuredContent: { ...d, items: (d.items || []).map(x => ({ ...x, image: x.image ? abs(x.image) : null })) } };
17211
17220
  }));
17212
17221
  server.registerTool('localize_ad', {
17213
17222
  title: 'Localize a static ad into other languages',
17214
- description: "Translate the on-image text of ONE finished static ad into other languages and keep everything else: same picture, layout, typeface, colours, logo and product. Pass `image` and `languages` (up to 5, e.g. [\"Spanish\", \"German\", \"French (Canada)\"]). The ad's text is read (3 credits), translated the way a native copywriter in each market would write it (brand and product names, URLs and prices kept as written), then one image edit per language; each output is proofread and flagged (textCheck) if the words do not match, never silently re-rendered. Priced before it runs. For a VIDEO use dub_video.",
17223
+ description: "Translate the on-image text of ONE finished static ad into other languages and keep everything else: same picture, layout, typeface, colours, logo and product. Pass `image` and `languages` (up to 5, e.g. [\"Spanish\", \"German\", \"French (Canada)\"]). The ad's text is read (3 credits), translated the way a native copywriter in each market would write it (brand and product names, URLs and prices kept as written), then one image edit per language; each output is proofread and flagged (textCheck) if the words do not match, never silently re-rendered. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. Priced before it runs. For a VIDEO use dub_video.",
17215
17224
  inputSchema: {
17216
17225
  image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
17217
17226
  languages: z.array(z.string()).min(1).max(5).describe('target languages, by name'),
17227
+ fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
17218
17228
  },
17219
17229
  outputSchema: { items: staticItemsOut, note: z.string().optional(), source: z.array(z.string()).optional().describe('the lines read off the source ad') },
17220
17230
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17221
- }, wrap(async ({ image, languages }) => {
17222
- const d = await apiPost('/api/static/localize', { image: await toRef(image), languages });
17231
+ }, wrap(async ({ image, languages, fixLabel }) => {
17232
+ const d = await apiPost('/api/static/localize', { image: await toRef(image), languages, ...(fixLabel === false ? { fixLabel: false } : {}) });
17223
17233
  const imgs = await staticImages(d);
17224
17234
  return { content: [{ type: 'text', text: `Localized:\n${staticItemsText(d, 'language')}` }, ...imgs], structuredContent: { ...d, items: (d.items || []).map(x => ({ ...x, image: x.image ? abs(x.image) : null })) } };
17225
17235
  }));
@@ -17479,11 +17489,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
17479
17489
 
17480
17490
  server.registerTool('post_edit', {
17481
17491
  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.",
17492
+ 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
17493
  inputSchema: {
17484
17494
  videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link'),
17485
17495
  ops: z.array(z.object({
17486
- op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'append_card', 'watermark', 'grain', 'text', 'join']),
17496
+ op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'music', 'append_card', 'watermark', 'grain', 'text', 'join']),
17487
17497
  start: z.number().optional().describe('trim/mute/text window start (s)'),
17488
17498
  end: z.number().optional().describe('trim/mute/text window end (s)'),
17489
17499
  text: z.string().optional().describe('text: the words, verbatim'),
@@ -17498,7 +17508,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
17498
17508
  transition: z.string().optional().describe("join: 'cut' default, 'crossfade', or any ffmpeg xfade name (wipeleft…)"),
17499
17509
  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
17510
  factor: z.number().optional().describe('speed 0.5-2'),
17501
- db: z.number().optional().describe('audio_gain -20..+6 dB'),
17511
+ db: z.number().optional().describe('audio_gain -20..+6 dB / music: trim on its automatic level, -20..+12'),
17512
+ 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)"),
17513
+ mood: z.string().optional().describe('music: mood words for the library pick, or the brief for generated music'),
17514
+ from: z.number().optional().describe('music: the second of the track to start from'),
17515
+ duck: z.union([z.literal('auto'), z.boolean()]).optional().describe("music: 'auto' default = dips while the clip's own sound plays"),
17516
+ replace: z.boolean().optional().describe("music: true = replaces the clip's own sound"),
17517
+ fadeOut: z.number().optional().describe('music: fade-out seconds 0-5'),
17502
17518
  seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s / transition 0.2-1.5s'),
17503
17519
  headline: z.string().optional().describe('append_card: big line (default: brand name)'),
17504
17520
  tagline: z.string().optional().describe('append_card: smaller line under the headline'),
@@ -18111,12 +18127,14 @@ const MEMORY_PII_RE = /(\+\d[\d\s().-]{7,}\d)|(\(?\b\d{3}\)?[\s.-]\d{3}[\s.-]\d{
18111
18127
  // 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
18128
  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
18129
  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;
18130
+ // A language the user asked for on EVERY ad ("Always write our ads in Spanish") is a standing rule, and is kept.
18131
+ 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
18132
  function memoryNoteVerdict(text) {
18115
18133
  const t = String(text || '').trim();
18116
18134
  if (!t) return { ok: false, reason: 'empty' };
18117
18135
  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
18136
  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").' };
18137
+ 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
18138
  return { ok: true };
18121
18139
  }
18122
18140
  server.registerTool('remember', {
@@ -20313,7 +20331,8 @@ function memoryNoteVerdict(text) {
20313
20331
  const url = abs(d.image);
20314
20332
  const img = await imageBlock(url); // show the cloned creative inline, not just a link
20315
20333
  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 } };
20334
+ 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
20335
+ return { content: [{ type: 'text', text: `Cloned ad ready: ${url}${d.model ? ` (${d.model})` : ''}${resid}${prod}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: url } };
20317
20336
  }));
20318
20337
  server.registerTool('remix_static', {
20319
20338
  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.336",
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",