hermoso 0.1.514 → 0.1.518
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/client.mjs +2 -1
- package/mcp/tools.mjs +48 -13
- package/package.json +1 -1
package/mcp/client.mjs
CHANGED
|
@@ -546,7 +546,8 @@ export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent
|
|
|
546
546
|
// ended the follow-up for good. A 404 is re-read for a short window before it is reported as absent; any other answer
|
|
547
547
|
// returns at once, and an id that really does not exist costs only that window. null = absent after the window.
|
|
548
548
|
export const JOB_READ_GRACE_MS = 12_000;
|
|
549
|
-
|
|
549
|
+
// Only a job_-shaped id gets the grace: anything else (a file link, a made-up id) was never a job, so one read settles it.
|
|
550
|
+
export async function getJobSettled(id, { graceMs = /^job_/i.test(String(id ?? '').trim()) ? JOB_READ_GRACE_MS : 0, intervalMs = 3000, getJobFn = getJob } = {}) {
|
|
550
551
|
const until = Date.now() + Math.max(0, graceMs);
|
|
551
552
|
for (;;) {
|
|
552
553
|
try { return await getJobFn(id); }
|
package/mcp/tools.mjs
CHANGED
|
@@ -474,6 +474,11 @@ async function imageBlock(url) {
|
|
|
474
474
|
// "connect your … account" — those are precisely the two alternatives that swallowed the refusals above. The branch
|
|
475
475
|
// retires the day that throw becomes notConnected('microsoft_onedrive'); nothing else has to move.
|
|
476
476
|
const NOT_CONNECTED_LEGACY_RE = /isn['’`]?t connected\b|\bConnect [A-Z][\w .+&-]{1,28} first\b/;
|
|
477
|
+
// A WRONG ID IS NOT A MISSING CONNECTION (2026-11-04, live on schedule_post): "Page 999… isn’t connected to this brand.
|
|
478
|
+
// Connected Page: …" is a refusal on a connector that WORKS, about an id that is not one of this brand's, and it already
|
|
479
|
+
// names the right ones. "Ask your human to connect it" sent the agent to reconnect Meta. An id as the subject, or a list
|
|
480
|
+
// of what IS connected, marks that class; the legacy branch above is only for a connection that is absent outright.
|
|
481
|
+
const WRONG_ID_RE = /\d{4,}\)?\s+isn['’`]?t connected to this\b|\bConnected [A-Z]?[a-z]+(?: [a-z]+)?s?:\s/;
|
|
477
482
|
// LEGACY-ONLY provider resolution, reached solely by the branch above. It is order-sensitive in a way that bites
|
|
478
483
|
// (/tiktok/ matches "TikTok Ads", so the ads entries MUST precede the organic ones) and it has always been missing
|
|
479
484
|
// live providers — which is exactly the fragility the marker removes, and exactly why nothing on the marker path
|
|
@@ -507,6 +512,8 @@ function notConnectedHint(e, msg) {
|
|
|
507
512
|
if (status === 401) return marker ? ((/\?connect=[a-z_]/.test(String(msg || '')) ? '' : CONNECT_LINK_HINT(marker, e?.connectUrl)) + KEY_CONNECT_HINT(marker, msg)) : '';
|
|
508
513
|
if (marker) return ''; // connected already — a sharing / ownership / provider refusal that merely names its connector
|
|
509
514
|
if (status === 501) return ''; // a provider key WE have not set; no user connection can supply it
|
|
515
|
+
if (e?.apiCode) return ''; // the server classified it with its own code, so it is not the unclassified legacy throw (a wrong Page id is `meta_page_not_connected`)
|
|
516
|
+
if (WRONG_ID_RE.test(String(msg || ''))) return ''; // a wrong id on a working connector, from a server that sent no code
|
|
510
517
|
if (!NOT_CONNECTED_LEGACY_RE.test(String(msg || ''))) return '';
|
|
511
518
|
const hit = CONNECT_LEGACY_PROVIDERS.find(([re]) => re.test(msg));
|
|
512
519
|
return hit ? CONNECT_LINK_HINT(hit[1]) : CONNECT_ASK_HINT;
|
|
@@ -3971,6 +3978,28 @@ const TEXT_STYLE_Z = z.union([
|
|
|
3971
3978
|
tilt: z.number().optional().describe('degrees, ±45'), cardColor: z.string().optional(),
|
|
3972
3979
|
}),
|
|
3973
3980
|
]);
|
|
3981
|
+
// ── WHAT get_job SAYS WHEN IT FINDS NOTHING (chat review 2026-11-04, fi:c9eb3a08e010) ─────────────────────────────────
|
|
3982
|
+
// Two generate_voice calls were billed and get_job was then polled eight times with an id that was not a job: the voice
|
|
3983
|
+
// tools finish inside their own call and return the file, they never had a job. A real job id is `job_…` and comes back
|
|
3984
|
+
// as `jobId` from a render that runs in the background, or from list_jobs. Anything else is read ONCE (an id the server
|
|
3985
|
+
// knows is never refused on its shape) and, when nothing is there, the reply names the id, says what it looks like and
|
|
3986
|
+
// where real ids come from. PURE, so tools/voice-job-id-check.mjs runs it.
|
|
3987
|
+
export const JOB_ID_RE = /^job_[a-z0-9]+$/i;
|
|
3988
|
+
export const INLINE_RESULT_NOTE = 'Finished in this call: the file above is the result, so there is no job to poll with get_job.';
|
|
3989
|
+
export function jobMissReply(id) {
|
|
3990
|
+
const s = String(id ?? '').trim();
|
|
3991
|
+
if (JOB_ID_RE.test(s)) {
|
|
3992
|
+
return { text: `No job with id "${s}". Nothing is rendering under that id: it does not exist on this workspace (it may have been mistyped, or it may belong to another account or brand). STOP POLLING IT. Do NOT re-run the render on the strength of this: call list_jobs to see this workspace's recent jobs and their real ids.`,
|
|
3993
|
+
error: `No job with id "${s}" on this workspace.` };
|
|
3994
|
+
}
|
|
3995
|
+
const fileLike = /^(https?:)?\/\//i.test(s) || /^\/(generated|api|uploads?)\//i.test(s) || /\.(mp3|wav|m4a|aac|ogg|flac|mp4|mov|webm|png|jpe?g|webp|gif|avif|pdf)(\?.*)?$/i.test(s);
|
|
3996
|
+
return { text: `"${s}" is not a job id, and nothing on this workspace is rendering under it. STOP POLLING IT.`
|
|
3997
|
+
+ (fileLike ? ' It is a file link: that file is already finished, so use it as it is.' : '')
|
|
3998
|
+
+ ' Real job ids start with job_ and come back as jobId from a render that runs in the background (generate_video, render_ad, edit_video, plan_week and the like), or from list_jobs.'
|
|
3999
|
+
+ ' generate_voice and generate_sfx never make a job: they return their MP3 directly in the same reply, so there is nothing to poll for them.'
|
|
4000
|
+
+ ' Do NOT re-run anything on the strength of this.',
|
|
4001
|
+
error: `"${s}" is not a job id.` };
|
|
4002
|
+
}
|
|
3974
4003
|
export function registerTools(rawServer, opts = {}) {
|
|
3975
4004
|
const server = wellFormedServer(rawServer); // the ONE crossing — see above
|
|
3976
4005
|
// THE CACHE HOLDS SCHEMAS, SO A FLAG THAT CHANGES A SCHEMA MUST INVALIDATE IT (2026-09-16). `hosted` decides
|
|
@@ -6743,7 +6772,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6743
6772
|
// no need for one edge case just for Facebook."
|
|
6744
6773
|
server.registerTool('schedule_post', {
|
|
6745
6774
|
title: 'Schedule a post for later',
|
|
6746
|
-
description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills per call: hermoso_credits gives the exact credits per X post). THE FREE PLAN INCLUDES 10 POSTS A MONTH ON UP TO 3 CHANNELS (a post counts once, however many channels it goes to; counted in the UTC month the post goes out); every paid plan posts without a limit. A post over that allowance is refused here and nothing is queued; the refusal says how many are used and when the count resets. Relay it as written, never retry it and never split the post to fit. billing_status and list_scheduled show how many are used. Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack — every one live. whop works where its connector is offered (list_connectors says) and is held back elsewhere: there a whop post is refused, not queued. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile
|
|
6775
|
+
description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills per call: hermoso_credits gives the exact credits per X post). THE FREE PLAN INCLUDES 10 POSTS A MONTH ON UP TO 3 CHANNELS (a post counts once, however many channels it goes to; counted in the UTC month the post goes out); every paid plan posts without a limit. A post over that allowance is refused here and nothing is queued; the refusal says how many are used and when the count resets. Relay it as written, never retry it and never split the post to fit. billing_status and list_scheduled show how many are used. Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack — every one live. whop works where its connector is offered (list_connectors says) and is held back elsewhere: there a whop post is refused, not queued. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages): a Facebook post on a brand with several Pages and none named is refused when you schedule it, with the Pages listed by name and id, so ask the user which one. Several Google Business listings need `locationId` (list_business_locations), and without it that post is refused when it fires. Resolve those FIRST and let the user pick. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.',
|
|
6747
6776
|
inputSchema: {
|
|
6748
6777
|
...HOOK_ATTR,
|
|
6749
6778
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])).describe('one or more channels to post to at that time'),
|
|
@@ -9336,6 +9365,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9336
9365
|
instagramPositions: z.array(z.string()).optional().describe('stream, story, reels, explore, profile_feed, …'),
|
|
9337
9366
|
messengerPositions: z.array(z.string()).optional().describe('Messenger placements, e.g. sponsored_messages'), audienceNetworkPositions: z.array(z.string()).optional().describe('Audience Network placement names, as Meta defines them'),
|
|
9338
9367
|
devicePlatforms: z.array(z.enum(['mobile', 'desktop'])).optional().describe('mobile or desktop'), userOs: z.array(z.enum(['iOS', 'Android'])).optional().describe('iOS or Android'),
|
|
9368
|
+
deviceExpansion: z.boolean().optional().describe('only with devicePlatforms / userOs: true lets Meta also show the ad on OTHER devices and operating systems (its "Reach more people across other mobile devices", Sales campaigns only). Default false keeps delivery to exactly the devices and OS you targeted — Hermoso sends the opt-out explicitly, because Meta turns this on by default for new Sales ad sets from Graph v27'),
|
|
9339
9369
|
advantageAudience: z.boolean().optional().describe('let Meta expand beyond your audience (Advantage+ audience)'),
|
|
9340
9370
|
}).loose().optional(); // loose for the same reason as metaGeoShape — the server REFUSES an unknown targeting key by name; this twin must not swallow it first
|
|
9341
9371
|
const metaAdSetFields = {
|
|
@@ -10075,7 +10105,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10075
10105
|
}));
|
|
10076
10106
|
server.registerTool('create_meta_audience', {
|
|
10077
10107
|
title: 'Create a Meta custom audience or lookalike',
|
|
10078
|
-
description: 'Build a retargeting audience on a connected Meta ad account. Four kinds: kind:"website" (people whose visited URL contains urlContains, seen by pixelId — pass the brand’s own domain for "all visitors"; retentionDays up to 180), kind:"engagement" (people who did `event` on the brand’s Facebook Page, or its Instagram business profile with source:"instagram"; retentionDays up to 730), or kind:"lookalike" (sourceAudienceId + country + ratio 0.01–0.20, lookalikeType "similarity" or "reach"). CREATING AN AUDIENCE SPENDS NOTHING — it is a definition; money only moves when an ad set targets it and that campaign is activated through set_meta_campaign_status(confirm:true). Meta needs roughly 30 minutes and ~1,000 people before a new audience can be targeted, so a fresh one reporting no size is normal. kind:"customerfile" is the brand\'s OWN CUSTOMER LIST (emails, phones, names…) for retargeting, suppression or as a lookalike seed: pass customerFileSource (a required attestation of where the data came from: USER_PROVIDED_ONLY, PARTNER_PROVIDED_ONLY or BOTH_USER_AND_PARTNER_PROVIDED), plus schema (the column names in order, from EMAIL, PHONE, FN, LN, FI, CT, ST, ZIP, COUNTRY, DOB, DOBY, GEN, MADID, EXTERN_ID) and rows (arrays in that order, up to 10,000 per call) to upload them in the same call. Hermoso normalizes and SHA-256-hashes every value before it leaves the server, exactly as Meta requires, and reports Meta’s own match counts (a mostly-rejected upload is a format problem and says so). To ADD more people to an existing list, call again with kind:"customerfile" + audienceId + schema + rows (uploads add, they never replace). Meta asks the ad account to accept its Custom Audience Terms once; if it has not, Meta’s refusal says so.',
|
|
10108
|
+
description: 'Build a retargeting audience on a connected Meta ad account. Four kinds: kind:"website" (people whose visited URL contains urlContains, seen by pixelId — pass the brand’s own domain for "all visitors"; retentionDays up to 180), kind:"engagement" (people who did `event` on the brand’s Facebook Page, or its Instagram business profile with source:"instagram"; retentionDays up to 730), or kind:"lookalike" (sourceAudienceId + country + ratio 0.01–0.20 = the top 1%–20% most similar people, lookalikeType "similarity" or "reach", or startingRatio for a band such as 1%–3%). Meta delivers a lookalike to the locations of the AD SET that targets it, so `country` is where you will run it — target the same country in that ad set. CREATING AN AUDIENCE SPENDS NOTHING — it is a definition; money only moves when an ad set targets it and that campaign is activated through set_meta_campaign_status(confirm:true). Meta needs roughly 30 minutes and ~1,000 people before a new audience can be targeted, so a fresh one reporting no size is normal. kind:"customerfile" is the brand\'s OWN CUSTOMER LIST (emails, phones, names…) for retargeting, suppression or as a lookalike seed: pass customerFileSource (a required attestation of where the data came from: USER_PROVIDED_ONLY, PARTNER_PROVIDED_ONLY or BOTH_USER_AND_PARTNER_PROVIDED), plus schema (the column names in order, from EMAIL, PHONE, FN, LN, FI, CT, ST, ZIP, COUNTRY, DOB, DOBY, GEN, MADID, EXTERN_ID) and rows (arrays in that order, up to 10,000 per call) to upload them in the same call. Hermoso normalizes and SHA-256-hashes every value before it leaves the server, exactly as Meta requires, and reports Meta’s own match counts (a mostly-rejected upload is a format problem and says so). To ADD more people to an existing list, call again with kind:"customerfile" + audienceId + schema + rows (uploads add, they never replace). Meta asks the ad account to accept its Custom Audience Terms once; if it has not, Meta’s refusal says so.',
|
|
10079
10109
|
inputSchema: {
|
|
10080
10110
|
adAccountId: z.string().describe('ad account id (act_… or digits)'),
|
|
10081
10111
|
kind: z.enum(['website', 'engagement', 'lookalike', 'customerfile']).describe('which kind of audience to build — customerfile = your own customer list'),
|
|
@@ -10088,9 +10118,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10088
10118
|
source: z.enum(['page', 'instagram']).optional().describe('engagement: Facebook Page (default) or the linked Instagram business profile'),
|
|
10089
10119
|
event: z.string().optional().describe('engagement: page_engaged | page_visited | page_liked | page_messaged | page_cta_clicked | page_or_post_save | page_post_interaction — or ig_business_profile_all | ig_business_profile_engaged | ig_user_messaged_business | ig_business_profile_visit'),
|
|
10090
10120
|
sourceAudienceId: z.string().optional().describe('lookalike: the existing audience to model (from list_meta_audiences)'),
|
|
10091
|
-
country: z.string().optional().describe('lookalike: 2-letter country to
|
|
10092
|
-
ratio: z.number().optional().describe('lookalike: 0.01–0.20 = the top 1%–20% most similar people
|
|
10093
|
-
startingRatio: z.number().optional().describe('lookalike: optional lower bound
|
|
10121
|
+
country: z.string().optional().describe('lookalike: REQUIRED 2-letter country you will run it in — Meta delivers a lookalike to the locations of the ad set that targets it, so target this country there'),
|
|
10122
|
+
ratio: z.number().optional().describe('lookalike: 0.01–0.20 = the top 1%–20% most similar people (default 0.01)'),
|
|
10123
|
+
startingRatio: z.number().optional().describe('lookalike: optional lower bound for a band (e.g. 0.01 with ratio 0.03 = the 1%–3% band) — at least 0.01 and less than ratio; not combinable with lookalikeType'),
|
|
10094
10124
|
lookalikeType: z.enum(['similarity', 'reach']).optional().describe('lookalike: similarity (tighter) or reach (broader) — default similarity'),
|
|
10095
10125
|
prefill: z.boolean().optional().describe('website/engagement: seed it with activity from BEFORE the audience existed (default true)'),
|
|
10096
10126
|
customerFileSource: z.enum(['USER_PROVIDED_ONLY', 'PARTNER_PROVIDED_ONLY', 'BOTH_USER_AND_PARTNER_PROVIDED']).optional().describe('customerfile: REQUIRED attestation of where the list came from (customers gave it to you = USER_PROVIDED_ONLY)'),
|
|
@@ -17610,11 +17640,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17610
17640
|
}));
|
|
17611
17641
|
server.registerTool('create_reddit_ads_catalog', {
|
|
17612
17642
|
title: 'Create a Reddit product catalog',
|
|
17613
|
-
description: 'Create an empty Reddit PRODUCT CATALOG on the business behind the shared ad account — the product inventory a catalog SALES campaign advertises (dynamic product ads, collection ads). Spends nothing. Name it, give the CURRENCY your prices are in (USD, GBP, CAD, EUR, AUD, JPY, CHF, NZD, SEK or
|
|
17643
|
+
description: 'Create an empty Reddit PRODUCT CATALOG on the business behind the shared ad account — the product inventory a catalog SALES campaign advertises (dynamic product ads, collection ads). Spends nothing. Name it, give the CURRENCY your prices are in (USD, GBP, CAD, EUR, AUD, JPY, CHF, NZD, SEK, NOK or BRL — Reddit’s list) and optionally the language and the Reddit pixel(s) to match products against (list_reddit_ads_pixels; catalog ads optimize on pixel events such as PURCHASE). The currency and language cannot be changed later. Then fill it — upsert_reddit_ads_products for a handful, or create_reddit_ads_product_feed to have Reddit fetch your product file on a schedule — and define which products to advertise with create_reddit_ads_product_set. Reddit requires its Catalog manager role for catalog writes, which Business admin does not include (a login that can run campaigns can still be refused “no permissions to Create Catalog action”) — that refusal is about the role, not the connection, and the reply says how to fix it. Read back from Reddit.',
|
|
17614
17644
|
inputSchema: {
|
|
17615
17645
|
adAccountId: z.string().optional().describe('Reddit ad account id (a2_...); omit when only one is shared'),
|
|
17616
17646
|
name: z.string().describe('catalog name'),
|
|
17617
|
-
defaultCurrency: z.enum(['USD', 'GBP', 'CAD', 'EUR', 'AUD', 'JPY', 'CHF', 'NZD', 'SEK', 'NOK']).describe('the currency your product prices are in — fixed once created'),
|
|
17647
|
+
defaultCurrency: z.enum(['USD', 'GBP', 'CAD', 'EUR', 'AUD', 'JPY', 'CHF', 'NZD', 'SEK', 'NOK', 'BRL']).describe('the currency your product prices are in — fixed once created'),
|
|
17618
17648
|
defaultLanguage: z.enum(['en', 'de', 'es', 'fr', 'it', 'pt']).optional().describe('default en — fixed once created'),
|
|
17619
17649
|
pixelIds: z.array(z.string()).optional().describe('Reddit pixel ids from list_reddit_ads_pixels to match products against; each must be on the shared ad account'),
|
|
17620
17650
|
},
|
|
@@ -22804,6 +22834,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22804
22834
|
credits: z.number().optional().describe('dryRun: the exact credits it would charge'),
|
|
22805
22835
|
dryRun: z.boolean().optional(),
|
|
22806
22836
|
creditsUsed: z.number().optional().describe('credits billed for this clip'),
|
|
22837
|
+
nothingToPoll: z.boolean().optional().describe('true: the MP3 in `audio` is the finished result, never call get_job for it'),
|
|
22807
22838
|
},
|
|
22808
22839
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22809
22840
|
}, wrap(async ({ text, engine, voice, lines, dryRun }) => {
|
|
@@ -22811,13 +22842,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22811
22842
|
const d = await apiPost('/api/generate/voice', { lines, ...(engine ? { engine } : {}), ...(dryRun ? { dryRun: true } : {}) });
|
|
22812
22843
|
const cast = (d.cast || []).map((c) => `${c.speaker}: ${c.voice}`).join(', ');
|
|
22813
22844
|
if (d.dryRun) return ok(`DIALOGUE QUOTE (nothing made, nothing charged): ${d.lines} lines, ${d.characters} characters, ${cast}, on ${d.model}. Price: ${d.credits} credits, exactly what it charges. Call again without dryRun to make it.`, d);
|
|
22814
|
-
return ok(`Dialogue ready (${d.durationSeconds ?? '?'}s, ${d.model}; ${cast}, ${d.creditsUsed} credits): ${abs(d.audio)}`, { ...d, audio: abs(d.audio) });
|
|
22845
|
+
return ok(`Dialogue ready (${d.durationSeconds ?? '?'}s, ${d.model}; ${cast}, ${d.creditsUsed} credits): ${abs(d.audio)}\n${INLINE_RESULT_NOTE}`, { ...d, audio: abs(d.audio), nothingToPoll: true });
|
|
22815
22846
|
}
|
|
22816
22847
|
const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}), ...(dryRun ? { dryRun: true } : {}) });
|
|
22817
22848
|
if (d.dryRun) return ok(`VOICE QUOTE (nothing made, nothing charged): ${d.characters} characters on ${d.model}. Price: ${d.credits} credits, exactly what it charges.`, d);
|
|
22818
22849
|
// REPORT WHAT SPOKE (GEN-2). `d.voice` is the DISPATCHED voice and is null on a fixed-voice engine — the server
|
|
22819
22850
|
// refuses an unknown voice for free rather than rendering in a different one and echoing the ask back.
|
|
22820
|
-
|
|
22851
|
+
// NO JOB TO POLL (fi:c9eb3a08e010): the clip is finished in this reply, said in words and in structuredContent.
|
|
22852
|
+
return ok(`Voice clip ready — ${d.voice || 'the engine’s own voice'}${d.model ? ` · ${d.model}` : ''}: ${abs(d.audio)}${d.voiceNote ? `\n⚠ ${d.voiceNote}` : ''}\n${INLINE_RESULT_NOTE}`, { ...d, audio: abs(d.audio), nothingToPoll: true });
|
|
22821
22853
|
}));
|
|
22822
22854
|
|
|
22823
22855
|
// A SOUND EFFECT OR AMBIENCE MADE FROM WORDS (2026-11-03): POST /api/generate/sfx, the same engine as the Studio's generate_sfx.
|
|
@@ -22843,12 +22875,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22843
22875
|
credits: z.number().optional().describe('dryRun: the exact credits it would charge'),
|
|
22844
22876
|
dryRun: z.boolean().optional(),
|
|
22845
22877
|
creditsUsed: z.number().optional(),
|
|
22878
|
+
nothingToPoll: z.boolean().optional().describe('true: the MP3 in `audio` is the finished result, never call get_job for it'),
|
|
22846
22879
|
},
|
|
22847
22880
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22848
22881
|
}, wrap(async ({ describe, seconds, loop, engine, dryRun }) => {
|
|
22849
22882
|
const d = await apiPost('/api/generate/sfx', { describe, ...(seconds ? { seconds } : {}), ...(loop ? { loop: true } : {}), ...(engine ? { engine } : {}), ...(dryRun ? { dryRun: true } : {}) });
|
|
22850
22883
|
if (d.dryRun) return ok(`SOUND QUOTE (nothing made, nothing charged): ${d.seconds}s${d.loop ? ' loopable' : ''} on ${d.model}. Price: ${d.credits} credits, exactly what it charges.`, d);
|
|
22851
|
-
return ok(`Sound ready (${d.durationSeconds ?? '?'}s, ${d.model}, ${d.creditsUsed} credits): ${abs(d.audio)}${d.exactLength === false ? '\nThe length was asked for in words, so it may run a little long or short.' : ''}${d.loopNote ? `\n${d.loopNote}` : ''}`, { ...d, audio: abs(d.audio) });
|
|
22884
|
+
return ok(`Sound ready (${d.durationSeconds ?? '?'}s, ${d.model}, ${d.creditsUsed} credits): ${abs(d.audio)}${d.exactLength === false ? '\nThe length was asked for in words, so it may run a little long or short.' : ''}${d.loopNote ? `\n${d.loopNote}` : ''}\n${INLINE_RESULT_NOTE}`, { ...d, audio: abs(d.audio), nothingToPoll: true });
|
|
22852
22885
|
}));
|
|
22853
22886
|
|
|
22854
22887
|
server.registerTool('generate_music', {
|
|
@@ -23862,7 +23895,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23862
23895
|
server.registerTool('get_job', {
|
|
23863
23896
|
title: 'Get render job',
|
|
23864
23897
|
description: 'Poll a render job by id. Returns status (queued|running|done|error), progress, and on done the served media URL. Renders take 1–3 minutes: keep calling this until done/error without asking the user — several calls is normal, not a stall. An id that does not exist on this account answers status "not_found" — that is FINAL: stop polling it, and do not re-fire the render (that double-charges).',
|
|
23865
|
-
inputSchema: { id: z.string().describe('the job id,
|
|
23898
|
+
inputSchema: { id: z.string().describe('the job id: starts with job_, from a render tool\'s jobId or from list_jobs (generate_voice and generate_sfx return their file directly and have no job)') },
|
|
23866
23899
|
outputSchema: {
|
|
23867
23900
|
id: z.string().optional().describe('the job id'),
|
|
23868
23901
|
status: z.string().optional().describe('queued | running | done | error | not_found (no job with this id on this account)'),
|
|
@@ -23896,7 +23929,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23896
23929
|
// guessing from prose. The sentence below is unambiguous for the model; `status:'not_found'` is unambiguous for
|
|
23897
23930
|
// the card; and both say STOP rather than leaving a poller to grind for six minutes.
|
|
23898
23931
|
const j = await getJobSettled(id); // a 404 is re-read for a few seconds first: a revision taking over mid-render has not yet seen the job (getJobSettled)
|
|
23899
|
-
|
|
23932
|
+
// The sentence says what the id LOOKS like (jobMissReply): a voice clip's file link polled as a job is told it is a
|
|
23933
|
+
// finished file, and where real job ids come from (fi:c9eb3a08e010). A non-job_ id is read once (getJobSettled).
|
|
23934
|
+
if (!j) { const miss = jobMissReply(id); return ok(miss.text, { id, status: 'not_found', progress: 0, error: miss.error, url: null }); }
|
|
23900
23935
|
const res = jobResult(j);
|
|
23901
23936
|
const url = abs(res?.video || res?.image || res?.url);
|
|
23902
23937
|
// A RESUMED RENDER READS THE SAME SENTENCE AS AN INLINE ONE. `stillRendering` makes this tool the ordinary way a
|
|
@@ -25763,7 +25798,7 @@ function memoryNoteVerdict(text) {
|
|
|
25763
25798
|
server.registerTool('search_instagram', {
|
|
25764
25799
|
_meta: openaiMeta(AD_SPY_URI, 'Searching Instagram reels…', 'Found Instagram reels'),
|
|
25765
25800
|
title: 'Search Instagram',
|
|
25766
|
-
description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search — our only IG keyword surface; profile/hashtag pulls go through fetch_social_data with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. Spends about a credit.",
|
|
25801
|
+
description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search — our only IG keyword surface; profile/hashtag pulls go through fetch_social_data with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. It matches captions worldwide and cannot be limited to a country or language: for one market, search in the words people there use, or read named local accounts' Reels through fetch_social_data's Instagram profile endpoint, which is the better first step for a local market. Spends about a credit.",
|
|
25767
25802
|
inputSchema: {
|
|
25768
25803
|
query: z.string().describe('keyword to search reels for'),
|
|
25769
25804
|
limit: z.number().int().optional().describe('max reels returned (1–25, default 8)'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.518",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1175 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 10 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|