hermoso 0.1.281 → 0.1.285

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/mcp/tools.mjs +17 -7
  2. package/package.json +1 -1
package/mcp/tools.mjs CHANGED
@@ -450,7 +450,7 @@ const wrap = (fn) => {
450
450
  // Read from the STRUCTURED signal, exactly as the sentence above is — never from the prose.
451
451
  // META'S SECURITY HOLD (code 31/3858385): the server's sentence already carries the steps; this names the move
452
452
  // so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
453
- if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security check: as the Facebook profile that connected Hermoso, open Ads Manager within 24 hours and follow the red banner, or if there is none create or slightly edit an ad in Ads Manager, wait for Verifying your edits, then follow the Fix errors prompt to the email verification; then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it' });
453
+ if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security check: as the Facebook profile that connected Hermoso, on a computer (not the Ads Manager app), create a NEW draft ad in Ads Manager, click Start authentication (or Fix errors) on the error it shows and complete it, or follow a red banner if one is shown; then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it' });
454
454
  if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
455
455
  }
456
456
  // ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
@@ -4174,7 +4174,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4174
4174
  const mkLine = (c) => { const f = (v) => v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(v >= 1e5 ? 0 : 1)}K` : String(v); const b = []; if (c.followers != null) b.push(`${f(c.followers)} followers`); if (c.country) b.push(c.country); if (c.badges && c.badges.length) b.push(`badges: ${c.badges.join(', ')}`); if (c.email) b.push(c.email); return `• @${c.handle}${c.verified ? ' (verified)' : ''}${b.length ? `: ${b.join(', ')}` : ''}${c.bio ? `. ${String(c.bio).slice(0, 120)}` : ''}`; };
4175
4175
  server.registerTool('find_instagram_marketplace_creators', {
4176
4176
  title: 'Search Instagram’s creator marketplace',
4177
- description: "SEARCH INSTAGRAM'S CREATOR MARKETPLACE: Meta's own directory of creators who partner with brands, searched AS the brand's Instagram account, so the recommendations are personalised to it. Use it when the user wants creators with first-party data (followers, audience, badges such as Partnership ads / Branded content / Strong hooks / Responsive, marketplace email) or Meta's recommendations. Search by query (keywords), by similarTo (up to 5 handles, not with query), by recommendation (most_relevant_for_me, high_ad_performance, most_ads_experience, similar_brands, similar_audience, interested_in_collaboration), or filters on the creator (countries, states with exactly one country, minFollowers/maxFollowers bands, ageBucket, gender, up to 5 interests, minEngaged/maxEngaged, language, followerGrowth, lastPostWithin, verifiedAccount, hasPortfolio, hasPublicContactEmail, featuredInPaidAds, excludeMessagedCreators) and their audience (audienceCountries, audienceStates, audienceAgeBucket, audienceGender, audienceDevices), or customAudienceId from list_instagram_marketplace_audiences. username looks up one creator (no other filter allowed). find_creators ranks creators by their public posts in a niche and takes marketplace:true to add this source beside it. Needs the Meta (Facebook Login) connector with the Page linked to the brand's Instagram, and Meta's instagram_creator_marketplace_discovery permission; when it cannot run it says exactly why and what fixes it. Free.",
4177
+ description: "SEARCH INSTAGRAM'S CREATOR MARKETPLACE: Meta's own directory of creators who partner with brands, searched AS the brand's Instagram account, so the recommendations are personalised to it. Use it when the user wants creators with first-party data (followers, audience, badges such as Partnership ads / Branded content / Strong hooks / Responsive, marketplace email) or Meta's recommendations. Search by query (keywords), by similarTo (up to 5 handles, not with query), by recommendation (most_relevant_for_me, high_ad_performance, most_ads_experience, similar_brands, similar_audience, interested_in_collaboration), or filters on the creator (countries, states with exactly one country, minFollowers/maxFollowers bands, ageBucket, gender, up to 5 interests, minEngaged/maxEngaged, language, followerGrowth, lastPostWithin, verifiedAccount, hasPortfolio, hasPublicContactEmail, featuredInPaidAds, excludeMessagedCreators) and their audience (audienceCountries, audienceStates, audienceAgeBucket, audienceGender, audienceDevices), or customAudienceId from list_instagram_marketplace_audiences. username looks up one creator (no other filter allowed). find_creators ranks creators by their public posts in a niche and takes marketplace:true to add this source beside it. Needs the Meta (Facebook Login) connector with the Page linked to the brand's Instagram, and Meta's instagram_creator_marketplace_discovery permission; when it cannot run it says exactly why and what fixes it, and when the reason is that Meta has not approved Hermoso's app for that permission yet (read LIVE from Meta, and named under Meta by list_connectors) it says so: that is Meta's decision about Hermoso, never a reason to tell the user to reconnect. Free.",
4178
4178
  inputSchema: {
4179
4179
  query: z.string().optional().describe('keywords, e.g. "skincare" or "trail running"'),
4180
4180
  username: z.string().optional().describe('one creator handle; no other filter allowed with it'),
@@ -7210,7 +7210,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7210
7210
  }));
7211
7211
  server.registerTool('list_instagram_shopping_catalogs', {
7212
7212
  title: 'What Instagram Shopping can tag',
7213
- description: 'Whether this Instagram account can tag products at all, and which catalogs its SHOP can tag from. CALL THIS FIRST: product tagging needs an APPROVED INSTAGRAM SHOP, and if the account does not have one, tagging fails AFTER the photo is already uploaded. The reply says which of three things is true — eligible, not eligible (a Commerce Manager approval nothing in Hermoso can grant, and not a sign anything is broken), or "could not tell", which is NOT the same as not eligible. AN EMPTY CATALOG LIST IS NOT AN EMPTY CATALOG: Instagram reaches a catalog through the account\'s SHOP, while list_meta_catalogs reads the business PORTFOLIO — a merchant can have a full catalog there and nothing available here until the shop is approved. Read-only, 0 credits.',
7213
+ description: 'Whether this Instagram account can tag products at all, and which catalogs its SHOP can tag from. CALL THIS FIRST: product tagging needs an APPROVED INSTAGRAM SHOP, and if the account does not have one, tagging fails AFTER the photo is already uploaded. The reply says which of three things is true — eligible, not eligible (a Commerce Manager approval nothing in Hermoso can grant, and not a sign anything is broken), or "could not tell", which is NOT the same as not eligible. AN EMPTY CATALOG LIST IS NOT AN EMPTY CATALOG: Instagram reaches a catalog through the account\'s SHOP, while list_meta_catalogs reads the business PORTFOLIO — a merchant can have a full catalog there and nothing available here until the shop is approved. Read-only, 0 credits. Whether Meta has approved Hermoso\'s app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool\'s refusal says Meta hasn\'t approved Hermoso for it yet. That is Meta\'s decision about Hermoso, not the user\'s connection, so never tell them to reconnect for it.',
7214
7214
  inputSchema: { pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected. Its linked Instagram account is the one that gets tagged.') },
7215
7215
  outputSchema: { igId: z.string().optional(), account: z.string().optional(), eligible: z.boolean().nullable().optional(), count: z.number().optional(), catalogs: z.array(z.any()).optional(), eligibilityNote: z.string().optional(), note: z.string().optional(), readError: z.string().optional(), limits: z.any().optional() },
7216
7216
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -7223,7 +7223,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7223
7223
  }));
7224
7224
  server.registerTool('search_instagram_shopping_products', {
7225
7225
  title: 'Find products to tag on Instagram',
7226
- description: 'The products in one catalog that can actually be TAGGED on an Instagram post — this is where the product_id for a product tag comes from. Omit `q` to see everything tag-eligible; pass a product name or SKU to narrow it. THIS IS A SMALLER SET THAN THE CATALOG HOLDS: a product can be in the catalog, counted by list_meta_catalog_products, and still not be taggable, so an empty answer here is never evidence the catalog is empty. Meta only SHOWS a tag whose product review_status is "approved" — an unapproved one is accepted, stored and shown to nobody, so the reply flags them. Read-only, 0 credits.',
7226
+ description: 'The products in one catalog that can actually be TAGGED on an Instagram post — this is where the product_id for a product tag comes from. Omit `q` to see everything tag-eligible; pass a product name or SKU to narrow it. THIS IS A SMALLER SET THAN THE CATALOG HOLDS: a product can be in the catalog, counted by list_meta_catalog_products, and still not be taggable, so an empty answer here is never evidence the catalog is empty. Meta only SHOWS a tag whose product review_status is "approved" — an unapproved one is accepted, stored and shown to nobody, so the reply flags them. Read-only, 0 credits. Whether Meta has approved Hermoso\'s app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool\'s refusal says Meta hasn\'t approved Hermoso for it yet. That is Meta\'s decision about Hermoso, not the user\'s connection, so never tell them to reconnect for it.',
7227
7227
  inputSchema: {
7228
7228
  catalogId: z.string().describe('from list_instagram_shopping_catalogs. Meta REQUIRES it — there is no search-every-catalog form.'),
7229
7229
  q: z.string().optional().describe('product name or SKU. Omit it to list every tag-eligible product, which is a real ask rather than a missing argument.'),
@@ -7239,7 +7239,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7239
7239
  }));
7240
7240
  server.registerTool('manage_instagram_product_tags', {
7241
7241
  title: 'Read or update the product tags on a published Instagram post',
7242
- description: 'READ the product tags on a post the brand has already published, or ADD/MOVE tags on it. Pass `tags` to update: Meta\'s own behaviour is "updates coordinates if the product is already tagged; otherwise adds new tag" — so it is ADD-OR-MOVE, never replace-all, and it CANNOT be used to take a tag off. THERE IS NO WAY TO REMOVE A PRODUCT TAG: Meta documents Creating, Reading and Updating on this edge and no delete at all, so Hermoso will not guess at one — deleting the post is the only thing that removes its tags, and the reply says so rather than implying otherwise. The answer is always READ BACK from Instagram, and it separates tags that are STORED from tags that will actually be SHOWN — only an "approved" product ever appears on a published post. Read is free; the update costs 0 credits too.',
7242
+ description: 'READ the product tags on a post the brand has already published, or ADD/MOVE tags on it. Pass `tags` to update: Meta\'s own behaviour is "updates coordinates if the product is already tagged; otherwise adds new tag" — so it is ADD-OR-MOVE, never replace-all, and it CANNOT be used to take a tag off. THERE IS NO WAY TO REMOVE A PRODUCT TAG: Meta documents Creating, Reading and Updating on this edge and no delete at all, so Hermoso will not guess at one — deleting the post is the only thing that removes its tags, and the reply says so rather than implying otherwise. The answer is always READ BACK from Instagram, and it separates tags that are STORED from tags that will actually be SHOWN — only an "approved" product ever appears on a published post. Read is free; the update costs 0 credits too. Whether Meta has approved Hermoso\'s app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool\'s refusal says Meta hasn\'t approved Hermoso for it yet. That is Meta\'s decision about Hermoso, not the user\'s connection, so never tell them to reconnect for it.',
7243
7243
  inputSchema: {
7244
7244
  mediaId: z.string().describe('the numeric Instagram media id, from list_instagram_media'),
7245
7245
  tags: z.array(z.object({ product_id: z.string(), x: z.number(), y: z.number() })).optional().describe('ADD or MOVE these tags. x and y are FRACTIONS of the image, 0.0 (left/top) to 1.0 (right/bottom) — 0.5,0.5 is the middle. Omit to just read. Max 20 on a feed post.'),
@@ -17251,7 +17251,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17251
17251
 
17252
17252
  server.registerTool('post_edit', {
17253
17253
  title: 'Post-production edit',
17254
- description: "MECHANICAL post-production on an EXISTING video (its URL): an ordered plan of whitelisted primitives run by ffmpeg (+ Chrome for type) in seconds for ~2 credits flat, NO AI model, the original untouched (returns a NEW video). Ops: a branded end card (ADDS its seconds, never re-renders), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, watermark (corner logo), grain (anti-AI), text (timed words over the clip in a native look, no branding: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle, position, start/end), join (this video FOLLOWED BY clips[], each a Library URL, a direct file or a public TikTok / Reel / Facebook / X / YouTube post link, as one 1080x1920 video with matched loudness; transition 'cut' or 'crossfade'). Up to 6 ops, in order. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}]}]. Brand assets load from the workspace brand. NEVER use generate_video/render_ad for these.",
17254
+ description: "MECHANICAL post-production on an EXISTING video (its URL): an ordered plan of whitelisted primitives run by ffmpeg (+ Chrome for type) in seconds for ~2 credits flat, NO AI model, the original untouched (returns a NEW video). Ops: a branded end card (adds its seconds), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, watermark (corner logo), grain (anti-AI), text (timed words over the clip in a native look, no branding: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle, position, start/end), join (this video FOLLOWED BY clips[], each a Library URL, a direct file or a public TikTok / Reel / Facebook / X / YouTube post link, as one 1080x1920 video with matched loudness; transition 'cut' or 'crossfade'). Up to 6 ops, in order. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}], bridge}], and it ALWAYS gets a bridge unless the user asks for a bare cut: {kind:'impact'} cuts the hook just before its payoff (found from the footage; cutAt overrides) and lands our clip on a punch-in and flash, with the payoff sound taken FROM THE HOOK ITSELF: its own audio carries across the cut, else a sound generated from its frames (then up to 8 credits), else a neutral impact (sound 'auto' default | 'own' never a model | 'impact' | 'whoosh' | 'none'). {kind:'text', text, then?} only when the user asks for words over the cut. No voiceover bridge: best, make our clip's host say the connecting line. matchCut = where our clip starts. NEVER use generate_video/render_ad for these.",
17255
17255
  inputSchema: {
17256
17256
  videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link'),
17257
17257
  ops: z.array(z.object({
@@ -17263,6 +17263,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17263
17263
  style: z.union([z.string(), z.object({}).passthrough()]).optional().describe('text: a look name or a textStyle'),
17264
17264
  clips: z.array(z.object({ url: z.string(), start: z.number().optional(), end: z.number().optional() })).optional().describe('join: the clips after this video'),
17265
17265
  transition: z.enum(['cut', 'crossfade']).optional().describe('join'),
17266
+ bridge: z.object({ kind: z.enum(['impact', 'text']), cutAt: z.number().optional().describe('omit: found from the footage'), matchCut: z.number().optional(), sound: z.enum(['auto', 'own', 'impact', 'whoosh', 'none']).optional(), flash: z.boolean().optional(), shake: z.boolean().optional(), text: z.string().optional(), then: z.string().optional() }).optional().describe('join: connects the hook to the first clip'),
17266
17267
  factor: z.number().optional().describe('speed 0.5-2'),
17267
17268
  db: z.number().optional().describe('audio_gain -20..+6 dB'),
17268
17269
  seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s / crossfade 0.2-1.5s'),
@@ -18228,6 +18229,15 @@ function memoryNoteVerdict(text) {
18228
18229
  // cannot word one fact three ways. `unknown` prints NOTHING — a grant we could not read must never be relayed
18229
18230
  // as a limitation ([[failed-read-is-not-empty]]).
18230
18231
  const posting = on.filter(c => c && c.grantMode === 'posting' && String(c.grantModeNote || '').trim());
18232
+ // ── WAITING ON META APPROVING HERMOSO, NOT ON THE USER (2026-09-24) ─────────────────────────────────────────
18233
+ // A feature whose permission Meta has not approved Hermoso's app for yet, read live by the server off Meta's own
18234
+ // answer about the app. It is the OPPOSITE instruction to the permission gap above: nothing the user does fixes
18235
+ // it, so it is never phrased as a reconnect. The sentence is the SERVER'S (`awaitingApprovalNote`).
18236
+ const waiting = on.filter(c => c && String(c.awaitingApprovalNote || '').trim());
18237
+ const waitingNote = waiting.length
18238
+ ? '\n\n' + waiting.map(c => `ℹ ${c.provider}: ${c.awaitingApprovalNote}`).join('\n')
18239
+ + '\nThose tools stay callable (they work for people with a role on the Hermoso app), but for this user Meta will refuse them until it approves Hermoso: say that plainly and never suggest reconnecting for it.'
18240
+ : '';
18231
18241
  const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})${c.grantMode === 'posting' ? ' · POSTING ONLY' : ''}${c.scopeDrift?.status === 'missing' ? ` ⚠ missing ${c.scopeDrift.missing.length} permission(s) — needs a reconnect` : ''}`);
18232
18242
  const postingNote = posting.length
18233
18243
  ? '\n\n' + posting.map(c => `ℹ ${c.grantModeNote}`).join('\n')
@@ -18239,7 +18249,7 @@ function memoryNoteVerdict(text) {
18239
18249
  + '\nThis is not something an agent can fix: re-authorizing is an OAuth consent screen, so the user has to do it in a browser — Workspace ▸ Connectors ▸ the connector ▸ Reconnect. Everything the connection already carries keeps working until then.' + gap.map(c => `\n Reconnect ${c.provider}: ${linkFor(c.provider)}`).join('')
18240
18250
  : '';
18241
18251
  const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
18242
- return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, connectable from here with connect_connector or in the app: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : empty}${gapNote}${postingNote}`, safe);
18252
+ return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, connectable from here with connect_connector or in the app: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : empty}${gapNote}${waitingNote}${postingNote}`, safe);
18243
18253
  }));
18244
18254
  // ── CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two
18245
18255
  // halves of connector management are, and were web-only: DISCONNECTING, and choosing WHICH accounts a brand may
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.281",
3
+ "version": "0.1.285",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 856 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",