hermoso 0.1.227 → 0.1.229

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.
@@ -131,10 +131,23 @@ export function toolProvider(name) {
131
131
  // Returns TRUE only when we KNOW the read succeeded AND we can attribute the tool to a provider AND that provider
132
132
  // is not connected. Every other answer is FALSE, i.e. keep it — which is properties 1 and 2 expressed as the
133
133
  // default rather than as two branches somebody could forget to write.
134
+ // AN INSTAGRAM LOGIN CONNECTION SERVES PART OF THE META FAMILY (2026-09-12). Each tool below is filed under `meta`
135
+ // by name, but its server route resolves an Instagram Login token when the brand has one (igStandaloneFor in
136
+ // metaPublish, igGraphFor, igDmCtx, igAccountInsights, igEngage, the collab and audio readers). So a brand that
137
+ // connected only Instagram, with no Facebook Page and no Meta connection, must still be offered them: before this it
138
+ // could connect Instagram and then had no tool to post, reply, read its inbox or measure with. Only routes verified
139
+ // to take the Login token are listed; a Page-only tool (ads, hashtag search, comment_on_meta_post) stays gated on meta.
140
+ export const INSTAGRAM_LOGIN_TOOLS = new Set([
141
+ 'post_to_meta', 'list_meta_posts', 'list_instagram_media', 'instagram_insights', 'meta_post_insights',
142
+ 'list_meta_comments', 'reply_to_meta_comment', 'moderate_meta_comment', 'like_instagram',
143
+ 'list_meta_conversations', 'read_meta_conversation', 'reply_to_meta_message',
144
+ 'list_instagram_collab_invites', 'list_instagram_collab_media', 'respond_instagram_collab_invite', 'search_instagram_audio',
145
+ ]);
134
146
  export function toolHeldBackByConnectors(name, conn) {
135
147
  if (!conn || !conn.readOk) return false; // property 1 — fail OPEN on an unreadable store
136
148
  const p = toolProvider(name);
137
149
  if (p === null) return false; // property 2 — unmapped is never held back
138
150
  const on = conn.connected instanceof Set ? conn.connected : new Set(conn.connected || []);
151
+ if (p === 'meta' && on.has('instagram') && INSTAGRAM_LOGIN_TOOLS.has(name)) return false;
139
152
  return !on.has(p);
140
153
  }
package/mcp/tools.mjs CHANGED
@@ -2601,7 +2601,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
2601
2601
  const desc = String(h.description || '');
2602
2602
  let score = 0;
2603
2603
  if (q) {
2604
- const words = q.split(/[\s,]+/).map(expandQueryWord).filter(Boolean);
2604
+ // A NAME-SHAPED ASK IS ALSO ITS WORDS (2026-09-12). Agents search the name they guess (list_meta_campaigns,
2605
+ // update_meta_ad, edit_meta): kept as one literal token it matched nothing and filed a dead end, while its parts
2606
+ // (meta + campaigns, update + meta) name real tools. The literal still scores first when it exists.
2607
+ const words = q.split(/[\s,]+/).flatMap((r) => (r.includes('_') ? [r, ...r.split('_').filter((p) => p.length >= 2)] : [r])).map(expandQueryWord).filter(Boolean);
2605
2608
  const descLc = desc.toLowerCase();
2606
2609
  const nameTokens = name.split('_');
2607
2610
  let nameHits = 0, covered = 0;
@@ -3046,7 +3049,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
3046
3049
  }, wrap(async () => {
3047
3050
  const d = await apiGet('/api/credits');
3048
3051
  const bal = d.accountBalance ?? d.balance; // accountBalance = the caller's Hermoso credits (authed); balance = the local-dev usage pill
3049
- return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}. ${d.costModel || 'Credits are spent ONLY on AI models and Ad Spy research; posting, scheduling, campaign management, analytics and connectors are free (X is the one per-call exception).'}`, d);
3052
+ // THE CHARGES THEMSELVES (2026-09-12): the same ledger list as Billing ▸ Usage, so "where did my credits go" has an
3053
+ // answer here too. Best effort: a reply about the balance never fails on the history read.
3054
+ let recent = ''; try { const u = await apiGet('/api/billing/usage', { limit: 10 }); if (u?.items?.length) recent = `\nRecent charges (newest first):\n${u.items.map((r) => `• ${String(r.at).slice(0, 16).replace('T', ' ')} UTC · ${r.label} · ${r.credits} credits`).join('\n')}`; } catch {}
3055
+ return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}. ${d.costModel || 'Credits are spent ONLY on AI models and Ad Spy research; posting, scheduling, campaign management, analytics and connectors are free (X is the one per-call exception).'}${recent}`, d);
3050
3056
  }));
