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.
- package/mcp/tools.mjs +43 -24
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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",
|