hermoso 0.1.86 → 0.1.88

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 +21 -12
  2. package/package.json +2 -2
package/mcp/tools.mjs CHANGED
@@ -1525,7 +1525,7 @@ export function registerTools(rawServer, opts = {}) {
1525
1525
  // empty description box and no tags. The caption cannot stand in for them — it becomes the video TITLE.
1526
1526
  description: z.string().optional().describe('YOUTUBE — the video DESCRIPTION, max 5000 characters: the box under the video carrying the links, the CTA and everything YouTube search reads. It is NOT the caption — a scheduled YouTube item’s text becomes its TITLE — so omitting this publishes the video with an empty description.'),
1527
1527
  tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
1528
- altText: z.string().optional().describe('ACCESSIBILITY the screen-reader description of the attached image. X (max 1000 characters), Pinterest (max 500) and LinkedIn COMPANY PAGE posts each attach it to the picture; the schedule is REFUSED if it is longer than the tightest of the channels on it, rather than truncated on the way out. Write one whenever the post carries an image: describe what is IN the picture, never a repeat of the caption, which a screen reader already reads. A LinkedIn post to a PERSONAL profile carries none \u2014 LinkedIn\u2019s member posting API has no alt-text field at all.'),
1528
+ altText: z.string().optional().describe('ACCESSIBILITY \u2014 the screen-reader description of the attached image. X (max 1000 characters), Pinterest (max 500), LinkedIn COMPANY PAGE posts (max 4086) and INSTAGRAM (max 1000) each attach it to the picture; the schedule is REFUSED if it is longer than the tightest of the channels on it, rather than truncated on the way out. Write one whenever the post carries an image: describe what is IN the picture, never a repeat of the caption, which a screen reader already reads. TWO CHANNELS CARRY NONE, and neither is refused \u2014 the post still publishes, just undescribed there: a LinkedIn post to a PERSONAL profile (LinkedIn\u2019s member posting API has no alt-text field at all), and an INSTAGRAM VIDEO/Reel (Meta support alt_text on image posts only). On an Instagram CAROUSEL the one description is applied to every image slide.'),
1529
1529
  link: z.string().optional().describe('a link to attach (Facebook)'),
1530
1530
  // ── PER-CHANNEL PUBLISH SETTINGS (server-side SCHED_META_FIELDS, 2026-08-03). Every one of these has been on
1531
1531
  // the immediate publish helper since the day it was written — tiktokPost's disclosures and interaction
@@ -1602,7 +1602,7 @@ export function registerTools(rawServer, opts = {}) {
1602
1602
  title: z.string().optional().describe('PINTEREST / YOUTUBE — replace the headline; "" clears it and goes back to deriving one from the caption'),
1603
1603
  description: z.string().optional().describe('YOUTUBE — replace the video description; "" clears it. Remember the caption is the TITLE, not the description.'),
1604
1604
  tags: z.array(z.string()).optional().describe('YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.'),
1605
- altText: z.string().optional().describe('ACCESSIBILITY replace the image\u2019s screen-reader description (used by X, Pinterest and LinkedIn company Pages); "" clears it.'),
1605
+ altText: z.string().optional().describe('ACCESSIBILITY \u2014 replace the image\u2019s screen-reader description (used by X, Pinterest, LinkedIn company Pages and Instagram image posts); "" clears it.'),
1606
1606
  link: z.string().optional(),
1607
1607
  // The per-channel publish settings (SCHED_META_FIELDS), on the same omitted-means-keep rule as everything else
1608
1608
  // here: send only what changes. A whole-record field (`event`, `offer`, `poll`, `thread`) REPLACES the record.
@@ -1652,12 +1652,18 @@ export function registerTools(rawServer, opts = {}) {
1652
1652
  // HTTP route and the in-app agent call.
1653
1653
  server.registerTool('retry_scheduled', {
1654
1654
  title: 'Retry a failed scheduled post',
1655
- description: 'Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause: reconnecting the account, picking a board, shortening the caption. To send the same thing again ON PURPOSE, use duplicate_scheduled.',
1655
+ description: 'Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.',
1656
1656
  inputSchema: {
1657
1657
  id: z.string().describe('the scheduled post id from list_scheduled'),
1658
1658
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
1659
1659
  at: z.string().optional().describe('hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.'),
1660
1660
  allowDuplicate: z.boolean().optional().describe('ONLY for a channel you have checked by hand and confirmed the post is genuinely NOT there. It bypasses the double-post protection and can publish a second public copy, so never set it to work around a refusal you have not investigated.'),
1661
+ boardId: z.string().optional().describe('CORRECT THE BOARD on retry — the Pinterest board the Pin goes on, from list_pinterest_boards. A Pin aimed at a board Pinterest refuses fails the same way on every retry until this is changed.'),
1662
+ pageId: z.string().optional().describe('CORRECT THE PAGE on retry — which connected Facebook Page (and its linked Instagram/Threads) publishes, from list_meta_pages.'),
1663
+ linkedinOrganizationId: z.string().optional().describe('CORRECT THE LINKEDIN AUTHOR on retry — the company Page id from list_linkedin_pages. Set it to an empty string to fall back to the personal profile.'),
1664
+ locationId: z.string().optional().describe('CORRECT THE LISTING on retry — which Google Business Profile location, e.g. "locations/123" from list_business_locations.'),
1665
+ message: z.string().optional().describe('CORRECT THE CAPTION on retry — use this when the original was refused for length or content. Anything not named here is copied from the original post.'),
1666
+ captions: z.record(z.string()).optional().describe('CORRECT ONE CHANNEL’S CAPTION on retry, e.g. { "x": "..." } when only that channel refused the text.'),
1661
1667
  },
1662
1668
  outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), retryOf: z.string().optional(), retrying: z.array(z.string()).optional(), alreadyPublished: z.array(z.string()).optional(), note: z.string().optional() },
1663
1669
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -7341,7 +7347,7 @@ export function registerTools(rawServer, opts = {}) {
7341
7347
  // leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
7342
7348
  server.registerTool('clip_video', {
7343
7349
  title: 'Clip a long video',
7344
- 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. 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.",
7350
+ 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.",
7345
7351
  inputSchema: {
7346
7352
  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'),
7347
7353
  count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
@@ -8193,16 +8199,19 @@ export function registerTools(rawServer, opts = {}) {
8193
8199
  const hit = live.find(c => c.provider === provider);
8194
8200
  if (!hit) return { content: [{ type: 'text', text: `Nothing connected for "${provider}". Connected: ${live.map(c => c.provider).join(', ') || '(none)'}.` }], isError: true };
8195
8201
  // agentLabel, never accountLabel — the confirm sentence is model-visible text like any other (2026-08-04).
8196
- // THE REVOKE CLAIM IS THE SERVER'S (2026-08-12). This sentence used to assert "our access is revoked at the
8197
- // provider" for every provider false for the three with no revoker (threads, microsoft_onedrive,
8198
- // microsoft_ads), where the grant survives and the user must remove it themselves. `removalNote` comes from
8199
- // disconnectClaim() in adapters/connectors.js and rides every /api/connectors row; it names the user-side
8200
- // removal path where one exists, so the agent has something to relay instead of a false reassurance.
8202
+ // THE REVOKE CLAIM IS THE SERVER'S, AND SINCE 2026-08-13 IT IS THE SAME FOR EVERY PROVIDER: a disconnect
8203
+ // removes THIS BRAND'S row and calls nothing at the provider. That is not a limitation to apologise for, it is
8204
+ // the point an OAuth grant belongs to the (user, app) pair, so revoking from one brand would have killed the
8205
+ // same person's connection on every OTHER brand they had connected the account to. `removalNote` comes from
8206
+ // disconnectClaim() and names where the user can really revoke us. It is printed UNCONDITIONALLY on both the
8207
+ // confirm prompt and the result, because "this is brand-scoped" is a capability limit that stays true forever
8208
+ // rather than a per-provider quirk — the connector-card rule about desc-vs-setup copy, applied to an agent.
8201
8209
  if (a.confirm !== true) return ok(`This disconnects ${hit.provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this workspace: the stored credentials are deleted and every ${hit.provider} tool stops working until someone reconnects it IN A BROWSER — I can't do that step. ${hit.removalNote || ''} Already-published posts and running campaigns are untouched. Confirm with the user, then call again with confirm:true.`, { ok: false, provider, revokesAtProvider: hit.revokesAtProvider || null });
8202
- // Straight through the app's own disconnect route → Connectors.remove(), which carries the shared-grant guard
8203
- // (all six google_* connectors ride ONE OAuth client id, so a naive revoke would silently kill the siblings).
8210
+ // Straight through the app's own disconnect route → Connectors.remove(). The shared-grant guard that used to
8211
+ // matter here (all seven google_* connectors ride ONE OAuth client id, so a naive revoke killed the siblings)
8212
+ // is moot now that nothing revokes — the hazard is closed rather than guarded.
8204
8213
  await apiPost(`/api/connectors/${encodeURIComponent(provider)}/disconnect`, {});
8205
- return ok(`Disconnected ${provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''}.${hit.revokesAtProvider === 'local' && hit.removalNote ? ` ${hit.removalNote}` : ''} Reconnect from the app: Workspace ▸ Connectors ▸ ${provider}.`, { ok: true, provider, disconnected: true, revokesAtProvider: hit.revokesAtProvider || null });
8214
+ return ok(`Disconnected ${provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this brand.${hit.removalNote ? ` ${hit.removalNote}` : ''} Reconnect from the app: Workspace ▸ Connectors ▸ ${provider}.`, { ok: true, provider, disconnected: true, revokesAtProvider: hit.revokesAtProvider || null });
8206
8215
  }));
8207
8216
  // ── REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11) ────────────────────────────────────────────────────────────────
8208
8217
  // The web gained a "Remove my account" control the same day, and a WEB-ONLY capability is a defect
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio + marketing MCP (415 tools): build and manage ad campaigns on Meta, Google Ads, Reddit, X, TikTok, Snapchat, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads generate finished video, image and UGC avatar ads, publish them to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest, spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus organic TikTok/Instagram/YouTube/Reddit, and read what they achieved in Google Analytics 4. CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
5
+ "description": "AI ad studio + marketing MCP (415 tools): build and manage ad campaigns on Meta, Google Ads, Reddit, X, TikTok, Snapchat, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads \u2014 generate finished video, image and UGC avatar ads, publish them to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest, spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus organic TikTok/Instagram/YouTube/Reddit, and read what they achieved in Google Analytics 4. CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"