hermoso 0.1.471 → 0.1.473
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 +17 -10
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -601,6 +601,7 @@ const wrap = (fn) => {
|
|
|
601
601
|
// A PUBLISH OUTCOME IS DATA (2026-10-25, fb_679fd3573ff0): "nothing was posted" and "could not confirm" both arrive
|
|
602
602
|
// as 502, so the hint follows the server's `remedy.posted`, never the status or the prose.
|
|
603
603
|
if (e?.remedy?.code === 'publish_not_sent') _hints.push({ do: 'call this tool again with the same arguments', why: 'nothing was posted: it failed before anything was sent to the platform, so a retry cannot double-post' });
|
|
604
|
+
else if (e?.remedy?.code === 'publish_refused') _hints.push(e.remedy.retryable === false ? { do: 'tell the user what the platform refused and what to change; do not send the same post again until it is changed', why: 'the platform answered that this post failed, so nothing is live, and the same post would be refused the same way' } : { do: 'call this tool again with the same arguments', why: 'the platform answered that this post failed, so nothing is live and a retry cannot double-post' });
|
|
604
605
|
else if (e?.remedy?.code === 'publish_unconfirmed' || e?.remedy?.code === 'publish_in_progress') _hints.push({ do: 'check the account for the post before anything else; do not retry with a new idempotency key', why: 'the publishing call did not answer cleanly, so the post may be live; allowDuplicate:true is the only way to post it again' });
|
|
605
606
|
}
|
|
606
607
|
// ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
|
|
@@ -4800,8 +4801,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4800
4801
|
// binds both, so the human is told exactly what they pay and what they get.
|
|
4801
4802
|
const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
|
|
4802
4803
|
const priceText = (p) => `$${p.priceUsd}`;
|
|
4804
|
+
// THE PACK-VS-PLAN COMPARISON (2026-10-30): an account with no monthly plan is told, on the pack it is about to buy, the
|
|
4805
|
+
// plan that beats it and by how much, measured by the server (packs[].planCompare, lib/pack-plan-compare.mjs). Not on a
|
|
4806
|
+
// host that renders widgets, the same rule as hermoso_credits' upsell line.
|
|
4807
|
+
const _cmp = new Map((cfg.packs || []).filter(p => p.planCompare).map(p => [p.id, p.planCompare]));
|
|
4808
|
+
const cmpOf = (id) => (hostRendersWidgets() ? null : _cmp.get(id) || null);
|
|
4809
|
+
const cmpLine = (c) => c ? `\n\nBefore paying: ${c.line} A monthly plan is the better buy here; upgrade_plan moves the account to ${c.planName} (a person pays on Stripe).` : '';
|
|
4803
4810
|
if (!pack) {
|
|
4804
|
-
const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · ${priceText(p)}`).join('\n') || '(no packs configured)';
|
|
4811
|
+
const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · ${priceText(p)}${cmpOf(p.id) ? ` (${cmpOf(p.id).headline})` : ''}`).join('\n') || '(no packs configured)';
|
|
4805
4812
|
let _up = null; try { _up = (await apiGet('/api/billing/status'))?.planUpsell || null; } catch {}
|
|
4806
4813
|
return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>". With a saved card it's a one-click charge (you'll be asked to confirm); otherwise you get a checkout link for your human.${_up ? `\n\n${_up.line} ${_up.note} Top-up credits use standard prices; upgrade_plan lists the monthly plans.` : ''}\n\nTip: to never run out again, turn on auto-reload (set_auto_reload) — it auto-tops-up from the saved card the moment your balance runs low.`, { packs });
|
|
4807
4814
|
}
|
|
@@ -4822,7 +4829,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4822
4829
|
const mintQuote = () => {
|
|
4823
4830
|
const t = `qt1|${match.id}|${match.priceUsd}|${Math.floor(Date.now() / 1000) + 600}|${(globalThis.crypto?.randomUUID?.() || String(Date.now())).slice(0, 8)}`;
|
|
4824
4831
|
_mintedQuotes.add(t); if (_mintedQuotes.size > 50) _mintedQuotes.delete(_mintedQuotes.values().next().value); // bounded
|
|
4825
|
-
return ok(`Ready to charge ${card} ${priceText(match)} for ${match.credits.toLocaleString()} credits (one-click, no redirect — same as the app's Add credits button). Confirm with your human if they haven't already asked for this, then call buy_credits again with pack="${match.id}", confirm:true and quote_token="${t}" (valid 10 minutes)
|
|
4832
|
+
return ok(`Ready to charge ${card} ${priceText(match)} for ${match.credits.toLocaleString()} credits (one-click, no redirect — same as the app's Add credits button). Confirm with your human if they haven't already asked for this, then call buy_credits again with pack="${match.id}", confirm:true and quote_token="${t}" (valid 10 minutes).${cmpLine(cmpOf(match.id))}`, { quote: { packId: match.id, credits: match.credits, priceUsd: match.priceUsd, card: st.card || null, quoteToken: t, expiresInSeconds: 600 } });
|
|
4826
4833
|
};
|
|
4827
4834
|
if (!confirm || !quote_token) return mintQuote();
|
|
4828
4835
|
const qp = String(quote_token).split('|');
|
|
@@ -4836,7 +4843,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4836
4843
|
return ok(`Done — charged ${card} ${priceText(match)}; ${match.credits.toLocaleString()} credits are on the account now. (Receipt lands in Billing → invoice history.)`, d);
|
|
4837
4844
|
}
|
|
4838
4845
|
const d = await apiPost('/api/billing/checkout-link', { packId: pack });
|
|
4839
|
-
return ok(`Checkout link for ${Number(d.credits ?? match.credits).toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — credits post automatically once payment completes, and their card saves for one-click top-ups (in-app AND via this tool) from then on. Nothing is charged until they pay
|
|
4846
|
+
return ok(`Checkout link for ${Number(d.credits ?? match.credits).toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — credits post automatically once payment completes, and their card saves for one-click top-ups (in-app AND via this tool) from then on. Nothing is charged until they pay.${cmpLine(hostRendersWidgets() ? null : d.planCompare || cmpOf(match.id))}`, d);
|
|
4840
4847
|
}));
|
|
4841
4848
|
|
|
4842
4849
|
// BILLING SURFACE (read → top-up → plan/auto-reload): hermoso_credits (balance) → buy_credits (top-up link) →
|
|
@@ -8404,13 +8411,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8404
8411
|
yourBrand: z.boolean().optional().describe('discloses that this promotes the creator’s own brand'),
|
|
8405
8412
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (TikTok video_cover_timestamp_ms, on a direct post \u2014 a draft takes no cover, you pick it in the TikTok app). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
8406
8413
|
},
|
|
8407
|
-
outputSchema: { ok: z.boolean().optional(), publishId: z.string().optional(), status: z.string().optional(), destination: z.string().optional(), media: z.string().optional(), images: z.number().optional(), coverIndex: z.number().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), account: z.string().nullable().optional(), pending: z.boolean().optional() },
|
|
8414
|
+
outputSchema: { ok: z.boolean().optional(), publishId: z.string().optional(), status: z.string().optional(), destination: z.string().optional(), media: z.string().optional(), images: z.number().optional(), coverIndex: z.number().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), account: z.string().nullable().optional(), pending: z.boolean().optional(), mediaConformedNote: z.string().optional(), reencodedNote: z.string().optional() },
|
|
8408
8415
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
8409
8416
|
}, wrap(async (a) => {
|
|
8410
8417
|
const d = await apiPost('/api/tiktok/post', a);
|
|
8411
8418
|
const what = d.media === 'photo' ? (d.images > 1 ? `photo post (${d.images} slides)` : 'photo post') : 'video';
|
|
8412
|
-
if (d.destination === 'draft') return ok(`Sent the ${what} to TikTok${d.account ? ` on @${d.account}` : ''} — it's waiting in the TikTok app (inbox notification, or + ▸ drafts) for the user to finish and post.${d.pending ? ' TikTok was still processing when polling stopped; it usually lands within a minute.' : ''}`, d);
|
|
8413
|
-
return ok(`Posted the ${what} to TikTok${d.account ? ` as @${d.account}` : ''}${d.url ? ` — ${d.url}` : ''}.${d.pending ? ' TikTok was still processing when polling stopped — it normally appears within a minute or two. Do not post it again.' : ''}`, d);
|
|
8419
|
+
if (d.destination === 'draft') return ok(`Sent the ${what} to TikTok${d.account ? ` on @${d.account}` : ''} — it's waiting in the TikTok app (inbox notification, or + ▸ drafts) for the user to finish and post.${[d.mediaConformedNote, d.reencodedNote].filter(Boolean).map(n => ` ${n}`).join('')}${d.pending ? ' TikTok was still processing when polling stopped; it usually lands within a minute.' : ''}`, d);
|
|
8420
|
+
return ok(`Posted the ${what} to TikTok${d.account ? ` as @${d.account}` : ''}${d.url ? ` — ${d.url}` : ''}.${d.pending ? ' TikTok was still processing when polling stopped — it normally appears within a minute or two. Do not post it again.' : ''}${[d.mediaConformedNote, d.reencodedNote].filter(Boolean).map(n => ` ${n}`).join('')}`, d);
|
|
8414
8421
|
}));
|
|
8415
8422
|
// A publish TikTok was still PROCESSING when post_to_tiktok stopped polling came back `pending:true` with a publishId
|
|
8416
8423
|
// and the words "do not post it again", and nothing to follow it with: an agent asked call_tool for
|
|
@@ -8422,12 +8429,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8422
8429
|
publishId: z.string().describe('the publishId post_to_tiktok returned'),
|
|
8423
8430
|
account: z.string().optional().describe('which connected TikTok account the post went out on (@handle or id from list_connector_accounts) when the profile has more than one'),
|
|
8424
8431
|
},
|
|
8425
|
-
outputSchema: { publishId: z.string().optional(), status: z.string().optional(), done: z.boolean().optional(), failed: z.boolean().optional(), postId: z.string().nullable().optional(), failReason: z.string().nullable().optional(), uploadedBytes: z.number().nullable().optional() },
|
|
8432
|
+
outputSchema: { publishId: z.string().optional(), status: z.string().optional(), done: z.boolean().optional(), failed: z.boolean().optional(), postId: z.string().nullable().optional(), failReason: z.string().nullable().optional(), failMessage: z.string().optional(), uploadedBytes: z.number().nullable().optional() },
|
|
8426
8433
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
8427
8434
|
}, wrap(async (a) => {
|
|
8428
8435
|
const d = await apiGet('/api/tiktok/publish-status', { publishId: a.publishId, ...(a.account ? { account: a.account } : {}) });
|
|
8429
8436
|
if (d.done) return ok(`TikTok finished publishing it${d.postId ? ` (post id ${d.postId})` : ''}.`, d);
|
|
8430
|
-
if (d.failed) return ok(`TikTok did not publish it: ${d.failReason || 'no reason given'}.`, d);
|
|
8437
|
+
if (d.failed) return ok(d.failMessage || `TikTok did not publish it: ${d.failReason || 'no reason given'}.`, d);
|
|
8431
8438
|
return ok(`TikTok is still processing it (${d.status || 'no status yet'}). That is normal for a few minutes; check again shortly and do not post it again.`, d);
|
|
8432
8439
|
}));
|
|
8433
8440
|
server.group('channel_admin');
|
|
@@ -13630,7 +13637,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13630
13637
|
targetUrl: z.string().describe('the landing page (must not block OAI-AdsBot / OAI-SearchBot in robots.txt)'),
|
|
13631
13638
|
imageUrl: z.string().optional().describe('public https URL of a STILL image — every text ad needs imageUrl OR fileId (ChatGPT Ads refuses an ad with no creative.file_id unless the campaign is a product feed; measured 2026-09-06); a video URL is refused, this channel has no video format'),
|
|
13632
13639
|
fileId: z.string().optional().describe('reuse an image ChatGPT Ads already holds: the fileId list_openai_ads_campaigns returns for an existing ad (list with adGroupId). Wins over imageUrl; use it to copy an ad exactly'),
|
|
13633
|
-
imageUrls: z.array(z.string()).optional().describe('4–6 public https URLs of
|
|
13640
|
+
imageUrls: z.array(z.string()).optional().describe('4–6 public https URLs of EXACTLY square (1:1) stills, in display order, for an ad with MULTIPLE images (one title, body and landing page shared by all). Each URL is measured before anything is uploaded: OpenAI refuses an image even 1 pixel off square, and a set of 16:9 images. OpenAI enables this per ad account, and with automatic bidding only if the account is also enabled for that — otherwise use a fixed bid. Not with imageUrl/fileId or price'),
|
|
13634
13641
|
fileIds: z.array(z.string()).optional().describe('the same as imageUrls with images already on the account (4–6 file ids); may be mixed with imageUrls, ids first. On an edit, omit both to keep the ad\u2019s current images; a new list replaces them all'),
|
|
13635
13642
|
price: z.string().optional().describe('optional price string shown on the card'),
|
|
13636
13643
|
});
|
|
@@ -15113,7 +15120,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15113
15120
|
}));
|
|
15114
15121
|
server.registerTool('create_openai_ads_ad', {
|
|
15115
15122
|
title: 'Create a ChatGPT Ads ad',
|
|
15116
|
-
description: 'Create the actual AD inside a ChatGPT Ads ad group — the object that carries the creative; a campaign or ad group alone shows nothing at all. The creative is a TEXT + IMAGE CARD: a title of 3–50 characters, body copy of 100 characters maximum, one landing page URL and one still image. THERE IS NO VIDEO FORMAT ON THIS PLATFORM — a video URL is refused outright, so never offer one. Created paused; creating it active inside a live ad group starts REAL AD SPEND, so that needs confirm:true. OpenAI REVIEWS every ad (usually a few minutes) and the ad is read back with its review status: until that says approved the ad CANNOT show, so report the review status rather than calling it live. The landing page is also checked against robots.txt for OAI-AdsBot / OAI-SearchBot blocks — a page that blocks those agents cannot run ChatGPT ads at all, and the note says so.',
|
|
15123
|
+
description: 'Create the actual AD inside a ChatGPT Ads ad group — the object that carries the creative; a campaign or ad group alone shows nothing at all. The creative is a TEXT + IMAGE CARD: a title of 3–50 characters, body copy of 100 characters maximum, one landing page URL and one still image. THERE IS NO VIDEO FORMAT ON THIS PLATFORM — a video URL is refused outright, so never offer one. Created paused; creating it active inside a live ad group starts REAL AD SPEND, so that needs confirm:true. OpenAI REVIEWS every ad (usually a few minutes) and the ad is read back with its review status: until that says approved the ad CANNOT show, so report the review status rather than calling it live. The landing page is also checked against robots.txt for OAI-AdsBot / OAI-SearchBot blocks — a page that blocks those agents cannot run ChatGPT ads at all, and the note says so. OpenAI creative enhancements (on by default for self-serve campaigns since 2026-10-30) may serve this copy rewritten, translated or on another page of the same site; the switch is in ChatGPT Ads Manager (account and campaign) and the API does not expose it, so never promise the exact words will run.',
|
|
15117
15124
|
inputSchema: {
|
|
15118
15125
|
adGroupId: z.string().describe('ad group id to create the ad in'), name: z.string().optional().describe('internal name — defaults to the title'),
|
|
15119
15126
|
creative: oaiCreativeShape.describe('title 3-50 characters, body up to 100, landing page URL and one still image; no video'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.473",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1089 tools. No markup on models. Publishing, scheduling, ad campaign management, comments, DMs and analytics are free on every plan. 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 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",
|