3051
3057
 
3052
3058
  // AGENT BILLING: out of credits → top up. With a saved card + billing-admin rights this is the SAME one-click
@@ -4177,7 +4183,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4177
4183
  // no need for one edge case just for Facebook."
4178
4184
  server.registerTool('schedule_post', {
4179
4185
  title: 'Schedule a post for later',
4180
- description: 'Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, and every one of them is live. google_business is accepted by the schema too but is currently HELD BACK: Google allowlists that API per project and ours reads 0 QPM, so it is refused at enqueue rather than failing hours later). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption\u2019s first sentence rather than truncating the caption mid-word, which is what a Pin headline used to be. A YOUTUBE ITEM’S CAPTION IS ITS TITLE, NOT ITS DESCRIPTION: pass `description` (≤5000 chars) for the box under the video — the links, the CTA and everything YouTube search reads — plus `tags` (up to 30). Omit them and the upload lands with an empty description, which is not recoverable by the time anyone notices. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. PER-CHANNEL SETTINGS, all carried straight through to the real publisher: TIKTOK takes the paid-partnership disclosure (`brandedContent`) and the own-brand one (`yourBrand`) — set them whenever the post is commercial, they are compliance declarations — plus `disableComment` and, on a video, `disableDuet` / `disableStitch` / `coverTimestampMs`. GOOGLE BUSINESS takes `topicType` (STANDARD / EVENT / OFFER / ALERT) with `event` and `offer`, and a real `actionType` button instead of the hard-coded Learn more. X takes a whole `thread`, a `poll`, `replySettings` and `madeWithAi`. INSTAGRAM takes `collaborators` — up to 3 usernames invited to CO-AUTHOR the post, which puts it on their profile too once they accept (the invite is sent when the post fires, and is pending until then). Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.',
4186
+ description: 'Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, and every one of them is live. google_business is accepted by the schema too but is currently HELD BACK: Google allowlists that API per project and ours reads 0 QPM, so it is refused at enqueue rather than failing hours later). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption\u2019s first sentence rather than truncating the caption mid-word. A YOUTUBE ITEM’S CAPTION IS ITS TITLE, NOT ITS DESCRIPTION: pass `description` (≤5000 chars) for the box under the video — the links, the CTA and everything YouTube search reads — plus `tags` (up to 30). Omit them and the upload lands with an empty description, which is not recoverable by the time anyone notices. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. PER-CHANNEL SETTINGS, all carried straight through to the real publisher: TIKTOK takes the paid-partnership disclosure (`brandedContent`) and the own-brand one (`yourBrand`) — set them whenever the post is commercial, they are compliance declarations — plus `disableComment` and, on a video, `disableDuet` / `disableStitch` / `coverTimestampMs`. GOOGLE BUSINESS takes `topicType` (STANDARD / EVENT / OFFER / ALERT) with `event` and `offer`, and a real `actionType` button instead of the hard-coded Learn more. X takes a whole `thread`, a `poll`, `replySettings` and `madeWithAi`. INSTAGRAM takes `collaborators` — up to 3 usernames invited to CO-AUTHOR the post, which puts it on their profile too once they accept (the invite is sent when the post fires, and is pending until then). Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.',
4181
4187
  inputSchema: {
4182
4188
  ...HOOK_ATTR,
4183
4189
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).describe('one or more channels to post to at that time'),
@@ -4273,7 +4279,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4273
4279
  }));
