hermoso 0.1.86 → 0.1.87

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 +14 -11
  2. package/package.json +1 -1
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.
@@ -7341,7 +7341,7 @@ export function registerTools(rawServer, opts = {}) {
7341
7341
  // leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
7342
7342
  server.registerTool('clip_video', {
7343
7343
  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.",
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. 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
7345
  inputSchema: {
7346
7346
  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
7347
  count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
@@ -8193,16 +8193,19 @@ export function registerTools(rawServer, opts = {}) {
8193
8193
  const hit = live.find(c => c.provider === provider);
8194
8194
  if (!hit) return { content: [{ type: 'text', text: `Nothing connected for "${provider}". Connected: ${live.map(c => c.provider).join(', ') || '(none)'}.` }], isError: true };
8195
8195
  // 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.
8196
+ // THE REVOKE CLAIM IS THE SERVER'S, AND SINCE 2026-08-13 IT IS THE SAME FOR EVERY PROVIDER: a disconnect
8197
+ // removes THIS BRAND'S row and calls nothing at the provider. That is not a limitation to apologise for, it is
8198
+ // the point an OAuth grant belongs to the (user, app) pair, so revoking from one brand would have killed the
8199
+ // same person's connection on every OTHER brand they had connected the account to. `removalNote` comes from
8200
+ // disconnectClaim() and names where the user can really revoke us. It is printed UNCONDITIONALLY on both the
8201
+ // confirm prompt and the result, because "this is brand-scoped" is a capability limit that stays true forever
8202
+ // rather than a per-provider quirk — the connector-card rule about desc-vs-setup copy, applied to an agent.
8201
8203
  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).
8204
+ // Straight through the app's own disconnect route → Connectors.remove(). The shared-grant guard that used to
8205
+ // matter here (all seven google_* connectors ride ONE OAuth client id, so a naive revoke killed the siblings)
8206
+ // is moot now that nothing revokes — the hazard is closed rather than guarded.
8204
8207
  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 });
8208
+ 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
8209
  }));
8207
8210
  // ── REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11) ────────────────────────────────────────────────────────────────
8208
8211
  // The web gained a "Remove my account" control the same day, and a WEB-ONLY capability is a defect
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.86",
3
+ "version": "0.1.87",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
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.",
6
6
  "type": "module",