hermoso 0.1.385 → 0.1.389
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/mcp/static-batch.mjs +15 -5
- package/mcp/tools.mjs +119 -81
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**906 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
|
|
@@ -231,7 +231,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
231
231
|
|
|
232
232
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
233
233
|
|
|
234
|
-
### What the
|
|
234
|
+
### What the 906 tools cover
|
|
235
235
|
|
|
236
236
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
237
237
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/static-batch.mjs
CHANGED
|
@@ -125,22 +125,32 @@ export function plannerBrief(product, { brief = '', angles = [] } = {}) {
|
|
|
125
125
|
].filter(Boolean).join('\n');
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// One render's prompt: the planner's visual brief, then the
|
|
129
|
-
// the model prints them as given; a static
|
|
130
|
-
|
|
128
|
+
// One render's prompt: the planner's visual brief, then the slot's layout (when the batch assigned one), then the
|
|
129
|
+
// exact words, then the product and logo rules. The words are QUOTED so the model prints them as given; a static
|
|
130
|
+
// whose headline is misspelled is not finished. `layout` = {directive, type, cta} from lib/static-variety.mjs (the
|
|
131
|
+
// server assigns it); without one the old single generic typography line is used.
|
|
132
|
+
// ONE PRODUCT, TWO HANDS (2026-10-06): the first live batch put two sticks in one frame (one in hand, one floating)
|
|
133
|
+
// and gave a 9:16 a third hand. Both are image-MODEL failures a sensible plan cannot prevent, so they are stated once
|
|
134
|
+
// here, not in the planner: one unit of the product unless the layout is a flat lay or a set, and every hand belongs
|
|
135
|
+
// to a visible arm, at most two per person.
|
|
136
|
+
export function staticAdPrompt(v, { logo = 'server', productPhoto = false, layout = null, ratioNote = '' } = {}) {
|
|
131
137
|
const x = v && typeof v === 'object' ? v : {};
|
|
132
138
|
const lines = [String(x.prompt || x.visual || '').trim()];
|
|
139
|
+
const L = layout && typeof layout === 'object' ? layout : null;
|
|
140
|
+
if (L && L.directive) lines.push(`Layout: ${String(L.directive).trim()}.${L.type ? ` Headline set in ${String(L.type).trim()}, in the brand's colours.` : ''}${L.cta ? ` Set ${String(L.cta).trim()}.` : ''}${ratioNote ? ` ${ratioNote}` : ''}`);
|
|
141
|
+
else if (ratioNote) lines.push(ratioNote);
|
|
133
142
|
const words = [
|
|
134
143
|
x.headline ? `headline "${String(x.headline).trim()}"` : '',
|
|
135
144
|
x.supporting ? `supporting line "${String(x.supporting).trim()}"` : '',
|
|
136
145
|
x.cta ? `call to action "${String(x.cta).trim()}"` : '',
|
|
137
146
|
].filter(Boolean);
|
|
138
|
-
if (words.length) lines.push(`On-image text, spelled exactly as given and nothing else: ${words.join(', ')}
|
|
147
|
+
if (words.length) lines.push(`On-image text, spelled exactly as given and nothing else: ${words.join(', ')}.${L ? '' : ' Bold ad typography with a clear hierarchy, crisp and correctly spelled.'} A finished, ready-to-run static ad.`);
|
|
148
|
+
if (L) lines.push('Show the product once (one unit as it is sold) unless the layout is a flat lay or a set of its range; any person has at most two hands and every hand in frame belongs to a visible arm.');
|
|
139
149
|
if (productPhoto) lines.push('The first reference image is the REAL product: keep its shape, colours and label exactly.');
|
|
140
150
|
// THE LOGO IS LAID ON, NEVER PAINTED (2026-10-06): 'overlay' (and the old 'attached' / 'server') add nothing here. The
|
|
141
151
|
// render goes with brandLogo:true, and the image route tells the model to keep a corner clear and lays the brand's real
|
|
142
152
|
// logo file over the finished ad (lib/logo-intent.mjs). The model is never handed the logo to re-draw.
|
|
143
|
-
if (!['overlay', 'attached', 'server'].includes(logo)) lines.push('Do not draw or invent any logo or wordmark.');
|
|
153
|
+
if (!['overlay', 'in_scene', 'auto', 'attached', 'server'].includes(logo)) lines.push('Do not draw or invent any logo or wordmark.');
|
|
144
154
|
return lines.filter(Boolean).join('\n\n');
|
|
145
155
|
}
|
|
146
156
|
|
package/mcp/tools.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { withHints, videoChoiceText, videoChoiceHints, neutralCreditsText, credi
|
|
|
27
27
|
// ./openai-extensions.mjs. Same twin-safe specifier rule.
|
|
28
28
|
import { registerLibraryApp, installEntrypointIcons, chatgptExtOn, EXT_PREFS_KEY } from './openai-extensions.mjs';
|
|
29
29
|
// STATIC ADS AT SCALE (2026-10-06): the pure catalog/plan/prompt helpers behind make_static_ads. Same twin-safe specifier rule.
|
|
30
|
-
import { STATIC_BATCH,
|
|
30
|
+
import { STATIC_BATCH, normalizeRatios, renderList } from './static-batch.mjs';
|
|
31
31
|
|
|
32
32
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
33
33
|
// /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
|
|
@@ -329,7 +329,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
329
329
|
'• CREATE (finished ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
|
|
330
330
|
'• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
|
|
331
331
|
'• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here, each with its own brand details, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_team / invite_member / remove_member / set_role.',
|
|
332
|
-
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post);
|
|
332
|
+
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send), and the Instagram-only save_instagram_dm_automation / test_instagram_dm_automation / list_instagram_dm_automations / delete_instagram_dm_automation; X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE what the listing says — hours, phone, website, description, categories, name, address; the edit is live on Search and Maps, so the unconfirmed call writes nothing and shows the before-and-after), google_business_account (whose account it is on and whether that role can edit it); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated: reconnecting needs a browser). LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
|
|
333
333
|
'YOUR ROSTER IS CORE-FIRST, AND NOTHING IS MISSING OR UNFINISHED. What you are LISTED is the core tools plus whatever this connection asked for or you have switched on; the rest of the product \u2014 hundreds of tools across research, creation, publishing, paid campaigns, analytics and channel administration \u2014 is held out of the LIST on SIZE ALONE. IT IS ALL CALLABLE RIGHT NOW. find_tools({query}) searches EVERY tool, listed or not, and each row carries its parameters, its credit cost and its recent health; call_tool({name, args}) then runs it through the same handler, the same account and the same permissions; a direct tools/call to a name you already know works too. This route needs no reload and works on every host, including claude.ai and ChatGPT, which fix their tool list when the connection is made. enable_tools({groups:[\u2026]}) additionally LISTS a group for hosts that re-list \u2014 free and instant: the MOMENT the user asks to build, budget, target, report on or change a campaign, enable_tools({groups:[\'ads\']}); for sessions, conversions, revenue by channel or how their site ranks, enable_tools({groups:[\'analytics\']}); to read, moderate, reply to, measure or delete something ALREADY on a channel, enable_tools({groups:[\'channel_admin\']}). IF THE TOOLS DO NOT APPEAR after that call \u2014 some clients cache their tool list for the whole conversation \u2014 do NOT tell the user the capability does not exist and do not keep retrying: just use call_tool, which does not depend on the roster changing at all. Reconnecting with `?tools=all` on the server URL lists everything at connect time. NEVER tell a user Hermoso cannot manage their campaigns or read their analytics, cannot read a comment, answer a DM or pull a channel\u2019s numbers, because you cannot see the tool \u2014 look it up with find_tools and run it with call_tool. The full set of group names is: core, research, create, channels, channel_admin, analytics, files, workspace, ads \u2014 or \'all\'.',
|
|
334
334
|
// HINTS (2026-09-17). The advice was always in the prose; this says the keyed copy exists so a client that
|
|
335
335
|
// prefers structure does not have to parse a sentence to find it.
|
|
@@ -2269,7 +2269,7 @@ export const widgetHostHides = (name, widgetHost) => (widgetHost
|
|
|
2269
2269
|
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
2270
2270
|
// direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
|
|
2271
2271
|
// edit_timeline, and edit_timeline's names video_frames, so a caller that starts from post_edit reaches both.
|
|
2272
|
-
export const ON_DEMAND_TOOLS = new Set(['edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads']); // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine)
|
|
2272
|
+
export const ON_DEMAND_TOOLS = new Set(['edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads', 'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product']); // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine)
|
|
2273
2273
|
|
|
2274
2274
|
// ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
|
|
2275
2275
|
// ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
|
|
@@ -5725,6 +5725,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5725
5725
|
const draftLines = drafts.slice(0, 20).map((x, i) => ` ${i + 1}. ${x.id} · ${x.at} → ${(x.channels || []).join(', ')} · ${x.kind}\n ${String(x.message || '').replace(/\n+/g, ' / ').slice(0, 200)}\n ${x.media || '(no file)'}`);
|
|
5726
5726
|
return ok([
|
|
5727
5727
|
`Autoposting: ${state}.${d.note ? `\n${d.note}` : ''}`,
|
|
5728
|
+
d.creditNote || '',
|
|
5728
5729
|
`Posting times: ${(d.postingTimes || []).join(', ') || 'none set (add some on the Schedule tab)'} ${d.postingSchedule?.timezone || 'UTC'}. It fills ${d.daysAhead} day(s) ahead; ${d.queuedPosts} post(s) queued now.`,
|
|
5729
5730
|
(d.channelsView || []).length ? `Channels:\n${d.channelsView.map((c) => ` ${c.on ? '[on] ' : '[off]'} ${c.label} (${c.channel}): ${c.on ? `${c.postsPerDay} a day${c.times.length ? ` at ${c.times.join(', ')}` : ''}` : 'not posting'}${c.note ? ` · ${c.note}` : ''}`).join('\n')}` : (d.connectedUnreadable ? 'Channels: could not be read just now.' : 'Channels: none connected — autopilot has nowhere to post. Connect one under Workspace ▸ Connectors.'),
|
|
5730
5731
|
`Makes ${d.postsPerDayMade || 0} new post(s) a day, every one a fresh render made for it${d.estCreditsPerDay != null && d.postsPerDayMade ? `, about ${d.estCreditsPerDay} credits a day at today's prices` : ''}.${d.zeroBudget ? ` ${d.budgetNote}` : ''} Used: ${d.spend?.today || 0} credits today, ${d.spend?.week || 0} this week.`,
|
|
@@ -5763,9 +5764,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5763
5764
|
const { goal, goalNote, pillars, formats, avoid, learnings, forgetCaptionEdits, ...rest } = a;
|
|
5764
5765
|
const brief = Object.fromEntries(Object.entries({ goal, goalNote, pillars, formats, avoid, learnings, ...(forgetCaptionEdits ? { editExamples: [] } : {}) }).filter(([, v]) => v !== undefined));
|
|
5765
5766
|
const d = await apiPut('/api/schedule/refill', { ...rest, ...(Object.keys(brief).length ? { brief } : {}) });
|
|
5766
|
-
return ok(d.enabled
|
|
5767
|
+
return ok((d.enabled
|
|
5767
5768
|
? `Autoposting is ON in ${d.mode === 'auto' ? 'AUTO-PUBLISH mode — each run makes fresh posts and schedules them to go out on their own.' : 'REVIEW mode — each run makes fresh posts and keeps them as drafts; nothing is scheduled until you approve them with run_post_refill.'} get_post_refill lists the channels it posts to and what it costs a day. Run run_post_refill (a preview by default: it renders and queues nothing, and writing its copy costs a few credits) to see a plan.`
|
|
5768
|
-
: 'Autoposting is OFF. The recurring job is gone and nothing new will be made. Posts already in the calendar are untouched — cancel those with cancel_scheduled if you want them gone.', d);
|
|
5769
|
+
: 'Autoposting is OFF. The recurring job is gone and nothing new will be made. Posts already in the calendar are untouched — cancel those with cancel_scheduled if you want them gone.') + (d.creditNote ? `\n${d.creditNote}` : ''), d);
|
|
5769
5770
|
}));
|
|
5770
5771
|
server.registerTool('run_post_refill', {
|
|
5771
5772
|
title: 'Run autoposting / review its drafts',
|
|
@@ -5814,7 +5815,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5814
5815
|
return ok([
|
|
5815
5816
|
d.summary || '',
|
|
5816
5817
|
lines.length ? `\nThe posts:\n${lines.join('\n')}` : '',
|
|
5817
|
-
(d.noChannels || d.zeroBudget || (d.enabled === false && !(d.posts || []).length)) ? '' : d.dryRun ? `\nNOTHING WAS RENDERED OR QUEUED — this was a preview${d.copyCredits ? ` (writing its copy cost ${d.copyCredits} credit(s))` : ''}. Show it to the user; call again with dryRun:false only if they say yes.`
|
|
5818
|
+
(d.noChannels || d.zeroBudget || (d.outOfCredits && !(d.posts || []).length) || (d.enabled === false && !(d.posts || []).length)) ? '' : d.dryRun ? `\nNOTHING WAS RENDERED OR QUEUED — this was a preview${d.copyCredits ? ` (writing its copy cost ${d.copyCredits} credit(s))` : ''}. Show it to the user; call again with dryRun:false only if they say yes.`
|
|
5818
5819
|
: (d.mode === 'auto' ? `\n${q} post(s) scheduled. Pull any of them before it goes out with cancel_scheduled.` : ((d.drafts || []).length ? `\n${d.drafts.length} new draft(s) waiting for approval — approve, edit or discard them with run_post_refill.` : '\nNo new drafts were made by this run (get_post_refill lists any drafts already waiting).')),
|
|
5819
5820
|
qFail.length ? `\n${qFail.length} could not be queued: ${qFail.map(x => `${x.at} — ${x.error}`).join(' · ')}` : '',
|
|
5820
5821
|
(d.notes || []).length ? `\nNotes: ${d.notes.join(' · ')}` : '',
|
|
@@ -17990,7 +17991,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17990
17991
|
model: z.string().optional().describe('image model id from hermoso_capabilities. A model whose `refs.mode` is "edit" there (gpt-image-2.5) takes your refImages on ITS OWN editor, up to its `refs.max`, instead of the default compositor'),
|
|
17991
17992
|
imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
|
|
17992
17993
|
fixLabel: z.boolean().optional().describe('default true: when the saved brand\'s product photo rides in this render, the product\'s label on the finished image is READ and compared with the photo, and re-printed from the photo at close range ONLY if it came out wrong (a label that is already right costs only the check, a credit or two; a re-print adds about ten). The reply says whether the label was checked, fixed or left as rendered (`labelPass`). Pass false when the user wants the packaging left exactly as generated: nothing is checked or re-printed.'),
|
|
17993
|
-
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay:
|
|
17994
|
+
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: real logo file laid flat. in_scene: on something in the scene, from the file, checked. auto (default): from the prompt'),
|
|
17994
17995
|
mask: z.string().optional().describe('MASKED EDIT — change ONE region of an image and keep the rest: a local path or URL of a mask image for refImages[0] (the image being edited). Either convention works and the reply says which it read: TRANSPARENT pixels = change, or, on a mask with no transparency, WHITE = change and black = keep. Any size; it is scaled to the image. The mask GUIDES the edit rather than stencilling it: the new content can blend a little past its edge. Runs on the model hermoso_capabilities marks `refs.mask` (gpt-image-2.5): leave `model` empty or name that one — any other named model is refused, free. Needs refImages; the result keeps the source image\'s own frame, so aspectRatio is not applied.'),
|
|
17995
17996
|
},
|
|
17996
17997
|
outputSchema: {
|
|
@@ -18150,7 +18151,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18150
18151
|
restrainedGrade: z.boolean().optional().describe('true for a calm / premium / muted look instead of the default punchy poster grade'),
|
|
18151
18152
|
logo: z.string().optional().describe('a brand logo URL or path to place into the composition'),
|
|
18152
18153
|
logo3d: z.boolean().optional().describe('first turn the flat logo into a volumetric 3D render (one extra billed image), then composite that'),
|
|
18153
|
-
logoPlacement: z.enum(['auto', 'overlay', 'in_scene']).optional().describe('overlay
|
|
18154
|
+
logoPlacement: z.enum(['auto', 'overlay', 'in_scene']).optional().describe('overlay (exact, flat) | in_scene (from the file, checked); auto: in_scene with logo3d'),
|
|
18154
18155
|
split: z.object({ mode: z.enum(['plain', 'before_after', 'versus', 'custom']), panels: z.array(z.string()).optional() }).passthrough().optional().describe('split/panel LAYOUT — only when the user asks for one ("split", "before/after", "versus screen"). "X vs Y" as a SCENE stays one unified frame'),
|
|
18155
18156
|
reference: z.object({}).passthrough().optional().describe("fields YOU extracted by eye from a reference thumbnail. Extract ALL of: brief (one dense sentence on the concept), subject (pose/action generically, NEVER a specific identity), elements, location, composition, background, split (boolean), split_count, person_count (0-3), emotion (one of the 11 presets or 'other'), emotion_detail (one vivid sentence covering eyes, brows, mouth, head angle). emotion + emotion_detail carry the reference's actual facial performance, which is the single biggest CTR lever on a face; split/split_count reproduce its panel structure. The reference image itself is never sent to the model"),
|
|
18156
18157
|
tweak: z.object({ kind: z.string(), value: z.string() }).describe('surgical pixel-faithful edit of a FINISHED thumbnail (needs sourceImage): kind emotion / background / background_color / rim_light, or any other kind with the edit in words as value').optional(),
|
|
@@ -19444,13 +19445,15 @@ function memoryNoteVerdict(text) {
|
|
|
19444
19445
|
})));
|
|
19445
19446
|
server.registerTool('update_brand', {
|
|
19446
19447
|
title: 'Update profile fields',
|
|
19447
|
-
description: 'Patch SPECIFIC fields of the active profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal, and how the brand and product names are pronounced) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”. To onboard a brand from scratch, use draft_brand. If no brand is saved yet and you only INFERRED one from what the user is making, ask them to confirm it is their brand before saving it (they may be working for a client or just trying things). Only pass the fields you’re changing.',
|
|
19448
|
+
description: 'Patch SPECIFIC fields of the active profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal, logo, light logo, and how the brand and product names are pronounced) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”. To onboard a brand from scratch, use draft_brand. If no brand is saved yet and you only INFERRED one from what the user is making, ask them to confirm it is their brand before saving it (they may be working for a client or just trying things). Only pass the fields you’re changing.',
|
|
19448
19449
|
inputSchema: { ...STORE_BRAND,
|
|
19449
19450
|
name: z.string().optional(), domain: z.string().optional().describe('website domain'), sells: z.string().optional().describe('what the brand sells'),
|
|
19450
19451
|
summary: z.string().optional().describe('one-line description'), category: z.string().optional(), audience: z.string().optional(),
|
|
19451
19452
|
positioning: z.string().optional(), voice: z.string().optional().describe('brand voice/tone'), style: z.string().optional().describe('visual style — palette, typography, aesthetic'), goal: z.string().optional().describe('current marketing goal'),
|
|
19452
19453
|
pronounce: z.string().optional().describe('how the brand NAME is said aloud, as a simple respelling with the stressed syllable in capitals (e.g. "KOH-dee-ak"). Videos use it as a delivery note beside the spoken line; set it when a render mispronounced the name.'),
|
|
19453
19454
|
pronunciations: z.record(z.string()).optional().describe('how PRODUCT names are said aloud, e.g. {"Power Cakes": "POW-er cakes"}. Merged into the saved ones; an empty string removes one.'),
|
|
19455
|
+
logo: z.string().optional().describe('logo image URL or path'),
|
|
19456
|
+
logoLight: z.string().optional().describe('light logo for dark pictures; "none" removes'),
|
|
19454
19457
|
},
|
|
19455
19458
|
outputSchema: { ok: z.boolean().optional(), updated: z.array(z.string()).optional(), brand: z.any().optional() },
|
|
19456
19459
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
@@ -19464,6 +19467,15 @@ function memoryNoteVerdict(text) {
|
|
|
19464
19467
|
for (const [k, v] of Object.entries(a.pronunciations).slice(0, 30)) { const n = String(k || '').trim().slice(0, 60); if (!n) continue; const say = String(v || '').trim().slice(0, 60); if (say) cur[n] = say; else delete cur[n]; }
|
|
19465
19468
|
patch.pronunciations = cur;
|
|
19466
19469
|
}
|
|
19470
|
+
// THE LOGO FILES (2026-10-06): the logo, and the optional light version laid on when the original would not read on a
|
|
19471
|
+
// dark or busy background. A local path is uploaded first (toRef); only a raster file is kept (an SVG cannot be laid on).
|
|
19472
|
+
for (const k of ['logo', 'logoLight']) {
|
|
19473
|
+
if (a[k] == null || !String(a[k]).trim()) continue;
|
|
19474
|
+
if (k === 'logoLight' && /^(none|remove|off)$/i.test(String(a[k]).trim())) { patch.logoLight = ''; continue; }
|
|
19475
|
+
const ref = await toRef(String(a[k]).trim());
|
|
19476
|
+
if (!ref || !/^(https?:\/\/|data:image\/(png|jpe?g|webp|gif);base64,)/i.test(ref) || /\.svg(\?|$)/i.test(ref)) return { content: [{ type: 'text', text: `The ${k === 'logo' ? 'logo' : 'light logo'} must be a PNG, JPEG or WebP image (a URL or a local file). Nothing was changed.` }], isError: true };
|
|
19477
|
+
patch[k] = ref;
|
|
19478
|
+
}
|
|
19467
19479
|
if (!Object.keys(patch).length) return { content: [{ type: 'text', text: 'Pass at least one brand field to change.' }], isError: true };
|
|
19468
19480
|
const merged = { ...brand, ...patch };
|
|
19469
19481
|
await writeStore('heist.brand.v1', merged); // the store PUT preserves server-side brand enrichments (playbook/pronounce)
|
|
@@ -21260,30 +21272,33 @@ function memoryNoteVerdict(text) {
|
|
|
21260
21272
|
return ok(text, d);
|
|
21261
21273
|
}));
|
|
21262
21274
|
|
|
21263
|
-
// STATIC ADS AT SCALE (2026-10-06). The ads-studio shape in one call: products × count → finished statics,
|
|
21264
|
-
//
|
|
21265
|
-
//
|
|
21266
|
-
//
|
|
21267
|
-
//
|
|
21275
|
+
// STATIC ADS AT SCALE (2026-10-06). The ads-studio shape in one call: products × count → finished statics, each a
|
|
21276
|
+
// different selling angle AND a different layout (the server assigns every ad in a batch a distinct layout archetype,
|
|
21277
|
+
// type treatment and CTA style before planning — lib/static-variety.mjs), headline and CTA on the image. ONE
|
|
21278
|
+
// implementation: POST /api/static-ads, the same route the web Ads Studio page calls, so the catalog match, the
|
|
21279
|
+
// fresh-price rule, the layouts and the queue behave identically on every surface. Pure half: ./static-batch.mjs.
|
|
21268
21280
|
server.registerTool('make_static_ads', {
|
|
21269
21281
|
title: 'Make static ads at scale',
|
|
21270
|
-
description: "Make a BATCH of finished static image ads for one or several products in one call: each ad a genuinely different selling angle (offer, material or ingredient story, how it is used, a lifestyle moment, an objection, proof the brand really has) with its headline, supporting line and CTA printed on the image
|
|
21282
|
+
description: "Make a BATCH of finished static image ads for one or several products in one call: each ad a genuinely different selling angle (offer, material or ingredient story, how it is used, a lifestyle moment, an objection, proof the brand really has) AND a different layout (editorial still life, poster headline, packshot on colour, in hand, flat lay, split compare, native phone photo and more; never one look repeated across a batch), with its headline, supporting line and CTA printed on the image and the product's real photo and the brand's real logo composited in. `products` names products from the brand's product catalog by id or name (list_products; add one from its page link with add_product; ['all'] takes every product with a photo; omit for the hero product). A product's price is printed only when it was read from its live page within the last hour: older rows are re-read first, and a page that cannot be read means no price on that ad. `count` is ads PER PRODUCT (1-20). `aspectRatios` any of 1:1, 4:5, 9:16 (each ad is composed natively once per ratio, so the ratios are different pictures with the same words; for one picture in several sizes render one ratio and use resize_ad). `brief` steers (a sale, a season, an audience) and is never turned into a price, discount or claim the brand does not state; `angles` names the angles to spread across; `layouts` (free words) replaces the layout set. ALWAYS PRICED FIRST: dryRun:true returns the plan and the quote; then pass that `plan` (and its `batchId`) back to render exactly it without planning again. Renders are QUEUED jobs, at most " + STATIC_BATCH.QUEUE_PER_CALL + " per call: the rest comes back as `remaining`, to send again as `plan` once some finish. Read each job with get_job; never describe an ad before its URL arrives. For variants of ONE finished ad use headline_variants / resize_ad; to copy a competitor's static use clone_static.",
|
|
21271
21283
|
inputSchema: {
|
|
21272
|
-
products: z.array(z.string()).optional().describe("product names from
|
|
21284
|
+
products: z.array(z.string()).optional().describe("product ids or names from list_products, ['all'] for every product with a photo, or omit for the hero product"),
|
|
21273
21285
|
count: z.number().int().min(1).max(STATIC_BATCH.COUNT_MAX).optional().describe(`ads per product, 1-${STATIC_BATCH.COUNT_MAX} (default ${STATIC_BATCH.COUNT_DEFAULT})`),
|
|
21274
21286
|
aspectRatios: z.array(z.enum(['1:1', '4:5', '9:16'])).optional().describe("canvases; every ad is rendered once per ratio (default ['4:5'])"),
|
|
21275
21287
|
brief: z.string().optional().describe('what to lean into: a sale the brand is really running, a season, an audience, a tone'),
|
|
21276
21288
|
angles: z.array(z.string()).optional().describe("the selling angles to spread across, e.g. ['price or offer', 'material story', 'how it is used', 'lifestyle moment']; omit and the planner picks a spread"),
|
|
21289
|
+
layouts: z.array(z.string()).optional().describe('your own layout descriptions in words, used slot by slot instead of the built-in layout set'),
|
|
21277
21290
|
language: z.string().optional().describe('language of the on-image copy (default English)'),
|
|
21278
21291
|
model: z.string().optional().describe('image model id from hermoso_capabilities; omit for the default'),
|
|
21279
21292
|
brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile (passing it pins it like use_brand)'),
|
|
21280
21293
|
plan: z.array(z.any()).optional().describe('the `plan` (or `remaining`) a previous call returned: render exactly those ads, no new planning'),
|
|
21294
|
+
batchId: z.string().optional().describe('the batchId a previous call returned, so the rest lands in the same batch'),
|
|
21281
21295
|
dryRun: z.boolean().optional().describe('true = plan and quote only, nothing rendered'),
|
|
21282
|
-
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay (default)
|
|
21296
|
+
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay (default) | in_scene (on something, checked) | none | auto'),
|
|
21283
21297
|
},
|
|
21284
21298
|
outputSchema: {
|
|
21285
|
-
|
|
21286
|
-
|
|
21299
|
+
batchId: z.string().optional(),
|
|
21300
|
+
plan: z.array(z.any()).optional().describe('[{productId, product, image, price, variants:[{angle, headline, supporting, cta, prompt, layout}]}]'),
|
|
21301
|
+
jobs: z.array(z.any()).optional().describe('one queued image job per ad × ratio: {id, product, angle, headline, layout, aspectRatio}'),
|
|
21287
21302
|
remaining: z.array(z.any()).optional().describe('the part of the plan not queued this call — pass it back as `plan`'),
|
|
21288
21303
|
perAdCredits: z.number().nullable().optional(),
|
|
21289
21304
|
totalCredits: z.number().nullable().optional(),
|
|
@@ -21291,69 +21306,92 @@ function memoryNoteVerdict(text) {
|
|
|
21291
21306
|
notes: z.array(z.string()).optional(),
|
|
21292
21307
|
},
|
|
21293
21308
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
21294
|
-
}, wrap(async ({ products, count, aspectRatios, brief, angles, language, model, brandId, plan, dryRun, logoPlacement }) => {
|
|
21295
|
-
|
|
21296
|
-
const
|
|
21297
|
-
const
|
|
21298
|
-
|
|
21299
|
-
|
|
21300
|
-
|
|
21301
|
-
|
|
21302
|
-
|
|
21303
|
-
|
|
21304
|
-
|
|
21305
|
-
|
|
21306
|
-
const d = await apiPost('/api/batch/plan', { product: plannerBrief(t.name, { brief, angles }), count: n, language: language || '' });
|
|
21307
|
-
const vs = (d?.variants || []).slice(0, n).map((v) => ({ angle: v.angle || '', headline: v.headline || '', supporting: v.supporting || '', cta: v.cta || '', prompt: v.prompt || '' }));
|
|
21308
|
-
thePlan.push({ product: t.name, image: t.image || '', hero: !!t.hero, variants: vs });
|
|
21309
|
-
}
|
|
21310
|
-
}
|
|
21311
|
-
const list = renderList(thePlan, ratios);
|
|
21312
|
-
let status = null; try { status = await apiGet('/api/generate/status'); } catch {}
|
|
21313
|
-
const per = perAdCredits(status, model);
|
|
21314
|
-
const total = per == null ? null : per * list.length;
|
|
21315
|
-
const quote = per == null ? 'the per-ad price could not be read just now (each render reserves its own price when queued)' : `about ${per} credits per ad, plus about 3 where the product label is checked · about ${total}-${total + 3 * list.length} for ${list.length}`;
|
|
21316
|
-
const lines = thePlan.flatMap((p) => p.variants.map((v, i) => `${p.product} #${i + 1} [${v.angle}] "${v.headline}" · ${v.cta}`));
|
|
21317
|
-
if (dryRun) {
|
|
21318
|
-
return { content: [{ type: 'text', text: `PLAN (nothing rendered): ${list.length} ad${list.length === 1 ? '' : 's'} (${ratios.join(', ')}).\n${lines.join('\n')}\nQuote: ${quote}.${notes.length ? '\n' + notes.join('\n') : ''}\nTo render exactly these, call make_static_ads again with plan set to this plan and the same aspectRatios.` }], structuredContent: { plan: thePlan, perAdCredits: per, totalCredits: total, dryRun: true, ...(notes.length ? { notes } : {}) } };
|
|
21319
|
-
}
|
|
21320
|
-
if (total != null) {
|
|
21321
|
-
let bal = null; try { bal = (await apiGet('/api/credits'))?.balance; } catch {}
|
|
21322
|
-
if (bal != null && Number.isFinite(+bal) && +bal < total) throw Object.assign(new Error(`This batch needs about ${total} credits (${list.length} ads at about ${per}) and the balance is ${bal}. Nothing was charged. Ask for fewer ads, or top up (buy_credits).`), { status: 402, _userInput: true });
|
|
21323
|
-
}
|
|
21324
|
-
const { now, later } = splitForQueue(list);
|
|
21325
|
-
const heroImg = heroImageOf(brand);
|
|
21326
|
-
const logo = typeof brand?.logo === 'string' && /^https?:\/\//i.test(brand.logo) && !/\.svg(\?|#|$)/i.test(brand.logo) ? brand.logo : '';
|
|
21327
|
-
// THE LOGO, PER AD (2026-10-06): overlay by default (a flat lockup of the real file); in_scene puts it on something in
|
|
21328
|
-
// the ad from the real file and checks it; none leaves it off; auto reads each ad's visual. Never handed over as a
|
|
21329
|
-
// plain reference for the model to re-draw.
|
|
21330
|
-
const lmode = !logo || logoPlacement === 'none' ? 'none' : 'overlay';
|
|
21331
|
-
const lbody = lmode === 'none' ? (logo ? { logoPlacement: 'none' } : {}) : { brandLogo: true, logoPlacement: logoPlacement || 'overlay' };
|
|
21332
|
-
const jobs = [];
|
|
21333
|
-
let stoppedAt = null;
|
|
21334
|
-
for (let i = 0; i < now.length; i++) {
|
|
21335
|
-
const it = now[i];
|
|
21336
|
-
const isHero = it.image ? it.image === heroImg : true;
|
|
21337
|
-
let body;
|
|
21338
|
-
if (isHero && (it.image || brand)) {
|
|
21339
|
-
body = { prompt: staticAdPrompt(it.variant, { logo: lmode }), useBrand: true, ...lbody }; // the server attaches the hero photo and puts the real logo in the way lmode says
|
|
21340
|
-
} else if (it.image) {
|
|
21341
|
-
body = { prompt: staticAdPrompt(it.variant, { logo: lmode, productPhoto: true }), refImages: [it.image], useBrand: true, ...lbody };
|
|
21342
|
-
} else {
|
|
21343
|
-
body = logo ? { prompt: staticAdPrompt(it.variant, { logo: lmode }), useBrand: true, ...lbody } : { prompt: staticAdPrompt(it.variant, { logo: 'none' }), useBrand: false };
|
|
21344
|
-
}
|
|
21345
|
-
try {
|
|
21346
|
-
const q = await apiPost('/api/generate/image', { ...body, aspectRatio: it.aspectRatio, ...(model ? { model } : {}), queue: true });
|
|
21347
|
-
jobs.push({ id: q.jobId, product: it.product, angle: it.variant.angle, headline: it.variant.headline, aspectRatio: it.aspectRatio });
|
|
21348
|
-
} catch (e) {
|
|
21349
|
-
if (e && (e.status === 429 || e.status === 402) && jobs.length) { stoppedAt = i; notes.push(e.status === 429 ? 'The render queue is full, so the rest was not queued.' : String(e.message || '')); break; }
|
|
21350
|
-
throw e;
|
|
21351
|
-
}
|
|
21309
|
+
}, wrap(async ({ products, count, aspectRatios, brief, angles, layouts, language, model, brandId, plan, batchId, dryRun, logoPlacement }) => {
|
|
21310
|
+
if (brandId) await activeBrand(brandId);
|
|
21311
|
+
const d = await apiPost('/api/static-ads', { products, count, aspectRatios, brief, angles, layouts, language, model, plan, batchId, dryRun: !!dryRun, ...(logoPlacement ? { logoPlacement } : {}) });
|
|
21312
|
+
const thePlan = Array.isArray(d?.plan) ? d.plan : [];
|
|
21313
|
+
const ratios = Array.isArray(d?.aspectRatios) ? d.aspectRatios : normalizeRatios(aspectRatios);
|
|
21314
|
+
const per = d?.perAdCredits ?? null, total = d?.totalCredits ?? null;
|
|
21315
|
+
const adsN = d?.ads ?? renderList(thePlan, ratios).length;
|
|
21316
|
+
const quote = per == null ? 'the per-ad price could not be read just now (each render reserves its own price when queued)' : `about ${per} credits per ad, plus about 3 where the product label is checked · about ${total}-${d?.totalCreditsHigh ?? total} for ${adsN}`;
|
|
21317
|
+
const notes = Array.isArray(d?.notes) ? d.notes : [];
|
|
21318
|
+
const lines = thePlan.flatMap((p) => (p.variants || []).map((v, i) => `${p.product}${p.price ? ` (${p.price})` : ''} #${i + 1} ${v.layout?.label ? `{${v.layout.label}} ` : ''}[${v.angle}] "${v.headline}" · ${v.cta}`));
|
|
21319
|
+
if (d?.dryRun) {
|
|
21320
|
+
return { content: [{ type: 'text', text: `PLAN (nothing rendered): ${adsN} ad${adsN === 1 ? '' : 's'} (${ratios.join(', ')}), batch ${d.batchId}.\n${lines.join('\n')}\nQuote: ${quote}.${notes.length ? '\n' + notes.join('\n') : ''}\nTo render exactly these, call make_static_ads again with plan set to this plan, batchId ${d.batchId} and the same aspectRatios.` }], structuredContent: { batchId: d.batchId, plan: thePlan, perAdCredits: per, totalCredits: total, dryRun: true, ...(notes.length ? { notes } : {}) } };
|
|
21352
21321
|
}
|
|
21353
|
-
const
|
|
21354
|
-
const remaining =
|
|
21355
|
-
const text = `Queued ${jobs.length} static ad${jobs.length === 1 ? '' : 's'} (${quote}).\n${jobs.map((j, i) => `${i + 1}. ${j.product} [${j.angle}] "${j.headline}" ${j.aspectRatio} → job ${j.id}`).join('\n')}${remaining.length ? `\n${
|
|
21356
|
-
return { content: [{ type: 'text', text }], structuredContent: { plan: thePlan, jobs, ...(remaining.length ? { remaining } : {}), perAdCredits: per, totalCredits: total, ...(notes.length ? { notes } : {}) } };
|
|
21322
|
+
const jobs = Array.isArray(d?.jobs) ? d.jobs : [];
|
|
21323
|
+
const remaining = Array.isArray(d?.remaining) ? d.remaining : [];
|
|
21324
|
+
const text = `Queued ${jobs.length} static ad${jobs.length === 1 ? '' : 's'} in batch ${d?.batchId} (${quote}).\n${jobs.map((j, i) => `${i + 1}. ${j.product} {${j.layout || 'Static'}} [${j.angle}] "${j.headline}" ${j.aspectRatio} → job ${j.id}`).join('\n')}${remaining.length ? `\n${d.remainingAds || 'Some'} more not queued yet: call make_static_ads again with plan set to \`remaining\`, batchId ${d.batchId} and the same aspectRatios once some of these finish (the web Ads Studio page also queues them while it is open).` : ''}${notes.length ? '\n' + notes.join('\n') : ''}\nRead each with get_job until it reports done; there is no image until then.`;
|
|
21325
|
+
return { content: [{ type: 'text', text }], structuredContent: { batchId: d?.batchId, plan: thePlan, jobs, ...(remaining.length ? { remaining } : {}), perAdCredits: per, totalCredits: total, ...(notes.length ? { notes } : {}) } };
|
|
21326
|
+
}));
|
|
21327
|
+
|
|
21328
|
+
// THE PRODUCT CATALOG (2026-10-06): a cache of each product's live page — title, link, price, currency, description,
|
|
21329
|
+
// photos and its own selling points, with when it was last read there. Free (plain page reads, no model). The page
|
|
21330
|
+
// always wins: a re-read overwrites what we held (except what the user typed), and a failed read is said, never
|
|
21331
|
+
// treated as fresh. Same routes as the web Ads Studio page's product grid.
|
|
21332
|
+
const productLine = (p) => `${p.id} · ${p.title}${p.priceLabel ? ` · ${p.priceLabel}${p.priceCurrent ? '' : ' (not current)'}` : ''} · ${p.images?.length || 0} photo${p.images?.length === 1 ? '' : 's'} · ${p.checked}${p.lastError ? ` · last read FAILED: ${p.lastError}` : ''}${p.url ? ` · ${p.url}` : ''}`;
|
|
21333
|
+
const productsText = (d, head) => `${head}\n${(d?.products || []).map(productLine).join('\n') || 'No products yet: add one from its page link with add_product, or refresh_products with sync:true to import the store catalog.'}`;
|
|
21334
|
+
server.registerTool('list_products', {
|
|
21335
|
+
title: 'List the product catalog',
|
|
21336
|
+
description: "The brand's product catalog: each product's id, title, page link, price (and whether it is current), photos, its own selling points and audience, and when it was last checked against its live page. Free. `refresh:'stale'` re-reads rows older than a day first, `refresh:'all'` re-reads every page. Use the ids with make_static_ads.",
|
|
21337
|
+
inputSchema: { refresh: z.enum(['stale', 'all']).optional().describe("re-read product pages before listing: 'stale' (older than a day) or 'all'"), brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile') },
|
|
21338
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
21339
|
+
}, wrap(async ({ refresh, brandId }) => {
|
|
21340
|
+
if (brandId) await activeBrand(brandId);
|
|
21341
|
+
const d = await apiGet(`/api/products${refresh ? `?refresh=${refresh}` : ''}`);
|
|
21342
|
+
return ok(productsText(d, `${d?.count || 0} product${d?.count === 1 ? '' : 's'}. A price prints on an ad only when it was read from the live page within ${d?.freshForPricesMinutes || 60} minutes.${(d?.failed || []).length ? ` ${d.failed.length} page${d.failed.length === 1 ? '' : 's'} could not be re-read.` : ''}`), d);
|
|
21343
|
+
}));
|
|
21344
|
+
server.registerTool('add_product', {
|
|
21345
|
+
title: 'Add a product from a link',
|
|
21346
|
+
description: "Add a product to the brand's catalog from its product-page link: reads the page's own data (name, every photo, price and currency, description, its bullet-point selling points), no model, 0 credits. Re-adding a link updates the row (the live page wins). Without a url, `title` adds one by hand. `audience` and `sellingPoints` you pass are kept through later re-reads.",
|
|
21347
|
+
inputSchema: {
|
|
21348
|
+
url: z.string().optional().describe('the product page link'),
|
|
21349
|
+
title: z.string().optional().describe('to add a product by hand (no link)'),
|
|
21350
|
+
images: z.array(z.string()).optional().describe('photo urls for a hand-added product'),
|
|
21351
|
+
price: z.string().optional().describe('hand-added only; never printed as current unless read from a page'),
|
|
21352
|
+
currency: z.string().optional(),
|
|
21353
|
+
description: z.string().optional(),
|
|
21354
|
+
sellingPoints: z.array(z.string()).optional(),
|
|
21355
|
+
audience: z.string().optional().describe('who this product is for'),
|
|
21356
|
+
brandId: z.string().optional(),
|
|
21357
|
+
},
|
|
21358
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
21359
|
+
}, wrap(async ({ brandId, ...a }) => {
|
|
21360
|
+
if (brandId) await activeBrand(brandId);
|
|
21361
|
+
const d = await apiPost('/api/products', a);
|
|
21362
|
+
const p = d?.product;
|
|
21363
|
+
return ok(p ? `Added: ${productLine(p)}${p.sellingPoints?.length ? `\nSelling points: ${p.sellingPoints.join('; ')}` : ''}${d.changed?.length ? `\nChanged since last read: ${d.changed.join(', ')}` : ''}` : 'Nothing added.', d);
|
|
21364
|
+
}));
|
|
21365
|
+
server.registerTool('refresh_products', {
|
|
21366
|
+
title: 'Re-check products against their pages',
|
|
21367
|
+
description: "Re-read product pages now (all products with a page, or `ids`) and update the catalog: the live page wins, a failed read is recorded and said. `sync:true` instead imports the whole store catalog (the brand's connected Shopify store, else its website's public catalog), merging into existing rows. Free.",
|
|
21368
|
+
inputSchema: { ids: z.array(z.string()).optional(), sync: z.boolean().optional().describe('import the store catalog instead of re-reading pages'), brandId: z.string().optional() },
|
|
21369
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
21370
|
+
}, wrap(async ({ ids, sync, brandId }) => {
|
|
21371
|
+
if (brandId) await activeBrand(brandId);
|
|
21372
|
+
const d = sync ? await apiPost('/api/products/sync', {}) : await apiPost('/api/products/refresh', ids && ids.length ? { ids } : {});
|
|
21373
|
+
const head = sync ? `Imported ${d?.synced || 0} products from ${d?.from || 'the store'}.` : `Re-read ${(d?.refreshed || []).length} page${(d?.refreshed || []).length === 1 ? '' : 's'}${(d?.refreshed || []).filter((r) => r.changed?.length).length ? ` (${d.refreshed.filter((r) => r.changed?.length).map((r) => `${r.id}: ${r.changed.join(', ')}`).join('; ')})` : ', nothing changed'}.${(d?.failed || []).length ? ` FAILED: ${d.failed.map((f) => `${f.title}: ${f.error}`).join('; ')}` : ''}${d?.noPage ? ` ${d.noPage} product${d.noPage === 1 ? ' has' : 's have'} no page to re-read.` : ''}`;
|
|
21374
|
+
return ok(productsText(d, head), d);
|
|
21375
|
+
}));
|
|
21376
|
+
server.registerTool('update_product', {
|
|
21377
|
+
title: 'Edit a product',
|
|
21378
|
+
description: "Edit a catalog product's title, description, selling points, audience or page link. What you set here is kept through later page re-reads. Free.",
|
|
21379
|
+
inputSchema: { id: z.string(), title: z.string().optional(), description: z.string().optional(), sellingPoints: z.array(z.string()).optional(), audience: z.string().optional(), url: z.string().optional(), brandId: z.string().optional() },
|
|
21380
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
21381
|
+
}, wrap(async ({ id, brandId, ...a }) => {
|
|
21382
|
+
if (brandId) await activeBrand(brandId);
|
|
21383
|
+
const d = await apiPost(`/api/products/${encodeURIComponent(id)}`, a);
|
|
21384
|
+
return ok(`Updated: ${productLine(d.product)}`, d);
|
|
21385
|
+
}));
|
|
21386
|
+
server.registerTool('remove_product', {
|
|
21387
|
+
title: 'Remove a product',
|
|
21388
|
+
description: "Remove one product from the brand's catalog (its ads already made stay in the Library). Free.",
|
|
21389
|
+
inputSchema: { id: z.string(), brandId: z.string().optional() },
|
|
21390
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
21391
|
+
}, wrap(async ({ id, brandId }) => {
|
|
21392
|
+
if (brandId) await activeBrand(brandId);
|
|
21393
|
+
const d = await apiDelete(`/api/products/${encodeURIComponent(id)}`);
|
|
21394
|
+
return ok(`Removed ${id}. ${d?.count ?? 0} product${d?.count === 1 ? '' : 's'} left.`, d);
|
|
21357
21395
|
}));
|
|
21358
21396
|
|
|
21359
21397
|
// ---------- research analysis & creative remix (webapp Create-chat parity — the last four app-only chat tools, now headless) ----------
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.389",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "Marketing on autopilot, run from your own AI agent.
|
|
5
|
+
"description": "Marketing on autopilot, run from your own AI agent. 906 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, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|