4274
4280
  server.registerTool('list_scheduled', {
4275
4281
  title: 'List scheduled and past posts',
4276
- description: 'Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Read-only, 0 credits.',
4282
+ description: 'Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Past items are rebuilt from published-post records, one row per channel; they are not job runs (use list_jobs for those). Read-only, 0 credits.',
4277
4283
  inputSchema: {},
4278
4284
  outputSchema: {
4279
4285
  scheduled: z.array(z.object({ id: z.string().optional(), at: z.string().nullable().optional(), channels: z.array(z.string()).optional(), message: z.string().optional(), status: z.string().optional() })).optional(),
@@ -4559,7 +4565,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4559
4565
  title: z.string().describe('the Article title — X requires one and refuses a draft without it. This is what shows on the timeline card.'),
4560
4566
  body: z.string().describe('the article body, as markdown or plain prose. Markdown headings, lists, quotes, links, emphasis, ``` code fences and | pipe | tables | are all converted to X’s own Article structure.'),
4561
4567
  coverImageUrl: z.string().optional().describe('optional cover picture for the Article — a Hermoso render URL or an upload_file url. Must be a STILL image; X Article covers are not videos.'),
4562
- headings: z.enum(['blocks', 'text']).optional().describe('how headings are rendered. “blocks” (default) uses X’s own heading block types, which is the faithful conversion. “text” renders each heading as a BOLD standalone paragraph instead: the words survive and the heading structure does not, the reply says so, and it is the documented fallback if X’s Articles service rejects heading blocks.'),
4568
+ headings: z.enum(['blocks', 'text']).optional().describe('how headings are rendered. “blocks” (default) uses X’s own heading block types for # and ## headings; ### and deeper become bold lines, because X Articles refuse a third-level heading (the reply says so). “text” renders every heading as a BOLD standalone paragraph instead: the words survive and the heading structure does not, and the reply says so.'),
4563
4569
  allowLossy: z.boolean().optional().describe('publish even though part of the source cannot be represented on X, rendering those parts as plain text. OFF by default and it should usually stay off — silently publishing a user’s copy with formatting missing is worse than refusing and telling them.'),
4564
4570
  publish: z.boolean().optional().describe('default true. Pass false to save it as a DRAFT in the account’s X Articles composer instead — nothing becomes public, the user can review and publish it from X, and it does not spend one of the five daily publishes.'),
4565
4571
  },
@@ -16073,7 +16079,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16073
16079
  // leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
16074
16080
  server.registerTool('clip_video', {
16075
16081
  title: 'Clip a long video',
16076
- description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut → Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip (a per-frame crop truncates to whole pixels and shimmers, so it is deliberately not tracked). It costs one small vision call per clip, billed as its own event. When nothing is being discarded, or no single subject can be located, the crop stays dead centre exactly as before — read `reframedToSubject` and each clip's `reframeWhy` back off the result rather than assuming either way. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.",
16082
+ description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut → Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip. It costs one small vision call per clip, billed as its own event. When nothing is being discarded, or no single subject can be located, the crop stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back off the result rather than assuming either way. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.",
16077
16083
  inputSchema: {
16078
16084
  video: z.string().describe('the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL'),
16079
16085
  count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
@@ -18377,7 +18383,7 @@ function memoryNoteVerdict(text) {
18377
18383
  residual: z.any().optional().describe('source-branding sweep result ({clean, note})'),
18378
18384
  },
18379
18385
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
18380
- }, cloneStaticHandler);
18386
+ }, (a, extra) => cloneStaticHandler(a, extra)); // its OWN function object: the registry stamps each handler with one tool name, and a shared one ended up named remix_static for both
18381
18387
 
18382
18388
  server.registerTool('mine_angles', {
18383
18389
  title: 'Mine customer angles',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.227",
3
+ "version": "0.1.229",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI ad studio and marketing MCP server with 825 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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",